pub mod actions;
mod discovery;
pub mod table;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::header::{HeaderValue, ACCEPT, CONTENT_TYPE};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use serde_json::{json, Value};
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::protocol::{Event, EventType};
use crate::server::connection::ConnectionId;
use crate::state::app_state::AppState;
use crate::state::ServerId;
use crate::{console_debug, console_error, console_info};
pub use discovery::{status_object, ApiResource, ApiSurface};
#[derive(Clone, Debug)]
pub struct KubernetesConfig {
pub surface: Arc<ApiSurface>,
pub kubernetes_version: String,
}
impl Default for KubernetesConfig {
fn default() -> Self {
Self {
surface: Arc::new(ApiSurface::builtin()),
kubernetes_version: DEFAULT_KUBERNETES_VERSION.to_string(),
}
}
}
pub const DEFAULT_KUBERNETES_VERSION: &str = "v1.29.4";
pub const MAX_REQUEST_BODY_BYTES: usize = 3 * 1024 * 1024;
const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
pub fn model_status_code(raw: Option<u64>, default: u16) -> Option<u16> {
match raw {
None => Some(default),
Some(code) if (100..600).contains(&code) => Some(code as u16),
Some(_) => None,
}
}
pub struct KubernetesServer;
impl KubernetesServer {
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: ServerId,
tls_config: Option<Arc<rustls::ServerConfig>>,
config: KubernetesConfig,
) -> anyhow::Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
let scheme = if tls_config.is_some() {
"https"
} else {
"http"
};
console_info!(
status_tx,
"Kubernetes API server listening on {}://{} (claiming {})",
scheme,
local_addr,
config.kubernetes_version
);
let protocol = Arc::new(actions::KubernetesProtocol::new());
let tls_acceptor = tls_config.map(tokio_rustls::TlsAcceptor::from);
let config = Arc::new(config);
let server_address = format!("{scheme}://{local_addr}");
let task_registrar = app_state.clone();
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"Kubernetes",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
info!(
"Kubernetes API connection {} from {}",
connection_id, remote_addr
);
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr,
local_addr: local_addr_conn,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let ctx = RequestContext {
llm_client: llm_client.clone(),
app_state: app_state.clone(),
status_tx: status_tx.clone(),
protocol: protocol.clone(),
config: config.clone(),
server_id,
server_address: server_address.clone(),
};
let tls_acceptor = tls_acceptor.clone();
let app_state_for_close = app_state.clone();
let status_for_close = status_tx.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
match tokio::time::timeout(
FIRST_BYTE_READ_TIMEOUT,
stream.peek(&mut [0u8; 1]),
)
.await
{
Ok(Ok(0)) | Ok(Err(_)) => {
app_state_for_close
.close_connection_on_server(server_id, connection_id)
.await;
let _ = status_for_close.send("__UPDATE_UI__".to_string());
return;
}
Ok(Ok(_)) => {}
Err(_) => {
debug!(
"Kubernetes peer {} sent nothing for {}s; closing \
before the request line",
remote_addr,
FIRST_BYTE_READ_TIMEOUT.as_secs()
);
app_state_for_close
.close_connection_on_server(server_id, connection_id)
.await;
let _ = status_for_close.send("__UPDATE_UI__".to_string());
return;
}
}
match tls_acceptor {
Some(acceptor) => match acceptor.accept(stream).await {
Ok(tls_stream) => {
debug!(
"Kubernetes TLS handshake complete with {}",
remote_addr
);
serve_connection(TokioIo::new(tls_stream), ctx).await;
}
Err(e) => {
console_error!(
status_for_close,
"Kubernetes TLS handshake failed with {}: {}",
remote_addr,
e
);
}
},
None => {
serve_connection(TokioIo::new(stream), ctx).await;
}
}
app_state_for_close
.close_connection_on_server(server_id, connection_id)
.await;
let _ = status_for_close.send(format!(
"[INFO] Kubernetes API connection {connection_id} closed"
));
let _ = status_for_close.send("__UPDATE_UI__".to_string());
})
.await;
}
Err(e) => {
console_error!(
status_tx,
"Failed to accept Kubernetes API connection: {}",
e
);
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
}
#[derive(Clone)]
struct RequestContext {
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
protocol: Arc<actions::KubernetesProtocol>,
config: Arc<KubernetesConfig>,
server_id: ServerId,
server_address: String,
}
async fn serve_connection<T>(io: TokioIo<T>, ctx: RequestContext)
where
T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
let activity_for_service = Arc::clone(&activity);
let service = service_fn(move |req: Request<Incoming>| {
let ctx = ctx.clone();
let activity = Arc::clone(&activity_for_service);
async move {
let _busy = activity.busy();
Ok::<_, Infallible>(handle_request(req, ctx).await)
}
});
let conn = http1::Builder::new().serve_connection(io, service);
tokio::pin!(conn);
tokio::select! {
result = &mut conn => {
if let Err(err) = result {
debug!("Kubernetes API connection ended: {:?}", err);
}
}
_ = crate::server::accept_bounded::watch_idle(
Arc::clone(&activity),
IDLE_BETWEEN_REQUESTS_TIMEOUT,
) => {
debug!(
"Kubernetes API connection idle for {}s; closing",
IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
);
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceRoute {
pub group: String,
pub version: String,
pub resource: String,
pub namespace: Option<String>,
pub name: Option<String>,
pub subresource: Option<String>,
}
impl ResourceRoute {
fn group_version(&self) -> String {
if self.group.is_empty() {
self.version.clone()
} else {
format!("{}/{}", self.group, self.version)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Route {
Version,
Health,
CoreApiVersions,
ApiGroupList,
ApiGroup(String),
ApiResourceList { group: String, version: String },
Resource(ResourceRoute),
Unknown,
}
pub fn resolve_route(path: &str) -> Route {
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
match segments.as_slice() {
[] => Route::Unknown,
["version"] => Route::Version,
["healthz"] | ["livez"] | ["readyz"] => Route::Health,
["api"] => Route::CoreApiVersions,
["apis"] => Route::ApiGroupList,
["api", version] => Route::ApiResourceList {
group: String::new(),
version: (*version).to_string(),
},
["apis", group] => Route::ApiGroup((*group).to_string()),
["apis", group, version] => Route::ApiResourceList {
group: (*group).to_string(),
version: (*version).to_string(),
},
["api", version, rest @ ..] => {
Route::Resource(resource_route(String::new(), (*version).to_string(), rest))
}
["apis", group, version, rest @ ..] => Route::Resource(resource_route(
(*group).to_string(),
(*version).to_string(),
rest,
)),
_ => Route::Unknown,
}
}
fn resource_route(group: String, version: String, rest: &[&str]) -> ResourceRoute {
if rest.first() == Some(&"namespaces") && rest.len() >= 3 {
ResourceRoute {
group,
version,
resource: rest[2].to_string(),
namespace: Some(rest[1].to_string()),
name: rest.get(3).map(|s| (*s).to_string()),
subresource: rest.get(4).map(|s| (*s).to_string()),
}
} else {
ResourceRoute {
group,
version,
resource: rest.first().map(|s| (*s).to_string()).unwrap_or_default(),
namespace: None,
name: rest.get(1).map(|s| (*s).to_string()),
subresource: rest.get(2).map(|s| (*s).to_string()),
}
}
}
fn parse_query(query: &str) -> serde_json::Map<String, Value> {
let mut map = serde_json::Map::new();
for pair in query.split('&').filter(|p| !p.is_empty()) {
let (key, value) = match pair.split_once('=') {
Some((k, v)) => (k, v),
None => (pair, ""),
};
map.insert(
percent_decode(key),
Value::String(percent_decode(value.replace('+', " ").as_str())),
);
}
map
}
fn percent_decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(byte) => {
out.push(byte);
i += 3;
continue;
}
Err(_) => {
out.push(bytes[i]);
i += 1;
continue;
}
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn wants_table(accept: Option<&str>) -> bool {
accept
.map(|value| value.to_ascii_lowercase().contains("as=table"))
.unwrap_or(false)
}
fn json_response(status: StatusCode, body: &Value) -> Response<Full<Bytes>> {
let bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
let mut response = Response::new(Full::new(Bytes::from(bytes)));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
response
}
fn status_response(
code: u16,
reason: &str,
message: &str,
details: Option<Value>,
) -> Response<Full<Bytes>> {
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
json_response(status, &status_object(code, reason, message, details))
}
fn text_response(status: StatusCode, body: &str) -> Response<Full<Bytes>> {
let mut response = Response::new(Full::new(Bytes::from(body.to_string())));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("text/plain"));
response
}
async fn handle_request(req: Request<Incoming>, ctx: RequestContext) -> Response<Full<Bytes>> {
let (parts, body) = req.into_parts();
let method = parts.method.clone();
let path = parts.uri.path().to_string();
let query_string = parts.uri.query().unwrap_or("").to_string();
let query = parse_query(&query_string);
let accept = parts
.headers
.get(ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let user_agent = parts
.headers
.get(hyper::header::USER_AGENT)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
debug!("Kubernetes API {} {}{}", method, path, {
if query_string.is_empty() {
String::new()
} else {
format!("?{query_string}")
}
});
let _ = ctx
.status_tx
.send(format!("[DEBUG] Kubernetes API {method} {path}"));
let route = resolve_route(&path);
trace!("Kubernetes API route: {:?}", route);
match &route {
Route::Version => {
return json_response(
StatusCode::OK,
&discovery::version_info(&ctx.config.kubernetes_version),
)
}
Route::Health => return text_response(StatusCode::OK, "ok"),
Route::CoreApiVersions => {
return json_response(
StatusCode::OK,
&ctx.config.surface.api_versions(&ctx.server_address),
)
}
Route::ApiGroupList => {
return json_response(StatusCode::OK, &ctx.config.surface.api_group_list())
}
Route::ApiGroup(group) => {
return match ctx.config.surface.api_group(group) {
Some(value) => json_response(StatusCode::OK, &value),
None => status_response(
404,
"NotFound",
&format!("the server could not find the requested resource: /apis/{group}"),
Some(json!({"group": group})),
),
}
}
Route::ApiResourceList { group, version } => {
return match ctx.config.surface.api_resource_list(group, version) {
Some(value) => json_response(StatusCode::OK, &value),
None => status_response(
404,
"NotFound",
&format!("the server could not find the requested resource: {path}"),
None,
),
}
}
Route::Unknown => {
return status_response(
404,
"NotFound",
&format!("the server could not find the requested resource: {path}"),
None,
)
}
Route::Resource(_) => {}
}
let Route::Resource(resource_route) = route else {
unreachable!("non-resource routes returned above");
};
let watching = query
.get("watch")
.and_then(Value::as_str)
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if watching {
warn!(
"Kubernetes API watch requested on {} - not implemented",
path
);
return status_response(
501,
"NotImplemented",
"netget's Kubernetes API server does not implement watch (?watch=true); use a \
polling client or omit --watch",
None,
);
}
if ctx
.config
.surface
.find(
&resource_route.group,
&resource_route.version,
&resource_route.resource,
)
.is_none()
{
return status_response(
404,
"NotFound",
&format!(
"the server could not find the requested resource: {} is not advertised at {}",
resource_route.resource,
resource_route.group_version()
),
Some(json!({
"group": resource_route.group,
"kind": resource_route.resource,
})),
);
}
let body_bytes = match http_body_util::Limited::new(body, MAX_REQUEST_BODY_BYTES)
.collect()
.await
{
Ok(collected) => collected.to_bytes(),
Err(e) => {
error!(
"Kubernetes {} {}: decision=fail_closed_body_rejected (limit {} bytes): {}",
method, path, MAX_REQUEST_BODY_BYTES, e
);
console_error!(
ctx.status_tx,
"Kubernetes {} {} -> refusing request body (limit {} bytes)",
method,
path,
MAX_REQUEST_BODY_BYTES
);
return status_response(
413,
"RequestEntityTooLarge",
"the request body is larger than this server accepts",
Some(json!({"limitBytes": MAX_REQUEST_BODY_BYTES})),
);
}
};
let as_table = wants_table(accept.as_deref());
let is_write = !matches!(method, Method::GET | Method::HEAD);
let (event_type, mut event_data): (&'static EventType, Value) = if is_write {
let parsed_body = serde_json::from_slice::<Value>(&body_bytes).ok();
let mut data = base_event_data(&resource_route, &method, &path, &query, &user_agent);
match parsed_body {
Some(value) => data["body"] = value,
None if !body_bytes.is_empty() => {
data["body_text"] = json!(String::from_utf8_lossy(&body_bytes).into_owned());
}
None => {}
}
(&actions::K8S_WRITE_REQUEST, data)
} else if resource_route.name.is_some() {
(
&actions::K8S_GET_REQUEST,
base_event_data(&resource_route, &method, &path, &query, &user_agent),
)
} else {
(
&actions::K8S_LIST_REQUEST,
base_event_data(&resource_route, &method, &path, &query, &user_agent),
)
};
event_data["as_table"] = json!(as_table);
if ctx.app_state.get_instruction(ctx.server_id).await.is_none() {
error!("Kubernetes server {} not found in state", ctx.server_id);
return status_response(
500,
"InternalError",
"netget server instance is no longer registered",
None,
);
}
console_debug!(
ctx.status_tx,
"Calling LLM for Kubernetes {} {}",
method,
path
);
let event = Event::new(event_type, event_data);
let llm_result = call_llm(
&ctx.llm_client,
&ctx.app_state,
ctx.server_id,
None,
&event,
ctx.protocol.as_ref(),
)
.await;
match llm_result {
Ok(execution) => {
for result in execution.protocol_results {
let action_name = match &result {
crate::llm::ActionResult::Custom { name, .. } => name.clone(),
_ => String::new(),
};
if let Some(response) =
build_response(result, &resource_route, as_table, &ctx.config)
{
if action_name == "k8s_status" {
info!(
"Kubernetes {} {}: decision=model_reject status={}",
method,
path,
response.status().as_u16()
);
}
return response;
}
}
error!(
"Kubernetes {} {}: decision=fail_closed_no_action (model returned no k8s_* action)",
method, path
);
console_error!(
ctx.status_tx,
"Kubernetes: model returned no k8s action for {} {}",
method,
path
);
status_response(
500,
"InternalError",
"the handler returned no Kubernetes action for this request",
None,
)
}
Err(e) => {
let failure = crate::utils::WireFailure::classify(&e);
error!(
"Kubernetes {} {}: decision=fail_closed_llm_error category={:?} error={}",
method, path, failure, e
);
console_error!(ctx.status_tx, "Kubernetes LLM call failed: {}", e);
match failure {
crate::utils::WireFailure::Overloaded => {
let mut response = status_response(
503,
"ServiceUnavailable",
failure.prefixed_text(),
Some(json!({"retryAfterSeconds": 5})),
);
response
.headers_mut()
.insert(hyper::header::RETRY_AFTER, HeaderValue::from_static("5"));
response
}
crate::utils::WireFailure::Unavailable => {
status_response(500, "InternalError", failure.prefixed_text(), None)
}
}
}
}
}
fn base_event_data(
route: &ResourceRoute,
method: &Method,
path: &str,
query: &serde_json::Map<String, Value>,
user_agent: &str,
) -> Value {
let mut data = json!({
"method": method.as_str(),
"path": path,
"api_group": route.group,
"api_version": route.version,
"group_version": route.group_version(),
"resource": route.resource,
"user_agent": user_agent,
});
if let Some(namespace) = &route.namespace {
data["namespace"] = json!(namespace);
}
if let Some(name) = &route.name {
data["name"] = json!(name);
}
if let Some(subresource) = &route.subresource {
data["subresource"] = json!(subresource);
}
for key in ["labelSelector", "fieldSelector", "limit", "resourceVersion"] {
if let Some(value) = query.get(key) {
data[key] = value.clone();
}
}
data
}
fn build_response(
action_result: crate::llm::ActionResult,
route: &ResourceRoute,
as_table: bool,
config: &KubernetesConfig,
) -> Option<Response<Full<Bytes>>> {
use crate::llm::ActionResult;
let ActionResult::Custom { name, data } = action_result else {
return None;
};
match name.as_str() {
"k8s_list_response" => {
let item_kind = data
.get("kind")
.and_then(Value::as_str)
.map(|k| k.trim_end_matches("List").to_string())
.unwrap_or_else(|| kind_for_route(route, config));
let items: Vec<Value> = data
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let api_version = data
.get("apiVersion")
.and_then(Value::as_str)
.map(|s| s.to_string())
.unwrap_or_else(|| route.group_version());
let resource_version = data
.get("resourceVersion")
.and_then(Value::as_str)
.unwrap_or("1")
.to_string();
if as_table {
return Some(json_response(
StatusCode::OK,
&table::table_from_items(&item_kind, &items),
));
}
Some(json_response(
StatusCode::OK,
&json!({
"kind": format!("{item_kind}List"),
"apiVersion": api_version,
"metadata": {"resourceVersion": resource_version},
"items": items,
}),
))
}
"k8s_table_response" => {
let columns: Vec<Value> = data
.get("columns")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let rows: Vec<Value> = data
.get("rows")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
Some(json_response(
StatusCode::OK,
&table::table_from_columns_and_rows(&columns, &rows),
))
}
"k8s_object_response" => {
let Some(object) = data.get("object").cloned() else {
error!("Kubernetes: decision=fail_closed_no_action (k8s_object_response without 'object')");
return Some(status_response(
500,
"InternalError",
"the handler returned no object for this request",
None,
));
};
let mut object = object;
if object.get("kind").is_none() {
object["kind"] = json!(kind_for_route(route, config));
}
if object.get("apiVersion").is_none() {
object["apiVersion"] = json!(route.group_version());
}
let raw_code = data.get("status_code").and_then(Value::as_u64);
let status =
match model_status_code(raw_code, 200).and_then(|c| StatusCode::from_u16(c).ok()) {
Some(status) => status,
None => {
error!(
"Kubernetes: decision=fail_closed_bad_status (k8s_object_response \
status_code {raw_code:?} is not an HTTP status)"
);
return Some(status_response(
500,
"InternalError",
"the handler returned an unusable status code",
None,
));
}
};
if as_table {
let kind = object
.get("kind")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
return Some(json_response(
status,
&table::table_from_items(&kind, std::slice::from_ref(&object)),
));
}
Some(json_response(status, &object))
}
"k8s_status" => {
let raw_code = data.get("code").and_then(Value::as_u64);
let code = model_status_code(raw_code, 500).unwrap_or_else(|| {
error!(
"Kubernetes: decision=fail_closed_bad_status (k8s_status code {raw_code:?} \
is not an HTTP status)"
);
500
});
let reason = data
.get("reason")
.and_then(Value::as_str)
.unwrap_or("InternalError");
let message = data
.get("message")
.and_then(Value::as_str)
.unwrap_or("unspecified error");
Some(status_response(
code,
reason,
message,
data.get("details").cloned(),
))
}
other => {
debug!("Kubernetes: ignoring non-Kubernetes action '{}'", other);
None
}
}
}
fn kind_for_route(route: &ResourceRoute, config: &KubernetesConfig) -> String {
config
.surface
.find(&route.group, &route.version, &route.resource)
.map(|r| r.kind.clone())
.unwrap_or_else(|| "Unknown".to_string())
}