Skip to main content

netget/client/kubernetes/
actions.rs

1//! Kubernetes client protocol actions implementation
2
3use 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
15/// Kubernetes client connected event
16pub 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
41/// Kubernetes client resource received event
42pub 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
76/// Kubernetes client protocol action handler
77pub 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
91// Implement Protocol trait with common methods
92impl 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    /// The two statics, cloned — **not** freshly built copies.
340    ///
341    /// This used to construct a second `EventType` per id with its own wording and no
342    /// `.with_parameters(...)`, so the model was shown a `k8s_connected` with no fields while
343    /// the event that actually fires declares `cluster_url`. Two declarations of one event
344    /// drift the moment either is edited; there is only one now.
345    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    /// **`remote_addr` must be `"default"`, or a `kubeconfig` startup parameter must name a
399    /// file.** These examples said `"kubernetes.local:6443"`, which `connect()` refuses
400    /// outright — this client has no host:port form, because a cluster address alone carries
401    /// no credentials or CA and `kube` cannot be pointed at one. A model copying the old
402    /// example got "address is not understood" every time.
403    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            // LLM mode: LLM handles Kubernetes cluster queries
409            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            // Script mode: Code-based Kubernetes handling, against an explicit kubeconfig
419            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            // Static mode: Fixed Kubernetes action
437            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
459// Implement Client trait with client-specific methods
460impl 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}