1pub mod actions;
17mod discovery;
18pub mod table;
21
22use std::convert::Infallible;
23use std::net::SocketAddr;
24use std::sync::Arc;
25
26use bytes::Bytes;
27use http_body_util::{BodyExt, Full};
28use hyper::body::Incoming;
29use hyper::header::{HeaderValue, ACCEPT, CONTENT_TYPE};
30use hyper::server::conn::http1;
31use hyper::service::service_fn;
32use hyper::{Method, Request, Response, StatusCode};
33use hyper_util::rt::TokioIo;
34use serde_json::{json, Value};
35use tokio::sync::mpsc;
36use tracing::{debug, error, info, trace, warn};
37
38use crate::llm::action_helper::call_llm;
39use crate::llm::ollama_client::OllamaClient;
40use crate::protocol::{Event, EventType};
41use crate::server::connection::ConnectionId;
42use crate::state::app_state::AppState;
43use crate::state::ServerId;
44use crate::{console_debug, console_error, console_info};
45
46pub use discovery::{status_object, ApiResource, ApiSurface};
47
48#[derive(Clone, Debug)]
50pub struct KubernetesConfig {
51 pub surface: Arc<ApiSurface>,
53 pub kubernetes_version: String,
55}
56
57impl Default for KubernetesConfig {
58 fn default() -> Self {
59 Self {
60 surface: Arc::new(ApiSurface::builtin()),
61 kubernetes_version: DEFAULT_KUBERNETES_VERSION.to_string(),
62 }
63 }
64}
65
66pub const DEFAULT_KUBERNETES_VERSION: &str = "v1.29.4";
68
69pub const MAX_REQUEST_BODY_BYTES: usize = 3 * 1024 * 1024;
78
79const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
88
89const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
102
103const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
109
110const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
117 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
118
119pub fn model_status_code(raw: Option<u64>, default: u16) -> Option<u16> {
128 match raw {
129 None => Some(default),
130 Some(code) if (100..600).contains(&code) => Some(code as u16),
131 Some(_) => None,
132 }
133}
134
135pub struct KubernetesServer;
137
138impl KubernetesServer {
139 pub async fn spawn_with_llm_actions(
147 listen_addr: SocketAddr,
148 llm_client: OllamaClient,
149 app_state: Arc<AppState>,
150 status_tx: mpsc::UnboundedSender<String>,
151 server_id: ServerId,
152 tls_config: Option<Arc<rustls::ServerConfig>>,
153 config: KubernetesConfig,
154 ) -> anyhow::Result<SocketAddr> {
155 let listener =
156 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
157 let local_addr = listener.local_addr()?;
158
159 let scheme = if tls_config.is_some() {
160 "https"
161 } else {
162 "http"
163 };
164 console_info!(
165 status_tx,
166 "Kubernetes API server listening on {}://{} (claiming {})",
167 scheme,
168 local_addr,
169 config.kubernetes_version
170 );
171
172 let protocol = Arc::new(actions::KubernetesProtocol::new());
173 let tls_acceptor = tls_config.map(tokio_rustls::TlsAcceptor::from);
174 let config = Arc::new(config);
175 let server_address = format!("{scheme}://{local_addr}");
176
177 let task_registrar = app_state.clone();
178 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
179 let accept_handle = tokio::spawn(async move {
180 loop {
181 match crate::server::accept_bounded::accept_bounded(
182 &listener,
183 &limiter,
184 CONNECTION_CAP_REFUSAL,
185 "Kubernetes",
186 Some(&status_tx),
187 )
188 .await
189 {
190 Ok((stream, remote_addr, permit)) => {
191 let connection_id =
192 ConnectionId::new(app_state.get_next_unified_id().await);
193 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
194 info!(
195 "Kubernetes API connection {} from {}",
196 connection_id, remote_addr
197 );
198
199 use crate::state::server::{
200 ConnectionState as ServerConnectionState, ConnectionStatus,
201 ProtocolConnectionInfo,
202 };
203 let now = crate::utils::clock::Instant::now();
204 let conn_state = ServerConnectionState {
205 id: connection_id,
206 remote_addr,
207 local_addr: local_addr_conn,
208 bytes_sent: 0,
209 bytes_received: 0,
210 packets_sent: 0,
211 packets_received: 0,
212 last_activity: now,
213 status: ConnectionStatus::Active,
214 status_changed_at: now,
215 protocol_info: ProtocolConnectionInfo::empty(),
216 };
217 app_state
218 .add_connection_to_server(server_id, conn_state)
219 .await;
220 let _ = status_tx.send("__UPDATE_UI__".to_string());
221
222 let ctx = RequestContext {
223 llm_client: llm_client.clone(),
224 app_state: app_state.clone(),
225 status_tx: status_tx.clone(),
226 protocol: protocol.clone(),
227 config: config.clone(),
228 server_id,
229 server_address: server_address.clone(),
230 };
231 let tls_acceptor = tls_acceptor.clone();
232 let app_state_for_close = app_state.clone();
233 let status_for_close = status_tx.clone();
234
235 let task_owner = app_state.clone();
236 task_owner
237 .spawn_server_task(server_id, async move {
238 let _permit = permit;
242
243 match tokio::time::timeout(
251 FIRST_BYTE_READ_TIMEOUT,
252 stream.peek(&mut [0u8; 1]),
253 )
254 .await
255 {
256 Ok(Ok(0)) | Ok(Err(_)) => {
257 app_state_for_close
258 .close_connection_on_server(server_id, connection_id)
259 .await;
260 let _ = status_for_close.send("__UPDATE_UI__".to_string());
261 return;
262 }
263 Ok(Ok(_)) => {}
264 Err(_) => {
265 debug!(
266 "Kubernetes peer {} sent nothing for {}s; closing \
267 before the request line",
268 remote_addr,
269 FIRST_BYTE_READ_TIMEOUT.as_secs()
270 );
271 app_state_for_close
272 .close_connection_on_server(server_id, connection_id)
273 .await;
274 let _ = status_for_close.send("__UPDATE_UI__".to_string());
275 return;
276 }
277 }
278
279 match tls_acceptor {
280 Some(acceptor) => match acceptor.accept(stream).await {
281 Ok(tls_stream) => {
282 debug!(
283 "Kubernetes TLS handshake complete with {}",
284 remote_addr
285 );
286 serve_connection(TokioIo::new(tls_stream), ctx).await;
287 }
288 Err(e) => {
289 console_error!(
290 status_for_close,
291 "Kubernetes TLS handshake failed with {}: {}",
292 remote_addr,
293 e
294 );
295 }
296 },
297 None => {
298 serve_connection(TokioIo::new(stream), ctx).await;
299 }
300 }
301
302 app_state_for_close
303 .close_connection_on_server(server_id, connection_id)
304 .await;
305 let _ = status_for_close.send(format!(
306 "[INFO] Kubernetes API connection {connection_id} closed"
307 ));
308 let _ = status_for_close.send("__UPDATE_UI__".to_string());
309 })
310 .await;
311 }
312 Err(e) => {
313 console_error!(
314 status_tx,
315 "Failed to accept Kubernetes API connection: {}",
316 e
317 );
318 break;
319 }
320 }
321 }
322 });
323
324 task_registrar
325 .register_server_task(server_id, accept_handle)
326 .await;
327
328 Ok(local_addr)
329 }
330}
331
332#[derive(Clone)]
334struct RequestContext {
335 llm_client: OllamaClient,
336 app_state: Arc<AppState>,
337 status_tx: mpsc::UnboundedSender<String>,
338 protocol: Arc<actions::KubernetesProtocol>,
339 config: Arc<KubernetesConfig>,
340 server_id: ServerId,
341 server_address: String,
342}
343
344async fn serve_connection<T>(io: TokioIo<T>, ctx: RequestContext)
345where
346 T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
347{
348 let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
353 let activity_for_service = Arc::clone(&activity);
354
355 let service = service_fn(move |req: Request<Incoming>| {
356 let ctx = ctx.clone();
357 let activity = Arc::clone(&activity_for_service);
358 async move {
359 let _busy = activity.busy();
360 Ok::<_, Infallible>(handle_request(req, ctx).await)
361 }
362 });
363
364 let conn = http1::Builder::new().serve_connection(io, service);
365 tokio::pin!(conn);
366 tokio::select! {
367 result = &mut conn => {
368 if let Err(err) = result {
369 debug!("Kubernetes API connection ended: {:?}", err);
370 }
371 }
372 _ = crate::server::accept_bounded::watch_idle(
373 Arc::clone(&activity),
374 IDLE_BETWEEN_REQUESTS_TIMEOUT,
375 ) => {
376 debug!(
377 "Kubernetes API connection idle for {}s; closing",
378 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
379 );
380 }
381 }
382}
383
384#[derive(Clone, Debug, PartialEq, Eq)]
390pub struct ResourceRoute {
391 pub group: String,
393 pub version: String,
395 pub resource: String,
397 pub namespace: Option<String>,
399 pub name: Option<String>,
401 pub subresource: Option<String>,
403}
404
405impl ResourceRoute {
406 fn group_version(&self) -> String {
407 if self.group.is_empty() {
408 self.version.clone()
409 } else {
410 format!("{}/{}", self.group, self.version)
411 }
412 }
413}
414
415#[derive(Clone, Debug, PartialEq, Eq)]
417pub enum Route {
418 Version,
420 Health,
422 CoreApiVersions,
424 ApiGroupList,
426 ApiGroup(String),
428 ApiResourceList { group: String, version: String },
430 Resource(ResourceRoute),
432 Unknown,
434}
435
436pub fn resolve_route(path: &str) -> Route {
442 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
443 match segments.as_slice() {
444 [] => Route::Unknown,
445 ["version"] => Route::Version,
446 ["healthz"] | ["livez"] | ["readyz"] => Route::Health,
447 ["api"] => Route::CoreApiVersions,
448 ["apis"] => Route::ApiGroupList,
449 ["api", version] => Route::ApiResourceList {
450 group: String::new(),
451 version: (*version).to_string(),
452 },
453 ["apis", group] => Route::ApiGroup((*group).to_string()),
454 ["apis", group, version] => Route::ApiResourceList {
455 group: (*group).to_string(),
456 version: (*version).to_string(),
457 },
458 ["api", version, rest @ ..] => {
459 Route::Resource(resource_route(String::new(), (*version).to_string(), rest))
460 }
461 ["apis", group, version, rest @ ..] => Route::Resource(resource_route(
462 (*group).to_string(),
463 (*version).to_string(),
464 rest,
465 )),
466 _ => Route::Unknown,
467 }
468}
469
470fn resource_route(group: String, version: String, rest: &[&str]) -> ResourceRoute {
471 if rest.first() == Some(&"namespaces") && rest.len() >= 3 {
474 ResourceRoute {
475 group,
476 version,
477 resource: rest[2].to_string(),
478 namespace: Some(rest[1].to_string()),
479 name: rest.get(3).map(|s| (*s).to_string()),
480 subresource: rest.get(4).map(|s| (*s).to_string()),
481 }
482 } else {
483 ResourceRoute {
484 group,
485 version,
486 resource: rest.first().map(|s| (*s).to_string()).unwrap_or_default(),
487 namespace: None,
488 name: rest.get(1).map(|s| (*s).to_string()),
489 subresource: rest.get(2).map(|s| (*s).to_string()),
490 }
491 }
492}
493
494fn parse_query(query: &str) -> serde_json::Map<String, Value> {
496 let mut map = serde_json::Map::new();
497 for pair in query.split('&').filter(|p| !p.is_empty()) {
498 let (key, value) = match pair.split_once('=') {
499 Some((k, v)) => (k, v),
500 None => (pair, ""),
501 };
502 map.insert(
503 percent_decode(key),
504 Value::String(percent_decode(value.replace('+', " ").as_str())),
505 );
506 }
507 map
508}
509
510fn percent_decode(input: &str) -> String {
512 let bytes = input.as_bytes();
513 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
514 let mut i = 0;
515 while i < bytes.len() {
516 if bytes[i] == b'%' && i + 2 < bytes.len() {
517 let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
518 match u8::from_str_radix(hex, 16) {
519 Ok(byte) => {
520 out.push(byte);
521 i += 3;
522 continue;
523 }
524 Err(_) => {
525 out.push(bytes[i]);
526 i += 1;
527 continue;
528 }
529 }
530 }
531 out.push(bytes[i]);
532 i += 1;
533 }
534 String::from_utf8_lossy(&out).into_owned()
535}
536
537fn wants_table(accept: Option<&str>) -> bool {
539 accept
540 .map(|value| value.to_ascii_lowercase().contains("as=table"))
541 .unwrap_or(false)
542}
543
544fn json_response(status: StatusCode, body: &Value) -> Response<Full<Bytes>> {
549 let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
550 let mut response = Response::new(Full::new(Bytes::from(bytes)));
551 *response.status_mut() = status;
552 response
553 .headers_mut()
554 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
555 response
556}
557
558fn status_response(
559 code: u16,
560 reason: &str,
561 message: &str,
562 details: Option<Value>,
563) -> Response<Full<Bytes>> {
564 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
565 json_response(status, &status_object(code, reason, message, details))
566}
567
568fn text_response(status: StatusCode, body: &str) -> Response<Full<Bytes>> {
569 let mut response = Response::new(Full::new(Bytes::from(body.to_string())));
570 *response.status_mut() = status;
571 response
572 .headers_mut()
573 .insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
574 response
575}
576
577async fn handle_request(req: Request<Incoming>, ctx: RequestContext) -> Response<Full<Bytes>> {
578 let (parts, body) = req.into_parts();
579 let method = parts.method.clone();
580 let path = parts.uri.path().to_string();
581 let query_string = parts.uri.query().unwrap_or("").to_string();
582 let query = parse_query(&query_string);
583 let accept = parts
584 .headers
585 .get(ACCEPT)
586 .and_then(|v| v.to_str().ok())
587 .map(|s| s.to_string());
588 let user_agent = parts
589 .headers
590 .get(hyper::header::USER_AGENT)
591 .and_then(|v| v.to_str().ok())
592 .unwrap_or("")
593 .to_string();
594
595 debug!("Kubernetes API {} {}{}", method, path, {
596 if query_string.is_empty() {
597 String::new()
598 } else {
599 format!("?{query_string}")
600 }
601 });
602 let _ = ctx
603 .status_tx
604 .send(format!("[DEBUG] Kubernetes API {method} {path}"));
605
606 let route = resolve_route(&path);
607 trace!("Kubernetes API route: {:?}", route);
608
609 match &route {
613 Route::Version => {
614 return json_response(
615 StatusCode::OK,
616 &discovery::version_info(&ctx.config.kubernetes_version),
617 )
618 }
619 Route::Health => return text_response(StatusCode::OK, "ok"),
620 Route::CoreApiVersions => {
621 return json_response(
622 StatusCode::OK,
623 &ctx.config.surface.api_versions(&ctx.server_address),
624 )
625 }
626 Route::ApiGroupList => {
627 return json_response(StatusCode::OK, &ctx.config.surface.api_group_list())
628 }
629 Route::ApiGroup(group) => {
630 return match ctx.config.surface.api_group(group) {
631 Some(value) => json_response(StatusCode::OK, &value),
632 None => status_response(
633 404,
634 "NotFound",
635 &format!("the server could not find the requested resource: /apis/{group}"),
636 Some(json!({"group": group})),
637 ),
638 }
639 }
640 Route::ApiResourceList { group, version } => {
641 return match ctx.config.surface.api_resource_list(group, version) {
642 Some(value) => json_response(StatusCode::OK, &value),
643 None => status_response(
644 404,
645 "NotFound",
646 &format!("the server could not find the requested resource: {path}"),
647 None,
648 ),
649 }
650 }
651 Route::Unknown => {
652 return status_response(
653 404,
654 "NotFound",
655 &format!("the server could not find the requested resource: {path}"),
656 None,
657 )
658 }
659 Route::Resource(_) => {}
660 }
661
662 let Route::Resource(resource_route) = route else {
663 unreachable!("non-resource routes returned above");
664 };
665
666 let watching = query
670 .get("watch")
671 .and_then(Value::as_str)
672 .map(|v| v == "true" || v == "1")
673 .unwrap_or(false);
674 if watching {
675 warn!(
676 "Kubernetes API watch requested on {} - not implemented",
677 path
678 );
679 return status_response(
680 501,
681 "NotImplemented",
682 "netget's Kubernetes API server does not implement watch (?watch=true); use a \
683 polling client or omit --watch",
684 None,
685 );
686 }
687
688 if ctx
689 .config
690 .surface
691 .find(
692 &resource_route.group,
693 &resource_route.version,
694 &resource_route.resource,
695 )
696 .is_none()
697 {
698 return status_response(
699 404,
700 "NotFound",
701 &format!(
702 "the server could not find the requested resource: {} is not advertised at {}",
703 resource_route.resource,
704 resource_route.group_version()
705 ),
706 Some(json!({
707 "group": resource_route.group,
708 "kind": resource_route.resource,
709 })),
710 );
711 }
712
713 let body_bytes = match http_body_util::Limited::new(body, MAX_REQUEST_BODY_BYTES)
718 .collect()
719 .await
720 {
721 Ok(collected) => collected.to_bytes(),
722 Err(e) => {
723 error!(
725 "Kubernetes {} {}: decision=fail_closed_body_rejected (limit {} bytes): {}",
726 method, path, MAX_REQUEST_BODY_BYTES, e
727 );
728 console_error!(
729 ctx.status_tx,
730 "Kubernetes {} {} -> refusing request body (limit {} bytes)",
731 method,
732 path,
733 MAX_REQUEST_BODY_BYTES
734 );
735 return status_response(
736 413,
737 "RequestEntityTooLarge",
738 "the request body is larger than this server accepts",
739 Some(json!({"limitBytes": MAX_REQUEST_BODY_BYTES})),
740 );
741 }
742 };
743
744 let as_table = wants_table(accept.as_deref());
745 let is_write = !matches!(method, Method::GET | Method::HEAD);
746
747 let (event_type, mut event_data): (&'static EventType, Value) = if is_write {
748 let parsed_body = serde_json::from_slice::<Value>(&body_bytes).ok();
749 let mut data = base_event_data(&resource_route, &method, &path, &query, &user_agent);
750 match parsed_body {
751 Some(value) => data["body"] = value,
752 None if !body_bytes.is_empty() => {
753 data["body_text"] = json!(String::from_utf8_lossy(&body_bytes).into_owned());
754 }
755 None => {}
756 }
757 (&actions::K8S_WRITE_REQUEST, data)
758 } else if resource_route.name.is_some() {
759 (
760 &actions::K8S_GET_REQUEST,
761 base_event_data(&resource_route, &method, &path, &query, &user_agent),
762 )
763 } else {
764 (
765 &actions::K8S_LIST_REQUEST,
766 base_event_data(&resource_route, &method, &path, &query, &user_agent),
767 )
768 };
769 event_data["as_table"] = json!(as_table);
770
771 if ctx.app_state.get_instruction(ctx.server_id).await.is_none() {
772 error!("Kubernetes server {} not found in state", ctx.server_id);
773 return status_response(
774 500,
775 "InternalError",
776 "netget server instance is no longer registered",
777 None,
778 );
779 }
780
781 console_debug!(
782 ctx.status_tx,
783 "Calling LLM for Kubernetes {} {}",
784 method,
785 path
786 );
787
788 let event = Event::new(event_type, event_data);
789 let llm_result = call_llm(
790 &ctx.llm_client,
791 &ctx.app_state,
792 ctx.server_id,
793 None,
794 &event,
795 ctx.protocol.as_ref(),
796 )
797 .await;
798
799 match llm_result {
800 Ok(execution) => {
801 for result in execution.protocol_results {
802 let action_name = match &result {
803 crate::llm::ActionResult::Custom { name, .. } => name.clone(),
804 _ => String::new(),
805 };
806 if let Some(response) =
807 build_response(result, &resource_route, as_table, &ctx.config)
808 {
809 if action_name == "k8s_status" {
813 info!(
814 "Kubernetes {} {}: decision=model_reject status={}",
815 method,
816 path,
817 response.status().as_u16()
818 );
819 }
820 return response;
821 }
822 }
823 error!(
827 "Kubernetes {} {}: decision=fail_closed_no_action (model returned no k8s_* action)",
828 method, path
829 );
830 console_error!(
831 ctx.status_tx,
832 "Kubernetes: model returned no k8s action for {} {}",
833 method,
834 path
835 );
836 status_response(
837 500,
838 "InternalError",
839 "the handler returned no Kubernetes action for this request",
840 None,
841 )
842 }
843 Err(e) => {
844 let failure = crate::utils::WireFailure::classify(&e);
847 error!(
848 "Kubernetes {} {}: decision=fail_closed_llm_error category={:?} error={}",
849 method, path, failure, e
850 );
851 console_error!(ctx.status_tx, "Kubernetes LLM call failed: {}", e);
852 match failure {
853 crate::utils::WireFailure::Overloaded => {
856 let mut response = status_response(
857 503,
858 "ServiceUnavailable",
859 failure.prefixed_text(),
860 Some(json!({"retryAfterSeconds": 5})),
861 );
862 response
863 .headers_mut()
864 .insert(hyper::header::RETRY_AFTER, HeaderValue::from_static("5"));
865 response
866 }
867 crate::utils::WireFailure::Unavailable => {
868 status_response(500, "InternalError", failure.prefixed_text(), None)
869 }
870 }
871 }
872 }
873}
874
875fn base_event_data(
876 route: &ResourceRoute,
877 method: &Method,
878 path: &str,
879 query: &serde_json::Map<String, Value>,
880 user_agent: &str,
881) -> Value {
882 let mut data = json!({
883 "method": method.as_str(),
884 "path": path,
885 "api_group": route.group,
886 "api_version": route.version,
887 "group_version": route.group_version(),
888 "resource": route.resource,
889 "user_agent": user_agent,
890 });
891 if let Some(namespace) = &route.namespace {
892 data["namespace"] = json!(namespace);
893 }
894 if let Some(name) = &route.name {
895 data["name"] = json!(name);
896 }
897 if let Some(subresource) = &route.subresource {
898 data["subresource"] = json!(subresource);
899 }
900 for key in ["labelSelector", "fieldSelector", "limit", "resourceVersion"] {
901 if let Some(value) = query.get(key) {
902 data[key] = value.clone();
903 }
904 }
905 data
906}
907
908fn build_response(
911 action_result: crate::llm::ActionResult,
912 route: &ResourceRoute,
913 as_table: bool,
914 config: &KubernetesConfig,
915) -> Option<Response<Full<Bytes>>> {
916 use crate::llm::ActionResult;
917
918 let ActionResult::Custom { name, data } = action_result else {
919 return None;
920 };
921
922 match name.as_str() {
923 "k8s_list_response" => {
924 let item_kind = data
925 .get("kind")
926 .and_then(Value::as_str)
927 .map(|k| k.trim_end_matches("List").to_string())
928 .unwrap_or_else(|| kind_for_route(route, config));
929 let items: Vec<Value> = data
930 .get("items")
931 .and_then(Value::as_array)
932 .cloned()
933 .unwrap_or_default();
934 let api_version = data
935 .get("apiVersion")
936 .and_then(Value::as_str)
937 .map(|s| s.to_string())
938 .unwrap_or_else(|| route.group_version());
939 let resource_version = data
940 .get("resourceVersion")
941 .and_then(Value::as_str)
942 .unwrap_or("1")
943 .to_string();
944
945 if as_table {
946 return Some(json_response(
947 StatusCode::OK,
948 &table::table_from_items(&item_kind, &items),
949 ));
950 }
951 Some(json_response(
952 StatusCode::OK,
953 &json!({
954 "kind": format!("{item_kind}List"),
955 "apiVersion": api_version,
956 "metadata": {"resourceVersion": resource_version},
957 "items": items,
958 }),
959 ))
960 }
961 "k8s_table_response" => {
962 let columns: Vec<Value> = data
963 .get("columns")
964 .and_then(Value::as_array)
965 .cloned()
966 .unwrap_or_default();
967 let rows: Vec<Value> = data
968 .get("rows")
969 .and_then(Value::as_array)
970 .cloned()
971 .unwrap_or_default();
972 Some(json_response(
973 StatusCode::OK,
974 &table::table_from_columns_and_rows(&columns, &rows),
975 ))
976 }
977 "k8s_object_response" => {
978 let Some(object) = data.get("object").cloned() else {
983 error!("Kubernetes: decision=fail_closed_no_action (k8s_object_response without 'object')");
984 return Some(status_response(
985 500,
986 "InternalError",
987 "the handler returned no object for this request",
988 None,
989 ));
990 };
991 let mut object = object;
992 if object.get("kind").is_none() {
993 object["kind"] = json!(kind_for_route(route, config));
994 }
995 if object.get("apiVersion").is_none() {
996 object["apiVersion"] = json!(route.group_version());
997 }
998 let raw_code = data.get("status_code").and_then(Value::as_u64);
999 let status =
1000 match model_status_code(raw_code, 200).and_then(|c| StatusCode::from_u16(c).ok()) {
1001 Some(status) => status,
1002 None => {
1003 error!(
1004 "Kubernetes: decision=fail_closed_bad_status (k8s_object_response \
1005 status_code {raw_code:?} is not an HTTP status)"
1006 );
1007 return Some(status_response(
1008 500,
1009 "InternalError",
1010 "the handler returned an unusable status code",
1011 None,
1012 ));
1013 }
1014 };
1015 if as_table {
1016 let kind = object
1017 .get("kind")
1018 .and_then(Value::as_str)
1019 .unwrap_or("")
1020 .to_string();
1021 return Some(json_response(
1022 status,
1023 &table::table_from_items(&kind, std::slice::from_ref(&object)),
1024 ));
1025 }
1026 Some(json_response(status, &object))
1027 }
1028 "k8s_status" => {
1029 let raw_code = data.get("code").and_then(Value::as_u64);
1030 let code = model_status_code(raw_code, 500).unwrap_or_else(|| {
1033 error!(
1034 "Kubernetes: decision=fail_closed_bad_status (k8s_status code {raw_code:?} \
1035 is not an HTTP status)"
1036 );
1037 500
1038 });
1039 let reason = data
1040 .get("reason")
1041 .and_then(Value::as_str)
1042 .unwrap_or("InternalError");
1043 let message = data
1044 .get("message")
1045 .and_then(Value::as_str)
1046 .unwrap_or("unspecified error");
1047 Some(status_response(
1048 code,
1049 reason,
1050 message,
1051 data.get("details").cloned(),
1052 ))
1053 }
1054 other => {
1055 debug!("Kubernetes: ignoring non-Kubernetes action '{}'", other);
1056 None
1057 }
1058 }
1059}
1060
1061fn kind_for_route(route: &ResourceRoute, config: &KubernetesConfig) -> String {
1064 config
1065 .surface
1066 .find(&route.group, &route.version, &route.resource)
1067 .map(|r| r.kind.clone())
1068 .unwrap_or_else(|| "Unknown".to_string())
1069}