1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::log_template::LogTemplate;
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15pub static K8S_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
17 EventType::new(
18 "k8s_connected",
19 "Kubernetes client connected to cluster API",
20 json!({"type": "k8s_list_pods", "namespace": "default"}),
21 )
22 .with_parameters(vec![
23 Parameter {
24 name: "cluster_url".to_string(),
25 type_hint: "string".to_string(),
26 description: "The address this client was opened with: \"default\" when the \
27 kubeconfig decides the cluster, otherwise the kubeconfig path"
28 .to_string(),
29 required: true,
30 },
31 Parameter {
32 name: "namespace".to_string(),
33 type_hint: "string".to_string(),
34 description: "Namespace every action that does not name one itself will use"
35 .to_string(),
36 required: true,
37 },
38 ])
39});
40
41pub static K8S_CLIENT_RESOURCE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
43 EventType::new(
44 "k8s_resource_received",
45 "Kubernetes resource operation completed",
46 json!({"type": "k8s_list_pods", "namespace": "default"}),
47 )
48 .with_parameters(vec![
49 Parameter {
50 name: "operation".to_string(),
51 type_hint: "string".to_string(),
52 description: "Operation performed (list, get, create, delete, etc.)".to_string(),
53 required: true,
54 },
55 Parameter {
56 name: "resource_type".to_string(),
57 type_hint: "string".to_string(),
58 description: "Resource type (pods, deployments, services, etc.)".to_string(),
59 required: true,
60 },
61 Parameter {
62 name: "namespace".to_string(),
63 type_hint: "string".to_string(),
64 description: "Kubernetes namespace".to_string(),
65 required: true,
66 },
67 Parameter {
68 name: "response".to_string(),
69 type_hint: "object".to_string(),
70 description: "Operation response data".to_string(),
71 required: true,
72 },
73 ])
74});
75
76pub struct KubernetesClientProtocol;
78
79impl Default for KubernetesClientProtocol {
80 fn default() -> Self {
81 Self
82 }
83}
84
85impl KubernetesClientProtocol {
86 pub fn new() -> Self {
87 Self::default()
88 }
89}
90
91impl Protocol for KubernetesClientProtocol {
93 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
94 vec![
95 ParameterDefinition {
96 name: "namespace".to_string(),
97 description: "Default namespace for operations (default: 'default')".to_string(),
98 type_hint: "string".to_string(),
99 required: false,
100 example: json!("kube-system"),
101 default: None,
102 },
103 ParameterDefinition {
104 name: "kubeconfig".to_string(),
105 description: "Path to kubeconfig file (default: ~/.kube/config)".to_string(),
106 type_hint: "string".to_string(),
107 required: false,
108 example: json!("~/.kube/config"),
109 default: None,
110 },
111 ]
112 }
113
114 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
115 vec![
116 ActionDefinition {
117 name: "k8s_list_pods".to_string(),
118 description: "List all pods in a namespace".to_string(),
119 parameters: vec![
120 Parameter {
121 name: "namespace".to_string(),
122 type_hint: "string".to_string(),
123 description:
124 "Namespace to list pods from (optional, uses default if not specified)"
125 .to_string(),
126 required: false,
127 },
128 Parameter {
129 name: "label_selector".to_string(),
130 type_hint: "string".to_string(),
131 description: "Label selector to filter pods (e.g., 'app=nginx')"
132 .to_string(),
133 required: false,
134 },
135 ],
136 example: json!({
137 "type": "k8s_list_pods",
138 "namespace": "default",
139 "label_selector": "app=nginx"
140 }),
141 log_template: None,
142 },
143 ActionDefinition {
144 name: "k8s_get_pod".to_string(),
145 description: "Get details of a specific pod".to_string(),
146 parameters: vec![
147 Parameter {
148 name: "name".to_string(),
149 type_hint: "string".to_string(),
150 description: "Pod name".to_string(),
151 required: true,
152 },
153 Parameter {
154 name: "namespace".to_string(),
155 type_hint: "string".to_string(),
156 description: "Namespace (optional)".to_string(),
157 required: false,
158 },
159 ],
160 example: json!({
161 "type": "k8s_get_pod",
162 "name": "nginx-abc123",
163 "namespace": "default"
164 }),
165 log_template: None,
166 },
167 ActionDefinition {
168 name: "k8s_get_logs".to_string(),
169 description: "Get logs from a pod".to_string(),
170 parameters: vec![
171 Parameter {
172 name: "name".to_string(),
173 type_hint: "string".to_string(),
174 description: "Pod name".to_string(),
175 required: true,
176 },
177 Parameter {
178 name: "namespace".to_string(),
179 type_hint: "string".to_string(),
180 description: "Namespace (optional)".to_string(),
181 required: false,
182 },
183 ],
184 example: json!({
185 "type": "k8s_get_logs",
186 "name": "nginx-abc123",
187 "namespace": "default"
188 }),
189 log_template: None,
190 },
191 ActionDefinition {
192 name: "k8s_create_pod".to_string(),
193 description: "Create a new pod".to_string(),
194 parameters: vec![
195 Parameter {
196 name: "spec".to_string(),
197 type_hint: "object".to_string(),
198 description: "Pod specification (Kubernetes Pod manifest)".to_string(),
199 required: true,
200 },
201 Parameter {
202 name: "namespace".to_string(),
203 type_hint: "string".to_string(),
204 description: "Namespace (optional)".to_string(),
205 required: false,
206 },
207 ],
208 example: json!({
209 "type": "k8s_create_pod",
210 "namespace": "default",
211 "spec": {
212 "apiVersion": "v1",
213 "kind": "Pod",
214 "metadata": {
215 "name": "nginx"
216 },
217 "spec": {
218 "containers": [{
219 "name": "nginx",
220 "image": "nginx:latest"
221 }]
222 }
223 }
224 }),
225 log_template: None,
226 },
227 ActionDefinition {
228 name: "k8s_delete_pod".to_string(),
229 description: "Delete a pod by name in a namespace. The apiserver accepts the \
230 request immediately; a Deployment or ReplicaSet controlling the \
231 pod will create a replacement, so this restarts a pod rather \
232 than removing it for good."
233 .to_string(),
234 parameters: vec![
235 Parameter {
236 name: "name".to_string(),
237 type_hint: "string".to_string(),
238 description: "Pod name".to_string(),
239 required: true,
240 },
241 Parameter {
242 name: "namespace".to_string(),
243 type_hint: "string".to_string(),
244 description: "Namespace (optional)".to_string(),
245 required: false,
246 },
247 ],
248 example: json!({
249 "type": "k8s_delete_pod",
250 "name": "nginx",
251 "namespace": "default"
252 }),
253 log_template: None,
254 },
255 ActionDefinition {
256 name: "k8s_list_deployments".to_string(),
257 description: "List all deployments in a namespace".to_string(),
258 parameters: vec![
259 Parameter {
260 name: "namespace".to_string(),
261 type_hint: "string".to_string(),
262 description: "Namespace (optional)".to_string(),
263 required: false,
264 },
265 Parameter {
266 name: "label_selector".to_string(),
267 type_hint: "string".to_string(),
268 description: "Label selector (optional)".to_string(),
269 required: false,
270 },
271 ],
272 example: json!({
273 "type": "k8s_list_deployments",
274 "namespace": "default"
275 }),
276 log_template: None,
277 },
278 ActionDefinition {
279 name: "k8s_list_services".to_string(),
280 description: "List all services in a namespace".to_string(),
281 parameters: vec![
282 Parameter {
283 name: "namespace".to_string(),
284 type_hint: "string".to_string(),
285 description: "Namespace (optional)".to_string(),
286 required: false,
287 },
288 Parameter {
289 name: "label_selector".to_string(),
290 type_hint: "string".to_string(),
291 description: "Label selector (optional)".to_string(),
292 required: false,
293 },
294 ],
295 example: json!({
296 "type": "k8s_list_services",
297 "namespace": "kube-system"
298 }),
299 log_template: None,
300 },
301 ActionDefinition {
302 name: "disconnect".to_string(),
303 description: "Disconnect from the Kubernetes cluster".to_string(),
304 parameters: vec![],
305 example: json!({
306 "type": "disconnect"
307 }),
308 log_template: None,
309 },
310 ]
311 }
312
313 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
314 vec![ActionDefinition {
315 name: "k8s_list_pods".to_string(),
316 description: "List pods in response to a previous operation".to_string(),
317 parameters: vec![Parameter {
318 name: "namespace".to_string(),
319 type_hint: "string".to_string(),
320 description: "Namespace (optional)".to_string(),
321 required: false,
322 }],
323 example: json!({
324 "type": "k8s_list_pods",
325 "namespace": "default"
326 }),
327 log_template: Some(
328 LogTemplate::new()
329 .with_info("-> K8s list pods (ns={namespace})")
330 .with_debug("Kubernetes k8s_list_pods: namespace={namespace}"),
331 ),
332 }]
333 }
334
335 fn protocol_name(&self) -> &'static str {
336 "Kubernetes"
337 }
338
339 fn get_event_types(&self) -> Vec<EventType> {
346 vec![
347 K8S_CLIENT_CONNECTED_EVENT.clone(),
348 K8S_CLIENT_RESOURCE_RECEIVED_EVENT.clone(),
349 ]
350 }
351
352 fn stack_name(&self) -> &'static str {
353 "ETH>IP>TCP>TLS>HTTP>K8s API"
354 }
355
356 fn keywords(&self) -> Vec<&'static str> {
357 vec!["kubernetes", "k8s", "kubectl", "kube", "cluster"]
358 }
359
360 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
361 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
362
363 ProtocolMetadataV2::builder()
364 .state(DevelopmentState::Experimental)
365 .implementation(
366 "kube 0.99 + k8s-openapi 0.24 (v1_30 types) over rustls. Verbs: list pods / \
367 deployments / services, get pod, pod logs (last 100 lines), create pod, \
368 delete pod. No watch, exec, port-forward, patch, scale or custom resources.",
369 )
370 .llm_control(
371 "The model issues those verbs and is called again with the result \
372 (k8s_resource_received), so a listing can be followed up on. Follow-ups run \
373 through run_operation_once, which raises no event and therefore terminates.",
374 )
375 .e2e_testing(
376 "tests/client/kubernetes/command_channel_test.rs - no cluster and no LLM: \
377 KUBECONFIG is pointed at a throwaway file whose only cluster is a loopback \
378 HTTP stub, an injected k8s_list_pods is asserted to have reached \
379 /api/v1/namespaces/default/pods, and the developer's own ~/.kube/config is \
380 never read. There is NO test against a real cluster: the minikube/kind tests \
381 in e2e_test.rs were placeholders that asserted nothing and have been removed, \
382 which is why this stays Experimental.",
383 )
384 .build()
385 }
386
387 fn description(&self) -> &'static str {
388 "Kubernetes API client for cluster management"
389 }
390
391 fn example_prompt(&self) -> &'static str {
392 "Connect to Kubernetes cluster and list all pods in the default namespace"
393 }
394
395 fn group_name(&self) -> &'static str {
396 "Cloud & Orchestration"
397 }
398 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
404 use crate::llm::actions::StartupExamples;
405 use serde_json::json;
406
407 StartupExamples::new(
408 json!({
410 "type": "open_client",
411 "remote_addr": "default",
412 "base_stack": "kubernetes",
413 "instruction": "Connect to Kubernetes cluster and list all pods in the default namespace",
414 "startup_params": {
415 "namespace": "default"
416 }
417 }),
418 json!({
420 "type": "open_client",
421 "remote_addr": "default",
422 "base_stack": "kubernetes",
423 "startup_params": {
424 "namespace": "default",
425 "kubeconfig": "~/.kube/config"
426 },
427 "event_handlers": [{
428 "event_pattern": "k8s_connected",
429 "handler": {
430 "type": "script",
431 "language": "python",
432 "code": "<kubernetes_client_handler>"
433 }
434 }]
435 }),
436 json!({
438 "type": "open_client",
439 "remote_addr": "default",
440 "base_stack": "kubernetes",
441 "startup_params": {
442 "namespace": "default"
443 },
444 "event_handlers": [{
445 "event_pattern": "k8s_connected",
446 "handler": {
447 "type": "static",
448 "actions": [{
449 "type": "k8s_list_pods",
450 "namespace": "default"
451 }]
452 }
453 }]
454 }),
455 )
456 }
457}
458
459impl Client for KubernetesClientProtocol {
461 fn connect(
462 &self,
463 ctx: crate::protocol::ConnectContext,
464 ) -> std::pin::Pin<
465 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
466 > {
467 Box::pin(async move {
468 use crate::client::kubernetes::KubernetesClient;
469 KubernetesClient::connect_with_llm_actions(
470 ctx.remote_addr,
471 ctx.llm_client,
472 ctx.state,
473 ctx.status_tx,
474 ctx.client_id,
475 ctx.startup_params,
476 )
477 .await
478 })
479 }
480
481 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
482 let action_type = action
483 .get("type")
484 .and_then(|v| v.as_str())
485 .context("Missing 'type' field in action")?;
486
487 match action_type {
488 "k8s_list_pods" => {
489 let namespace = action
490 .get("namespace")
491 .and_then(|v| v.as_str())
492 .map(|s| s.to_string());
493
494 let label_selector = action
495 .get("label_selector")
496 .and_then(|v| v.as_str())
497 .map(|s| s.to_string());
498
499 Ok(ClientActionResult::Custom {
500 name: "k8s_operation".to_string(),
501 data: json!({
502 "operation": "list",
503 "resource_type": "pods",
504 "namespace": namespace,
505 "label_selector": label_selector,
506 }),
507 })
508 }
509 "k8s_get_pod" => {
510 let name = action
511 .get("name")
512 .and_then(|v| v.as_str())
513 .context("Missing 'name' field")?
514 .to_string();
515
516 let namespace = action
517 .get("namespace")
518 .and_then(|v| v.as_str())
519 .map(|s| s.to_string());
520
521 Ok(ClientActionResult::Custom {
522 name: "k8s_operation".to_string(),
523 data: json!({
524 "operation": "get",
525 "resource_type": "pod",
526 "name": name,
527 "namespace": namespace,
528 }),
529 })
530 }
531 "k8s_get_logs" => {
532 let name = action
533 .get("name")
534 .and_then(|v| v.as_str())
535 .context("Missing 'name' field")?
536 .to_string();
537
538 let namespace = action
539 .get("namespace")
540 .and_then(|v| v.as_str())
541 .map(|s| s.to_string());
542
543 Ok(ClientActionResult::Custom {
544 name: "k8s_operation".to_string(),
545 data: json!({
546 "operation": "logs",
547 "resource_type": "pod",
548 "name": name,
549 "namespace": namespace,
550 }),
551 })
552 }
553 "k8s_create_pod" => {
554 let spec = action.get("spec").context("Missing 'spec' field")?.clone();
555
556 let namespace = action
557 .get("namespace")
558 .and_then(|v| v.as_str())
559 .map(|s| s.to_string());
560
561 Ok(ClientActionResult::Custom {
562 name: "k8s_operation".to_string(),
563 data: json!({
564 "operation": "create",
565 "resource_type": "pod",
566 "spec": spec,
567 "namespace": namespace,
568 }),
569 })
570 }
571 "k8s_delete_pod" => {
572 let name = action
573 .get("name")
574 .and_then(|v| v.as_str())
575 .context("Missing 'name' field")?
576 .to_string();
577
578 let namespace = action
579 .get("namespace")
580 .and_then(|v| v.as_str())
581 .map(|s| s.to_string());
582
583 Ok(ClientActionResult::Custom {
584 name: "k8s_operation".to_string(),
585 data: json!({
586 "operation": "delete",
587 "resource_type": "pod",
588 "name": name,
589 "namespace": namespace,
590 }),
591 })
592 }
593 "k8s_list_deployments" => {
594 let namespace = action
595 .get("namespace")
596 .and_then(|v| v.as_str())
597 .map(|s| s.to_string());
598
599 let label_selector = action
600 .get("label_selector")
601 .and_then(|v| v.as_str())
602 .map(|s| s.to_string());
603
604 Ok(ClientActionResult::Custom {
605 name: "k8s_operation".to_string(),
606 data: json!({
607 "operation": "list",
608 "resource_type": "deployments",
609 "namespace": namespace,
610 "label_selector": label_selector,
611 }),
612 })
613 }
614 "k8s_list_services" => {
615 let namespace = action
616 .get("namespace")
617 .and_then(|v| v.as_str())
618 .map(|s| s.to_string());
619
620 let label_selector = action
621 .get("label_selector")
622 .and_then(|v| v.as_str())
623 .map(|s| s.to_string());
624
625 Ok(ClientActionResult::Custom {
626 name: "k8s_operation".to_string(),
627 data: json!({
628 "operation": "list",
629 "resource_type": "services",
630 "namespace": namespace,
631 "label_selector": label_selector,
632 }),
633 })
634 }
635 "disconnect" => Ok(ClientActionResult::Disconnect),
636 _ => Err(anyhow::anyhow!(
637 "Unknown Kubernetes client action: {}",
638 action_type
639 )),
640 }
641 }
642}