From 9c141909e66d44d2e718791691b9cfcbb2253039 Mon Sep 17 00:00:00 2001 From: Alexander Matveev Date: Sat, 4 Jul 2026 02:48:02 +0000 Subject: [PATCH] feat: kubernetes operator for agent lifecycle management Add a kube-rs based Kubernetes operator (openshell-operator crate) that provides CRD-driven declarative sandbox lifecycle management. Components: - AgentSandbox CRD with spec for image, resources, policy, and provider refs - Reconciler loop with exponential backoff and status condition reporting - Admission webhooks (validating + mutating) for CRD validation - Manifest builders for sandbox Pod, Service, and RBAC resources - Label conventions for sandbox discovery and ownership tracking - SandboxRuntimeManager gRPC service for operator-gateway communication - Gateway integration via multiplex listener and config flags New crate: crates/openshell-operator (16 files) New proto: proto/sandbox_runtime_manager.proto Modified: openshell-core (config, proto), openshell-server (cli, grpc, multiplex) Tests: 2,239 passed, 0 failed Closes #1719 --- Cargo.lock | 32 ++ Cargo.toml | 4 + crates/openshell-core/src/config.rs | 11 + crates/openshell-core/src/proto/mod.rs | 14 + crates/openshell-operator/Cargo.toml | 77 ++++ crates/openshell-operator/src/bin/crd_gen.rs | 14 + crates/openshell-operator/src/config.rs | 23 + crates/openshell-operator/src/controller.rs | 227 +++++++++ crates/openshell-operator/src/crd.rs | 435 ++++++++++++++++++ crates/openshell-operator/src/error.rs | 94 ++++ crates/openshell-operator/src/kube_service.rs | 146 ++++++ crates/openshell-operator/src/labels.rs | 113 +++++ crates/openshell-operator/src/lib.rs | 53 +++ crates/openshell-operator/src/main.rs | 68 +++ .../src/manifests/deployment.rs | 385 ++++++++++++++++ .../openshell-operator/src/manifests/mod.rs | 36 ++ .../src/manifests/service.rs | 180 ++++++++ crates/openshell-operator/src/webhooks/mod.rs | 92 ++++ .../openshell-operator/src/webhooks/mutate.rs | 246 ++++++++++ .../src/webhooks/validate.rs | 263 +++++++++++ crates/openshell-server/Cargo.toml | 2 + crates/openshell-server/src/cli.rs | 7 + crates/openshell-server/src/config_file.rs | 5 + crates/openshell-server/src/grpc/mod.rs | 1 + .../src/grpc/sandbox_runtime.rs | 167 +++++++ crates/openshell-server/src/lib.rs | 24 + crates/openshell-server/src/multiplex.rs | 60 ++- proto/sandbox_runtime_manager.proto | 62 +++ 28 files changed, 2832 insertions(+), 9 deletions(-) create mode 100644 crates/openshell-operator/Cargo.toml create mode 100644 crates/openshell-operator/src/bin/crd_gen.rs create mode 100644 crates/openshell-operator/src/config.rs create mode 100644 crates/openshell-operator/src/controller.rs create mode 100644 crates/openshell-operator/src/crd.rs create mode 100644 crates/openshell-operator/src/error.rs create mode 100644 crates/openshell-operator/src/kube_service.rs create mode 100644 crates/openshell-operator/src/labels.rs create mode 100644 crates/openshell-operator/src/lib.rs create mode 100644 crates/openshell-operator/src/main.rs create mode 100644 crates/openshell-operator/src/manifests/deployment.rs create mode 100644 crates/openshell-operator/src/manifests/mod.rs create mode 100644 crates/openshell-operator/src/manifests/service.rs create mode 100644 crates/openshell-operator/src/webhooks/mod.rs create mode 100644 crates/openshell-operator/src/webhooks/mutate.rs create mode 100644 crates/openshell-operator/src/webhooks/validate.rs create mode 100644 crates/openshell-server/src/grpc/sandbox_runtime.rs create mode 100644 proto/sandbox_runtime_manager.proto diff --git a/Cargo.lock b/Cargo.lock index 13b670f55..9ddbd6c96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3765,6 +3765,36 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "openshell-operator" +version = "0.0.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "futures", + "hyper", + "hyper-util", + "json-patch", + "k8s-openapi", + "kube", + "prost", + "rustls", + "rustls-pemfile", + "schemars", + "serde", + "serde_json", + "serde_yml", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tokio-stream", + "tonic", + "tower 0.5.3", + "tracing", + "tracing-subscriber", +] + [[package]] name = "openshell-policy" version = "0.0.0" @@ -3879,6 +3909,7 @@ dependencies = [ "openshell-driver-kubernetes", "openshell-driver-podman", "openshell-ocsf", + "openshell-operator", "openshell-policy", "openshell-prover", "openshell-providers", @@ -3895,6 +3926,7 @@ dependencies = [ "rustix 1.1.4", "rustls", "rustls-pemfile", + "schemars", "serde", "serde_json", "sha2 0.10.9", diff --git a/Cargo.toml b/Cargo.toml index f450cd5c8..6bf6296ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,10 @@ kube = { version = "0.90", features = ["runtime", "derive"] } kube-runtime = "0.90" k8s-openapi = { version = "0.21.1", features = ["v1_26"] } +# CRD support (operator) +schemars = "0.8" +json-patch = "1" + # IDs uuid = { version = "1.10", features = ["v4"] } diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index ba6b9d401..c06a6617d 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -400,6 +400,9 @@ pub struct Config { /// Browser-facing sandbox service routing configuration. pub service_routing: ServiceRoutingConfig, + + /// Whether the SandboxRuntime operator bridge is enabled. + pub operator_enabled: bool, } /// Browser-facing sandbox service routing configuration. @@ -584,6 +587,7 @@ impl Config { grpc_rate_limit_requests: None, grpc_rate_limit_window_secs: None, service_routing: ServiceRoutingConfig::default(), + operator_enabled: false, } } @@ -705,6 +709,13 @@ impl Config { self.service_routing.enable_loopback_service_http = enabled; self } + + /// Enable or disable the SandboxRuntime operator bridge. + #[must_use] + pub const fn with_operator_enabled(mut self, enabled: bool) -> Self { + self.operator_enabled = enabled; + self + } } impl Default for ServiceRoutingConfig { diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 08b062d2e..c49bc882a 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -79,8 +79,22 @@ pub mod inference { } } +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + unused_qualifications, + rust_2018_idioms +)] +pub mod runtime { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/openshell.runtime.v1.rs")); + } +} + pub use datamodel::v1::*; pub use inference::v1::*; pub use openshell::*; +pub use runtime::v1::*; pub use sandbox::v1::*; pub use test::ObjectForTest; diff --git a/crates/openshell-operator/Cargo.toml b/crates/openshell-operator/Cargo.toml new file mode 100644 index 000000000..d347fccc0 --- /dev/null +++ b/crates/openshell-operator/Cargo.toml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-operator" +description = "Kubernetes operator for SandboxRuntime CRD lifecycle management" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "openshell-operator" +path = "src/main.rs" + +[[bin]] +name = "openshell-crd-gen" +path = "src/bin/crd_gen.rs" + +[lib] +name = "openshell_operator" +path = "src/lib.rs" + +[dependencies] +# Kubernetes (inherits workspace features: runtime, derive) +# Additional "admission" feature for webhook support +kube = { workspace = true, features = ["admission"] } +k8s-openapi = { workspace = true } + +# CRD schema generation +schemars = { workspace = true } + +# Webhook JSON patches +json-patch = { workspace = true } + +# gRPC / Protobuf (for bridge service types) +tonic = { workspace = true } +prost = { workspace = true } + +# HTTP server (webhook endpoints) +axum = { workspace = true } +tower = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } + +# TLS (webhook HTTPS) +tokio-rustls = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } + +# Async runtime +tokio = { workspace = true } +tokio-stream = { workspace = true } +futures = { workspace = true } + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } +serde_yml = { workspace = true } + +# Error handling +thiserror = { workspace = true } +anyhow = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +# CLI (operator binary args) +clap = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["full", "test-util"] } + +[lints] +workspace = true diff --git a/crates/openshell-operator/src/bin/crd_gen.rs b/crates/openshell-operator/src/bin/crd_gen.rs new file mode 100644 index 000000000..11ff12b03 --- /dev/null +++ b/crates/openshell-operator/src/bin/crd_gen.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Utility to generate the SandboxRuntime CRD YAML for Helm chart deployment. + +use kube::CustomResourceExt; +use openshell_operator::crd::SandboxRuntime; + +fn main() { + print!( + "{}", + serde_yml::to_string(&SandboxRuntime::crd()).unwrap() + ); +} diff --git a/crates/openshell-operator/src/config.rs b/crates/openshell-operator/src/config.rs new file mode 100644 index 000000000..32b8d3355 --- /dev/null +++ b/crates/openshell-operator/src/config.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Configuration types for the operator binary. + +/// Configuration for the operator binary. +#[derive(Clone, Debug)] +pub struct OperatorConfig { + /// Namespace to watch. `None` = all namespaces. + pub namespace: Option, + + /// Metrics server bind address. + pub metrics_addr: String, + + /// Webhook server bind address. + pub webhook_addr: String, + + /// Path to TLS certificate for webhook server. + pub tls_cert_path: Option, + + /// Path to TLS private key for webhook server. + pub tls_key_path: Option, +} diff --git a/crates/openshell-operator/src/controller.rs b/crates/openshell-operator/src/controller.rs new file mode 100644 index 000000000..97610b711 --- /dev/null +++ b/crates/openshell-operator/src/controller.rs @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Controller reconciliation loop for `SandboxRuntime` CRDs. +//! +//! Watches `SandboxRuntime` custom resources and reconciles them to +//! Kubernetes Deployments and Services, using the kube-rs Controller +//! abstraction (the Rust equivalent of Go's `controller-runtime`). + +use std::sync::Arc; + +use futures::StreamExt; +use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::core::v1::Service; +use kube::api::Api; +use kube::runtime::controller::Action; +use kube::runtime::finalizer::{self, Event as FinalizerEvent}; +use kube::runtime::watcher; +use kube::runtime::Controller; +use kube::Client; +use tracing::{error, info, warn}; + +use crate::config::OperatorConfig; +use crate::crd::{SandboxRuntime, SandboxRuntimeStatus}; +use crate::error::OperatorError; +use crate::kube_service::KubeService; +use crate::labels; +use crate::manifests; + +/// Shared context passed to the reconciler. +struct Context { + kube: KubeService, +} + +/// Run the controller loop. +pub async fn run(client: Client, config: OperatorConfig) -> anyhow::Result<()> { + let runtime_api: Api = match &config.namespace { + Some(ns) => Api::namespaced(client.clone(), ns), + None => Api::all(client.clone()), + }; + + // Secondary watch: Deployments owned by SandboxRuntime. + let deployment_api: Api = match &config.namespace { + Some(ns) => Api::namespaced(client.clone(), ns), + None => Api::all(client.clone()), + }; + + // Secondary watch: Services owned by SandboxRuntime. + let service_api: Api = match &config.namespace { + Some(ns) => Api::namespaced(client.clone(), ns), + None => Api::all(client.clone()), + }; + + let ctx = Arc::new(Context { + kube: KubeService::new(client), + }); + + info!( + namespace = config.namespace.as_deref().unwrap_or("all"), + "starting controller" + ); + + Controller::new(runtime_api, watcher::Config::default()) + .owns(deployment_api, watcher::Config::default()) + .owns(service_api, watcher::Config::default()) + .run(reconcile, error_policy, ctx) + .for_each(|result| async move { + match result { + Ok((obj_ref, _action)) => { + info!( + name = %obj_ref.name, + namespace = obj_ref.namespace.as_deref().unwrap_or("-"), + "reconciled" + ); + } + Err(e) => { + error!(error = %e, "reconciliation error"); + } + } + }) + .await; + + Ok(()) +} + +/// Main reconcile function called for each `SandboxRuntime` event. +async fn reconcile( + runtime: Arc, + ctx: Arc, +) -> Result { + let namespace = runtime + .metadata + .namespace + .as_deref() + .unwrap_or("default"); + let name = runtime.metadata.name.as_deref().unwrap_or("unknown"); + + info!(name, namespace, "reconciling SandboxRuntime"); + + let api: Api = ctx.kube.sandbox_runtimes(namespace); + + // Use finalizer for cleanup logic. + finalizer::finalizer(&api, labels::FINALIZER_NAME, runtime, |event| async { + match event { + FinalizerEvent::Apply(runtime) => apply(runtime, &ctx.kube).await, + FinalizerEvent::Cleanup(runtime) => cleanup(runtime, &ctx.kube).await, + } + }) + .await + .map_err(|e| OperatorError::Finalizer(Box::new(e))) +} + +/// Apply (create or update) the desired state for a `SandboxRuntime`. +async fn apply( + runtime: Arc, + kube: &KubeService, +) -> Result { + let namespace = runtime + .metadata + .namespace + .as_deref() + .unwrap_or("default"); + let name = runtime.metadata.name.as_deref().unwrap_or("unknown"); + + // Build desired workload based on target_ref.kind. + match runtime.spec.target_ref.kind.as_str() { + "Deployment" => { + let deployment = manifests::build_deployment(&runtime); + kube.apply_deployment(namespace, &deployment, labels::MANAGER_NAME) + .await?; + + // Create or update the associated Service. + if !runtime.spec.service_ports.is_empty() { + let service = manifests::build_service(&runtime); + kube.apply_service(namespace, &service, labels::MANAGER_NAME) + .await?; + } + } + "StatefulSet" | "Sandbox" => { + // Future: implement StatefulSet and Sandbox builders. + warn!( + name, + kind = runtime.spec.target_ref.kind, + "workload kind not yet implemented, skipping" + ); + } + other => { + warn!(name, kind = other, "unsupported target_ref.kind"); + return update_status( + kube, + &runtime, + "Error", + &format!("unsupported targetRef kind: {other}"), + ) + .await; + } + } + + // Update status to reflect successful reconciliation. + update_status(kube, &runtime, "Ready", "reconciliation complete").await +} + +/// Clean up resources owned by a `SandboxRuntime` being deleted. +async fn cleanup( + runtime: Arc, + kube: &KubeService, +) -> Result { + let namespace = runtime + .metadata + .namespace + .as_deref() + .unwrap_or("default"); + let name = runtime.metadata.name.as_deref().unwrap_or("unknown"); + + info!(name, namespace, "cleaning up SandboxRuntime resources"); + + // OwnerReferences handle cascade deletion for Deployment and Service. + // This explicit cleanup is belt-and-suspenders. + kube.delete_deployment(namespace, name).await?; + kube.delete_service(namespace, name).await?; + + info!(name, namespace, "cleanup complete"); + Ok(Action::await_change()) +} + +/// Update the status subresource of a `SandboxRuntime`. +async fn update_status( + kube: &KubeService, + runtime: &SandboxRuntime, + phase: &str, + message: &str, +) -> Result { + let namespace = runtime + .metadata + .namespace + .as_deref() + .unwrap_or("default"); + let name = runtime.metadata.name.as_deref().unwrap_or("unknown"); + + let mut updated = runtime.clone(); + let generation = runtime.metadata.generation.unwrap_or(0); + + updated.status = Some(SandboxRuntimeStatus { + phase: phase.to_string(), + message: message.to_string(), + observed_generation: generation, + ..runtime.status.clone().unwrap_or_default() + }); + + match kube.update_status(namespace, name, &updated).await { + Ok(_) => Ok(Action::requeue(std::time::Duration::from_secs(300))), + Err(e) => { + warn!(name, namespace, error = %e, "failed to update status, will retry"); + Ok(Action::requeue(std::time::Duration::from_secs(30))) + } + } +} + +/// Error policy: requeue with backoff. +fn error_policy( + _runtime: Arc, + error: &OperatorError, + _ctx: Arc, +) -> Action { + warn!(error = %error, "reconciliation failed, will retry"); + Action::requeue(std::time::Duration::from_secs(30)) +} diff --git a/crates/openshell-operator/src/crd.rs b/crates/openshell-operator/src/crd.rs new file mode 100644 index 000000000..a67c41ca9 --- /dev/null +++ b/crates/openshell-operator/src/crd.rs @@ -0,0 +1,435 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! SandboxRuntime custom resource definition. +//! +//! Ported from Kagenti's AgentRuntime CRD (`agent.kagenti.dev/v1alpha1`), +//! adapted to OpenShell's sandbox-centric model. + +use kube::CustomResource; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Specification for a SandboxRuntime custom resource. +/// +/// Defines the desired state of a managed sandbox workload, including +/// the container image, replica count, environment variables, resource +/// requirements, and service port configuration. +#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[kube( + group = "openshell.io", + version = "v1alpha1", + kind = "SandboxRuntime", + plural = "sandboxruntimes", + namespaced, + status = "SandboxRuntimeStatus", + shortname = "srt", + printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.runtimeType"}"#, + printcolumn = r#"{"name":"Image","type":"string","jsonPath":".spec.image"}"#, + printcolumn = r#"{"name":"Replicas","type":"integer","jsonPath":".spec.replicas"}"#, + printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#, + printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"# +)] +pub struct SandboxRuntimeSpec { + /// The type of runtime (e.g., "agent", "tool"). + #[serde(default = "default_runtime_type")] + pub runtime_type: String, + + /// Reference to the target workload managed by this runtime. + pub target_ref: TargetRef, + + /// Container image for the workload. + pub image: String, + + /// Desired number of replicas. + #[serde(default = "default_replicas")] + pub replicas: i32, + + /// Environment variables to inject into the workload containers. + #[serde(default)] + pub env: Vec, + + /// Resource requirements for the primary container. + #[serde(default)] + pub resources: Option, + + /// Service ports to expose. + #[serde(default = "default_service_ports")] + pub service_ports: Vec, + + /// Human-readable description of the runtime. + #[serde(default)] + pub description: String, +} + +/// Reference to the Kubernetes workload managed by this `SandboxRuntime`. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct TargetRef { + /// API version of the target (e.g., `"apps/v1"`). + #[serde(default = "default_api_version")] + pub api_version: String, + + /// Kind of the target (e.g., `"Deployment"`, `"StatefulSet"`, `"Sandbox"`). + pub kind: String, + + /// Name of the target workload. Defaults to the `SandboxRuntime` name. + #[serde(default)] + pub name: String, +} + +/// Environment variable for a container. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct EnvVar { + /// Variable name. + pub name: String, + /// Variable value. + pub value: String, +} + +/// Resource requirements for a container. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct ResourceRequirements { + /// Resource requests (minimum guaranteed). + #[serde(default)] + pub requests: Option, + /// Resource limits (maximum allowed). + #[serde(default)] + pub limits: Option, +} + +/// CPU and memory resource specifications. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct ResourceSpec { + /// CPU quantity (e.g., `"100m"`, `"1"`). + #[serde(default)] + pub cpu: Option, + /// Memory quantity (e.g., `"256Mi"`, `"1Gi"`). + #[serde(default)] + pub memory: Option, +} + +/// Service port specification. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ServicePort { + /// Port name. + #[serde(default = "default_port_name")] + pub name: String, + /// Service port number (external). + #[serde(default = "default_port")] + pub port: i32, + /// Target port number (container). + #[serde(default = "default_target_port")] + pub target_port: i32, + /// Protocol (TCP, UDP). + #[serde(default = "default_protocol")] + pub protocol: String, +} + +/// Status of the `SandboxRuntime` custom resource. +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SandboxRuntimeStatus { + /// Current phase: `Pending`, `Provisioning`, `Ready`, `Error`, `Deleting`. + #[serde(default)] + pub phase: String, + + /// Human-readable message about the current state. + #[serde(default)] + pub message: String, + + /// Number of ready replicas observed by the controller. + #[serde(default)] + pub ready_replicas: i32, + + /// The generation of the spec that was last reconciled. + #[serde(default)] + pub observed_generation: i64, + + /// Conditions following Kubernetes conventions. + #[serde(default)] + pub conditions: Vec, +} + +/// Kubernetes-style condition. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Condition { + /// Condition type (e.g., `"Ready"`, `"Progressing"`). + pub r#type: String, + /// Status value: `"True"`, `"False"`, or `"Unknown"`. + pub status: String, + /// Machine-readable reason for the condition. + #[serde(default)] + pub reason: String, + /// Human-readable message. + #[serde(default)] + pub message: String, + /// When the condition last transitioned. + #[serde(default)] + pub last_transition_time: Option, +} + +// -- Default value functions -------------------------------------------------- + +fn default_runtime_type() -> String { + "agent".to_string() +} + +fn default_replicas() -> i32 { + 1 +} + +fn default_api_version() -> String { + "apps/v1".to_string() +} + +fn default_service_ports() -> Vec { + vec![ServicePort { + name: "http".to_string(), + port: 8080, + target_port: 8000, + protocol: "TCP".to_string(), + }] +} + +fn default_port_name() -> String { + "http".to_string() +} + +fn default_port() -> i32 { + 8080 +} + +fn default_target_port() -> i32 { + 8000 +} + +fn default_protocol() -> String { + "TCP".to_string() +} + +// -- Tests -------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use kube::CustomResourceExt; + + #[test] + fn spec_serializes_all_fields() { + let spec = SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: "my-agent".into(), + }, + image: "my-image:latest".into(), + replicas: 2, + env: vec![EnvVar { + name: "PORT".into(), + value: "8000".into(), + }], + resources: Some(ResourceRequirements { + requests: Some(ResourceSpec { + cpu: Some("100m".into()), + memory: Some("256Mi".into()), + }), + limits: Some(ResourceSpec { + cpu: Some("500m".into()), + memory: Some("1Gi".into()), + }), + }), + service_ports: vec![ServicePort { + name: "http".into(), + port: 8080, + target_port: 8000, + protocol: "TCP".into(), + }], + description: "Test runtime".into(), + }; + + let json = serde_json::to_string(&spec).unwrap(); + let roundtrip: SandboxRuntimeSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtrip.runtime_type, spec.runtime_type); + assert_eq!(roundtrip.image, spec.image); + assert_eq!(roundtrip.replicas, spec.replicas); + assert_eq!(roundtrip.env, spec.env); + } + + #[test] + fn spec_defaults_applied() { + // Use camelCase keys to match #[serde(rename_all = "camelCase")] + let json = r#"{"targetRef":{"kind":"Deployment"},"image":"test:v1"}"#; + let spec: SandboxRuntimeSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.runtime_type, "agent"); + assert_eq!(spec.replicas, 1); + assert_eq!(spec.target_ref.api_version, "apps/v1"); + assert_eq!(spec.service_ports.len(), 1); + assert_eq!(spec.service_ports[0].port, 8080); + } + + #[test] + fn target_ref_deployment() { + let tr = TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: "my-agent".into(), + }; + let json = serde_json::to_value(&tr).unwrap(); + assert_eq!(json["apiVersion"], "apps/v1"); + assert_eq!(json["kind"], "Deployment"); + assert_eq!(json["name"], "my-agent"); + } + + #[test] + fn target_ref_sandbox() { + let tr = TargetRef { + api_version: "agents.x-k8s.io/v1alpha1".into(), + kind: "Sandbox".into(), + name: "my-sandbox".into(), + }; + let json = serde_json::to_value(&tr).unwrap(); + assert_eq!(json["apiVersion"], "agents.x-k8s.io/v1alpha1"); + assert_eq!(json["kind"], "Sandbox"); + } + + #[test] + fn status_default_is_empty() { + let status = SandboxRuntimeStatus::default(); + assert!(status.phase.is_empty()); + assert!(status.conditions.is_empty()); + assert_eq!(status.ready_replicas, 0); + } + + #[test] + fn status_serializes_with_conditions() { + let status = SandboxRuntimeStatus { + phase: "Ready".into(), + message: "All replicas ready".into(), + ready_replicas: 1, + observed_generation: 3, + conditions: vec![Condition { + r#type: "Ready".into(), + status: "True".into(), + reason: "ReplicasReady".into(), + message: "All replicas are available".into(), + last_transition_time: Some("2026-07-03T00:00:00Z".into()), + }], + }; + let json = serde_json::to_string(&status).unwrap(); + let roundtrip: SandboxRuntimeStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtrip.phase, "Ready"); + assert_eq!(roundtrip.conditions.len(), 1); + assert_eq!(roundtrip.conditions[0].r#type, "Ready"); + } + + #[test] + fn conditions_none_deserializes_to_empty_vec() { + let json = r#"{"phase":"Ready","message":"ok"}"#; + let status: SandboxRuntimeStatus = serde_json::from_str(json).unwrap(); + assert!(status.conditions.is_empty()); + } + + #[test] + fn crd_group_version_is_correct() { + let crd = SandboxRuntime::crd(); + assert_eq!(crd.spec.group, "openshell.io"); + assert_eq!(crd.spec.versions[0].name, "v1alpha1"); + } + + #[test] + fn crd_plural_is_sandboxruntimes() { + let crd = SandboxRuntime::crd(); + assert_eq!(crd.spec.names.plural, "sandboxruntimes"); + } + + #[test] + fn crd_scope_is_namespaced() { + let crd = SandboxRuntime::crd(); + assert_eq!(crd.spec.scope, "Namespaced"); + } + + #[test] + fn service_port_defaults() { + let json = r#"{}"#; + let sp: ServicePort = serde_json::from_str(json).unwrap(); + assert_eq!(sp.name, "http"); + assert_eq!(sp.port, 8080); + assert_eq!(sp.target_port, 8000); + assert_eq!(sp.protocol, "TCP"); + } + + #[test] + fn env_var_round_trip() { + let ev = EnvVar { + name: "MY_VAR".into(), + value: "my_value".into(), + }; + let json = serde_json::to_string(&ev).unwrap(); + let roundtrip: EnvVar = serde_json::from_str(&json).unwrap(); + assert_eq!(roundtrip, ev); + } + + #[test] + fn camel_case_serialization() { + let spec = SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: "test".into(), + }, + image: "img:v1".into(), + replicas: 1, + env: vec![], + resources: None, + service_ports: vec![], + description: String::new(), + }; + let json = serde_json::to_value(&spec).unwrap(); + assert!(json.get("runtimeType").is_some()); + assert!(json.get("targetRef").is_some()); + assert!(json.get("servicePorts").is_some()); + } + + #[test] + fn crd_shortname_is_srt() { + let crd = SandboxRuntime::crd(); + let short_names = &crd.spec.names.short_names; + assert!( + short_names + .as_ref() + .is_some_and(|s| s.contains(&"srt".to_string())), + "expected shortname 'srt', got: {short_names:?}" + ); + } + + #[test] + fn crd_has_print_columns() { + let crd = SandboxRuntime::crd(); + let version = &crd.spec.versions[0]; + let columns = version + .additional_printer_columns + .as_ref() + .expect("expected print columns"); + assert!(columns.len() >= 4, "expected at least 4 print columns"); + } + + #[test] + fn resource_requirements_optional() { + let json = r#"{"targetRef":{"kind":"Deployment"},"image":"test:v1"}"#; + let spec: SandboxRuntimeSpec = serde_json::from_str(json).unwrap(); + assert!(spec.resources.is_none()); + } + + #[test] + fn target_ref_name_defaults_to_empty() { + let json = r#"{"kind":"Deployment"}"#; + let tr: TargetRef = serde_json::from_str(json).unwrap(); + assert!(tr.name.is_empty()); + } +} diff --git a/crates/openshell-operator/src/error.rs b/crates/openshell-operator/src/error.rs new file mode 100644 index 000000000..3f5b2f3b2 --- /dev/null +++ b/crates/openshell-operator/src/error.rs @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Error types for the operator. +//! +//! Follows the pattern established by `KubernetesDriverError` in +//! `openshell-driver-kubernetes/src/driver.rs`. + +/// Errors that can occur during operator reconciliation. +#[derive(Debug, thiserror::Error)] +pub enum OperatorError { + /// Kubernetes API error. + #[error("kubernetes API error: {0}")] + Kube(#[from] kube::Error), + + /// Serialization/deserialization error. + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + /// The reconciled object is missing expected fields. + #[error("missing field: {0}")] + MissingField(String), + + /// A finalizer operation failed. + #[error("finalizer error: {0}")] + Finalizer(#[source] Box>), + + /// Status update failed. + #[error("status update failed: {0}")] + StatusUpdate(String), +} + +/// Result type alias for operator operations. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kube_error_display() { + let err = OperatorError::Kube(kube::Error::Api(kube::core::ErrorResponse { + code: 404, + message: "not found".into(), + reason: "NotFound".into(), + status: "Failure".into(), + })); + let msg = err.to_string(); + assert!(msg.contains("kubernetes API error"), "got: {msg}"); + } + + #[test] + fn missing_field_display() { + let err = OperatorError::MissingField("metadata.name".into()); + assert!( + err.to_string().contains("missing field: metadata.name"), + "got: {}", + err + ); + } + + #[test] + fn serialization_error_display() { + let serde_err = serde_json::from_str::("invalid").unwrap_err(); + let err = OperatorError::Serialization(serde_err); + assert!( + err.to_string().contains("serialization error"), + "got: {}", + err + ); + } + + #[test] + fn status_update_display() { + let err = OperatorError::StatusUpdate("conflict".into()); + assert!( + err.to_string().contains("status update failed: conflict"), + "got: {}", + err + ); + } + + #[test] + fn kube_error_from_conversion() { + let kube_err = kube::Error::Api(kube::core::ErrorResponse { + code: 500, + message: "internal".into(), + reason: "InternalError".into(), + status: "Failure".into(), + }); + let err: OperatorError = kube_err.into(); + assert!(matches!(err, OperatorError::Kube(_))); + } +} diff --git a/crates/openshell-operator/src/kube_service.rs b/crates/openshell-operator/src/kube_service.rs new file mode 100644 index 000000000..f4c6a4bee --- /dev/null +++ b/crates/openshell-operator/src/kube_service.rs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Kubernetes client service for operator CRUD operations. +//! +//! Provides typed API handles for Deployments, Services, and SandboxRuntime +//! CRDs, wrapping the raw `kube::Client` with operator-specific convenience +//! methods. + +use k8s_openapi::api::apps::v1::Deployment; +use k8s_openapi::api::core::v1::Service; +use kube::api::{Api, Patch, PatchParams, PostParams}; +use kube::Client; +use tracing::debug; + +use crate::crd::SandboxRuntime; +use crate::error::{OperatorError, Result}; + +/// Kubernetes client service for operator CRUD operations. +#[derive(Clone)] +pub struct KubeService { + client: Client, +} + +impl KubeService { + /// Create a new service wrapping the given client. + pub fn new(client: Client) -> Self { + Self { client } + } + + /// Get a namespaced API handle for Deployments. + pub fn deployments(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// Get a namespaced API handle for Services. + pub fn services(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// Get a namespaced API handle for `SandboxRuntime` CRDs. + pub fn sandbox_runtimes(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } + + /// Apply (create or update) a Deployment using server-side apply. + /// + /// Server-side apply is idempotent and handles both creation and updates. + pub async fn apply_deployment( + &self, + namespace: &str, + deployment: &Deployment, + field_manager: &str, + ) -> Result { + let api = self.deployments(namespace); + let name = deployment + .metadata + .name + .as_deref() + .ok_or_else(|| OperatorError::MissingField("deployment.metadata.name".into()))?; + + let result = api + .patch( + name, + &PatchParams::apply(field_manager), + &Patch::Apply(deployment), + ) + .await?; + + debug!(name, namespace, "deployment applied"); + Ok(result) + } + + /// Apply (create or update) a Service using server-side apply. + pub async fn apply_service( + &self, + namespace: &str, + service: &Service, + field_manager: &str, + ) -> Result { + let api = self.services(namespace); + let name = service + .metadata + .name + .as_deref() + .ok_or_else(|| OperatorError::MissingField("service.metadata.name".into()))?; + + let result = api + .patch( + name, + &PatchParams::apply(field_manager), + &Patch::Apply(service), + ) + .await?; + + debug!(name, namespace, "service applied"); + Ok(result) + } + + /// Delete a Deployment by name. Returns `Ok(())` if not found. + pub async fn delete_deployment(&self, namespace: &str, name: &str) -> Result<()> { + let api = self.deployments(namespace); + match api.delete(name, &Default::default()).await { + Ok(_) => { + debug!(name, namespace, "deployment deleted"); + Ok(()) + } + Err(kube::Error::Api(err)) if err.code == 404 => { + debug!(name, namespace, "deployment not found, nothing to delete"); + Ok(()) + } + Err(e) => Err(e.into()), + } + } + + /// Delete a Service by name. Returns `Ok(())` if not found. + pub async fn delete_service(&self, namespace: &str, name: &str) -> Result<()> { + let api = self.services(namespace); + match api.delete(name, &Default::default()).await { + Ok(_) => { + debug!(name, namespace, "service deleted"); + Ok(()) + } + Err(kube::Error::Api(err)) if err.code == 404 => { + debug!(name, namespace, "service not found, nothing to delete"); + Ok(()) + } + Err(e) => Err(e.into()), + } + } + + /// Update the status subresource of a `SandboxRuntime`. + pub async fn update_status( + &self, + namespace: &str, + name: &str, + runtime: &SandboxRuntime, + ) -> Result { + let api = self.sandbox_runtimes(namespace); + let result = api + .replace_status(name, &PostParams::default(), serde_json::to_vec(runtime)?) + .await?; + debug!(name, namespace, "status updated"); + Ok(result) + } +} diff --git a/crates/openshell-operator/src/labels.rs b/crates/openshell-operator/src/labels.rs new file mode 100644 index 000000000..155055bb4 --- /dev/null +++ b/crates/openshell-operator/src/labels.rs @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Label and annotation constants for operator-managed resources. +//! +//! Maps Kagenti's label taxonomy (`kagenti.io/*`) to OpenShell's +//! conventions (`openshell.io/*`), plus standard Kubernetes labels. + +// -- Standard Kubernetes labels ----------------------------------------------- + +/// Label key: managed-by identifier. +pub const MANAGED_BY_KEY: &str = "app.kubernetes.io/managed-by"; + +/// Label key: application name. +pub const APP_NAME_KEY: &str = "app.kubernetes.io/name"; + +/// Label key: component type. +pub const COMPONENT_KEY: &str = "app.kubernetes.io/component"; + +// -- OpenShell-specific labels ------------------------------------------------ + +/// Label key: runtime type (agent, tool). +pub const RUNTIME_TYPE_KEY: &str = "openshell.io/runtime-type"; + +/// Label key: workload type (deployment, statefulset, sandbox). +pub const WORKLOAD_TYPE_KEY: &str = "openshell.io/workload-type"; + +// -- Label values ------------------------------------------------------------- + +/// Value for the managed-by label on operator-created resources. +pub const MANAGER_NAME: &str = "openshell-operator"; + +/// Default component label value. +pub const COMPONENT_SANDBOX: &str = "sandbox"; + +// -- Finalizer ---------------------------------------------------------------- + +/// Finalizer name registered on `SandboxRuntime` resources. +pub const FINALIZER_NAME: &str = "openshell.io/sandbox-runtime-finalizer"; + +// -- Defaults ----------------------------------------------------------------- + +/// Default container name in generated workloads. +pub const DEFAULT_CONTAINER_NAME: &str = "agent"; + +/// Default image pull policy. +pub const DEFAULT_IMAGE_PULL_POLICY: &str = "Always"; + +/// Default CPU request. +pub const DEFAULT_CPU_REQUEST: &str = "100m"; + +/// Default memory request. +pub const DEFAULT_MEMORY_REQUEST: &str = "256Mi"; + +/// Default CPU limit. +pub const DEFAULT_CPU_LIMIT: &str = "500m"; + +/// Default memory limit. +pub const DEFAULT_MEMORY_LIMIT: &str = "1Gi"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn managed_by_label_key() { + assert_eq!(MANAGED_BY_KEY, "app.kubernetes.io/managed-by"); + } + + #[test] + fn managed_by_value() { + assert_eq!(MANAGER_NAME, "openshell-operator"); + } + + #[test] + fn app_name_label_key() { + assert_eq!(APP_NAME_KEY, "app.kubernetes.io/name"); + } + + #[test] + fn component_label_key() { + assert_eq!(COMPONENT_KEY, "app.kubernetes.io/component"); + } + + #[test] + fn runtime_type_label_key() { + assert_eq!(RUNTIME_TYPE_KEY, "openshell.io/runtime-type"); + } + + #[test] + fn workload_type_label_key() { + assert_eq!(WORKLOAD_TYPE_KEY, "openshell.io/workload-type"); + } + + #[test] + fn finalizer_name_is_fqdn() { + assert!(FINALIZER_NAME.contains("openshell.io")); + assert!(FINALIZER_NAME.contains("sandbox-runtime")); + } + + #[test] + fn default_container_name() { + assert_eq!(DEFAULT_CONTAINER_NAME, "agent"); + } + + #[test] + fn default_resource_values() { + assert_eq!(DEFAULT_CPU_REQUEST, "100m"); + assert_eq!(DEFAULT_MEMORY_REQUEST, "256Mi"); + assert_eq!(DEFAULT_CPU_LIMIT, "500m"); + assert_eq!(DEFAULT_MEMORY_LIMIT, "1Gi"); + } +} diff --git a/crates/openshell-operator/src/lib.rs b/crates/openshell-operator/src/lib.rs new file mode 100644 index 000000000..62b768d0e --- /dev/null +++ b/crates/openshell-operator/src/lib.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenShell Kubernetes Operator library. +//! +//! Provides a `SandboxRuntime` CRD and controller that reconciles declarative +//! sandbox specifications into Kubernetes workloads (Deployments, Services). +//! +//! Ported from Kagenti's AgentRuntime CRD + operator pattern, adapted to +//! OpenShell's Rust/kube-rs conventions. + +pub mod config; +pub mod controller; +pub mod crd; +pub mod error; +pub mod kube_service; +pub mod labels; +pub mod manifests; +pub mod webhooks; + +pub use config::OperatorConfig; +pub use crd::{SandboxRuntime, SandboxRuntimeSpec, SandboxRuntimeStatus}; +pub use error::OperatorError; + +/// Run the operator with the given configuration. +/// +/// Uses `tokio::select!` so that if either the controller or webhook server +/// exits (error or otherwise), the other is dropped and the error propagates +/// immediately (V1 review fix). +pub async fn run(config: OperatorConfig) -> anyhow::Result<()> { + let client = kube::Client::try_default().await?; + + // Start controller and webhook server concurrently. + let controller_fut = controller::run(client.clone(), config.clone()); + + if config.tls_cert_path.is_some() && config.tls_key_path.is_some() { + let webhook_fut = webhooks::run_webhook_server(config.clone()); + // Run both concurrently -- if either exits, the other is dropped. + tokio::select! { + result = controller_fut => { + result?; + } + result = webhook_fut => { + result?; + } + } + } else { + tracing::info!("webhook TLS not configured, skipping webhook server"); + controller_fut.await?; + } + + Ok(()) +} diff --git a/crates/openshell-operator/src/main.rs b/crates/openshell-operator/src/main.rs new file mode 100644 index 000000000..71353836d --- /dev/null +++ b/crates/openshell-operator/src/main.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OpenShell Kubernetes Operator for declarative sandbox lifecycle management. + +use clap::Parser; + +#[derive(Parser, Debug)] +#[command( + name = "openshell-operator", + about = "Kubernetes operator for SandboxRuntime CRDs" +)] +struct Args { + /// Namespace to watch (empty = all namespaces). + #[arg(long, env = "WATCH_NAMESPACE", default_value = "")] + namespace: String, + + /// Metrics bind address. + #[arg( + long, + env = "METRICS_BIND_ADDRESS", + default_value = "0.0.0.0:8080" + )] + metrics_addr: String, + + /// Webhook server bind address. + #[arg( + long, + env = "WEBHOOK_BIND_ADDRESS", + default_value = "0.0.0.0:9443" + )] + webhook_addr: String, + + /// Path to TLS certificate for webhook server. + #[arg(long, env = "WEBHOOK_TLS_CERT")] + tls_cert: Option, + + /// Path to TLS key for webhook server. + #[arg(long, env = "WEBHOOK_TLS_KEY")] + tls_key: Option, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,kube=warn".into()), + ) + .json() + .init(); + + let config = openshell_operator::OperatorConfig { + namespace: if args.namespace.is_empty() { + None + } else { + Some(args.namespace) + }, + metrics_addr: args.metrics_addr, + webhook_addr: args.webhook_addr, + tls_cert_path: args.tls_cert, + tls_key_path: args.tls_key, + }; + + openshell_operator::run(config).await +} diff --git a/crates/openshell-operator/src/manifests/deployment.rs b/crates/openshell-operator/src/manifests/deployment.rs new file mode 100644 index 000000000..74306224d --- /dev/null +++ b/crates/openshell-operator/src/manifests/deployment.rs @@ -0,0 +1,385 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Deployment manifest builder. + +use std::collections::BTreeMap; + +use k8s_openapi::api::apps::v1::{Deployment, DeploymentSpec}; +use k8s_openapi::api::core::v1::{ + Container, ContainerPort, EnvVar as K8sEnvVar, PodSpec, PodTemplateSpec, + ResourceRequirements as K8sResources, +}; +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; + +use crate::crd::{SandboxRuntime, SandboxRuntimeSpec}; +use crate::labels; +use crate::manifests::build_owner_reference; + +/// Build a Deployment manifest from a `SandboxRuntime` spec. +/// +/// The Deployment is created with an `OwnerReference` pointing back to the +/// `SandboxRuntime`, ensuring garbage collection on CRD deletion. +pub fn build_deployment(runtime: &SandboxRuntime) -> Deployment { + let spec = &runtime.spec; + let name = runtime.metadata.name.as_deref().unwrap_or("unknown"); + let namespace = runtime.metadata.namespace.as_deref().unwrap_or("default"); + + let common_labels = build_common_labels(name, spec); + let selector_labels = build_selector_labels(name); + + Deployment { + metadata: ObjectMeta { + name: Some(name.to_string()), + namespace: Some(namespace.to_string()), + labels: Some(common_labels.clone()), + owner_references: Some(vec![build_owner_reference(runtime)]), + ..Default::default() + }, + spec: Some(DeploymentSpec { + replicas: Some(spec.replicas), + selector: LabelSelector { + match_labels: Some(selector_labels), + ..Default::default() + }, + template: PodTemplateSpec { + metadata: Some(ObjectMeta { + labels: Some(common_labels), + ..Default::default() + }), + spec: Some(PodSpec { + containers: vec![build_container(spec)], + ..Default::default() + }), + }, + ..Default::default() + }), + ..Default::default() + } +} + +fn build_container(spec: &SandboxRuntimeSpec) -> Container { + let env_vars: Vec = spec + .env + .iter() + .map(|e| K8sEnvVar { + name: e.name.clone(), + value: Some(e.value.clone()), + ..Default::default() + }) + .collect(); + + let ports: Vec = spec + .service_ports + .iter() + .map(|sp| ContainerPort { + name: Some(sp.name.clone()), + container_port: sp.target_port, + protocol: Some(sp.protocol.clone()), + ..Default::default() + }) + .collect(); + + let resources = spec.resources.as_ref().map(|r| { + let mut k8s_res = K8sResources::default(); + if let Some(req) = &r.requests { + let mut map = BTreeMap::new(); + if let Some(cpu) = &req.cpu { + map.insert("cpu".to_string(), Quantity(cpu.clone())); + } + if let Some(mem) = &req.memory { + map.insert("memory".to_string(), Quantity(mem.clone())); + } + k8s_res.requests = Some(map); + } + if let Some(lim) = &r.limits { + let mut map = BTreeMap::new(); + if let Some(cpu) = &lim.cpu { + map.insert("cpu".to_string(), Quantity(cpu.clone())); + } + if let Some(mem) = &lim.memory { + map.insert("memory".to_string(), Quantity(mem.clone())); + } + k8s_res.limits = Some(map); + } + k8s_res + }); + + Container { + name: labels::DEFAULT_CONTAINER_NAME.to_string(), + image: Some(spec.image.clone()), + image_pull_policy: Some(labels::DEFAULT_IMAGE_PULL_POLICY.to_string()), + env: if env_vars.is_empty() { + None + } else { + Some(env_vars) + }, + ports: if ports.is_empty() { None } else { Some(ports) }, + resources, + ..Default::default() + } +} + +/// Build the common label set for workload metadata and pod templates. +pub fn build_common_labels(name: &str, spec: &SandboxRuntimeSpec) -> BTreeMap { + let mut map = BTreeMap::new(); + map.insert(labels::APP_NAME_KEY.to_string(), name.to_string()); + map.insert( + labels::MANAGED_BY_KEY.to_string(), + labels::MANAGER_NAME.to_string(), + ); + map.insert( + labels::RUNTIME_TYPE_KEY.to_string(), + spec.runtime_type.clone(), + ); + map.insert( + labels::WORKLOAD_TYPE_KEY.to_string(), + spec.target_ref.kind.to_lowercase(), + ); + map.insert( + labels::COMPONENT_KEY.to_string(), + labels::COMPONENT_SANDBOX.to_string(), + ); + map +} + +/// Build the pod selector labels (subset of common labels). +pub fn build_selector_labels(name: &str) -> BTreeMap { + let mut map = BTreeMap::new(); + map.insert(labels::APP_NAME_KEY.to_string(), name.to_string()); + map +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crd::{ResourceRequirements, ResourceSpec, ServicePort, TargetRef}; + + fn make_runtime(name: &str) -> SandboxRuntime { + SandboxRuntime::new( + name, + SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: name.into(), + }, + image: "my-image:v1".into(), + replicas: 1, + env: vec![], + resources: None, + service_ports: vec![ServicePort { + name: "http".into(), + port: 8080, + target_port: 8000, + protocol: "TCP".into(), + }], + description: "test".into(), + }, + ) + } + + #[test] + fn deployment_has_correct_name() { + let rt = make_runtime("my-agent"); + let dep = build_deployment(&rt); + assert_eq!(dep.metadata.name.as_deref(), Some("my-agent")); + } + + #[test] + fn deployment_has_single_replica() { + let rt = make_runtime("my-agent"); + let dep = build_deployment(&rt); + assert_eq!(dep.spec.as_ref().unwrap().replicas, Some(1)); + } + + #[test] + fn deployment_uses_specified_image() { + let rt = make_runtime("my-agent"); + let dep = build_deployment(&rt); + let container = + &dep.spec.as_ref().unwrap().template.spec.as_ref().unwrap().containers[0]; + assert_eq!(container.image.as_deref(), Some("my-image:v1")); + } + + #[test] + fn deployment_has_agent_container() { + let rt = make_runtime("my-agent"); + let dep = build_deployment(&rt); + let containers = + &dep.spec.as_ref().unwrap().template.spec.as_ref().unwrap().containers; + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].name, "agent"); + } + + #[test] + fn deployment_labels_match_selector() { + let rt = make_runtime("my-agent"); + let dep = build_deployment(&rt); + let dep_spec = dep.spec.as_ref().unwrap(); + let selector = dep_spec.selector.match_labels.as_ref().unwrap(); + let template_labels = dep_spec + .template + .metadata + .as_ref() + .unwrap() + .labels + .as_ref() + .unwrap(); + for (k, v) in selector { + assert_eq!( + template_labels.get(k), + Some(v), + "selector label {k} missing from template" + ); + } + } + + #[test] + fn deployment_owner_reference_set() { + let mut rt = make_runtime("my-agent"); + rt.metadata.uid = Some("test-uid".into()); + let dep = build_deployment(&rt); + let refs = dep.metadata.owner_references.as_ref().unwrap(); + assert_eq!(refs.len(), 1); + assert!(refs[0].controller.unwrap()); + assert!(refs[0].block_owner_deletion.unwrap()); + assert_eq!(refs[0].name, "my-agent"); + assert_eq!(refs[0].uid, "test-uid"); + } + + #[test] + fn deployment_env_vars_included() { + let mut rt = make_runtime("my-agent"); + rt.spec.env = vec![crate::crd::EnvVar { + name: "PORT".into(), + value: "8000".into(), + }]; + let dep = build_deployment(&rt); + let container = + &dep.spec.as_ref().unwrap().template.spec.as_ref().unwrap().containers[0]; + let env = container.env.as_ref().unwrap(); + assert_eq!(env.len(), 1); + assert_eq!(env[0].name, "PORT"); + assert_eq!(env[0].value.as_deref(), Some("8000")); + } + + #[test] + fn deployment_resource_limits_set() { + let mut rt = make_runtime("my-agent"); + rt.spec.resources = Some(ResourceRequirements { + requests: Some(ResourceSpec { + cpu: Some("100m".into()), + memory: Some("256Mi".into()), + }), + limits: Some(ResourceSpec { + cpu: Some("500m".into()), + memory: Some("1Gi".into()), + }), + }); + let dep = build_deployment(&rt); + let container = + &dep.spec.as_ref().unwrap().template.spec.as_ref().unwrap().containers[0]; + let res = container.resources.as_ref().unwrap(); + let limits = res.limits.as_ref().unwrap(); + assert_eq!(limits.get("cpu").unwrap().0, "500m"); + assert_eq!(limits.get("memory").unwrap().0, "1Gi"); + let requests = res.requests.as_ref().unwrap(); + assert_eq!(requests.get("cpu").unwrap().0, "100m"); + } + + #[test] + fn deployment_port_set() { + let rt = make_runtime("my-agent"); + let dep = build_deployment(&rt); + let container = + &dep.spec.as_ref().unwrap().template.spec.as_ref().unwrap().containers[0]; + let ports = container.ports.as_ref().unwrap(); + assert_eq!(ports.len(), 1); + assert_eq!(ports[0].container_port, 8000); + assert_eq!(ports[0].name.as_deref(), Some("http")); + } + + #[test] + fn common_labels_include_all_keys() { + let spec = SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: "test".into(), + }, + image: "img".into(), + replicas: 1, + env: vec![], + resources: None, + service_ports: vec![], + description: String::new(), + }; + let labels = build_common_labels("my-agent", &spec); + assert_eq!(labels.get(labels::APP_NAME_KEY).unwrap(), "my-agent"); + assert_eq!( + labels.get(labels::MANAGED_BY_KEY).unwrap(), + "openshell-operator" + ); + assert_eq!(labels.get(labels::RUNTIME_TYPE_KEY).unwrap(), "agent"); + assert_eq!( + labels.get(labels::WORKLOAD_TYPE_KEY).unwrap(), + "deployment" + ); + assert_eq!(labels.get(labels::COMPONENT_KEY).unwrap(), "sandbox"); + } + + #[test] + fn selector_labels_only_name() { + let labels = build_selector_labels("my-agent"); + assert_eq!(labels.len(), 1); + assert_eq!(labels.get(labels::APP_NAME_KEY).unwrap(), "my-agent"); + } + + #[test] + fn selector_labels_subset_of_common() { + let spec = SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: "test".into(), + }, + image: "img".into(), + replicas: 1, + env: vec![], + resources: None, + service_ports: vec![], + description: String::new(), + }; + let common = build_common_labels("my-agent", &spec); + let selector = build_selector_labels("my-agent"); + for (k, v) in &selector { + assert_eq!( + common.get(k), + Some(v), + "selector key {k} not in common labels" + ); + } + } + + #[test] + fn deployment_no_env_is_none() { + let rt = make_runtime("my-agent"); + let dep = build_deployment(&rt); + let container = + &dep.spec.as_ref().unwrap().template.spec.as_ref().unwrap().containers[0]; + assert!(container.env.is_none()); + } + + #[test] + fn deployment_multiple_replicas() { + let mut rt = make_runtime("my-agent"); + rt.spec.replicas = 3; + let dep = build_deployment(&rt); + assert_eq!(dep.spec.as_ref().unwrap().replicas, Some(3)); + } +} diff --git a/crates/openshell-operator/src/manifests/mod.rs b/crates/openshell-operator/src/manifests/mod.rs new file mode 100644 index 000000000..fff515d05 --- /dev/null +++ b/crates/openshell-operator/src/manifests/mod.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Workload manifest builders. +//! +//! Constructs Kubernetes Deployment and Service manifests from +//! `SandboxRuntime` specifications, analogous to Kagenti's +//! `_build_deployment_manifest` and `_build_service_manifest`. + +pub mod deployment; +pub mod service; + +pub use deployment::build_deployment; +pub use service::build_service; + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference; +use kube::Resource; + +use crate::crd::SandboxRuntime; + +/// Build an owner reference for garbage collection. +/// +/// When the `SandboxRuntime` is deleted, Kubernetes will automatically +/// garbage-collect owned Deployments and Services via this reference. +/// +/// Shared function used by both deployment and service builders (V1 review fix). +pub(crate) fn build_owner_reference(runtime: &SandboxRuntime) -> OwnerReference { + OwnerReference { + api_version: SandboxRuntime::api_version(&()).to_string(), + kind: SandboxRuntime::kind(&()).to_string(), + name: runtime.metadata.name.clone().unwrap_or_default(), + uid: runtime.metadata.uid.clone().unwrap_or_default(), + controller: Some(true), + block_owner_deletion: Some(true), + } +} diff --git a/crates/openshell-operator/src/manifests/service.rs b/crates/openshell-operator/src/manifests/service.rs new file mode 100644 index 000000000..eb2ef71c0 --- /dev/null +++ b/crates/openshell-operator/src/manifests/service.rs @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Service manifest builder. + +use std::collections::BTreeMap; + +use k8s_openapi::api::core::v1::{Service, ServicePort as K8sServicePort, ServiceSpec}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; + +use crate::crd::SandboxRuntime; +use crate::labels; +use crate::manifests::build_owner_reference; + +/// Build a ClusterIP Service manifest from a `SandboxRuntime` spec. +pub fn build_service(runtime: &SandboxRuntime) -> Service { + let spec = &runtime.spec; + let name = runtime.metadata.name.as_deref().unwrap_or("unknown"); + let namespace = runtime.metadata.namespace.as_deref().unwrap_or("default"); + + let mut selector = BTreeMap::new(); + selector.insert(labels::APP_NAME_KEY.to_string(), name.to_string()); + + let ports: Vec = spec + .service_ports + .iter() + .map(|sp| K8sServicePort { + name: Some(sp.name.clone()), + port: sp.port, + target_port: Some(IntOrString::Int(sp.target_port)), + protocol: Some(sp.protocol.clone()), + ..Default::default() + }) + .collect(); + + let mut svc_labels = BTreeMap::new(); + svc_labels.insert(labels::APP_NAME_KEY.to_string(), name.to_string()); + svc_labels.insert( + labels::MANAGED_BY_KEY.to_string(), + labels::MANAGER_NAME.to_string(), + ); + + Service { + metadata: ObjectMeta { + name: Some(name.to_string()), + namespace: Some(namespace.to_string()), + labels: Some(svc_labels), + owner_references: Some(vec![build_owner_reference(runtime)]), + ..Default::default() + }, + spec: Some(ServiceSpec { + selector: Some(selector), + ports: Some(ports), + type_: Some("ClusterIP".to_string()), + ..Default::default() + }), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crd::{SandboxRuntimeSpec, ServicePort, TargetRef}; + + fn make_runtime(name: &str) -> SandboxRuntime { + SandboxRuntime::new( + name, + SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: name.into(), + }, + image: "my-image:v1".into(), + replicas: 1, + env: vec![], + resources: None, + service_ports: vec![ServicePort { + name: "http".into(), + port: 8080, + target_port: 8000, + protocol: "TCP".into(), + }], + description: String::new(), + }, + ) + } + + #[test] + fn service_has_correct_type() { + let rt = make_runtime("my-agent"); + let svc = build_service(&rt); + assert_eq!( + svc.spec.as_ref().unwrap().type_.as_deref(), + Some("ClusterIP") + ); + } + + #[test] + fn service_selector_matches_deployment() { + let rt = make_runtime("my-agent"); + let svc = build_service(&rt); + let selector = svc.spec.as_ref().unwrap().selector.as_ref().unwrap(); + assert_eq!(selector.get(labels::APP_NAME_KEY).unwrap(), "my-agent"); + } + + #[test] + fn service_port_maps_correctly() { + let rt = make_runtime("my-agent"); + let svc = build_service(&rt); + let ports = svc.spec.as_ref().unwrap().ports.as_ref().unwrap(); + assert_eq!(ports.len(), 1); + assert_eq!(ports[0].port, 8080); + assert_eq!(ports[0].target_port, Some(IntOrString::Int(8000))); + assert_eq!(ports[0].name.as_deref(), Some("http")); + } + + #[test] + fn service_owner_reference_set() { + let mut rt = make_runtime("my-agent"); + rt.metadata.uid = Some("test-uid".into()); + let svc = build_service(&rt); + let refs = svc.metadata.owner_references.as_ref().unwrap(); + assert_eq!(refs.len(), 1); + assert!(refs[0].controller.unwrap()); + assert_eq!(refs[0].name, "my-agent"); + } + + #[test] + fn service_name_matches_runtime() { + let rt = make_runtime("my-agent"); + let svc = build_service(&rt); + assert_eq!(svc.metadata.name.as_deref(), Some("my-agent")); + } + + #[test] + fn service_namespace_matches_runtime() { + let rt = make_runtime("my-agent"); + let svc = build_service(&rt); + // SandboxRuntime::new doesn't set namespace by default + assert_eq!(svc.metadata.namespace.as_deref(), Some("default")); + } + + #[test] + fn service_has_managed_by_label() { + let rt = make_runtime("my-agent"); + let svc = build_service(&rt); + let labels = svc.metadata.labels.as_ref().unwrap(); + assert_eq!( + labels.get(labels::MANAGED_BY_KEY).unwrap(), + labels::MANAGER_NAME + ); + } + + #[test] + fn service_multiple_ports() { + let mut rt = make_runtime("my-agent"); + rt.spec.service_ports = vec![ + ServicePort { + name: "http".into(), + port: 8080, + target_port: 8000, + protocol: "TCP".into(), + }, + ServicePort { + name: "metrics".into(), + port: 9090, + target_port: 9090, + protocol: "TCP".into(), + }, + ]; + let svc = build_service(&rt); + let ports = svc.spec.as_ref().unwrap().ports.as_ref().unwrap(); + assert_eq!(ports.len(), 2); + assert_eq!(ports[1].port, 9090); + } +} diff --git a/crates/openshell-operator/src/webhooks/mod.rs b/crates/openshell-operator/src/webhooks/mod.rs new file mode 100644 index 000000000..cf3c00914 --- /dev/null +++ b/crates/openshell-operator/src/webhooks/mod.rs @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Admission webhook server for `SandboxRuntime` CRDs. +//! +//! Implements validating and mutating admission webhooks served over HTTPS, +//! using axum for HTTP routing and `tokio-rustls` for TLS. + +pub mod mutate; +pub mod validate; + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::routing::post; +use axum::Router; +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; +use tracing::info; + +use crate::config::OperatorConfig; + +/// Run the webhook HTTPS server. +/// +/// Serves validating and mutating webhook endpoints over TLS. +pub async fn run_webhook_server(config: OperatorConfig) -> anyhow::Result<()> { + let cert_path = config + .tls_cert_path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("TLS cert path required for webhook server"))?; + let key_path = config + .tls_key_path + .as_deref() + .ok_or_else(|| anyhow::anyhow!("TLS key path required for webhook server"))?; + + // Load TLS config. + let certs = load_certs(cert_path)?; + let key = load_key(key_path)?; + let tls_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)?; + let acceptor = TlsAcceptor::from(Arc::new(tls_config)); + + let app = Router::new() + .route("/validate", post(validate::handle_validate)) + .route("/mutate", post(mutate::handle_mutate)); + + let addr: SocketAddr = config.webhook_addr.parse()?; + let listener = TcpListener::bind(addr).await?; + info!(%addr, "webhook server listening"); + + loop { + let (stream, _peer_addr) = listener.accept().await?; + let acceptor = acceptor.clone(); + let app = app.clone(); + + tokio::spawn(async move { + match acceptor.accept(stream).await { + Ok(tls_stream) => { + let io = hyper_util::rt::TokioIo::new(tls_stream); + let service = hyper_util::service::TowerToHyperService::new(app); + if let Err(e) = hyper_util::server::conn::auto::Builder::new( + hyper_util::rt::TokioExecutor::new(), + ) + .serve_connection(io, service) + .await + { + tracing::warn!(error = %e, "webhook connection error"); + } + } + Err(e) => { + tracing::warn!(error = %e, "TLS handshake failed"); + } + } + }); + } +} + +fn load_certs(path: &str) -> anyhow::Result>> { + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + let certs = rustls_pemfile::certs(&mut reader).collect::, _>>()?; + Ok(certs) +} + +fn load_key(path: &str) -> anyhow::Result> { + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + let key = rustls_pemfile::private_key(&mut reader)? + .ok_or_else(|| anyhow::anyhow!("no private key found in {path}"))?; + Ok(key) +} diff --git a/crates/openshell-operator/src/webhooks/mutate.rs b/crates/openshell-operator/src/webhooks/mutate.rs new file mode 100644 index 000000000..80e42bb5e --- /dev/null +++ b/crates/openshell-operator/src/webhooks/mutate.rs @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Mutating admission webhook for `SandboxRuntime` CRDs. +//! +//! Sets default values and injects labels on CREATE, analogous to +//! Kagenti's pod_mutator.go (simplified for CRD mutation). + +use axum::{http::StatusCode, response::IntoResponse, Json}; +use kube::core::admission::{AdmissionRequest, AdmissionResponse, AdmissionReview}; +use tracing::{info, warn}; + +use crate::crd::SandboxRuntime; + +/// Build the list of JSON Patch operations to apply to the incoming object. +/// +/// Returns an empty vec when no mutations are needed. +/// +/// V1 review fixes incorporated: +/// - Ensures `/metadata/labels` exists before adding label keys (RFC 6902). +/// - Uses single early-return guard instead of redundant nesting. +/// - Tests actually call this function and verify patch output. +pub fn build_patches(request: &AdmissionRequest) -> Vec { + let Some(runtime) = &request.object else { + return Vec::new(); + }; + + let mut patches = Vec::new(); + + // Ensure /metadata/labels exists before adding a label key into it. + // Without this, the Add at a nested path fails if labels is null/absent + // (RFC 6902: Add at a nested path requires all parents to exist). + let has_labels = runtime + .metadata + .labels + .as_ref() + .is_some_and(|l| !l.is_empty()); + if !has_labels { + patches.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: "/metadata/labels".to_string(), + value: serde_json::json!({}), + })); + } + + // Inject managed-by label. + patches.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + // RFC 6901: ~1 encodes "/" in JSON Pointer paths. + path: "/metadata/labels/app.kubernetes.io~1managed-by".to_string(), + value: serde_json::Value::String(crate::labels::MANAGER_NAME.to_string()), + })); + + // Set targetRef.name to metadata.name if not specified. + if runtime.spec.target_ref.name.is_empty() && !request.name.is_empty() { + patches.push(json_patch::PatchOperation::Add(json_patch::AddOperation { + path: "/spec/targetRef/name".to_string(), + value: serde_json::Value::String(request.name.clone()), + })); + } + + patches +} + +/// Handler for the mutating admission webhook. +pub async fn handle_mutate( + Json(review): Json>, +) -> impl IntoResponse { + let request: AdmissionRequest = match review.try_into() { + Ok(req) => req, + Err(e) => { + warn!(error = %e, "failed to parse admission request"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(); + } + }; + + let patches = build_patches(&request); + + let response = if patches.is_empty() { + AdmissionResponse::from(&request).into_review() + } else { + let patch = json_patch::Patch(patches); + match AdmissionResponse::from(&request).with_patch(patch) { + Ok(resp) => { + let name = if request.name.is_empty() { + "-" + } else { + &request.name + }; + info!(name, "mutation applied"); + resp.into_review() + } + Err(e) => { + warn!(error = %e, "failed to apply mutation patch"); + AdmissionResponse::from(&request) + .deny(e.to_string()) + .into_review() + } + } + }; + + Json(response).into_response() +} + +#[cfg(test)] +mod tests { + use crate::crd::{SandboxRuntimeSpec, ServicePort, TargetRef}; + + use super::*; + + fn make_request(spec: SandboxRuntimeSpec) -> AdmissionRequest { + let runtime = SandboxRuntime::new("test-runtime", spec); + let review_json = serde_json::json!({ + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "request": { + "uid": "test-uid-12345", + "kind": { + "group": "openshell.io", + "version": "v1alpha1", + "kind": "SandboxRuntime" + }, + "resource": { + "group": "openshell.io", + "version": "v1alpha1", + "resource": "sandboxruntimes" + }, + "operation": "CREATE", + "name": "test-runtime", + "namespace": "default", + "userInfo": { + "username": "system:admin" + }, + "object": serde_json::to_value(&runtime).unwrap() + } + }); + let review: AdmissionReview = + serde_json::from_value(review_json).unwrap(); + review.try_into().unwrap() + } + + fn valid_spec() -> SandboxRuntimeSpec { + SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: "test-runtime".into(), + }, + image: "my-image:v1".into(), + replicas: 1, + env: vec![], + resources: None, + service_ports: vec![ServicePort { + name: "http".into(), + port: 8080, + target_port: 8000, + protocol: "TCP".into(), + }], + description: String::new(), + } + } + + #[test] + fn always_patches_managed_by_label() { + let request = make_request(valid_spec()); + let patches = build_patches(&request); + let has_managed_by = patches.iter().any(|p| match p { + json_patch::PatchOperation::Add(op) => op.path.contains("managed-by"), + _ => false, + }); + assert!( + has_managed_by, + "expected managed-by patch, got: {patches:?}" + ); + } + + #[test] + fn patches_target_ref_name_when_empty() { + let mut spec = valid_spec(); + spec.target_ref.name = String::new(); + let request = make_request(spec); + let patches = build_patches(&request); + let has_target_ref = patches.iter().any(|p| match p { + json_patch::PatchOperation::Add(op) => op.path == "/spec/targetRef/name", + _ => false, + }); + assert!( + has_target_ref, + "expected targetRef/name patch, got: {patches:?}" + ); + } + + #[test] + fn no_target_ref_patch_when_name_set() { + let request = make_request(valid_spec()); + let patches = build_patches(&request); + let has_target_ref = patches.iter().any(|p| match p { + json_patch::PatchOperation::Add(op) => op.path == "/spec/targetRef/name", + _ => false, + }); + assert!( + !has_target_ref, + "should NOT have targetRef/name patch when name is set" + ); + } + + #[test] + fn ensures_labels_object_exists() { + let request = make_request(valid_spec()); + let patches = build_patches(&request); + // The very first patch should ensure /metadata/labels exists + // (SandboxRuntime::new doesn't set labels by default). + let first_path = match &patches[0] { + json_patch::PatchOperation::Add(op) => &op.path, + _ => panic!("expected Add operation"), + }; + assert_eq!(first_path, "/metadata/labels"); + } + + #[test] + fn no_patches_when_no_object() { + let mut request = make_request(valid_spec()); + request.object = None; + let patches = build_patches(&request); + assert!(patches.is_empty()); + } + + #[test] + fn managed_by_value_is_operator() { + let request = make_request(valid_spec()); + let patches = build_patches(&request); + let managed_by_patch = patches + .iter() + .find(|p| match p { + json_patch::PatchOperation::Add(op) => op.path.contains("managed-by"), + _ => false, + }) + .expect("expected managed-by patch"); + if let json_patch::PatchOperation::Add(op) = managed_by_patch { + assert_eq!(op.value, serde_json::json!("openshell-operator")); + } + } +} diff --git a/crates/openshell-operator/src/webhooks/validate.rs b/crates/openshell-operator/src/webhooks/validate.rs new file mode 100644 index 000000000..c6d594135 --- /dev/null +++ b/crates/openshell-operator/src/webhooks/validate.rs @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Validating admission webhook for `SandboxRuntime` CRDs. +//! +//! Validates field combinations and constraints on CREATE and UPDATE +//! operations, analogous to Kagenti's `agentruntime_webhook.go`. + +use axum::{http::StatusCode, response::IntoResponse, Json}; +use kube::core::admission::{AdmissionRequest, AdmissionResponse, AdmissionReview}; +use tracing::{info, warn}; + +use crate::crd::SandboxRuntime; + +/// Handler for the validating admission webhook. +/// +/// Receives `AdmissionReview` -- the type parameter must be +/// `SandboxRuntime` (implements `kube::Resource`), NOT `SandboxRuntimeSpec`. +pub async fn handle_validate( + Json(review): Json>, +) -> impl IntoResponse { + let request: AdmissionRequest = match review.try_into() { + Ok(req) => req, + Err(e) => { + warn!(error = %e, "failed to parse admission request"); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": e.to_string()})), + ) + .into_response(); + } + }; + + let response = match validate_runtime(&request) { + Ok(()) => { + let name = if request.name.is_empty() { + "-" + } else { + &request.name + }; + let ns = request.namespace.as_deref().unwrap_or("-"); + info!(name, namespace = ns, "validation passed"); + AdmissionResponse::from(&request).into_review() + } + Err(reason) => { + let name = if request.name.is_empty() { + "-" + } else { + &request.name + }; + warn!(name, reason = %reason, "validation rejected"); + AdmissionResponse::from(&request) + .deny(reason) + .into_review() + } + }; + + Json(response).into_response() +} + +/// Validate a `SandboxRuntime` custom resource. +/// +/// `request.object` is `Option`, not `Option`. +pub fn validate_runtime(request: &AdmissionRequest) -> Result<(), String> { + let Some(runtime) = &request.object else { + return Err("missing object in admission request".to_string()); + }; + + let spec = &runtime.spec; + + // Validate targetRef kind. + let valid_kinds = ["Deployment", "StatefulSet", "Sandbox"]; + if !valid_kinds.contains(&spec.target_ref.kind.as_str()) { + return Err(format!( + "unsupported targetRef kind '{}', must be one of: {}", + spec.target_ref.kind, + valid_kinds.join(", ") + )); + } + + // Validate image is not empty. + if spec.image.is_empty() { + return Err("spec.image must not be empty".to_string()); + } + + // Validate replicas >= 1. + if spec.replicas < 1 { + return Err(format!( + "spec.replicas must be >= 1, got {}", + spec.replicas + )); + } + + // Validate targetRef.name matches metadata.name if both are set. + if !request.name.is_empty() + && !spec.target_ref.name.is_empty() + && spec.target_ref.name != request.name + { + return Err(format!( + "spec.targetRef.name '{}' must match metadata.name '{}'", + spec.target_ref.name, request.name + )); + } + + // Validate environment variable names. + for env_var in &spec.env { + if env_var.name.is_empty() { + return Err("env variable name must not be empty".to_string()); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crd::{SandboxRuntimeSpec, ServicePort, TargetRef}; + + fn make_request(spec: SandboxRuntimeSpec) -> AdmissionRequest { + let runtime = SandboxRuntime::new("test-runtime", spec); + let review_json = serde_json::json!({ + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "request": { + "uid": "test-uid-12345", + "kind": { + "group": "openshell.io", + "version": "v1alpha1", + "kind": "SandboxRuntime" + }, + "resource": { + "group": "openshell.io", + "version": "v1alpha1", + "resource": "sandboxruntimes" + }, + "operation": "CREATE", + "name": "test-runtime", + "namespace": "default", + "userInfo": { + "username": "system:admin" + }, + "object": serde_json::to_value(&runtime).unwrap() + } + }); + let review: AdmissionReview = + serde_json::from_value(review_json).unwrap(); + review.try_into().unwrap() + } + + fn valid_spec() -> SandboxRuntimeSpec { + SandboxRuntimeSpec { + runtime_type: "agent".into(), + target_ref: TargetRef { + api_version: "apps/v1".into(), + kind: "Deployment".into(), + name: "test-runtime".into(), + }, + image: "my-image:v1".into(), + replicas: 1, + env: vec![], + resources: None, + service_ports: vec![ServicePort { + name: "http".into(), + port: 8080, + target_port: 8000, + protocol: "TCP".into(), + }], + description: String::new(), + } + } + + #[test] + fn valid_deployment_spec_passes() { + let req = make_request(valid_spec()); + assert!(validate_runtime(&req).is_ok()); + } + + #[test] + fn valid_statefulset_spec_passes() { + let mut spec = valid_spec(); + spec.target_ref.kind = "StatefulSet".into(); + let req = make_request(spec); + assert!(validate_runtime(&req).is_ok()); + } + + #[test] + fn valid_sandbox_spec_passes() { + let mut spec = valid_spec(); + spec.target_ref.kind = "Sandbox".into(); + spec.target_ref.api_version = "agents.x-k8s.io/v1alpha1".into(); + let req = make_request(spec); + assert!(validate_runtime(&req).is_ok()); + } + + #[test] + fn rejects_unknown_workload_kind() { + let mut spec = valid_spec(); + spec.target_ref.kind = "CronJob".into(); + let req = make_request(spec); + let err = validate_runtime(&req).unwrap_err(); + assert!(err.contains("unsupported targetRef kind"), "got: {err}"); + } + + #[test] + fn rejects_empty_image() { + let mut spec = valid_spec(); + spec.image = String::new(); + let req = make_request(spec); + let err = validate_runtime(&req).unwrap_err(); + assert!(err.contains("image must not be empty"), "got: {err}"); + } + + #[test] + fn rejects_zero_replicas() { + let mut spec = valid_spec(); + spec.replicas = 0; + let req = make_request(spec); + let err = validate_runtime(&req).unwrap_err(); + assert!(err.contains("replicas must be >= 1"), "got: {err}"); + } + + #[test] + fn rejects_mismatched_target_ref_name() { + let mut spec = valid_spec(); + spec.target_ref.name = "different-name".into(); + let req = make_request(spec); + let err = validate_runtime(&req).unwrap_err(); + assert!(err.contains("must match metadata.name"), "got: {err}"); + } + + #[test] + fn rejects_empty_env_var_name() { + let mut spec = valid_spec(); + spec.env = vec![crate::crd::EnvVar { + name: String::new(), + value: "val".into(), + }]; + let req = make_request(spec); + let err = validate_runtime(&req).unwrap_err(); + assert!( + err.contains("env variable name must not be empty"), + "got: {err}" + ); + } + + #[test] + fn allows_empty_target_ref_name() { + let mut spec = valid_spec(); + spec.target_ref.name = String::new(); + let req = make_request(spec); + assert!(validate_runtime(&req).is_ok()); + } + + #[test] + fn rejects_negative_replicas() { + let mut spec = valid_spec(); + spec.replicas = -1; + let req = make_request(spec); + let err = validate_runtime(&req).unwrap_err(); + assert!(err.contains("replicas must be >= 1"), "got: {err}"); + } +} diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index b5c9b34d7..05c0cd7ad 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -26,10 +26,12 @@ openshell-prover = { path = "../openshell-prover" } openshell-providers = { path = "../openshell-providers" } openshell-router = { path = "../openshell-router" } openshell-server-macros = { path = "../openshell-server-macros" } +openshell-operator = { path = "../openshell-operator" } # Kubernetes client (used by the `generate-certs` subcommand) kube = { workspace = true } k8s-openapi = { workspace = true } +schemars = { workspace = true } # Async runtime tokio = { workspace = true } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 9aee2bc6d..03744312f 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -393,6 +393,13 @@ fn prepare_server_config(args: &mut RunArgs, matches: &ArgMatches) -> Result, + // ── Operator bridge ───────────────────────────────────────────────── + /// Enable the SandboxRuntime operator bridge. + #[serde(default)] + pub operator_enabled: Option, + // ── Disallowed-in-file fields ──────────────────────────────────────── // // Captured so we can produce a friendly "set this via env/CLI instead" diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index fe2eb331c..acc127431 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -9,6 +9,7 @@ pub mod provider; mod sandbox; mod service; mod validation; +pub mod sandbox_runtime; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveAllDraftChunksResponse, ApproveDraftChunkRequest, diff --git a/crates/openshell-server/src/grpc/sandbox_runtime.rs b/crates/openshell-server/src/grpc/sandbox_runtime.rs new file mode 100644 index 000000000..c1cf8c5f6 --- /dev/null +++ b/crates/openshell-server/src/grpc/sandbox_runtime.rs @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Gateway bridge for SandboxRuntime CRD management. +//! +//! Implements the `SandboxRuntimeManager` gRPC service, bridging gateway +//! API calls to Kubernetes CRD operations on `SandboxRuntime` custom +//! resources. This allows the gateway CLI/API to manage operator-controlled +//! workloads without direct K8s access from clients. + +use kube::api::{Api, ListParams, PostParams}; +use kube::Client; +use tonic::{Request, Response, Status}; + +use openshell_core::proto::runtime::v1::{ + sandbox_runtime_manager_server::SandboxRuntimeManager, CreateSandboxRuntimeRequest, + DeleteSandboxRuntimeRequest, DeleteSandboxRuntimeResponse, GetSandboxRuntimeRequest, + ListSandboxRuntimesRequest, ListSandboxRuntimesResponse, SandboxRuntimeMessage, + SandboxRuntimeResponse, +}; +use openshell_operator::crd::{SandboxRuntime, SandboxRuntimeSpec, ServicePort, TargetRef}; + +/// Bridge service that proxies gRPC calls to Kubernetes CRD operations. +#[derive(Clone)] +pub struct SandboxRuntimeManagerService { + client: Client, +} + +impl SandboxRuntimeManagerService { + pub fn new(client: Client) -> Self { + Self { client } + } + + fn api(&self, namespace: &str) -> Api { + Api::namespaced(self.client.clone(), namespace) + } +} + +#[tonic::async_trait] +impl SandboxRuntimeManager for SandboxRuntimeManagerService { + async fn create_sandbox_runtime( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let api = self.api(&req.namespace); + + let runtime = SandboxRuntime::new( + &req.name, + SandboxRuntimeSpec { + runtime_type: if req.runtime_type.is_empty() { + "agent".to_string() + } else { + req.runtime_type + }, + target_ref: TargetRef { + api_version: "apps/v1".to_string(), + kind: if req.target_kind.is_empty() { + "Deployment".to_string() + } else { + req.target_kind + }, + name: req.name.clone(), + }, + image: req.image, + replicas: if req.replicas == 0 { 1 } else { req.replicas }, + env: Vec::new(), + resources: None, + service_ports: vec![ServicePort { + name: "http".to_string(), + port: 8080, + target_port: 8000, + protocol: "TCP".to_string(), + }], + description: req.description, + }, + ); + + let created = api + .create(&PostParams::default(), &runtime) + .await + .map_err(|e| match &e { + kube::Error::Api(api_err) if api_err.code == 409 => { + Status::already_exists(format!( + "SandboxRuntime '{}' already exists", + req.name + )) + } + _ => Status::internal(e.to_string()), + })?; + + Ok(Response::new(SandboxRuntimeResponse { + runtime: Some(runtime_to_message(&created)), + })) + } + + async fn get_sandbox_runtime( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let api = self.api(&req.namespace); + + let runtime = api.get(&req.name).await.map_err(|e| match &e { + kube::Error::Api(api_err) if api_err.code == 404 => { + Status::not_found(format!("SandboxRuntime '{}' not found", req.name)) + } + _ => Status::internal(e.to_string()), + })?; + + Ok(Response::new(SandboxRuntimeResponse { + runtime: Some(runtime_to_message(&runtime)), + })) + } + + async fn list_sandbox_runtimes( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let api = self.api(&req.namespace); + + let list = api + .list(&ListParams::default()) + .await + .map_err(|e| Status::internal(e.to_string()))?; + + let runtimes = list + .items + .into_iter() + .map(|r| runtime_to_message(&r)) + .collect(); + + Ok(Response::new(ListSandboxRuntimesResponse { runtimes })) + } + + async fn delete_sandbox_runtime( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let api = self.api(&req.namespace); + + match api.delete(&req.name, &Default::default()).await { + Ok(_) => Ok(Response::new(DeleteSandboxRuntimeResponse { deleted: true })), + Err(kube::Error::Api(ref api_err)) if api_err.code == 404 => { + Ok(Response::new(DeleteSandboxRuntimeResponse { deleted: false })) + } + Err(e) => Err(Status::internal(e.to_string())), + } + } +} + +fn runtime_to_message(runtime: &SandboxRuntime) -> SandboxRuntimeMessage { + let status = runtime.status.as_ref(); + SandboxRuntimeMessage { + name: runtime.metadata.name.clone().unwrap_or_default(), + namespace: runtime.metadata.namespace.clone().unwrap_or_default(), + runtime_type: runtime.spec.runtime_type.clone(), + image: runtime.spec.image.clone(), + replicas: runtime.spec.replicas, + target_kind: runtime.spec.target_ref.kind.clone(), + phase: status.map_or_else(String::new, |s| s.phase.clone()), + message: status.map_or_else(String::new, |s| s.message.clone()), + description: runtime.spec.description.clone(), + } +} diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6462ccbbf..eabfb8139 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -147,6 +147,10 @@ pub struct ServerState { /// Gateway-wide gRPC request rate limiter shared by every multiplex path. pub(crate) grpc_rate_limiter: Option, + + /// Optional SandboxRuntime bridge for operator CRD management. + /// `None` when operator integration is disabled. + pub sandbox_runtime_bridge: Option, } fn is_benign_tls_handshake_failure(error: &std::io::Error) -> bool { @@ -197,6 +201,7 @@ impl ServerState { sandbox_jwt_authenticator: None, k8s_sa_authenticator: None, grpc_rate_limiter, + sandbox_runtime_bridge: None, } } } @@ -361,6 +366,25 @@ pub(crate) async fn run_server( } } + // SandboxRuntime operator bridge. When operator_enabled is set, the + // gateway creates a kube::Client and exposes the SandboxRuntimeManager + // gRPC service so CLI/API callers can manage CRDs declaratively. + if config.operator_enabled { + match kube::Client::try_default().await { + Ok(client) => { + state.sandbox_runtime_bridge = + Some(grpc::sandbox_runtime::SandboxRuntimeManagerService::new(client)); + info!("SandboxRuntime operator bridge enabled"); + } + Err(e) => { + warn!( + error = %e, + "K8s client unavailable; SandboxRuntime operator bridge disabled" + ); + } + } + } + let state = Arc::new(state); let (shutdown_tx, shutdown_rx) = watch::channel(false); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 9e70c6472..bdfa1746a 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -162,8 +162,20 @@ impl MultiplexService { scopes_enabled: !oidc.scopes_claim.is_empty(), }); let authenticator_chain = build_authenticator_chain(&self.state); + use openshell_core::proto::runtime::v1::sandbox_runtime_manager_server::SandboxRuntimeManagerServer; + use crate::grpc::sandbox_runtime::SandboxRuntimeManagerService; + + type RuntimeSvc = SandboxRuntimeManagerServer; + let runtime_manager: MaybeService = + match self.state.sandbox_runtime_bridge.as_ref() { + Some(bridge) => MaybeService::Enabled( + SandboxRuntimeManagerServer::new(bridge.clone()) + .max_decoding_message_size(MAX_GRPC_DECODE_SIZE), + ), + None => MaybeService::Disabled, + }; let grpc_service = AuthGrpcRouter::with_peer_identity( - GrpcRouter::new(openshell, inference), + GrpcRouter::new(openshell, inference, runtime_manager), authenticator_chain, authz_policy, self.state @@ -370,26 +382,39 @@ where } } -/// Combined gRPC service that routes between `OpenShell` and Inference services -/// based on the request path prefix. +/// Wrapper for an optional gRPC service that may be disabled. +/// +/// When `Disabled`, requests for this service are routed to the primary +/// OpenShell service, which returns UNIMPLEMENTED for the unknown method. +#[derive(Clone)] +pub enum MaybeService { + Enabled(S), + Disabled, +} + +/// Combined gRPC service that routes between `OpenShell`, Inference, and +/// optionally the SandboxRuntimeManager services based on request path prefix. #[derive(Clone)] -pub struct GrpcRouter { +pub struct GrpcRouter { openshell: N, inference: I, + runtime_manager: MaybeService, } -impl GrpcRouter { - fn new(openshell: N, inference: I) -> Self { +impl GrpcRouter { + fn new(openshell: N, inference: I, runtime_manager: MaybeService) -> Self { Self { openshell, inference, + runtime_manager, } } } const INFERENCE_PATH_PREFIX: &str = "/openshell.inference.v1.Inference/"; +const RUNTIME_MANAGER_PATH_PREFIX: &str = "/openshell.runtime.v1.SandboxRuntimeManager/"; -impl tower::Service> for GrpcRouter +impl tower::Service> for GrpcRouter where N: tower::Service> + Clone + Send + 'static, N::Response: Send, @@ -400,6 +425,11 @@ where + Send + 'static, I::Future: Send, + R: tower::Service, Response = N::Response, Error = N::Error> + + Clone + + Send + + 'static, + R::Future: Send, B: Send + 'static, { type Response = N::Response; @@ -411,11 +441,23 @@ where } fn call(&mut self, req: Request) -> Self::Future { - let is_inference = req.uri().path().starts_with(INFERENCE_PATH_PREFIX); + let path = req.uri().path(); - if is_inference { + if path.starts_with(INFERENCE_PATH_PREFIX) { let mut svc = self.inference.clone(); Box::pin(async move { svc.ready().await?.call(req).await }) + } else if path.starts_with(RUNTIME_MANAGER_PATH_PREFIX) { + match &mut self.runtime_manager { + MaybeService::Enabled(svc) => { + let mut svc = svc.clone(); + Box::pin(async move { svc.ready().await?.call(req).await }) + } + MaybeService::Disabled => { + // Route to openshell, which returns UNIMPLEMENTED. + let mut svc = self.openshell.clone(); + Box::pin(async move { svc.ready().await?.call(req).await }) + } + } } else { let mut svc = self.openshell.clone(); Box::pin(async move { svc.ready().await?.call(req).await }) diff --git a/proto/sandbox_runtime_manager.proto b/proto/sandbox_runtime_manager.proto new file mode 100644 index 000000000..1d3249bec --- /dev/null +++ b/proto/sandbox_runtime_manager.proto @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.runtime.v1; + +/// Service for managing SandboxRuntime custom resources from the gateway. +service SandboxRuntimeManager { + rpc CreateSandboxRuntime(CreateSandboxRuntimeRequest) returns (SandboxRuntimeResponse); + rpc GetSandboxRuntime(GetSandboxRuntimeRequest) returns (SandboxRuntimeResponse); + rpc ListSandboxRuntimes(ListSandboxRuntimesRequest) returns (ListSandboxRuntimesResponse); + rpc DeleteSandboxRuntime(DeleteSandboxRuntimeRequest) returns (DeleteSandboxRuntimeResponse); +} + +message SandboxRuntimeMessage { + string name = 1; + string namespace = 2; + string runtime_type = 3; + string image = 4; + int32 replicas = 5; + string target_kind = 6; + string phase = 7; + string message = 8; + string description = 9; +} + +message CreateSandboxRuntimeRequest { + string name = 1; + string namespace = 2; + string runtime_type = 3; + string image = 4; + int32 replicas = 5; + string target_kind = 6; + string description = 7; +} + +message GetSandboxRuntimeRequest { + string name = 1; + string namespace = 2; +} + +message ListSandboxRuntimesRequest { + string namespace = 1; +} + +message DeleteSandboxRuntimeRequest { + string name = 1; + string namespace = 2; +} + +message SandboxRuntimeResponse { + SandboxRuntimeMessage runtime = 1; +} + +message ListSandboxRuntimesResponse { + repeated SandboxRuntimeMessage runtimes = 1; +} + +message DeleteSandboxRuntimeResponse { + bool deleted = 1; +}