1pub 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
25fn 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
40pub struct KubernetesClient;
42
43impl KubernetesClient {
44 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 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 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 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 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 let namespace = namespace.unwrap_or_else(|| "default".to_string());
156
157 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 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 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 let conn_state = app_state.clone();
212 let conn_status = status_tx.clone();
213 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 Ok("0.0.0.0:0".parse().unwrap())
291 }
292
293 #[allow(clippy::too_many_arguments)]
298 #[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 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 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 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 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 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 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 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 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 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 app_state.remove_client_handle(client_id).await;
720 let _ = status_tx.send("__UPDATE_UI__".to_string());
721 }
722
723 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 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 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 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 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 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 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 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 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 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}