Skip to main content

netget/client/kubernetes/
mod.rs

1//! Kubernetes API client implementation
2pub mod actions;
3
4pub use actions::KubernetesClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info};
11
12use crate::client::kubernetes::actions::K8S_CLIENT_RESOURCE_RECEIVED_EVENT;
13use crate::client::llm_budget::call_llm_for_client;
14use crate::llm::actions::client_trait::{Client, ClientActionResult};
15use crate::llm::actions::protocol_trait::Protocol;
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ClientLlmResult;
18use crate::logging::emit::Log;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23use crate::utils::truncate::truncate_for_log;
24
25/// Expand a leading `~/` in a kubeconfig path.
26///
27/// The parameter's own example is `~/.kube/config`, and `std::fs` does no tilde expansion —
28/// a path taken literally would fail with "No such file or directory" on the one value the
29/// documentation suggests.
30fn expand_home(path: &str) -> std::path::PathBuf {
31    match path.strip_prefix("~/") {
32        Some(rest) => match std::env::var_os("HOME") {
33            Some(home) => std::path::PathBuf::from(home).join(rest),
34            None => std::path::PathBuf::from(path),
35        },
36        None => std::path::PathBuf::from(path),
37    }
38}
39
40/// Kubernetes client that interacts with Kubernetes API server
41pub struct KubernetesClient;
42
43impl KubernetesClient {
44    /// Connect to a Kubernetes cluster with integrated LLM actions
45    pub async fn connect_with_llm_actions(
46        remote_addr: String,
47        _llm_client: OllamaClient,
48        app_state: Arc<AppState>,
49        status_tx: mpsc::UnboundedSender<String>,
50        client_id: ClientId,
51        startup_params: Option<crate::protocol::StartupParams>,
52    ) -> Result<SocketAddr> {
53        // For Kubernetes, "connection" means establishing API client configuration
54        // The kube client is stateless and makes requests on-demand
55
56        // `kube` builds a rustls `ClientConfig` even for an `http://` apiserver, and rustls
57        // 0.23 **panics** at that point unless exactly one of its `ring` / `aws-lc-rs` features
58        // is active or a process-wide provider has been installed. Feature unification makes
59        // both active whenever `kubernetes` compiles alongside the AWS SDK protocols, so
60        // `--features kubernetes,s3` had two and neither won.
61        //
62        // `src/bin/netget.rs` installs one up front for every feature that enables `dep:rustls`
63        // (`tests/rustls_provider_gate_test.rs` keeps that list honest), so the shipped binary
64        // is covered — but that is `main`, not this library, and anything embedding NetGet or
65        // testing it goes straight past it. Install here too; it is the same one-liner `dot`,
66        // `tls`, `dc` and `http3` already carry, and `install_default` returning `Err` when a
67        // provider is already set is exactly the outcome we want.
68        let _ = rustls::crypto::ring::default_provider().install_default();
69
70        info!(
71            "Kubernetes client {} initializing for cluster {}",
72            client_id, remote_addr
73        );
74
75        // `kubeconfig` and `namespace` were both declared and neither was read. `kubeconfig`
76        // named a file this client then ignored - it always ran `try_default()`, which reads
77        // `$KUBECONFIG` or `~/.kube/config` - and `namespace` was overwritten by a hardcoded
78        // "default" one line later, so every operation that did not name a namespace itself
79        // went to `default` whatever the operator asked for.
80        let (kubeconfig_path, namespace) = match &startup_params {
81            Some(params) => (
82                params.get_optional_string("kubeconfig")?,
83                params.get_optional_string("namespace")?,
84            ),
85            None => (None, None),
86        };
87
88        let k8s_client = match &kubeconfig_path {
89            // An explicit kubeconfig file, read from where the operator said it is.
90            Some(path) => {
91                let path = expand_home(path);
92                let kubeconfig = kube::config::Kubeconfig::read_from(&path).map_err(|e| {
93                    anyhow::anyhow!("Failed to read kubeconfig at {}: {}", path.display(), e)
94                })?;
95                let config = kube::Config::from_custom_kubeconfig(
96                    kubeconfig,
97                    &kube::config::KubeConfigOptions::default(),
98                )
99                .await
100                .map_err(|e| {
101                    anyhow::anyhow!(
102                        "Failed to build Kubernetes configuration from {}: {}",
103                        path.display(),
104                        e
105                    )
106                })?;
107                let client = kube::Client::try_from(config).map_err(|e| {
108                    anyhow::anyhow!(
109                        "Failed to connect to Kubernetes using kubeconfig {}: {}",
110                        path.display(),
111                        e
112                    )
113                })?;
114                info!(
115                    "Kubernetes client {} connected using kubeconfig {}",
116                    client_id,
117                    path.display()
118                );
119                client
120            }
121            None if remote_addr == "default" || remote_addr == "~/.kube/config" => {
122                // Use default kubeconfig ($KUBECONFIG, else ~/.kube/config)
123                match kube::Client::try_default().await {
124                    Ok(client) => {
125                        info!(
126                            "Kubernetes client {} connected using default kubeconfig",
127                            client_id
128                        );
129                        client
130                    }
131                    Err(e) => {
132                        error!(
133                            "Failed to connect to Kubernetes using default kubeconfig: {}",
134                            e
135                        );
136                        return Err(anyhow::anyhow!(
137                            "Failed to connect to Kubernetes: {}. Make sure kubeconfig is configured.",
138                            e
139                        ));
140                    }
141                }
142            }
143            None => {
144                return Err(anyhow::anyhow!(
145                    "Kubernetes address '{}' is not understood. Use 'default' to read \
146                     $KUBECONFIG (else ~/.kube/config), or pass the `kubeconfig` startup \
147                     parameter naming a kubeconfig file.",
148                    remote_addr
149                ));
150            }
151        };
152
153        // The `namespace` startup parameter is the default for every operation that does
154        // not name one itself.
155        let namespace = namespace.unwrap_or_else(|| "default".to_string());
156
157        // Store client configuration in protocol_data
158        app_state
159            .with_client_mut(client_id, |client| {
160                client
161                    .set_protocol_field("k8s_client".to_string(), serde_json::json!("initialized"));
162                client.set_protocol_field("namespace".to_string(), serde_json::json!(namespace));
163                client
164                    .set_protocol_field("cluster_url".to_string(), serde_json::json!(remote_addr));
165            })
166            .await;
167
168        // Update status
169        app_state
170            .update_client_status(client_id, ClientStatus::Connected)
171            .await;
172        Log::new(Some(&status_tx))
173            .info(format!("Kubernetes client {} ready for cluster", client_id));
174        let _ = status_tx.send("__UPDATE_UI__".to_string());
175
176        // Command channel for injected actions (the dashboard's [ send ] row).
177        //
178        // The `kube::Client` built above is carried into the command task rather than
179        // discarded: it is cheaply cloneable and internally shared, so both paths use
180        // one configured handle instead of re-running `try_default()` (and re-reading
181        // the kubeconfig) per operation.
182        //
183        // The command loop also replaces the 5s "has the client been removed yet"
184        // poll: `remove_client` drops the handle, the sender goes with it, and
185        // `recv()` returns None - promptly, and without a timer.
186        let command_rx =
187            crate::client::command_support::register_command_channel(&app_state, client_id).await;
188        let task_registrar = app_state.clone();
189        let conn_llm = _llm_client.clone();
190        let conn_k8s = k8s_client.clone();
191        let task_handle = tokio::spawn(Self::command_loop(
192            command_rx,
193            k8s_client,
194            client_id,
195            app_state.clone(),
196            _llm_client.clone(),
197            status_tx.clone(),
198        ));
199        task_registrar
200            .register_client_task(client_id, task_handle)
201            .await;
202
203        // Raise the connected event.
204        //
205        // K8S_CLIENT_CONNECTED_EVENT was declared and nothing raised it, so the model was
206        // never consulted when a Kubernetes client came up.
207        //
208        // From a registered task, not inline: a dashboard-created client defaults to a
209        // `*` -> manual rule and awaiting a parked answer would block creation. Operations
210        // go through run_operation_once, which raises no event.
211        let conn_state = app_state.clone();
212        let conn_status = status_tx.clone();
213        // The event declares `cluster_url` as a required parameter, so it has to actually carry
214        // one: the data used to be `{}`, which told the model a field was present and then
215        // withheld it. `namespace` goes with it — the model's actions may omit a namespace and
216        // it should know which one that resolves to.
217        let conn_cluster_url = remote_addr.clone();
218        let conn_namespace = namespace.clone();
219        let conn_task = tokio::spawn(async move {
220            let Some(instruction) = conn_state.get_instruction_for_client(client_id).await else {
221                return;
222            };
223            let protocol = crate::client::kubernetes::actions::KubernetesClientProtocol::new();
224            let event = Event::new(
225                &crate::client::kubernetes::actions::K8S_CLIENT_CONNECTED_EVENT,
226                serde_json::json!({
227                    "cluster_url": conn_cluster_url,
228                    "namespace": conn_namespace,
229                }),
230            );
231            match crate::client::llm_budget::call_llm_for_client(
232                &conn_llm,
233                &conn_state,
234                client_id.to_string(),
235                &instruction,
236                "",
237                Some(&event),
238                &protocol,
239                &conn_status,
240            )
241            .await
242            {
243                Ok(result) => {
244                    if let Some(mem) = result.memory_updates {
245                        conn_state.set_memory_for_client(client_id, mem).await;
246                    }
247                    use crate::llm::actions::client_trait::{Client, ClientActionResult};
248                    for action in result.actions {
249                        let Ok(ClientActionResult::Custom { name, data }) =
250                            protocol.execute_action(action.clone())
251                        else {
252                            continue;
253                        };
254                        if name != "k8s_operation" {
255                            continue;
256                        }
257                        let str_of =
258                            |k: &str| data.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
259                        if let Err(e) = Self::run_operation_once(
260                            &conn_k8s,
261                            client_id,
262                            str_of("operation").unwrap_or_default(),
263                            str_of("resource_type").unwrap_or_default(),
264                            str_of("namespace"),
265                            str_of("name"),
266                            data.get("spec").cloned().filter(|v| !v.is_null()),
267                            str_of("label_selector"),
268                            &conn_state,
269                        )
270                        .await
271                        {
272                            error!(
273                                "Kubernetes client {} connect-time operation failed: {}",
274                                client_id, e
275                            );
276                        }
277                    }
278                }
279                Err(e) => error!(
280                    "Kubernetes client {} LLM error on connected event: {}",
281                    client_id, e
282                ),
283            }
284        });
285        task_registrar
286            .register_client_task(client_id, conn_task)
287            .await;
288
289        // Return a dummy local address (Kubernetes API is HTTP-based, connectionless)
290        Ok("0.0.0.0:0".parse().unwrap())
291    }
292
293    /// Execute a Kubernetes API operation against an already-configured client.
294    ///
295    /// Takes the `kube::Client` rather than calling `try_default()` itself, so the
296    /// handle built once at connect time is the one every operation uses.
297    #[allow(clippy::too_many_arguments)]
298    /// Run one operation. Raises no event, calls no LLM.
299    ///
300    /// Deliberately free of any path back into `notify_response`: that would make the async
301    /// type self-referential (notify -> operation -> notify), which rustc cannot prove
302    /// `Send`, so `tokio::spawn` refuses it. Follow-up actions the model returns for a
303    /// response event run through here, which breaks that cycle and bounds the loop.
304    #[allow(clippy::too_many_arguments)]
305    pub async fn run_operation_once(
306        k8s_client: &kube::Client,
307        client_id: ClientId,
308        operation: String,
309        resource_type: String,
310        namespace: Option<String>,
311        name: Option<String>,
312        data: Option<serde_json::Value>,
313        label_selector: Option<String>,
314        app_state: &Arc<AppState>,
315    ) -> Result<serde_json::Value> {
316        // Determine namespace
317        let ns = if let Some(n) = namespace {
318            n
319        } else {
320            app_state
321                .with_client_mut(client_id, |client| {
322                    client
323                        .get_protocol_field("namespace")
324                        .and_then(|v| v.as_str())
325                        .map(|s| s.to_string())
326                })
327                .await
328                .flatten()
329                .unwrap_or_else(|| "default".to_string())
330        };
331
332        info!(
333            "Kubernetes client {} executing {} on {} in namespace {}",
334            client_id, operation, resource_type, ns
335        );
336
337        // Execute operation based on resource type and operation
338        let result = match (operation.as_str(), resource_type.as_str()) {
339            ("list", "pods") => Self::list_pods(k8s_client, &ns, label_selector.as_deref()).await,
340            ("get", "pod") => {
341                if let Some(pod_name) = name {
342                    Self::get_pod(k8s_client, &ns, &pod_name).await
343                } else {
344                    Err(anyhow::anyhow!("Pod name required for get operation"))
345                }
346            }
347            ("logs", "pod") => {
348                if let Some(pod_name) = name {
349                    Self::get_pod_logs(k8s_client, &ns, &pod_name).await
350                } else {
351                    Err(anyhow::anyhow!("Pod name required for logs operation"))
352                }
353            }
354            ("create", "pod") => {
355                if let Some(pod_spec) = data {
356                    Self::create_pod(k8s_client, &ns, pod_spec).await
357                } else {
358                    Err(anyhow::anyhow!(
359                        "Pod specification required for create operation"
360                    ))
361                }
362            }
363            ("delete", "pod") => {
364                if let Some(pod_name) = name {
365                    Self::delete_pod(k8s_client, &ns, &pod_name).await
366                } else {
367                    Err(anyhow::anyhow!("Pod name required for delete operation"))
368                }
369            }
370            ("list", "deployments") => {
371                Self::list_deployments(k8s_client, &ns, label_selector.as_deref()).await
372            }
373            ("list", "services") => {
374                Self::list_services(k8s_client, &ns, label_selector.as_deref()).await
375            }
376            _ => Err(anyhow::anyhow!(
377                "Unsupported operation '{}' on resource type '{}'",
378                operation,
379                resource_type
380            )),
381        };
382
383        result
384    }
385
386    pub async fn execute_operation(
387        k8s_client: &kube::Client,
388        client_id: ClientId,
389        operation: String,
390        resource_type: String,
391        namespace: Option<String>,
392        name: Option<String>,
393        data: Option<serde_json::Value>,
394        label_selector: Option<String>,
395        app_state: Arc<AppState>,
396        llm_client: OllamaClient,
397        status_tx: mpsc::UnboundedSender<String>,
398    ) -> Result<serde_json::Value> {
399        // Resolve the namespace here too: the notify payload below reports it, and
400        // `run_operation_once` takes ownership of the Option.
401        let ns = if let Some(n) = namespace.clone() {
402            n
403        } else {
404            app_state
405                .with_client_mut(client_id, |client| {
406                    client
407                        .get_protocol_field("namespace")
408                        .and_then(|v| v.as_str())
409                        .map(|s| s.to_string())
410                })
411                .await
412                .flatten()
413                .unwrap_or_else(|| "default".to_string())
414        };
415
416        let result = Self::run_operation_once(
417            k8s_client,
418            client_id,
419            operation.clone(),
420            resource_type.clone(),
421            namespace,
422            name,
423            data,
424            label_selector,
425            &app_state,
426        )
427        .await;
428
429        match result {
430            Ok(response) => {
431                info!("Kubernetes client {} operation successful", client_id);
432
433                // Raise the response event from its own registered task rather than
434                // inline. A dashboard-created client defaults to a `*` -> manual routing
435                // rule, so this LLM call can park for minutes waiting for a human;
436                // awaiting it here would wedge the command loop and make an injected
437                // action that in fact succeeded look to the dashboard like a timeout.
438                let event_data = serde_json::json!({
439                    "operation": operation,
440                    "resource_type": resource_type,
441                    "namespace": ns,
442                    "response": response,
443                });
444                let notify = tokio::spawn(Self::notify_response(
445                    client_id,
446                    k8s_client.clone(),
447                    event_data,
448                    app_state.clone(),
449                    llm_client,
450                    status_tx,
451                ));
452                app_state.register_client_task(client_id, notify).await;
453
454                Ok(response)
455            }
456            Err(e) => {
457                Log::new(Some(&status_tx)).error(format!(
458                    "Kubernetes client {} operation failed: {}",
459                    client_id, e
460                ));
461                Err(e)
462            }
463        }
464    }
465
466    /// Raise `k8s_resource_received` and fold in any memory update. Spawned, never
467    /// awaited by a caller that holds the command loop: an event handler may park this
468    /// call for a human answer.
469    async fn notify_response(
470        client_id: ClientId,
471        k8s_client: kube::Client,
472        event_data: serde_json::Value,
473        app_state: Arc<AppState>,
474        llm_client: OllamaClient,
475        status_tx: mpsc::UnboundedSender<String>,
476    ) {
477        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
478            return;
479        };
480        let protocol = crate::client::kubernetes::actions::KubernetesClientProtocol::new();
481        let event = Event::new(&K8S_CLIENT_RESOURCE_RECEIVED_EVENT, event_data);
482        let memory = app_state
483            .get_memory_for_client(client_id)
484            .await
485            .unwrap_or_default();
486
487        match call_llm_for_client(
488            &llm_client,
489            &app_state,
490            client_id.to_string(),
491            &instruction,
492            &memory,
493            Some(&event),
494            &protocol,
495            &status_tx,
496        )
497        .await
498        {
499            Ok(ClientLlmResult {
500                actions,
501                memory_updates,
502            }) => {
503                if let Some(mem) = memory_updates {
504                    app_state.set_memory_for_client(client_id, mem).await;
505                }
506
507                // Execute what the model asked for. These were discarded, so answering
508                // k8s_resource_received did nothing -- a model that listed pods and wanted
509                // to fetch the logs of one it had just seen was ignored.
510                //
511                // Dispatch goes through `run_operation_once`, which raises no event: that
512                // bounds the loop and keeps the async type non-recursive, which is what
513                // tokio::spawn's Send bound requires.
514                use crate::llm::actions::client_trait::{Client, ClientActionResult};
515                for action in actions {
516                    let Ok(ClientActionResult::Custom { name, data }) =
517                        protocol.execute_action(action.clone())
518                    else {
519                        continue;
520                    };
521                    if name != "k8s_operation" {
522                        continue;
523                    }
524                    let str_of =
525                        |k: &str| data.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
526                    let outcome = Self::run_operation_once(
527                        &k8s_client,
528                        client_id,
529                        str_of("operation").unwrap_or_default(),
530                        str_of("resource_type").unwrap_or_default(),
531                        str_of("namespace"),
532                        str_of("name"),
533                        data.get("spec").cloned().filter(|v| !v.is_null()),
534                        str_of("label_selector"),
535                        &app_state,
536                    )
537                    .await;
538                    match outcome {
539                        Ok(v) => info!(
540                            "Kubernetes client {} follow-up -> {}",
541                            client_id,
542                            crate::utils::truncate_for_log(&v.to_string(), 120)
543                        ),
544                        Err(e) => error!(
545                            "Kubernetes client {} follow-up action failed: {}",
546                            client_id, e
547                        ),
548                    }
549                }
550            }
551            Err(e) => {
552                error!("LLM error for Kubernetes client {}: {}", client_id, e);
553            }
554        }
555    }
556
557    /// Apply one executed action against the cluster. Shared by every path that can
558    /// produce an action, so an injected one behaves identically to an LLM one.
559    ///
560    /// `kube` owns the socket and reports no wire byte count, so a completed
561    /// operation is `Executed { detail }` naming the operation and its result -
562    /// never `Sent`, which would be a fabricated byte count.
563    async fn apply_action(
564        result: ClientActionResult,
565        k8s_client: &kube::Client,
566        client_id: ClientId,
567        app_state: &Arc<AppState>,
568        llm_client: &OllamaClient,
569        status_tx: &mpsc::UnboundedSender<String>,
570    ) -> ClientSendOutcome {
571        match result {
572            ClientActionResult::Custom { name, data } if name == "k8s_operation" => {
573                let operation = data
574                    .get("operation")
575                    .and_then(|v| v.as_str())
576                    .unwrap_or_default()
577                    .to_string();
578                let resource_type = data
579                    .get("resource_type")
580                    .and_then(|v| v.as_str())
581                    .unwrap_or_default()
582                    .to_string();
583                let namespace = data
584                    .get("namespace")
585                    .and_then(|v| v.as_str())
586                    .map(|s| s.to_string());
587                let resource_name = data
588                    .get("name")
589                    .and_then(|v| v.as_str())
590                    .map(|s| s.to_string());
591                let label_selector = data
592                    .get("label_selector")
593                    .and_then(|v| v.as_str())
594                    .map(|s| s.to_string());
595                // `execute_action` names the pod body "spec"; the operation takes it
596                // as its generic `data` payload.
597                let spec = data.get("spec").cloned().filter(|v| !v.is_null());
598
599                let label = format!("{} {}", operation, resource_type);
600                match Self::execute_operation(
601                    k8s_client,
602                    client_id,
603                    operation,
604                    resource_type,
605                    namespace,
606                    resource_name,
607                    spec,
608                    label_selector,
609                    app_state.clone(),
610                    llm_client.clone(),
611                    status_tx.clone(),
612                )
613                .await
614                {
615                    Ok(value) => ClientSendOutcome::Executed {
616                        detail: format!(
617                            "{} completed: {}",
618                            label,
619                            truncate_for_log(&value.to_string(), 200)
620                        ),
621                    },
622                    Err(e) => ClientSendOutcome::Executed {
623                        detail: format!(
624                            "{} failed: {}",
625                            label,
626                            truncate_for_log(&e.to_string(), 200)
627                        ),
628                    },
629                }
630            }
631            ClientActionResult::Custom { name, .. } => ClientSendOutcome::Executed {
632                detail: format!("unknown Kubernetes result '{}' was not applied", name),
633            },
634            ClientActionResult::Disconnect => ClientSendOutcome::Disconnected,
635            ClientActionResult::WaitForMore => ClientSendOutcome::Executed {
636                detail: "wait_for_more".to_string(),
637            },
638            ClientActionResult::NoAction => ClientSendOutcome::Executed {
639                detail: "no_action".to_string(),
640            },
641            ClientActionResult::SendData(_) => ClientSendOutcome::Executed {
642                detail: "send_data has no meaning for the Kubernetes client: it speaks the \
643                         apiserver REST API through `kube`, not a socket this client owns"
644                    .to_string(),
645            },
646            ClientActionResult::Multiple(_) => ClientSendOutcome::Executed {
647                detail: "the Kubernetes client's own verbs never produce Multiple; nothing was \
648                         applied"
649                    .to_string(),
650            },
651        }
652    }
653
654    /// Drain injected commands until the channel closes (the client was removed) or
655    /// an injected `disconnect` ends the session.
656    ///
657    /// `command_support::handle_stream_client_command` cannot serve this client:
658    /// every Kubernetes verb yields `ClientActionResult::Custom` and there is no
659    /// write half to put bytes on. Actions therefore go through
660    /// [`Self::apply_action`], and the outcome is logged and replied exactly the way
661    /// the generic arm does it.
662    async fn command_loop(
663        mut command_rx: mpsc::Receiver<ClientCommand>,
664        k8s_client: kube::Client,
665        client_id: ClientId,
666        app_state: Arc<AppState>,
667        llm_client: OllamaClient,
668        status_tx: mpsc::UnboundedSender<String>,
669    ) {
670        let protocol = crate::client::kubernetes::actions::KubernetesClientProtocol::new();
671
672        while let Some(command) = command_rx.recv().await {
673            let action = command.action.clone();
674            let outcome = match protocol.execute_action(action.clone()) {
675                Err(e) => ClientSendOutcome::Rejected {
676                    error: e.to_string(),
677                },
678                Ok(result) => {
679                    Self::apply_action(
680                        result,
681                        &k8s_client,
682                        client_id,
683                        &app_state,
684                        &llm_client,
685                        &status_tx,
686                    )
687                    .await
688                }
689            };
690            let disconnected = matches!(outcome, ClientSendOutcome::Disconnected);
691
692            app_state
693                .record_access_log(
694                    AccessLogOwner::Client(client_id.as_u32()),
695                    protocol.protocol_name(),
696                    None,
697                    "injected_action",
698                    action,
699                    vec![serde_json::to_value(&outcome).unwrap_or(serde_json::Value::Null)],
700                )
701                .await;
702            let _ = status_tx.send("__UPDATE_UI__".to_string());
703            crate::client::command_support::reply(command, Ok(outcome));
704
705            if disconnected {
706                info!(
707                    "Kubernetes client {} disconnecting on injected action",
708                    client_id
709                );
710                app_state
711                    .update_client_status(client_id, ClientStatus::Disconnected)
712                    .await;
713                break;
714            }
715        }
716
717        info!("Kubernetes client {} command loop stopped", client_id);
718        // Never leave the dashboard offering [ send ] into a client that is gone.
719        app_state.remove_client_handle(client_id).await;
720        let _ = status_tx.send("__UPDATE_UI__".to_string());
721    }
722
723    /// List pods in a namespace
724    async fn list_pods(
725        client: &kube::Client,
726        namespace: &str,
727        label_selector: Option<&str>,
728    ) -> Result<serde_json::Value> {
729        use k8s_openapi::api::core::v1::Pod;
730        use kube::Api;
731
732        let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
733
734        let mut list_params = kube::api::ListParams::default();
735        if let Some(selector) = label_selector {
736            list_params = list_params.labels(selector);
737        }
738
739        let pod_list = pods
740            .list(&list_params)
741            .await
742            .context("Failed to list pods")?;
743
744        // Convert to JSON
745        let pod_names: Vec<String> = pod_list
746            .items
747            .iter()
748            .filter_map(|pod| pod.metadata.name.clone())
749            .collect();
750
751        Ok(serde_json::json!({
752            "count": pod_list.items.len(),
753            "pods": pod_names,
754        }))
755    }
756
757    /// Get a specific pod
758    async fn get_pod(
759        client: &kube::Client,
760        namespace: &str,
761        name: &str,
762    ) -> Result<serde_json::Value> {
763        use k8s_openapi::api::core::v1::Pod;
764        use kube::Api;
765
766        let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
767        let pod = pods
768            .get(name)
769            .await
770            .with_context(|| format!("Failed to get pod {}", name))?;
771
772        // Extract relevant info
773        let status = pod
774            .status
775            .as_ref()
776            .and_then(|s| s.phase.clone())
777            .unwrap_or_else(|| "Unknown".to_string());
778
779        Ok(serde_json::json!({
780            "name": name,
781            "namespace": namespace,
782            "status": status,
783        }))
784    }
785
786    /// Get pod logs
787    async fn get_pod_logs(
788        client: &kube::Client,
789        namespace: &str,
790        name: &str,
791    ) -> Result<serde_json::Value> {
792        use k8s_openapi::api::core::v1::Pod;
793        use kube::Api;
794
795        let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
796
797        let log_params = kube::api::LogParams {
798            tail_lines: Some(100),
799            ..Default::default()
800        };
801
802        let logs = pods
803            .logs(name, &log_params)
804            .await
805            .with_context(|| format!("Failed to get logs for pod {}", name))?;
806
807        Ok(serde_json::json!({
808            "pod": name,
809            "namespace": namespace,
810            "logs": logs,
811        }))
812    }
813
814    /// Create a pod
815    async fn create_pod(
816        client: &kube::Client,
817        namespace: &str,
818        pod_spec: serde_json::Value,
819    ) -> Result<serde_json::Value> {
820        use k8s_openapi::api::core::v1::Pod;
821        use kube::Api;
822
823        let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
824
825        // Deserialize pod spec
826        let pod: Pod =
827            serde_json::from_value(pod_spec).context("Failed to parse pod specification")?;
828
829        let created_pod = pods
830            .create(&kube::api::PostParams::default(), &pod)
831            .await
832            .context("Failed to create pod")?;
833
834        let pod_name = created_pod
835            .metadata
836            .name
837            .unwrap_or_else(|| "unknown".to_string());
838
839        Ok(serde_json::json!({
840            "created": true,
841            "name": pod_name,
842            "namespace": namespace,
843        }))
844    }
845
846    /// Delete a pod
847    async fn delete_pod(
848        client: &kube::Client,
849        namespace: &str,
850        name: &str,
851    ) -> Result<serde_json::Value> {
852        use k8s_openapi::api::core::v1::Pod;
853        use kube::Api;
854
855        let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
856
857        pods.delete(name, &kube::api::DeleteParams::default())
858            .await
859            .with_context(|| format!("Failed to delete pod {}", name))?;
860
861        Ok(serde_json::json!({
862            "deleted": true,
863            "name": name,
864            "namespace": namespace,
865        }))
866    }
867
868    /// List deployments in a namespace
869    async fn list_deployments(
870        client: &kube::Client,
871        namespace: &str,
872        label_selector: Option<&str>,
873    ) -> Result<serde_json::Value> {
874        use k8s_openapi::api::apps::v1::Deployment;
875        use kube::Api;
876
877        let deployments: Api<Deployment> = Api::namespaced(client.clone(), namespace);
878
879        let mut list_params = kube::api::ListParams::default();
880        if let Some(selector) = label_selector {
881            list_params = list_params.labels(selector);
882        }
883
884        let deployment_list = deployments
885            .list(&list_params)
886            .await
887            .context("Failed to list deployments")?;
888
889        let deployment_names: Vec<String> = deployment_list
890            .items
891            .iter()
892            .filter_map(|dep| dep.metadata.name.clone())
893            .collect();
894
895        Ok(serde_json::json!({
896            "count": deployment_list.items.len(),
897            "deployments": deployment_names,
898        }))
899    }
900
901    /// List services in a namespace
902    async fn list_services(
903        client: &kube::Client,
904        namespace: &str,
905        label_selector: Option<&str>,
906    ) -> Result<serde_json::Value> {
907        use k8s_openapi::api::core::v1::Service;
908        use kube::Api;
909
910        let services: Api<Service> = Api::namespaced(client.clone(), namespace);
911
912        let mut list_params = kube::api::ListParams::default();
913        if let Some(selector) = label_selector {
914            list_params = list_params.labels(selector);
915        }
916
917        let service_list = services
918            .list(&list_params)
919            .await
920            .context("Failed to list services")?;
921
922        let service_names: Vec<String> = service_list
923            .items
924            .iter()
925            .filter_map(|svc| svc.metadata.name.clone())
926            .collect();
927
928        Ok(serde_json::json!({
929            "count": service_list.items.len(),
930            "services": service_names,
931        }))
932    }
933}