diff --git a/.agents/skills/debug-openshell-cluster/SKILL.md b/.agents/skills/debug-openshell-cluster/SKILL.md index 7a73131ea..bc35e0d35 100644 --- a/.agents/skills/debug-openshell-cluster/SKILL.md +++ b/.agents/skills/debug-openshell-cluster/SKILL.md @@ -71,7 +71,7 @@ Use gateway metadata, deployment values, or the user's setup notes to identify t Before debugging the compute platform, inspect gateway logs for failures in dependencies initialized before the listener becomes ready. -For out-of-tree compute drivers, confirm the custom driver name and socket agree across CLI flags or `gateway.toml`, and that the operator-owned driver is running before the gateway starts: +For out-of-tree compute drivers, confirm the selected driver name and socket agree across CLI flags or `gateway.toml`, and that the operator-owned driver is running before the gateway starts: ```bash rg -n 'compute_drivers|socket_path' /etc/openshell/gateway.toml @@ -80,7 +80,7 @@ journalctl -u --no-pager --lines=200 journalctl -u openshell-gateway --no-pager --lines=200 ``` -The custom driver name must not be a reserved built-in name (`docker`, `podman`, `kubernetes`, or `vm`). The socket must be accessible only to the intended gateway identity. Check gateway logs for connection errors, `GetCapabilities` failures, or an unexpected advertised driver name. The gateway does not create or supervise out-of-tree driver processes or sockets. +Custom names use `[openshell.drivers.].socket_path`. A launch-time `--compute-driver-socket` override may also use `docker`, `podman`, `kubernetes`, or `vm`; the endpoint then takes precedence over built-in construction. The socket must be accessible only to the intended gateway identity. Check gateway logs for connection errors, `GetCapabilities` failures, or an unexpected advertised driver name. The advertised name is diagnostic metadata; negotiated features control optional behavior. The gateway does not create or supervise operator-supplied driver processes or sockets. For configured gateway interceptors, inspect `[[openshell.gateway.interceptors]]`, their Unix or network endpoints, and gateway startup logs: @@ -199,7 +199,9 @@ running intent should stop before the gateway exits and restart after it returns. Check for `Stopped sandbox during gateway shutdown` and `Started sandbox during gateway startup` in gateway logs. A sandbox explicitly stopped through the CLI remains stopped. Kubernetes sandboxes are cluster-owned and do -not follow this local gateway lifecycle. +not follow this local gateway lifecycle. Internal and external drivers follow +the same rule: `GetCapabilities.gateway_managed_lifecycle` must be true for the +gateway to run shutdown and startup sweeps. ### Step 5: Check Podman-Backed Gateways @@ -593,7 +595,7 @@ openshell logs | Binary WebSocket message passes without a middleware RPC | Binary is unsupported by the V1 text-message binding under both `on_error` modes | `WEBSOCKET_MIDDLEWARE_COVERAGE state=unsupported_message_type`; the next text RPC may have a valid sequence gap | | WebSocket messages stop reaching middleware after one failure | A fail-open stage stream was disabled for the rest of the connection | `openshell.middleware.websocket_stage_disabled`; middleware timeout/stream/protocol logs. A per-message capacity bypass alone leaves the stage active. Reconnect to create a fresh stream after a genuine stream failure | | Supervisor repeatedly fails to install middleware after enabling gateway JWT signing | Extension credential minting, distribution, or authenticated service connection failed; last-known-good registry remains active | Gateway `RefreshSandboxToken` logs, sandbox configuration events, service token-verification logs, registration TLS/audience settings | -| Custom compute driver is unavailable | Driver process/socket missing, inaccessible, or configured with a reserved/mismatched name | Socket ownership/mode, driver service logs, gateway `GetCapabilities` logs | +| Custom compute driver is unavailable | Driver process/socket missing, inaccessible, or selected name does not match its endpoint/config key | Socket ownership/mode, driver service logs, gateway `GetCapabilities` logs | | Sandbox remains `Stopping` or `Starting` | Driver stop/start failed, retained resource is missing, or a fresh supervisor has not connected | Gateway and driver logs; `docker inspect`, `podman inspect`, Agent Sandbox status/PVC, or VM state marker and launcher process | | Image pull failure | Gateway or sandbox image cannot be pulled | Runtime events and image pull credentials | | `K8s namespace not ready` with `envoy-gateway-openshell.yaml: the server could not find the requested resource` | Optional Gateway API manifest was applied without Envoy Gateway CRDs, or k3s Helm controller startup exceeded the namespace wait | Apply `deploy/kube/manifests/envoy-gateway-openshell.yaml` manually only after Envoy Gateway is installed and `grpcRoute` is enabled | diff --git a/architecture/compute-runtimes.md b/architecture/compute-runtimes.md index 0b901ef3d..308574530 100644 --- a/architecture/compute-runtimes.md +++ b/architecture/compute-runtimes.md @@ -23,6 +23,13 @@ references to gateway-internal types. The gateway owns the public `SandboxPhase::Ready` decision. This applies equally to extension drivers implementing `ComputeDriver` out of tree. +`compute_driver.proto` is the supported gateway/driver extension boundary. +At initialization the gateway snapshots the driver's identity, version, +default image, and gateway-lifecycle preference from `GetCapabilities`. +Process-identity omissions are preserved across this boundary so every driver +can apply its native image or runtime defaults. Driver-requested listeners are +structurally validated and remain restricted to sandbox callback RPCs. + Drivers own runtime-specific platform event interpretation. When an event should drive client provisioning UI, the driver attaches the shared `openshell.progress.*` metadata defined in `openshell-core` instead of requiring @@ -115,6 +122,11 @@ shared idempotent `StartSandbox` RPC before watch processing begins. Explicitly cluster-owned and continue running without gateway shutdown or startup lifecycle calls. +The driver reports this behavior through +`GetCapabilities.gateway_manages_lifecycle`. The same declaration works for +in-process and external drivers. Older drivers omit the field and retain the +conservative operator-managed behavior. + ## Deletion Lifecycle Lifecycle requests use per-sandbox gates to serialize stop, start, and @@ -152,7 +164,7 @@ delete, reconciliation removes the row; otherwise it can remain `Deleting`. | Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API and CDI GPU devices when available. Delivers the supervisor via OCI image volume by default; falls back to extracting the binary to a host-side cache and bind-mounting it when `userns` is configured (overlay does not support idmapped mounts). | | Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. | | VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Managed endpoint-backed driver. The gateway spawns `openshell-driver-vm`, waits for its Unix socket, and then consumes it through the same remote `compute_driver.proto` path used by unmanaged endpoint drivers. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. | -| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a non-reserved custom `compute_drivers = [""]` entry with `[openshell.drivers.].socket_path`, or at launch time by pairing `--drivers ` with `--compute-driver-socket=`. Reserved built-in names such as `vm`, `docker`, `podman`, and `kubernetes` cannot be used as unmanaged socket endpoints. The gateway connects to a UDS the operator already provisioned, runs `GetCapabilities`, logs the advertised `driver_name`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. | +| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a custom `compute_drivers = [""]` entry with `[openshell.drivers.].socket_path`, or at launch time by pairing `--drivers ` with `--compute-driver-socket=`. A launch-time endpoint may use a canonical built-in name to preserve its driver-config key while replacing in-process construction. The gateway connects to an operator-provisioned UDS, snapshots `GetCapabilities`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. | Per-sandbox CPU and memory values currently enter the driver layer through template resource limits. Docker and Podman apply them as runtime limits. diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index ae621fde0..8f88c0cd4 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; -use crate::proto::compute::v1::{DriverSandbox, GetCapabilitiesResponse}; +use crate::proto::compute::v1::DriverSandbox; pub use crate::container_paths::{ SANDBOX_TOKEN_MOUNT_PATH, SUPERVISOR_CONTAINER_BINARY, SUPERVISOR_CONTAINER_DIR, @@ -372,22 +372,6 @@ pub fn sandbox_token_path( Ok(path.join(sandbox_id).join("sandbox.jwt")) } -/// Build a [`GetCapabilitiesResponse`] from the common driver capability fields. -/// -/// Every compute driver constructs this response with the same fields. Shared -/// here to avoid repeating the struct literal in each driver crate. -pub fn build_capabilities_response( - driver_name: &str, - driver_version: impl Into, - default_image: impl Into, -) -> GetCapabilitiesResponse { - GetCapabilitiesResponse { - driver_name: driver_name.to_string(), - driver_version: driver_version.into(), - default_image: default_image.into(), - } -} - /// Return the effective log level for a sandbox. /// /// Uses the level from the sandbox spec when non-empty, falling back to diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index cd1a0a8c5..60c625aa2 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -490,11 +490,12 @@ impl DockerComputeDriver { } fn capabilities(&self) -> GetCapabilitiesResponse { - openshell_core::driver_utils::build_capabilities_response( - "docker", - &self.config.daemon_version, - &self.config.default_image, - ) + GetCapabilitiesResponse { + driver_name: "docker".to_string(), + driver_version: self.config.daemon_version.clone(), + default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: true, + } } #[cfg(test)] diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index ddc7fe2a4..b9b9de72a 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -544,11 +544,12 @@ impl KubernetesComputeDriver { } pub fn capabilities(&self) -> Result { - Ok(openshell_core::driver_utils::build_capabilities_response( - "kubernetes", - openshell_core::VERSION, - &self.config.default_image, - )) + Ok(GetCapabilitiesResponse { + driver_name: "kubernetes".to_string(), + driver_version: openshell_core::VERSION.to_string(), + default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: false, + }) } pub fn operator_allowlist(&self) -> Option<&OperatorNamespaceAllowlist> { diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 9f8a62ef2..f70141f3f 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -464,11 +464,12 @@ impl PodmanComputeDriver { /// Report driver capabilities. pub fn capabilities(&self) -> Result { - Ok(openshell_core::driver_utils::build_capabilities_response( - "podman", - openshell_core::VERSION, - &self.config.default_image, - )) + Ok(GetCapabilitiesResponse { + driver_name: "podman".to_string(), + driver_version: openshell_core::VERSION.to_string(), + default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: true, + }) } /// Report the gateway exposure needed by Podman's standard local callback aliases. diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index 4dd2ea059..13bcdb10c 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -520,6 +520,7 @@ impl VmDriver { driver_name: DRIVER_NAME.to_string(), driver_version: openshell_core::VERSION.to_string(), default_image: self.config.default_image.clone(), + gateway_manages_lifecycle: true, } } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 2e86c3a1b..02afb3917 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -115,9 +115,10 @@ struct RunArgs { /// implementing `compute_driver.proto`. /// /// When set, the socket is associated with the single driver name supplied - /// by `--drivers` or `OPENSHELL_DRIVERS`. Reserved built-in driver names - /// such as Docker, Podman, Kubernetes, and VM do not accept socket - /// endpoints. + /// by `--drivers` or `OPENSHELL_DRIVERS` and replaces normal construction + /// for that selected name, including canonical built-in names. The gateway + /// connects to this operator-provided endpoint; it does not provision the + /// remote driver. #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] compute_driver_socket: Option, @@ -755,7 +756,7 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches } if arg_defaulted(matches, "drivers") { return Err(miette::miette!( - "--compute-driver-socket requires --drivers or OPENSHELL_DRIVERS= to select a non-reserved compute driver name" + "--compute-driver-socket requires --drivers or OPENSHELL_DRIVERS= to select a compute driver name" )); } @@ -763,19 +764,6 @@ fn normalize_compute_driver_socket_args(args: &mut RunArgs, matches: &ArgMatches [driver] => { let driver = openshell_core::config::normalize_compute_driver_name(driver) .map_err(|err| miette::miette!("{err}"))?; - if matches!( - driver.parse::().ok(), - Some( - ComputeDriverKind::Docker - | ComputeDriverKind::Podman - | ComputeDriverKind::Kubernetes - | ComputeDriverKind::Vm - ) - ) { - return Err(miette::miette!( - "--compute-driver-socket cannot be combined with reserved built-in compute driver '{driver}'" - )); - } args.drivers[0] = driver; Ok(()) } @@ -1663,7 +1651,7 @@ ssh_session_ttl_secs = 1234 } #[test] - fn compute_driver_socket_rejects_reserved_builtin_drivers() { + fn compute_driver_socket_accepts_canonical_builtin_driver_name() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1679,16 +1667,12 @@ ssh_session_ttl_secs = 1234 "--compute-driver-socket", "/run/openshell/extension.sock", ]); - let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err(); - assert!( - err.to_string() - .contains("cannot be combined with reserved built-in compute driver 'docker'"), - "unexpected error: {err}" - ); + super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap(); + assert_eq!(args.drivers, ["docker"]); } #[test] - fn compute_driver_socket_rejects_vm_endpoint() { + fn compute_driver_socket_accepts_vm_endpoint() { let _lock = ENV_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -1704,12 +1688,8 @@ ssh_session_ttl_secs = 1234 "--compute-driver-socket", "/run/openshell/vm.sock", ]); - let err = super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap_err(); - assert!( - err.to_string() - .contains("cannot be combined with reserved built-in compute driver 'vm'"), - "unexpected error: {err}" - ); + super::normalize_compute_driver_socket_args(&mut args, &matches).unwrap(); + assert_eq!(args.drivers, ["vm"]); } #[test] diff --git a/crates/openshell-server/src/compute/mod.rs b/crates/openshell-server/src/compute/mod.rs index 82bf2e2c5..dfc1acf9a 100644 --- a/crates/openshell-server/src/compute/mod.rs +++ b/crates/openshell-server/src/compute/mod.rs @@ -277,6 +277,8 @@ pub struct ComputeDriverInfoSnapshot { pub driver_name: String, /// Driver-reported implementation version from the startup capability snapshot. pub driver_version: String, + /// Whether the driver asks the gateway to reconcile compute across restarts. + pub gateway_manages_lifecycle: bool, } /// Interval between store-vs-backend reconciliation sweeps. @@ -612,6 +614,7 @@ impl ComputeRuntime { name: driver_name.clone(), driver_name: capabilities.driver_name, driver_version: capabilities.driver_version, + gateway_manages_lifecycle: capabilities.gateway_manages_lifecycle, }; let default_image = capabilities.default_image; let gateway_listener_requirements = match driver @@ -2042,14 +2045,9 @@ impl ComputeRuntime { /// persisted lifecycle intent. /// /// An explicit sandbox stop persists `Stopped`; gateway shutdown does not. - /// Docker, Podman, and VM compute is stopped through the same public driver - /// RPC and restarted from the retained running-intent phase on gateway - /// startup. Kubernetes compute remains cluster-owned and is excluded. + /// Drivers request this sweep through their startup capability snapshot. async fn stop_persisted_sandboxes_on_shutdown(&self) -> Result<(), String> { - if !matches!( - self.driver_kind(), - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm) - ) { + if !self.driver_info.gateway_manages_lifecycle { return Ok(()); } @@ -2137,20 +2135,16 @@ impl ComputeRuntime { /// Reconcile running intent for local compute after a gateway restart. /// - /// Docker and Podman resources can outlive the gateway but may have been - /// stopped by their runtime, while VM compute exits with its gateway-owned - /// driver process. `StartSandbox` is idempotent for all three drivers, so - /// call it for every persisted phase that requires running compute. Stable - /// stopped, deleting, and error states are deliberately left alone. + /// `StartSandbox` is idempotent, so call it for every persisted phase that + /// requires running compute for drivers that request gateway-managed + /// lifecycle. Stable stopped, deleting, and error states are deliberately + /// left alone. /// /// Should be called once at gateway startup, before watchers spawn, /// so the watch loop sees the post-start state on its first poll. pub async fn start_persisted_sandboxes(&self) -> Result<(), String> { self.recover_persisted_lifecycle_transitions().await?; - if !matches!( - self.driver_kind(), - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman | ComputeDriverKind::Vm) - ) { + if !self.driver_info.gateway_manages_lifecycle { return Ok(()); } @@ -3949,6 +3943,7 @@ impl ComputeDriver for NoopTestDriver { driver_name: "noop-test-driver".to_string(), driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, }, )) } @@ -4089,6 +4084,7 @@ pub async fn new_test_runtime_with_driver( name: driver_name.to_string(), driver_name: driver_name.to_string(), driver_version: "test".to_string(), + gateway_manages_lifecycle: false, }, driver_process: None, default_image: "openshell/sandbox:test".to_string(), @@ -4251,6 +4247,7 @@ mod tests { driver_name: "test-driver".to_string(), driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, })) } @@ -4568,6 +4565,7 @@ mod tests { driver_name: "controlled-test-driver".to_string(), driver_version: "test".to_string(), default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, })) } @@ -4770,6 +4768,7 @@ mod tests { name: driver_name.to_string(), driver_name: driver_name.to_string(), driver_version: "test".to_string(), + gateway_manages_lifecycle: false, }, driver_process: None, default_image: "openshell/sandbox:test".to_string(), @@ -4785,6 +4784,15 @@ mod tests { } } + async fn test_runtime_with_gateway_managed_lifecycle( + driver: SharedComputeDriver, + driver_name: &str, + ) -> ComputeRuntime { + let mut runtime = test_runtime_for_driver(driver, driver_name).await; + runtime.driver_info.gateway_manages_lifecycle = true; + runtime + } + fn register_test_supervisor_session(runtime: &ComputeRuntime, sandbox_id: &str) { let (tx, _rx) = mpsc::channel(1); let (shutdown_tx, _shutdown_rx) = oneshot::channel(); @@ -8165,7 +8173,8 @@ mod tests { #[tokio::test] async fn shutdown_stops_running_intent_without_changing_persisted_phase() { let driver = ControlledDriver::new(); - let runtime = test_runtime_for_driver(driver.clone(), "docker").await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; for (id, name, phase) in [ ("sb-unspecified", "unspecified", SandboxPhase::Unspecified), @@ -8224,7 +8233,8 @@ mod tests { async fn shutdown_stop_sweep_continues_after_driver_errors() { let driver = ControlledDriver::new(); driver.set_stop_outcome(ControlledLifecycleOutcome::Error("runtime angry")); - let runtime = test_runtime_for_driver(driver.clone(), "podman").await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; for (id, name) in [("sb-1", "one"), ("sb-2", "two")] { runtime .store @@ -8246,7 +8256,8 @@ mod tests { async fn shutdown_stop_sweep_bounds_driver_concurrency() { let driver = ControlledDriver::new(); driver.block_stop(); - let runtime = test_runtime_for_driver(driver.clone(), "docker").await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; for index in 0..=SHUTDOWN_STOP_CONCURRENCY { runtime .store @@ -8285,7 +8296,8 @@ mod tests { #[tokio::test] async fn shutdown_stop_sweep_rechecks_intent_after_acquiring_gate() { let driver = ControlledDriver::new(); - let runtime = test_runtime_for_driver(driver.clone(), "docker").await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; runtime .store .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) @@ -8320,16 +8332,11 @@ mod tests { } #[tokio::test] - async fn shutdown_stop_sweep_runs_for_each_local_driver_only() { - for (driver_name, expected_calls) in [ - ("docker", 1), - ("podman", 1), - ("vm", 1), - ("kubernetes", 0), - ("extension", 0), - ] { + async fn shutdown_stop_sweep_runs_for_any_capable_driver() { + for driver_name in ["arbitrary", "docker"] { let driver = ControlledDriver::new(); - let runtime = test_runtime_for_driver(driver.clone(), driver_name).await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), driver_name).await; runtime .store .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) @@ -8343,16 +8350,41 @@ mod tests { assert_eq!( driver.stop_calls(), - expected_calls, + 1, "unexpected shutdown behavior for {driver_name}" ); } } + #[tokio::test] + async fn shutdown_stop_sweep_skips_drivers_without_capability() { + for driver_name in ["docker", "kubernetes", "extension"] { + let driver = ControlledDriver::new(); + let runtime = test_runtime_for_driver(driver.clone(), driver_name).await; + runtime + .store + .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) + .await + .unwrap(); + + runtime + .stop_persisted_sandboxes_on_shutdown() + .await + .unwrap(); + + assert_eq!( + driver.stop_calls(), + 0, + "{driver_name} should retain operator-owned lifecycle" + ); + } + } + #[tokio::test] async fn start_persisted_sandboxes_starts_running_phases() { let driver = ControlledDriver::new(); - let runtime = test_runtime_for_driver(driver.clone(), "docker").await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; for (id, name, phase) in [ ("sb-unspecified", "unspecified", SandboxPhase::Unspecified), @@ -8390,7 +8422,8 @@ mod tests { #[tokio::test] async fn startup_sweep_rechecks_intent_after_acquiring_gate() { let driver = ControlledDriver::new(); - let runtime = test_runtime_for_driver(driver.clone(), "docker").await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; runtime .store .put_message(&sandbox_record("sb-1", "sandbox", SandboxPhase::Ready)) @@ -8426,7 +8459,8 @@ mod tests { #[tokio::test] async fn lifecycle_sweeps_page_through_all_persisted_sandboxes() { let driver = ControlledDriver::new(); - let runtime = test_runtime_for_driver(driver.clone(), "docker").await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), "arbitrary").await; let sandbox_count = LIFECYCLE_SWEEP_PAGE_SIZE + 1; for index in 0..sandbox_count { runtime @@ -8454,7 +8488,7 @@ mod tests { async fn start_persisted_sandboxes_marks_missing_backend_as_error() { let driver = ControlledDriver::new(); driver.set_start_outcome(ControlledLifecycleOutcome::NotFound); - let runtime = test_runtime_for_driver(driver, "podman").await; + let runtime = test_runtime_with_gateway_managed_lifecycle(driver, "arbitrary").await; let sandbox = sandbox_record("sb-1", "missing", SandboxPhase::Ready); runtime.store.put_message(&sandbox).await.unwrap(); @@ -8484,7 +8518,7 @@ mod tests { async fn start_persisted_sandboxes_marks_failed_start_as_error() { let driver = ControlledDriver::new(); driver.set_start_outcome(ControlledLifecycleOutcome::Error("runtime angry")); - let runtime = test_runtime_for_driver(driver, "vm").await; + let runtime = test_runtime_with_gateway_managed_lifecycle(driver, "arbitrary").await; let sandbox = sandbox_record("sb-1", "broken", SandboxPhase::Provisioning); runtime.store.put_message(&sandbox).await.unwrap(); @@ -8511,10 +8545,11 @@ mod tests { } #[tokio::test] - async fn start_persisted_sandboxes_runs_for_each_local_driver() { - for driver_name in ["docker", "podman", "vm"] { + async fn start_persisted_sandboxes_runs_for_any_capable_driver() { + for driver_name in ["arbitrary", "docker"] { let driver = ControlledDriver::new(); - let runtime = test_runtime_for_driver(driver.clone(), driver_name).await; + let runtime = + test_runtime_with_gateway_managed_lifecycle(driver.clone(), driver_name).await; let sandbox = sandbox_record("sb-1", "local", SandboxPhase::Ready); runtime.store.put_message(&sandbox).await.unwrap(); @@ -8529,8 +8564,8 @@ mod tests { } #[tokio::test] - async fn start_persisted_sandboxes_skips_kubernetes_and_extension_drivers() { - for driver_name in ["kubernetes", "extension"] { + async fn start_persisted_sandboxes_skips_drivers_without_capability() { + for driver_name in ["docker", "kubernetes", "extension"] { let driver = ControlledDriver::new(); let runtime = test_runtime_for_driver(driver.clone(), driver_name).await; let sandbox = sandbox_record("sb-1", "remote", SandboxPhase::Ready); @@ -8757,13 +8792,14 @@ mod tests { let driver = FakeComputeDriver::new() .with_driver_name("fake-remote-driver") .with_default_image("openshell/sandbox:remote") + .with_gateway_manages_lifecycle() .with_gateway_listener_requirement( "172.19.0.1:17670", "external driver managed bridge", ); let _server = driver.serve_uds(&socket_path).unwrap(); - let endpoint = connect_remote_compute_driver("external-test", &socket_path) + let endpoint = connect_remote_compute_driver("docker", &socket_path) .await .unwrap(); let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); @@ -8781,7 +8817,7 @@ mod tests { runtime.gateway_listener_requirements(), &[GatewayListenerRequirement::Exact { address: "172.19.0.1:17670".parse().unwrap(), - driver_name: "external-test".to_string(), + driver_name: "docker".to_string(), reason: "external driver managed bridge".to_string(), }] ); @@ -8794,11 +8830,11 @@ mod tests { driver_config: Some(prost_types::Struct { fields: [ ( - "external-test".to_string(), + "docker".to_string(), struct_value([("pool", string_value("ci"))]), ), ( - "docker".to_string(), + "kubernetes".to_string(), struct_value([("network_mode", string_value("bridge"))]), ), ] @@ -8812,30 +8848,14 @@ mod tests { runtime.validate_sandbox_create(&sandbox).await.unwrap(); runtime.create_sandbox(sandbox, None).await.unwrap(); - assert!( - runtime - .delete_sandbox("default", "uds-sandbox") - .await - .unwrap() - .deleted - ); - let calls = driver.calls(); - assert_eq!(calls.len(), 5, "unexpected calls: {calls:?}"); - assert!(matches!(calls[0], FakeComputeDriverCall::GetCapabilities)); - assert!(matches!( - calls[1], - FakeComputeDriverCall::GetGatewayListenerRequirements - )); - + assert_eq!(calls.len(), 4, "unexpected calls: {calls:?}"); let validated = match &calls[2] { FakeComputeDriverCall::ValidateSandboxCreate { sandbox: Some(sandbox), } => sandbox, other => panic!("expected ValidateSandboxCreate call, got {other:?}"), }; - assert_eq!(validated.id, "sb-uds"); - assert_eq!(validated.name, "uds-sandbox"); let driver_config = validated .spec .as_ref() @@ -8845,16 +8865,36 @@ mod tests { assert!(driver_config.fields.contains_key("pool")); assert!(!driver_config.fields.contains_key("network_mode")); - let created = match &calls[3] { - FakeComputeDriverCall::CreateSandbox { - sandbox: Some(sandbox), - } => sandbox, - other => panic!("expected CreateSandbox call, got {other:?}"), - }; - assert_eq!(created.id, "sb-uds"); - assert_eq!(created.name, "uds-sandbox"); + driver.clear_calls(); + runtime + .stop_persisted_sandboxes_on_shutdown() + .await + .unwrap(); + assert!(matches!( + driver.calls().as_slice(), + [FakeComputeDriverCall::StopSandbox { sandbox_id, sandbox_name }] + if sandbox_id == "sb-uds" && sandbox_name == "uds-sandbox" + )); + + driver.clear_calls(); + runtime.start_persisted_sandboxes().await.unwrap(); + assert!(matches!( + driver.calls().as_slice(), + [FakeComputeDriverCall::StartSandbox { sandbox_id, sandbox_name }] + if sandbox_id == "sb-uds" && sandbox_name == "uds-sandbox" + )); + driver.clear_calls(); + assert!( + runtime + .delete_sandbox("default", "uds-sandbox") + .await + .unwrap() + .deleted + ); - match &calls[4] { + let calls = driver.calls(); + assert_eq!(calls.len(), 1, "unexpected calls: {calls:?}"); + match &calls[0] { FakeComputeDriverCall::DeleteSandbox { sandbox_id, sandbox_name, diff --git a/crates/openshell-server/src/gateway_listener.rs b/crates/openshell-server/src/gateway_listener.rs index 4e4c7e80d..640757bfb 100644 --- a/crates/openshell-server/src/gateway_listener.rs +++ b/crates/openshell-server/src/gateway_listener.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::compute::GatewayListenerRequirement; -use openshell_core::{ComputeDriverKind, Error, Result}; +use openshell_core::{Error, Result}; use socket2::{Domain, Protocol, Socket, Type}; use std::net::{IpAddr, SocketAddr}; use tokio::net::TcpListener; @@ -193,25 +193,11 @@ fn validate_gateway_listener_requirement( requirement: &GatewayListenerRequirement, ) -> Result<()> { match requirement { - GatewayListenerRequirement::Exact { - address, - driver_name, - .. - } if driver_name == ComputeDriverKind::Docker.as_str() - || driver_name == ComputeDriverKind::Podman.as_str() => - { + GatewayListenerRequirement::Exact { address, .. } => { validate_resolved_gateway_listener(primary_listener, *address) } - GatewayListenerRequirement::DefaultRouteInterface { driver_name, .. } - | GatewayListenerRequirement::LoopbackInterface { driver_name, .. } - if driver_name == ComputeDriverKind::Podman.as_str() => - { - Ok(()) - } - _ => Err(Error::config(format!( - "compute driver '{}' is not authorized to request this gateway listener selector", - requirement.driver_name() - ))), + GatewayListenerRequirement::DefaultRouteInterface { .. } + | GatewayListenerRequirement::LoopbackInterface { .. } => Ok(()), } } @@ -464,7 +450,7 @@ mod tests { } #[test] - fn gateway_listener_specs_reject_unauthorized_external_driver() { + fn gateway_listener_specs_accept_safe_external_driver_requirement() { let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); let requirement = GatewayListenerRequirement::Exact { address: "172.18.0.1:8080".parse().unwrap(), @@ -472,8 +458,10 @@ mod tests { reason: "external bridge".to_string(), }; - let err = gateway_listener_specs(primary, &[requirement]).unwrap_err(); - assert!(err.to_string().contains("not authorized")); + let specs = gateway_listener_specs(primary, &[requirement]).unwrap(); + assert_eq!(specs.len(), 2); + assert_eq!(specs[1].address, "172.18.0.1:8080".parse().unwrap()); + assert_eq!(specs[1].scope, GatewayListenerScope::ComputeDriverCallback); } #[test] @@ -622,15 +610,18 @@ mod tests { } #[test] - fn gateway_listener_specs_reject_cross_driver_selector_authority() { - let primary: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + fn gateway_listener_specs_validate_selector_independently_of_driver_name() { + let primary: SocketAddr = "192.168.20.20:8080".parse().unwrap(); let requirement = GatewayListenerRequirement::LoopbackInterface { driver_name: "docker".to_string(), reason: "wrong selector".to_string(), }; - let err = gateway_listener_specs(primary, &[requirement]).unwrap_err(); - assert!(err.to_string().contains("not authorized")); + let specs = gateway_listener_specs(primary, &[requirement]).unwrap(); + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].address, primary); + assert_eq!(specs[1].address, "127.0.0.1:8080".parse().unwrap()); + assert_eq!(specs[1].scope, GatewayListenerScope::ComputeDriverCallback); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index a2e8a7f0a..60d839e3a 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -81,9 +81,8 @@ use tonic::{Request, Response, Status}; use tracing::{debug, info, warn}; use super::validation::{ - level_matches, normalize_process_identity_for_driver, source_matches, validate_annotations, - validate_no_reserved_provider_policy_keys, validate_policy_safety, - validate_static_fields_unchanged, + level_matches, source_matches, validate_annotations, validate_no_reserved_provider_policy_keys, + validate_policy_safety, validate_static_fields_unchanged, }; use super::{MAX_PAGE_SIZE, StoredSettingValue, StoredSettings, clamp_limit}; use crate::persistence::current_time_ms; @@ -2705,7 +2704,6 @@ async fn handle_update_config_inner( Status::invalid_argument("policy is required for global policy update") })?; clear_provider_credentialed_markers(&mut new_policy); - normalize_process_identity_for_driver(&mut new_policy, state.compute.driver_kind()); validate_no_reserved_provider_policy_keys(&new_policy)?; validate_policy_safety(&new_policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) @@ -2999,10 +2997,7 @@ async fn handle_update_config_inner( provenance: &req.annotations, annotations: &req.annotations, }; - let mut baseline_policy = spec.policy.clone(); - if let Some(policy) = baseline_policy.as_mut() { - normalize_process_identity_for_driver(policy, state.compute.driver_kind()); - } + let baseline_policy = spec.policy.clone(); let (version, hash, updated_sandbox) = apply_merge_operations_with_retry( state.store.as_ref(), &sandbox_id, @@ -3078,7 +3073,6 @@ async fn handle_update_config_inner( .policy .ok_or_else(|| Status::invalid_argument("policy is required"))?; clear_provider_credentialed_markers(&mut new_policy); - normalize_process_identity_for_driver(&mut new_policy, state.compute.driver_kind()); let global_settings = load_global_settings(state.store.as_ref()).await?; if global_settings.settings.contains_key(POLICY_SETTING_KEY) { @@ -3104,11 +3098,7 @@ async fn handle_update_config_inner( } let backfill_policy = if let Some(baseline_policy) = spec.policy.as_ref() { - let mut comparable_baseline = baseline_policy.clone(); - normalize_process_identity_for_driver( - &mut comparable_baseline, - state.compute.driver_kind(), - ); + let comparable_baseline = baseline_policy.clone(); validate_static_fields_unchanged(&comparable_baseline, &new_policy)?; None } else { @@ -15422,6 +15412,7 @@ mod tests { assert_eq!(response.version, 1); // Verify the resource_version incremented and policy was backfilled + // without replacing an omitted process identity component. let updated_sandbox = state .store .get_message_by_name::("default", "test-sandbox") @@ -15433,9 +15424,9 @@ mod tests { .as_ref() .and_then(|spec| spec.policy.as_ref()) .and_then(|policy| policy.process.as_ref()) - .expect("legacy process identity should be persisted"); + .expect("partial process identity should be persisted"); assert_eq!(process.run_as_user, "1234"); - assert_eq!(process.run_as_group, "sandbox"); + assert!(process.run_as_group.is_empty()); assert_eq!( updated_sandbox.metadata.as_ref().unwrap().resource_version, current_version + 1, diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index d5dd4e04e..94de858ca 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -53,9 +53,8 @@ use super::provider::{ get_provider_record, is_valid_env_key, validate_provider_environment_keys_unique, }; use super::validation::{ - level_matches, normalize_process_identity_for_driver, source_matches, - validate_exec_request_fields, validate_no_reserved_provider_policy_keys, - validate_policy_safety, validate_sandbox_spec, + level_matches, source_matches, validate_exec_request_fields, + validate_no_reserved_provider_policy_keys, validate_policy_safety, validate_sandbox_spec, }; use super::{MAX_PAGE_SIZE, MAX_PROVIDERS, MAX_ROUTABLE_NAME_LEN, clamp_limit}; use crate::persistence::current_time_ms; @@ -268,11 +267,8 @@ async fn handle_create_sandbox_inner( template.image = state.compute.default_image().to_string(); } - // Docker and Podman preserve omitted identity fields for OCI USER - // fallback. Other drivers retain the legacy persisted sandbox defaults. if let Some(ref mut policy) = spec.policy { super::policy::clear_provider_credentialed_markers(policy); - normalize_process_identity_for_driver(policy, state.compute.driver_kind()); validate_no_reserved_provider_policy_keys(policy)?; validate_policy_safety(policy)?; crate::middleware::validate_policy(state.middleware_registry.as_ref(), policy).await?; @@ -3443,7 +3439,7 @@ mod tests { } #[tokio::test] - async fn create_and_get_restore_legacy_identity_defaults_for_non_local_driver() { + async fn create_and_get_preserve_partial_process_identity_for_kubernetes() { let state = test_server_state_with_driver(openshell_core::ComputeDriverKind::Kubernetes.as_str()) .await; @@ -3470,7 +3466,7 @@ mod tests { }), ) .await - .expect("Kubernetes identity defaults should be accepted") + .expect("partial Kubernetes process identity should be accepted") .into_inner(); let process = response @@ -3482,7 +3478,7 @@ mod tests { .unwrap() .process .unwrap(); - assert_eq!(process.run_as_user, "sandbox"); + assert!(process.run_as_user.is_empty()); assert_eq!(process.run_as_group, "1234"); } diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index f71623fa3..f64a539d9 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -8,7 +8,6 @@ #![allow(clippy::result_large_err)] // Validation returns Result<_, Status> -use openshell_core::ComputeDriverKind; use openshell_core::proto::{ CredentialHandle, ExecSandboxRequest, Provider, SandboxPolicy as ProtoSandboxPolicy, SandboxTemplate, @@ -28,23 +27,6 @@ use super::{ // Exec request validation // --------------------------------------------------------------------------- -/// Preserve process-identity omission only for the local OCI-aware drivers. -/// -/// Kubernetes, VM, and unknown/remote drivers retain the legacy persisted -/// `sandbox:sandbox` defaults so existing policy hashes and live-update -/// workflows do not change. -pub(super) fn normalize_process_identity_for_driver( - policy: &mut ProtoSandboxPolicy, - driver_kind: Option, -) { - if !matches!( - driver_kind, - Some(ComputeDriverKind::Docker | ComputeDriverKind::Podman) - ) { - openshell_policy::ensure_sandbox_process_identity(policy); - } -} - /// Maximum number of arguments in the command array. pub(super) const MAX_EXEC_COMMAND_ARGS: usize = 1024; /// Maximum length of a single command argument or environment value (bytes). @@ -1853,44 +1835,6 @@ mod tests { // ---- Policy safety ---- - #[test] - fn process_identity_omission_is_driver_scoped() { - use openshell_core::proto::ProcessPolicy; - - for driver in [ComputeDriverKind::Docker, ComputeDriverKind::Podman] { - let mut policy = ProtoSandboxPolicy { - process: Some(ProcessPolicy { - run_as_user: "1234".into(), - run_as_group: String::new(), - }), - ..Default::default() - }; - normalize_process_identity_for_driver(&mut policy, Some(driver)); - assert!( - policy.process.unwrap().run_as_group.is_empty(), - "{driver:?} must preserve omission" - ); - } - - for driver in [ - Some(ComputeDriverKind::Kubernetes), - Some(ComputeDriverKind::Vm), - None, - ] { - let mut policy = ProtoSandboxPolicy { - process: Some(ProcessPolicy { - run_as_user: "1234".into(), - run_as_group: String::new(), - }), - ..Default::default() - }; - normalize_process_identity_for_driver(&mut policy, driver); - let process = policy.process.unwrap(); - assert_eq!(process.run_as_user, "1234"); - assert_eq!(process.run_as_group, "sandbox"); - } - } - #[test] fn validate_policy_safety_rejects_root_user() { use openshell_core::proto::{FilesystemPolicy, ProcessPolicy}; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 979e37208..9ea8161a0 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -1248,13 +1248,14 @@ fn resolve_configured_compute_driver( ) -> Result { let name = openshell_core::config::normalize_compute_driver_name(driver_name) .map_err(Error::config)?; - let driver_kind = builtin_compute_driver(&name); - if driver_kind.is_some() && driver_startup.endpoint_overrides.contains_key(&name) { - return Err(Error::config(format!( - "compute driver '{name}' is a reserved built-in driver and cannot be selected with a socket endpoint" - ))); + // An operator-provided endpoint replaces normal construction for the + // selected name. The gateway connects to it; it does not provision a + // remote implementation for canonical built-in names. + if driver_startup.endpoint_overrides.contains_key(&name) { + return Ok(ConfiguredComputeDriver::Remote { name }); } + let driver_kind = builtin_compute_driver(&name); if let Some(kind) = driver_kind { return Ok(ConfiguredComputeDriver::Builtin(kind)); } @@ -1877,35 +1878,31 @@ mod tests { } #[test] - fn configured_compute_driver_rejects_vm_endpoint_from_config() { + fn configured_compute_driver_uses_vm_endpoint_override() { let config = Config::new(None) .with_compute_drivers([ComputeDriverKind::Vm]) .with_compute_driver_endpoint("vm", "/run/openshell/vm.sock"); - let err = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err(); - - assert!( - err.to_string() - .contains("reserved built-in driver and cannot be selected with a socket endpoint"), - "unexpected error: {err}" - ); + let driver = + configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); + assert!(matches!( + driver, + ConfiguredComputeDriver::Remote { name } if name == "vm" + )); } #[test] - fn configured_compute_driver_rejects_builtin_endpoint() { + fn configured_compute_driver_uses_builtin_endpoint_override() { let config = Config::new(None) .with_compute_drivers([ComputeDriverKind::Docker]) .with_compute_driver_endpoint("docker", "/run/openshell/docker.sock"); - let err = - configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap_err(); - - assert!( - err.to_string() - .contains("cannot be selected with a socket endpoint"), - "unexpected error: {err}" - ); + let driver = + configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); + assert!(matches!( + driver, + ConfiguredComputeDriver::Remote { name } if name == "docker" + )); } #[test] diff --git a/crates/openshell-server/src/test_support.rs b/crates/openshell-server/src/test_support.rs index f8124ded6..957db46fa 100644 --- a/crates/openshell-server/src/test_support.rs +++ b/crates/openshell-server/src/test_support.rs @@ -71,9 +71,7 @@ pub struct FakeComputeDriver { #[derive(Debug)] struct FakeComputeDriverState { - driver_name: String, - driver_version: String, - default_image: String, + capabilities: GetCapabilitiesResponse, gateway_listener_requirements: Vec, gateway_listener_requirements_supported: bool, sandboxes: HashMap, @@ -92,9 +90,12 @@ impl FakeComputeDriver { pub fn new() -> Self { Self { state: Arc::new(Mutex::new(FakeComputeDriverState { - driver_name: "fake-compute-driver".to_string(), - driver_version: "test".to_string(), - default_image: "openshell/sandbox:test".to_string(), + capabilities: GetCapabilitiesResponse { + driver_name: "fake-compute-driver".to_string(), + driver_version: "test".to_string(), + default_image: "openshell/sandbox:test".to_string(), + gateway_manages_lifecycle: false, + }, gateway_listener_requirements: Vec::new(), gateway_listener_requirements_supported: true, sandboxes: HashMap::new(), @@ -106,19 +107,25 @@ impl FakeComputeDriver { #[must_use] pub fn with_driver_name(self, driver_name: impl Into) -> Self { - self.with_state(|state| state.driver_name = driver_name.into()); + self.with_state(|state| state.capabilities.driver_name = driver_name.into()); self } #[must_use] pub fn with_driver_version(self, driver_version: impl Into) -> Self { - self.with_state(|state| state.driver_version = driver_version.into()); + self.with_state(|state| state.capabilities.driver_version = driver_version.into()); self } #[must_use] pub fn with_default_image(self, default_image: impl Into) -> Self { - self.with_state(|state| state.default_image = default_image.into()); + self.with_state(|state| state.capabilities.default_image = default_image.into()); + self + } + + #[must_use] + pub fn with_gateway_manages_lifecycle(self) -> Self { + self.with_state(|state| state.capabilities.gateway_manages_lifecycle = true); self } @@ -237,11 +244,7 @@ impl ComputeDriver for FakeComputeDriver { self.record_traceparent(request.metadata()); let response = self.with_state(|state| { state.calls.push(FakeComputeDriverCall::GetCapabilities); - GetCapabilitiesResponse { - driver_name: state.driver_name.clone(), - driver_version: state.driver_version.clone(), - default_image: state.default_image.clone(), - } + state.capabilities.clone() }); Ok(Response::new(response)) } diff --git a/proto/compute_driver.proto b/proto/compute_driver.proto index 0ce4f6153..76b8d9480 100644 --- a/proto/compute_driver.proto +++ b/proto/compute_driver.proto @@ -8,14 +8,17 @@ package openshell.compute.v1; import "google/protobuf/struct.proto"; import "options.proto"; -// Internal compute-driver contract used by the gateway. +// Gateway/compute-driver extension contract. // // Conventions: // - This file owns driver-native request, response, and observation types. // - Compute drivers must not import or return the public `openshell.v1.Sandbox` // resource model. -// - The gateway translates between these internal driver-native messages and +// - The gateway translates between these driver-native messages and // the public OpenShell API resource model. +// - Capability fields are additive. Drivers and gateways must ignore unknown +// feature values so independently versioned external drivers remain +// forward-compatible. service ComputeDriver { // Report driver capabilities and defaults. rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); @@ -72,6 +75,9 @@ message GetCapabilitiesResponse { string driver_version = 2; // Default sandbox image recommended by the driver. string default_image = 3; + // Whether the gateway should stop running sandbox compute during graceful + // shutdown and restart the retained running intent on startup. + bool gateway_manages_lifecycle = 6; } message GetGatewayListenerRequirementsRequest {}