Skip to main content

netget/server/kubernetes/
mod.rs

1//! Kubernetes API server implementation.
2//!
3//! NetGet impersonates a `kube-apiserver` well enough for a real `kubectl` to talk to it. The
4//! transport is plain HTTP/1.1 (optionally under TLS); everything on the wire is JSON, which is
5//! what `kubectl` speaks to an apiserver by default — no protobuf, and therefore no `protoc`
6//! and no `kube`/`k8s-openapi` dependency.
7//!
8//! **The model owns the cluster.** There is no resource store here: NetGet never remembers a
9//! pod. Every list, get and write is answered by the LLM (or by a script/static handler) via
10//! the `k8s_*` actions. What NetGet owns is the envelope — routing, discovery, `Table`
11//! rendering and the `Status` error object — the parts a real client will reject if they are
12//! wrong.
13//!
14//! See `src/server/kubernetes/CLAUDE.md` for the discovery/content split and its rationale.
15
16pub mod actions;
17mod discovery;
18/// `Table` rendering. Public so its cell derivation can be tested directly against the kinds of
19/// object a model actually returns, without standing a server up first.
20pub 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/// Everything the accept loop needs that is not the socket or the LLM.
49#[derive(Clone, Debug)]
50pub struct KubernetesConfig {
51    /// The advertised API discovery surface.
52    pub surface: Arc<ApiSurface>,
53    /// Version string reported by `GET /version`, e.g. `v1.29.4`.
54    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
66/// Version claimed by `GET /version` when `kubernetes_version` is not supplied.
67pub const DEFAULT_KUBERNETES_VERSION: &str = "v1.29.4";
68
69/// Largest request body this server will buffer, in bytes.
70///
71/// The same 3 MiB a real `kube-apiserver` enforces (`maxRequestBodyBytes` in
72/// `k8s.io/apiserver`), and for the same reason: hyper's `Incoming` has no limit of its own, so
73/// `collect()` on it buffers whatever the peer chose to send. This server performs no
74/// authentication, so that was one unauthenticated `POST` away from exhausting the process —
75/// and the body is then embedded whole in an LLM prompt, so there is no legitimate large one
76/// either. `Limited` errors as soon as the cap is passed rather than after buffering it.
77pub const MAX_REQUEST_BODY_BYTES: usize = 3 * 1024 * 1024;
78
79/// How long to wait for a peer's first byte after it connects.
80///
81/// Both shapes this server accepts are client-speaks-first: plain HTTP says nothing before the
82/// request line, and TLS says nothing before the ClientHello. So a peer that has connected and
83/// sent nothing has begun neither, which is the state an unauthenticated flood lives in — and
84/// this server performs no authentication at all. Enforced with `TcpStream::peek` on the raw
85/// socket *before* either the TLS acceptor or hyper sees it, so those bytes are still there for
86/// whichever one gets the stream afterwards.
87const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
88
89/// How long a connection may do nothing at all between requests.
90///
91/// **This bounds the silence, not the transfer.** Every response here is a `Full<Bytes>` built
92/// before the response is returned, and `?watch=true` — the one long-lived shape the Kubernetes
93/// API defines — is explicitly refused rather than streamed, so no legitimate request is held
94/// open waiting for something to happen. The watchdog reads `ConnectionActivity`, which reports
95/// a connection with a request in flight as **not idle at all**, so an LLM round-trip or a
96/// `manual` rule parked for a human can never close the connection it is an answer for.
97///
98/// Fifteen minutes is the wait between one request finishing and the next one starting. A real
99/// `kube-apiserver` closes an idle HTTP/1 connection well before that, and `kubectl` opens a
100/// connection, asks, and exits.
101const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
102
103/// Concurrent connections this server admits.
104///
105/// Each admitted connection may buffer a request body of up to [`MAX_REQUEST_BODY_BYTES`], and
106/// with TLS configured it also holds a rustls session, so the cap is what turns that
107/// per-connection bound into a total one.
108const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
109
110/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
111///
112/// A plaintext HTTP `503` with `Retry-After`, deliberately: a refused peer has not completed a
113/// TLS handshake, and a TLS alert would have to be preceded by a handshake this server is
114/// declining to perform. `kubectl` reports a transport error either way, but the bytes now say
115/// why, and a plain `curl` against an http-mode server reads the status directly.
116const 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
119/// Turn a model-supplied HTTP status code into a `u16`, or `None` when it is not one.
120///
121/// **The range check has to happen before the cast, not after.** `as u16` on a `u64` truncates
122/// silently, and truncation lands on plausible values: `65736 as u16 == 200`, so an
123/// out-of-range code the executor somehow let through would become a `200 OK` — a refusal
124/// turned into an answer, which is the LDAP `result_code as u8` defect in a different protocol.
125/// `execute_status` and `execute_object_response` already bound `code` to 100..600; this is the
126/// second lock on the same door, and it fails closed instead of narrowing.
127pub 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
135/// Kubernetes API server.
136pub struct KubernetesServer;
137
138impl KubernetesServer {
139    /// Bind, then spawn the accept loop.
140    ///
141    /// The listener is bound *before* the task is spawned so a bind failure is returned to the
142    /// caller and `server_startup` records `ServerStatus::Error` — a server that reports
143    /// `Running` while nothing is listening is worse than one that refuses to start. The
144    /// accept-loop handle is registered with `AppState::register_server_task` so `stop_server`
145    /// can abort it and release the port.
146    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                                // Held for the life of the connection: dropping it early
239                                // releases the slot while the peer is still here, which
240                                // silently un-caps the server.
241                                let _permit = permit;
242
243                                // First-byte bound on the raw socket, before either the TLS
244                                // acceptor or hyper sees it. Both are client-speaks-first, and
245                                // `peek` waits for data without consuming it, so the ClientHello
246                                // or the request line is still there afterwards. A deadline on
247                                // reads would be wrong rather than merely awkward: hyper keeps
248                                // polling the connection for frames *while a request is being
249                                // answered*, so it would fire inside an LLM round-trip.
250                                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/// Per-connection dependencies, cloned into each hyper service call.
333#[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    // Tracks whether this connection is answering anything. A request waiting on the model, or
349    // parked for a human at the dashboard, holds the count above zero, so the idle watchdog
350    // below cannot close the connection the answer belongs to however long it takes — only
351    // genuine silence counts.
352    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// ---------------------------------------------------------------------------
385// Routing
386// ---------------------------------------------------------------------------
387
388/// A request against a resource collection or object.
389#[derive(Clone, Debug, PartialEq, Eq)]
390pub struct ResourceRoute {
391    /// API group; empty for core.
392    pub group: String,
393    /// Group version.
394    pub version: String,
395    /// Plural resource name from the URL, e.g. `pods`.
396    pub resource: String,
397    /// Namespace, when the URL carried `/namespaces/{ns}/`.
398    pub namespace: Option<String>,
399    /// Object name, when the URL addressed a single object.
400    pub name: Option<String>,
401    /// Subresource, e.g. `log`, `status`.
402    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/// What a request path resolves to.
416#[derive(Clone, Debug, PartialEq, Eq)]
417pub enum Route {
418    /// `GET /version`
419    Version,
420    /// `GET /healthz`, `/livez`, `/readyz`
421    Health,
422    /// `GET /api`
423    CoreApiVersions,
424    /// `GET /apis`
425    ApiGroupList,
426    /// `GET /apis/{group}`
427    ApiGroup(String),
428    /// `GET /api/{version}` or `GET /apis/{group}/{version}`
429    ApiResourceList { group: String, version: String },
430    /// Anything addressing actual objects.
431    Resource(ResourceRoute),
432    /// Not part of the served surface.
433    Unknown,
434}
435
436/// Resolve a request path to a [`Route`].
437///
438/// Exposed for tests: routing is the part `kubectl` exercises hardest, and getting
439/// `/api/v1/namespaces` (list namespaces) to not be confused with
440/// `/api/v1/namespaces/default/pods` is the whole trick.
441pub 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    // `/namespaces` is both a cluster-scoped resource and the prefix of every namespaced
472    // path. It is only a prefix when there is a resource name *after* the namespace.
473    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
494/// Parse a query string into a flat key→value map, keeping the last value for repeated keys.
495fn 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
510/// Minimal percent-decoding; query values here are selectors like `app%3Dnginx`.
511fn 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
537/// True when the client asked for `Table` output — what `kubectl get` always sends.
538fn wants_table(accept: Option<&str>) -> bool {
539    accept
540        .map(|value| value.to_ascii_lowercase().contains("as=table"))
541        .unwrap_or(false)
542}
543
544// ---------------------------------------------------------------------------
545// Request handling
546// ---------------------------------------------------------------------------
547
548fn 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    // Discovery and health are served deterministically: kubectl performs them before every
610    // command and fails opaquely if they are wrong, and they describe the API's shape rather
611    // than the cluster's contents. See CLAUDE.md.
612    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    // `?watch=true` is a long-lived chunked stream this server does not implement. Say so
667    // explicitly rather than answering a watch with a one-shot list, which leaves the client
668    // reconnecting in a loop against a body it cannot decode.
669    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    // Bounded, and an unreadable body must never fall through as an empty one: the model would
714    // then be shown a create carrying no object and could admit it as though the client had
715    // sent none. Over the cap is `RequestEntityTooLarge`, the reason a real apiserver uses and
716    // the one `client-go` surfaces without retrying.
717    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            // The transport error names hyper internals; the peer gets the category only.
724            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                    // A `k8s_status` is the model *choosing* to refuse (404, 403, 409…). It
810                    // must stay distinguishable in the log from netget refusing on the model's
811                    // behalf, which is what the two branches below do.
812                    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            // No usable action. Refuse rather than inventing an empty list: an empty PodList
824            // is a claim about the cluster, and "the model said nothing" must not be
825            // indistinguishable from "the model said there are no pods".
826            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            // The peer gets a category; the log gets the error. Never interpolate `e` into a
845            // `Status` message — it names the backend, the model and netget's own internals.
846            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                // Saturated backend: 503 + Retry-After is what client-go backs off on, so the
854                // client retries instead of recording a permanent fault.
855                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
908/// Turn one action result into an HTTP response, or `None` if it is not a Kubernetes action
909/// so the caller can keep scanning (a model may emit `show_message` alongside the real answer).
910fn 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            // Defence in depth: `execute_object_response` already requires `object` and a
979            // 100..600 `status_code`. If either ever gets past it, refuse — serving `{}` with
980            // 200, or degrading an out-of-range code to 200, would be a claim about the
981            // cluster that nobody made.
982            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            // No usable code means netget refusing on the model's behalf, which is 500 — never
1031            // a narrowed value that could land inside 2xx and read as success.
1032            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
1061/// The object kind for a route, taken from the discovery table so the envelope matches what
1062/// discovery advertised even when the model omitted `kind`.
1063fn 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}