diff --git a/AGENTS.md b/AGENTS.md index 8866ead489..91768ec0f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,8 @@ Do not rely on this file for a full inventory. The detailed public and contribut | `crates/openshell-otel-test-support/` | OpenTelemetry test support | Shared loopback OTLP collector fixture for tracing tests | | `crates/openshell-crypto/` | Crypto backend | Backend-neutral primitives and TLS, PKI, JWT adapters; AWS-LC implementation | | `crates/openshell-core/` | Shared core | Common types, configuration, error handling | +| `crates/openshell-driver-sdk/` | Driver SDK | Narrow contract shared by core and the compute drivers: compute-driver proto, sandbox environment, JWT session tokens, extension protocol, admission and mount validation; `openshell-core` re-exports it | +| `crates/openshell-container-runtime/` | Container runtime helpers | Code used only by the desktop compute drivers (Docker, Podman, VM): CDI GPU selection, local API socket discovery, supervisor cache and tar helpers; depends on the SDK, never on `openshell-core` | | `crates/openshell-extension-core/` | Extension core | Shared extension identity, JWT claims, bearer-token rotation, and TLS transport primitives | | `crates/openshell-gateway/` | Gateway binary composition | Links selected first-party compute drivers into the backend-agnostic server registry | | `crates/openshell-sdk/` | Shared client SDK | Async Rust gateway client (gRPC transport, TLS, OIDC refresh, edge tunnel); consumed by CLI, TUI, and `@openshell/sdk` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e800debdf..2b55ed6165 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -381,6 +381,8 @@ These are the primary `mise` tasks for day-to-day development: | --------------- | --------------------------------------------- | | `crates/` | Rust crates | | `crates/openshell-policy-schema/` | Canonical authored policy DTOs and bounded YAML/JSON parser | +| `crates/openshell-driver-sdk/` | Narrow contract shared by core and the compute drivers; `openshell-core` re-exports it | +| `crates/openshell-container-runtime/` | Code shared only by the desktop compute drivers (Docker, Podman, VM) | | `crates/openshell-prover-cli/` | Standalone local policy boundary checker | | `python/` | Python SDK and bindings | | `sdk/go/` | Go SDK (types, gRPC clients, converters) | diff --git a/Cargo.lock b/Cargo.lock index d178930a4a..15d585c06a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4414,6 +4414,20 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-container-runtime" +version = "0.0.0" +dependencies = [ + "miette", + "openshell-driver-sdk", + "rustls", + "serde", + "serde_json", + "tar", + "tempfile", + "tonic", +] + [[package]] name = "openshell-core" version = "0.0.0" @@ -4425,11 +4439,11 @@ dependencies = [ "glob", "h2", "ipnet", - "jsonwebtoken", "libc", "miette", "nix 0.29.0", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-otel", "openshell-policy-schema", @@ -4443,7 +4457,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "tar", "tempfile", "thiserror 2.0.20", "tokio", @@ -4457,7 +4470,6 @@ dependencies = [ "tracing-subscriber", "url", "uuid", - "zeroize", ] [[package]] @@ -4541,7 +4553,8 @@ dependencies = [ "http 1.4.0", "libc", "miette", - "openshell-core", + "openshell-container-runtime", + "openshell-driver-sdk", "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", @@ -4670,8 +4683,9 @@ dependencies = [ "hyper-util", "miette", "nix 0.29.0", - "openshell-core", + "openshell-container-runtime", "openshell-crypto", + "openshell-driver-sdk", "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", @@ -4699,6 +4713,41 @@ dependencies = [ "uuid", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "clap", + "jsonwebtoken", + "libc", + "miette", + "nix 0.29.0", + "openshell-crypto", + "openshell-otel", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rcgen", + "rustix 1.1.4", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", + "zeroize", +] + [[package]] name = "openshell-driver-vault" version = "0.0.0" @@ -4740,9 +4789,10 @@ dependencies = [ "miette", "nix 0.29.0", "oci-client", - "openshell-core", + "openshell-container-runtime", "openshell-crypto", "openshell-driver-podman", + "openshell-driver-sdk", "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", @@ -4844,10 +4894,8 @@ dependencies = [ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ - "openshell-core", - "serde", + "openshell-driver-sdk", "serde_json", - "sha2 0.10.9", "tokio", ] @@ -5056,6 +5104,7 @@ dependencies = [ "libc", "openshell-core", "openshell-crypto", + "openshell-driver-sdk", "openshell-isolation-interface", "prost", "prost-types", diff --git a/crates/openshell-container-runtime/Cargo.toml b/crates/openshell-container-runtime/Cargo.toml new file mode 100644 index 0000000000..363e4957bc --- /dev/null +++ b/crates/openshell-container-runtime/Cargo.toml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-container-runtime" +description = "Code shared by the OpenShell desktop compute drivers (Docker, Podman, VM)" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +miette = { workspace = true } +serde = { workspace = true } +tonic = { workspace = true } +rustls = { workspace = true } +tar = "0.4" +tempfile = "3" +openshell-driver-sdk = { path = "../openshell-driver-sdk" } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-container-runtime/README.md b/crates/openshell-container-runtime/README.md new file mode 100644 index 0000000000..658d3db0da --- /dev/null +++ b/crates/openshell-container-runtime/README.md @@ -0,0 +1,19 @@ +# openshell-container-runtime + +Code used only by the desktop compute drivers (Docker, Podman, and VM). It +moves with those drivers when they leave this repository. + +Anything that `openshell-core` consumers (the gateway, supervisor, or the +Kubernetes driver) also use lives in `openshell-driver-sdk` instead. This crate +depends on the SDK and never on `openshell-core`. + +## Contents + +- `gpu`: CDI GPU inventory and selection, and VM specific-device validation. +- `local_api_socket`: container-runtime socket discovery helpers. +- `driver_utils`: supervisor image cache, tar extraction, ELF validation, + SPIFFE socket mounts, and workspace-validation conditions. +- `resource_admission`: provenance labels for Docker and Podman containers. +- `network_trust`: destination-trust generation labels for Podman containers. +- `config`: defaults for the container drivers. +- `proto_struct`: the serde helper for driver configuration string lists. diff --git a/crates/openshell-container-runtime/src/config.rs b/crates/openshell-container-runtime/src/config.rs new file mode 100644 index 0000000000..50df5e6b4f --- /dev/null +++ b/crates/openshell-container-runtime/src/config.rs @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Configuration defaults used only by the desktop compute drivers. + +use std::num::NonZeroI64; + +// ── Public default constants ──────────────────────────────────────────── +// +// Canonical source for default values used across multiple crates. +// Clap `default_value_t` annotations and runtime fallbacks should +// reference these constants instead of hardcoding literals. + +/// Default SSH port inside sandbox containers. +pub const DEFAULT_SSH_PORT: u16 = 2222; + +/// Default cgroup PID limit for local container sandboxes. +pub const DEFAULT_SANDBOX_PIDS_LIMIT: i64 = 2048; + +/// Typed default cgroup PID limit for local container sandboxes. +#[must_use] +pub fn default_sandbox_pids_limit() -> Option { + NonZeroI64::new(DEFAULT_SANDBOX_PIDS_LIMIT) +} + +/// CDI device identifier for requesting all NVIDIA GPUs. +pub const CDI_GPU_DEVICE_ALL: &str = "nvidia.com/gpu=all"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_sandbox_pids_default_matches_positive_constant() { + assert_eq!( + default_sandbox_pids_limit().map(NonZeroI64::get), + Some(DEFAULT_SANDBOX_PIDS_LIMIT) + ); + } +} diff --git a/crates/openshell-container-runtime/src/driver_utils.rs b/crates/openshell-container-runtime/src/driver_utils.rs new file mode 100644 index 0000000000..33eb36351c --- /dev/null +++ b/crates/openshell-container-runtime/src/driver_utils.rs @@ -0,0 +1,294 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Utility helpers shared across compute-driver crates. + +use std::path::{Path, PathBuf}; + +/// Container/pod label carrying the sandbox namespace. +pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace"; + +/// Ready-condition reason when the supervisor rejects an image-provided OCI +/// working directory because the sandbox identity lacks the required access. +pub const CONDITION_WORKSPACE_VALIDATION_FAILED: &str = "WorkspaceValidationFailed"; + +/// Driver condition message for [`CONDITION_WORKSPACE_VALIDATION_FAILED`]. +/// Fixed text, because supervisor output may contain secrets. +pub const WORKSPACE_VALIDATION_FAILED_MESSAGE: &str = + "OCI WorkingDir is not usable by the sandbox identity"; + +// --------------------------------------------------------------------------- + +/// Path to the sandbox supervisor binary inside the container image. +/// +/// All compute drivers must launch this binary as the container entrypoint to +/// start the sandboxed environment. The value must be kept in sync with the +/// path used when building the `openshell-sandbox` image layer. +pub const SANDBOX_RUNTIME_IMAGE_BINARY_PATH: &str = "/openshell-sandbox"; + +/// Legacy name for [`SANDBOX_RUNTIME_IMAGE_BINARY_PATH`]. +pub const SUPERVISOR_IMAGE_BINARY_PATH: &str = SANDBOX_RUNTIME_IMAGE_BINARY_PATH; + +/// Container-side mount path for the corporate proxy CA bundle. +/// +/// Drivers with a `proxy_ca_bundle` operator setting bind-mount the host PEM +/// file here (read-only) and pass the path on the supervisor's argv via +/// `--upstream-proxy-ca-bundle`. The supervisor trusts it for the TLS +/// handshake with an `https://` corporate egress proxy and for server +/// certificates re-signed by a TLS-intercepting proxy. Unlike the proxy +/// credential, a CA certificate is not secret, so a plain read-only bind +/// mount is used rather than a driver secret. +pub const PROXY_CA_MOUNT_PATH: &str = "/etc/openshell/tls/proxy/ca-bundle.pem"; + +/// Container-side directory where the provider SPIFFE Workload API socket is mounted. +pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = "/spiffe-workload-api"; + +/// Validate a host UNIX socket selected for provider SPIFFE projection. +/// +/// Local container drivers bind-mount the socket's dedicated parent directory, +/// not a broad host root. TCP endpoints are deliberately rejected here: a +/// container projection must be a filesystem socket, while VM guest TCP +/// exposure has its own explicit acknowledgement contract. +pub fn validate_provider_spiffe_unix_socket(path: &Path) -> Result<(), String> { + let raw = path + .to_str() + .ok_or_else(|| "provider_spiffe_workload_api_socket must be valid UTF-8".to_string())?; + if raw.trim() != raw || raw.is_empty() { + return Err("provider_spiffe_workload_api_socket must not be empty or contain surrounding whitespace".to_string()); + } + if raw.starts_with("tcp:") || raw.starts_with("unix:") { + return Err("provider_spiffe_workload_api_socket must be an absolute host UNIX socket path, not a URI".to_string()); + } + if !path.is_absolute() || path.parent().is_none_or(|parent| parent == Path::new("/")) { + return Err("provider_spiffe_workload_api_socket must be an absolute UNIX socket path below a dedicated parent directory".to_string()); + } + Ok(()) +} + +/// Return the guest/container path for a projected provider SPIFFE socket. +pub fn projected_provider_spiffe_socket_path(path: &Path) -> Result { + validate_provider_spiffe_unix_socket(path)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| "provider_spiffe_workload_api_socket must name a socket file".to_string())?; + Ok(format!( + "{PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR}/{file_name}" + )) +} + +/// Return the XDG state path for a driver's sandbox JWT token file. +/// +/// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. +/// +/// `driver_subdir` is driver-specific. When `namespace` is `Some`, it is +/// appended as an additional path component (with `/` and `\` replaced by +/// `-`). +/// +/// # Errors +/// Returns an error if the XDG state directory cannot be resolved. +pub fn sandbox_token_path( + driver_subdir: &str, + namespace: Option<&str>, + sandbox_id: &str, +) -> miette::Result { + let mut path = openshell_driver_sdk::paths::xdg_state_dir()? + .join("openshell") + .join(driver_subdir); + if let Some(ns) = namespace { + path = path.join(ns.replace(['/', '\\'], "-")); + } + Ok(path.join(sandbox_id).join("sandbox.jwt")) +} + +// --------------------------------------------------------------------------- +// Supervisor image helpers shared by container-backed drivers +// --------------------------------------------------------------------------- + +/// Return the tag portion of a supervisor image reference, or `None` if the +/// reference uses a digest (`@sha256:...`). +/// +/// Examples: +/// - `"ghcr.io/org/image:1.2.3"` → `Some("1.2.3")` +/// - `"ghcr.io/org/image:latest"` → `Some("latest")` +/// - `"ghcr.io/org/image"` → `Some("latest")` (implied tag) +/// - `"ghcr.io/org/image@sha256:abc"` → `None` (pinned by digest) +/// - `"ghcr.io/org/image:"` → `None` (empty tag) +pub fn supervisor_image_tag(image: &str) -> Option<&str> { + if image.contains('@') { + return None; + } + + let image_name = image.rsplit('/').next().unwrap_or(image); + image_name + .rsplit_once(':') + .map_or(Some("latest"), |(_, tag)| { + if tag.is_empty() { None } else { Some(tag) } + }) +} + +/// Return `true` if the supervisor image should be refreshed before each use. +/// +/// Mutable tags (`dev`, `latest`) are always re-pulled so that the running +/// container tracks the latest pushed version. Digest-pinned references and +/// all other versioned tags are treated as immutable and pulled at most once. +pub fn supervisor_image_should_refresh(image: &str) -> bool { + matches!(supervisor_image_tag(image), Some("dev" | "latest")) +} + +// --------------------------------------------------------------------------- +// Supervisor binary extraction helpers shared by container-backed drivers +// --------------------------------------------------------------------------- + +/// Extract the payload of the first regular-file entry in a tar archive. +/// +/// Container archive endpoints return a single-file tar when `path` points to +/// a file, so only the first entry is consumed. Returns an error when the +/// archive is empty, the first entry is not a regular file, or the payload is +/// empty. +pub fn extract_first_tar_entry(tar_bytes: &[u8]) -> Result, String> { + let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); + let mut entries = archive + .entries() + .map_err(|err| format!("open tar archive: {err}"))?; + let mut entry = entries + .next() + .ok_or_else(|| "tar archive was empty".to_string())? + .map_err(|err| format!("read tar entry: {err}"))?; + let kind = entry.header().entry_type(); + if !kind.is_file() { + return Err(format!( + "expected a regular file in tar archive, got type {kind:?}" + )); + } + let mut bytes = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut bytes) + .map_err(|err| format!("read tar entry payload: {err}"))?; + if bytes.is_empty() { + return Err("tar entry payload was empty".to_string()); + } + Ok(bytes) +} + +/// Atomically write `bytes` to `final_path` via a sibling temp file. +/// +/// Creates parent directories as needed. The temp file is synced, `chmod 755` +/// (on Unix), and renamed into place so concurrent readers never observe a +/// partial write. Returns a human-readable error string on failure. +pub fn write_cache_binary_atomic(final_path: &Path, bytes: &[u8]) -> Result<(), String> { + let dir = final_path + .parent() + .ok_or_else(|| format!("cache path '{}' has no parent", final_path.display()))?; + std::fs::create_dir_all(dir) + .map_err(|err| format!("failed to create cache dir '{}': {err}", dir.display()))?; + + let mut temp = tempfile::Builder::new() + .prefix(".openshell-sandbox-") + .tempfile_in(dir) + .map_err(|err| format!("failed to create temp file in '{}': {err}", dir.display()))?; + std::io::Write::write_all(&mut temp, bytes) + .map_err(|err| format!("failed to write supervisor binary: {err}"))?; + temp.as_file() + .sync_all() + .map_err(|err| format!("failed to sync supervisor binary: {err}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)) + .map_err(|err| format!("failed to chmod supervisor binary: {err}"))?; + } + + temp.persist(final_path).map_err(|err| { + format!( + "failed to persist supervisor binary to '{}': {}", + final_path.display(), + err.error, + ) + })?; + Ok(()) +} + +/// Return the host-side cache path for an extracted supervisor binary. +/// +/// The path is `$XDG_DATA_HOME/openshell///openshell-sandbox`. +/// `driver_subdir` distinguishes caches across drivers. +pub fn supervisor_cache_path(driver_subdir: &str, digest: &str) -> Result { + let base = openshell_driver_sdk::paths::xdg_data_dir() + .map_err(|err| format!("failed to resolve XDG data dir: {err}"))?; + Ok(supervisor_cache_path_with_base( + &base, + driver_subdir, + digest, + )) +} + +/// [`supervisor_cache_path`] with an explicit base directory (for testing). +pub fn supervisor_cache_path_with_base(base: &Path, driver_subdir: &str, digest: &str) -> PathBuf { + let sanitized = digest.replace(':', "-"); + base.join("openshell") + .join(driver_subdir) + .join(sanitized) + .join("openshell-sandbox") +} + +/// Generate a unique container name for supervisor binary extraction. +/// +/// Uses the process ID and an atomic counter to avoid collisions across +/// concurrent gateway starts. +pub fn temp_extract_container_name() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQ: AtomicU64 = AtomicU64::new(0); + let pid = std::process::id(); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + format!("openshell-supervisor-extract-{pid}-{seq}") +} + +/// Validate that the file at `path` starts with the ELF magic bytes (`\x7fELF`). +/// +/// Returns a human-readable error when the file cannot be read or is not a +/// Linux ELF binary. +pub fn validate_linux_elf_binary(path: &Path) -> Result<(), String> { + use std::io::Read; + let mut file = std::fs::File::open(path).map_err(|err| { + format!( + "failed to open supervisor binary '{}': {err}", + path.display() + ) + })?; + let mut magic = [0u8; 4]; + file.read_exact(&mut magic).map_err(|err| { + format!( + "failed to read supervisor binary '{}': {err}", + path.display() + ) + })?; + if magic != [0x7f, b'E', b'L', b'F'] { + return Err(format!( + "supervisor binary '{}' is not a Linux ELF executable", + path.display(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn projected_spiffe_socket_requires_dedicated_absolute_unix_path() { + assert_eq!( + projected_provider_spiffe_socket_path(Path::new("/run/spire/agent.sock")).unwrap(), + "/spiffe-workload-api/agent.sock" + ); + for path in ["relative.sock", "/agent.sock", "tcp:127.0.0.1:8081"] { + assert!( + validate_provider_spiffe_unix_socket(Path::new(path)).is_err(), + "{path}" + ); + } + } +} diff --git a/crates/openshell-container-runtime/src/gpu.rs b/crates/openshell-container-runtime/src/gpu.rs new file mode 100644 index 0000000000..1c1da21ef3 --- /dev/null +++ b/crates/openshell-container-runtime/src/gpu.rs @@ -0,0 +1,720 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared GPU resource requirement helpers. + +use crate::config::CDI_GPU_DEVICE_ALL; +use openshell_driver_sdk::gpu::effective_driver_gpu_count; +use openshell_driver_sdk::proto::compute::v1::GpuResourceRequirements as DriverGpuResourceRequirements; +use std::collections::HashSet; +use std::fmt; +use std::sync::RwLock; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const CDI_NVIDIA_GPU_PREFIX: &str = "nvidia.com/gpu="; + +const CDI_NVIDIA_GPU_ALL_SUFFIX: &str = "all"; + +/// Normalized CDI GPU inventory used by local container drivers. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CdiGpuInventory { + device_ids: Vec, +} + +impl CdiGpuInventory { + /// Build a normalized inventory from runtime-reported CDI device IDs. + #[must_use] + pub fn new(device_ids: impl IntoIterator>) -> Self { + let mut device_ids = device_ids + .into_iter() + .filter_map(|id| { + let id = id.as_ref().trim(); + id.starts_with(CDI_NVIDIA_GPU_PREFIX) + .then(|| id.to_string()) + }) + .collect::>(); + device_ids.sort(); + device_ids.dedup(); + Self { device_ids } + } + + #[must_use] + pub fn as_slice(&self) -> &[String] { + &self.device_ids + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.device_ids.is_empty() + } + + fn default_device_family( + &self, + allow_all_devices: bool, + ) -> Result, CdiGpuSelectionError> { + let mut indexed = self + .device_ids + .iter() + .filter_map(|id| { + let suffix = cdi_nvidia_gpu_suffix(id)?; + let index = suffix.parse::().ok()?; + Some((index, id.clone())) + }) + .collect::>(); + if !indexed.is_empty() { + indexed.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + return Ok(indexed.into_iter().map(|(_, id)| id).collect()); + } + + let mut named = self + .device_ids + .iter() + .filter_map(|id| { + let suffix = cdi_nvidia_gpu_suffix(id)?; + (suffix != CDI_NVIDIA_GPU_ALL_SUFFIX).then(|| id.clone()) + }) + .collect::>(); + if !named.is_empty() { + named.sort(); + return Ok(named); + } + + if self.device_ids.iter().any(|id| id == CDI_GPU_DEVICE_ALL) { + if !allow_all_devices { + return Err(CdiGpuSelectionError::AllDevicesDefaultUnsupported); + } + return Ok(vec![CDI_GPU_DEVICE_ALL.to_string()]); + } + + Err(CdiGpuSelectionError::NoAvailableDevices) + } +} + +#[derive(Debug)] +struct CdiGpuSelectorState { + inventory: CdiGpuInventory, + allow_all_devices: bool, +} + +/// Concurrency-safe default CDI GPU selector used by local container drivers. +#[derive(Debug)] +pub struct CdiGpuDefaultSelector { + state: RwLock, + round_robin: CdiGpuRoundRobin, +} + +impl CdiGpuDefaultSelector { + /// Create a selector with an initial discovered CDI GPU inventory snapshot. + #[must_use] + pub fn new(inventory: CdiGpuInventory, allow_all_devices: bool) -> Self { + Self { + state: RwLock::new(CdiGpuSelectorState { + inventory, + allow_all_devices, + }), + round_robin: CdiGpuRoundRobin::new(), + } + } + + /// Replace the cached inventory snapshot without resetting the cursor. + pub fn refresh(&self, inventory: CdiGpuInventory, allow_all_devices: bool) { + let mut state = self + .state + .write() + .expect("CDI GPU selector state lock poisoned"); + state.inventory = inventory; + state.allow_all_devices = allow_all_devices; + } + + /// Return the cached normalized inventory snapshot. + #[must_use] + pub fn device_ids(&self) -> Vec { + self.state + .read() + .expect("CDI GPU selector state lock poisoned") + .inventory + .as_slice() + .to_vec() + } + + /// Return the current default device IDs without advancing the cursor. + pub fn peek_device_ids(&self, count: u32) -> Result, CdiGpuSelectionError> { + self.selected_device_ids(count, false) + } + + /// Return the next default device IDs and advance the cursor. + pub fn next_device_ids(&self, count: u32) -> Result, CdiGpuSelectionError> { + self.selected_device_ids(count, true) + } + + fn selected_device_ids( + &self, + count: u32, + consume: bool, + ) -> Result, CdiGpuSelectionError> { + let state = self + .state + .read() + .expect("CDI GPU selector state lock poisoned"); + self.round_robin.selected_default_device_ids( + &state.inventory, + count, + consume, + state.allow_all_devices, + ) + } +} + +/// Concurrency-safe round-robin cursor for default CDI GPU selection. +#[derive(Debug, Default)] +struct CdiGpuRoundRobin { + next: AtomicUsize, +} + +impl CdiGpuRoundRobin { + #[must_use] + pub const fn new() -> Self { + Self { + next: AtomicUsize::new(0), + } + } + + fn selected_default_device_ids( + &self, + inventory: &CdiGpuInventory, + count: u32, + consume: bool, + allow_all_devices: bool, + ) -> Result, CdiGpuSelectionError> { + let devices = inventory.default_device_family(allow_all_devices)?; + let count = + usize::try_from(count).map_err(|_| CdiGpuSelectionError::InsufficientDevices { + requested: count, + available: u32::try_from(devices.len()).unwrap_or(u32::MAX), + })?; + let available = devices.len(); + if count > available { + return Err(CdiGpuSelectionError::InsufficientDevices { + requested: u32::try_from(count).unwrap_or(u32::MAX), + available: u32::try_from(available).unwrap_or(u32::MAX), + }); + } + let base = if consume { + self.next.fetch_add(count, Ordering::Relaxed) + } else { + self.next.load(Ordering::Relaxed) + }; + Ok((0..count) + .map(|offset| devices[(base + offset) % available].clone()) + .collect()) + } +} + +/// CDI GPU selection failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CdiGpuSelectionError { + NoAvailableDevices, + AllDevicesDefaultUnsupported, + InsufficientDevices { requested: u32, available: u32 }, +} + +impl fmt::Display for CdiGpuSelectionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NoAvailableDevices => f.write_str("no NVIDIA CDI GPU devices were discovered"), + Self::AllDevicesDefaultUnsupported => f.write_str( + "default GPU request resolved only to nvidia.com/gpu=all, which is not allowed on this platform; set driver_config.cdi_devices to [\"nvidia.com/gpu=all\"] explicitly to request all GPUs", + ), + Self::InsufficientDevices { + requested, + available: 0, + } => write!( + f, + "GPU sandbox requested {requested} GPUs, but no selectable NVIDIA CDI GPU devices are available" + ), + Self::InsufficientDevices { + requested, + available: 1, + } => write!( + f, + "GPU sandbox requested {requested} GPUs, but only 1 selectable NVIDIA CDI GPU device is available" + ), + Self::InsufficientDevices { + requested, + available, + } => write!( + f, + "GPU sandbox requested {requested} GPUs, but only {available} selectable NVIDIA CDI GPU devices are available" + ), + } + } +} + +impl std::error::Error for CdiGpuSelectionError {} + +fn cdi_nvidia_gpu_suffix(id: &str) -> Option<&str> { + id.strip_prefix(CDI_NVIDIA_GPU_PREFIX) +} + +// Local equivalent of the upstream CDI parser until its public API is released: +// https://github.com/cncf-tags/container-device-interface-rs/pull/177 +fn is_qualified_name(device: &str) -> bool { + let Some((kind, name)) = device.split_once('=') else { + return false; + }; + let Some((vendor, class)) = kind.split_once('/') else { + return false; + }; + let valid_component = |value: &str, device_name: bool| { + let bytes = value.as_bytes(); + bytes.first().is_some_and(|byte| { + if device_name { + byte.is_ascii_alphanumeric() + } else { + byte.is_ascii_alphabetic() + } + }) && bytes.last().is_some_and(u8::is_ascii_alphanumeric) + && bytes.iter().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'-' | b'.') + || (device_name && *byte == b':') + }) + }; + valid_component(vendor, false) && valid_component(class, false) && valid_component(name, true) +} + +/// Validate vendor-agnostic CDI qualified names without resolving or authorizing devices. +/// +/// # Errors +/// Returns an error for host paths or malformed `/=` selectors. +pub fn validate_cdi_device_names(devices: &[String], field: &str) -> Result<(), String> { + if devices.iter().any(|device| !is_qualified_name(device)) { + return Err(format!( + "{field} must contain CDI qualified names (/=)" + )); + } + Ok(()) +} + +/// Validate explicit CDI selectors and their GPU request requirements. +/// +/// Docker and Podman use this helper; drivers with other identifier formats use +/// [`validate_specific_gpu_device_request`] directly. +/// +/// # Errors +/// Returns an error for malformed CDI names or invalid GPU request requirements. +pub fn validate_cdi_gpu_device_request( + gpu: Option<&DriverGpuResourceRequirements>, + devices: &[String], + field: &str, +) -> Result<(), String> { + validate_cdi_device_names(devices, field)?; + validate_specific_gpu_device_request(gpu, devices, field) +} + +/// Validate a compute-driver GPU request against driver-owned specific devices. +/// +/// Drivers call this when a sandbox request combines portable GPU requirements +/// with exact device identifiers in `driver_config`. +/// +/// # Errors +/// Returns an error when the sandbox GPU request is absent, when `gpu.count` +/// is zero, when device IDs are duplicated, or when the effective GPU count +/// does not equal the number of specific devices. +pub fn validate_specific_gpu_device_request( + gpu: Option<&DriverGpuResourceRequirements>, + specific_devices: &[String], + driver_config_field: &str, +) -> Result<(), String> { + let device_count = specific_devices.len(); + if device_count == 0 { + return Ok(()); + } + + let mut seen = HashSet::with_capacity(device_count); + for device in specific_devices { + if !seen.insert(device.as_str()) { + return Err(format!( + "{driver_config_field} contains duplicate device ID '{device}'" + )); + } + } + + let Some(count) = effective_driver_gpu_count(gpu)? else { + return Err(format!("{driver_config_field} requires a gpu request")); + }; + + if usize::try_from(count).ok() != Some(device_count) { + return Err(format!( + "gpu count ({count}) must match {driver_config_field} length ({device_count})" + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cdi_device_names_accept_vendor_agnostic_selectors() { + for device in [ + "nvidia.com/gpu=0", + "nvidia.com/gpu=all", + "nvidia.com/gpu=GPU-5b2d", + "nvidia.com/gpu=MIG-5b2d", + "nvidia.com/gpu=0:1", + "example.com/accelerator=card_0.1", + "intel.com/gpu=0", + "amd.com/gpu=0", + "v/c=0", + ] { + let devices = vec![device.to_string()]; + validate_cdi_device_names(&devices, "driver_config.cdi_devices") + .unwrap_or_else(|error| panic!("{device:?}: {error}")); + } + } + + #[test] + fn cdi_device_names_reject_host_paths_and_malformed_selectors() { + for device in [ + "", + "/dev/sda", + "/dev", + "/dev/sda:/dev/sda:rwm", + "dev/sda", + "../dev/sda", + "vendor/gpu", + "/gpu=0", + "vendor/=0", + "vendor/gpu=", + "vendor/gpu=/dev/sda", + "vendor/gpu=0/1", + "vendor/gpu=0=1", + "vendor/gpu=0,1", + "vendor/gpu=-0", + "vendor/gpu=0:", + "vendor/gpu=0 ", + " vendor/gpu=0", + "vendor/gpu=0\n", + "vendor/gpu=0\0", + "vendor/gpu=é", + "véndor/gpu=0", + "vendor/gpü=0", + "0vendor/gpu=0", + "vendor/0gpu=0", + "vendor./gpu=0", + "vendor/gpu-=0", + "vendor/gpu/other=0", + ] { + let error = + validate_cdi_device_names(&[device.to_string()], "driver_config.cdi_devices") + .unwrap_err(); + assert!(error.contains("CDI qualified names"), "{device:?}: {error}"); + } + } + + #[test] + fn inventory_filters_and_deduplicates_nvidia_gpu_ids() { + let inventory = CdiGpuInventory::new([ + "nvidia.com/gpu=1", + "vendor.example/device=0", + "nvidia.com/gpu=1", + " nvidia.com/gpu=0 ", + ]); + + assert_eq!( + inventory.as_slice(), + &vec![ + "nvidia.com/gpu=0".to_string(), + "nvidia.com/gpu=1".to_string() + ] + ); + } + + #[test] + fn selector_prefers_indexed_family_and_sorts_numerically() { + let inventory = CdiGpuInventory::new([ + "nvidia.com/gpu=10", + "nvidia.com/gpu=UUID-b", + "nvidia.com/gpu=2", + "nvidia.com/gpu=all", + ]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=2".to_string()]) + ); + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=10".to_string()]) + ); + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=2".to_string()]) + ); + } + + #[test] + fn selector_uses_named_family_when_no_indexed_ids_exist() { + let inventory = CdiGpuInventory::new(["nvidia.com/gpu=UUID-b", "nvidia.com/gpu=UUID-a"]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=UUID-a".to_string()]) + ); + } + + #[test] + fn selector_uses_all_only_inventory_when_allowed() { + let inventory = CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]); + let selector = CdiGpuDefaultSelector::new(inventory, true); + + assert_eq!( + selector.next_device_ids(1), + Ok(vec![CDI_GPU_DEVICE_ALL.to_string()]) + ); + } + + #[test] + fn selector_rejects_all_only_inventory_when_not_allowed() { + let inventory = CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.next_device_ids(1), + Err(CdiGpuSelectionError::AllDevicesDefaultUnsupported) + ); + } + + #[test] + fn selector_rejects_empty_inventory() { + let inventory = CdiGpuInventory::new(["vendor.example/device=0"]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.next_device_ids(1), + Err(CdiGpuSelectionError::NoAvailableDevices) + ); + } + + #[test] + fn peek_does_not_advance_round_robin_cursor() { + let inventory = CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1"]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.peek_device_ids(1), + Ok(vec!["nvidia.com/gpu=0".to_string()]) + ); + assert_eq!( + selector.peek_device_ids(1), + Ok(vec!["nvidia.com/gpu=0".to_string()]) + ); + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=0".to_string()]) + ); + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=1".to_string()]) + ); + } + + #[test] + fn selector_selects_multiple_distinct_devices_in_cursor_order() { + let inventory = + CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1", "nvidia.com/gpu=2"]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.next_device_ids(2), + Ok(vec![ + "nvidia.com/gpu=0".to_string(), + "nvidia.com/gpu=1".to_string() + ]) + ); + assert_eq!( + selector.next_device_ids(2), + Ok(vec![ + "nvidia.com/gpu=2".to_string(), + "nvidia.com/gpu=0".to_string() + ]) + ); + } + + #[test] + fn selector_rejects_count_larger_than_selectable_family_without_advancing() { + let inventory = CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1"]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.next_device_ids(3), + Err(CdiGpuSelectionError::InsufficientDevices { + requested: 3, + available: 2 + }) + ); + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=0".to_string()]) + ); + } + + #[test] + fn selector_treats_all_only_inventory_as_one_selectable_device() { + let inventory = CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]); + let selector = CdiGpuDefaultSelector::new(inventory, true); + + assert_eq!( + selector.next_device_ids(2), + Err(CdiGpuSelectionError::InsufficientDevices { + requested: 2, + available: 1 + }) + ); + } + + #[test] + fn selector_refreshes_inventory_without_resetting_cursor() { + let inventory = CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1"]); + let selector = CdiGpuDefaultSelector::new(inventory, false); + + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=0".to_string()]) + ); + selector.refresh( + CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1", "nvidia.com/gpu=2"]), + false, + ); + assert_eq!( + selector.next_device_ids(1), + Ok(vec!["nvidia.com/gpu=1".to_string()]) + ); + } + + #[test] + fn specific_gpu_device_request_preserves_non_cdi_identifiers() { + let gpu = DriverGpuResourceRequirements { count: Some(1) }; + let devices = vec!["0000:2d:00.0".to_string()]; + validate_specific_gpu_device_request(Some(&gpu), &devices, "driver_config.gpu_device_ids") + .expect("generic GPU request validation must accept PCI device identifiers"); + let error = + validate_cdi_gpu_device_request(Some(&gpu), &devices, "driver_config.cdi_devices") + .expect_err("CDI drivers must reject non-CDI identifiers"); + assert!(error.contains("CDI qualified names")); + } + + #[test] + fn validate_specific_gpu_device_request_ignores_empty_devices() { + validate_specific_gpu_device_request(None, &[], "driver_config.cdi_devices") + .expect("empty exact device lists should not be validated"); + } + + #[test] + fn validate_specific_gpu_device_request_accepts_matching_count() { + let gpu = DriverGpuResourceRequirements { count: Some(2) }; + let specific_devices = vec![ + "nvidia.com/gpu=0".to_string(), + "nvidia.com/gpu=1".to_string(), + ]; + + validate_specific_gpu_device_request( + Some(&gpu), + &specific_devices, + "driver_config.cdi_devices", + ) + .expect("matching count should be accepted"); + } + + #[test] + fn validate_specific_gpu_device_request_accepts_missing_count_for_one_device() { + let gpu = DriverGpuResourceRequirements { count: None }; + let specific_devices = vec!["nvidia.com/gpu=0".to_string()]; + + validate_specific_gpu_device_request( + Some(&gpu), + &specific_devices, + "driver_config.cdi_devices", + ) + .expect("single exact device should be compatible with a default GPU request"); + } + + #[test] + fn validate_specific_gpu_device_request_rejects_missing_gpu_request() { + let specific_devices = vec!["nvidia.com/gpu=0".to_string()]; + + let err = validate_specific_gpu_device_request( + None, + &specific_devices, + "driver_config.cdi_devices", + ) + .expect_err("missing GPU request should be rejected"); + + assert_eq!(err, "driver_config.cdi_devices requires a gpu request"); + } + + #[test] + fn validate_specific_gpu_device_request_rejects_missing_count_for_multiple_devices() { + let gpu = DriverGpuResourceRequirements { count: None }; + let specific_devices = vec![ + "nvidia.com/gpu=0".to_string(), + "nvidia.com/gpu=1".to_string(), + ]; + + let err = validate_specific_gpu_device_request( + Some(&gpu), + &specific_devices, + "driver_config.cdi_devices", + ) + .expect_err("missing count should be rejected for multiple devices"); + + assert_eq!( + err, + "gpu count (1) must match driver_config.cdi_devices length (2)" + ); + } + + #[test] + fn validate_specific_gpu_device_request_rejects_mismatch() { + let gpu = DriverGpuResourceRequirements { count: Some(2) }; + let specific_devices = vec!["nvidia.com/gpu=0".to_string()]; + + let err = validate_specific_gpu_device_request( + Some(&gpu), + &specific_devices, + "driver_config.cdi_devices", + ) + .expect_err("mismatched count should be rejected"); + + assert_eq!( + err, + "gpu count (2) must match driver_config.cdi_devices length (1)" + ); + } + + #[test] + fn validate_specific_gpu_device_request_rejects_duplicate_ids() { + let gpu = DriverGpuResourceRequirements { count: Some(2) }; + let specific_devices = vec![ + "nvidia.com/gpu=0".to_string(), + "nvidia.com/gpu=0".to_string(), + ]; + + let err = validate_specific_gpu_device_request( + Some(&gpu), + &specific_devices, + "driver_config.cdi_devices", + ) + .expect_err("duplicates should be rejected"); + + assert_eq!( + err, + "driver_config.cdi_devices contains duplicate device ID 'nvidia.com/gpu=0'" + ); + } +} diff --git a/crates/openshell-container-runtime/src/lib.rs b/crates/openshell-container-runtime/src/lib.rs new file mode 100644 index 0000000000..d72c31b4c0 --- /dev/null +++ b/crates/openshell-container-runtime/src/lib.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Code shared by the `OpenShell` desktop compute drivers. +//! +//! Everything here is used only by the Docker, Podman, and VM drivers. Items +//! that core (the gateway, supervisor, or the Kubernetes driver) also uses live +//! in `openshell-driver-sdk` instead. This crate depends on the SDK and never on +//! `openshell-core`, so it can move with the drivers. + +pub mod config; +pub mod driver_utils; +pub mod gpu; +pub mod local_api_socket; +pub mod network_trust; +pub mod proto_struct; +pub mod resource_admission; diff --git a/crates/openshell-core/src/local_api_socket.rs b/crates/openshell-container-runtime/src/local_api_socket.rs similarity index 100% rename from crates/openshell-core/src/local_api_socket.rs rename to crates/openshell-container-runtime/src/local_api_socket.rs diff --git a/crates/openshell-container-runtime/src/network_trust.rs b/crates/openshell-container-runtime/src/network_trust.rs new file mode 100644 index 0000000000..4e4c74b62b --- /dev/null +++ b/crates/openshell-container-runtime/src/network_trust.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Resource-metadata markers for the destination-trust generation of a sandbox +//! startup artifact, used by the local container drivers. + +/// Protected resource metadata key recording the destination-trust generation +/// used to build a sandbox startup artifact. +/// +/// Kubernetes stores this value in an annotation because a `sha256:` +/// digest exceeds the 63-character label-value limit. Local container drivers +/// may store the same protected key in a container label. +pub const NETWORK_SUPERVISOR_TRUST_GENERATION_KEY: &str = "openshell.ai/additional-ca-generation"; + +/// Explicit generation marker for an omitted destination-trust configuration. +/// +/// An explicit value, rather than an absent marker, lets a stopped sandbox +/// distinguish a removal from a resource created by an older gateway. +pub const NETWORK_SUPERVISOR_TRUST_GENERATION_NONE: &str = "none"; diff --git a/crates/openshell-container-runtime/src/proto_struct.rs b/crates/openshell-container-runtime/src/proto_struct.rs new file mode 100644 index 0000000000..b5c4d5c20a --- /dev/null +++ b/crates/openshell-container-runtime/src/proto_struct.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Serde helpers for driver configuration read from `google.protobuf.Struct`. + +use serde::{Deserialize, Deserializer, de::Error as _}; + +/// Deserialize a present field as a non-empty list of non-empty strings. +/// +/// Use with `#[serde(default, deserialize_with = "...")]` on +/// `Option>` fields. Missing fields use the option default; present +/// fields must be arrays and cannot be empty. +pub fn deserialize_optional_non_empty_string_list<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let values = Vec::::deserialize(deserializer)?; + if values.is_empty() { + return Err(D::Error::custom("must be a non-empty list of strings")); + } + + for (idx, value) in values.iter().enumerate() { + if value.trim().is_empty() { + return Err(D::Error::custom(format!( + "[{idx}] must be a non-empty string" + ))); + } + } + + Ok(Some(values)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Default, Deserialize)] + #[serde(default)] + struct TestConfig { + #[serde( + default, + deserialize_with = "deserialize_optional_non_empty_string_list" + )] + devices: Option>, + } + + #[test] + fn optional_non_empty_string_list_defaults_when_absent() { + let config: TestConfig = serde_json::from_value(serde_json::json!({})).unwrap(); + + assert_eq!(config.devices, None); + } + + #[test] + fn optional_non_empty_string_list_parses_present_list() { + let config: TestConfig = + serde_json::from_value(serde_json::json!({"devices": ["nvidia.com/gpu=0"]})).unwrap(); + + assert_eq!(config.devices, Some(vec!["nvidia.com/gpu=0".to_string()])); + } + + #[test] + fn optional_non_empty_string_list_rejects_empty_list() { + let err = + serde_json::from_value::(serde_json::json!({"devices": []})).unwrap_err(); + + assert!(err.to_string().contains("non-empty list")); + } + + #[test] + fn optional_non_empty_string_list_rejects_empty_string() { + let err = + serde_json::from_value::(serde_json::json!({"devices": [""]})).unwrap_err(); + + assert!(err.to_string().contains("non-empty string")); + } +} diff --git a/crates/openshell-container-runtime/src/resource_admission.rs b/crates/openshell-container-runtime/src/resource_admission.rs new file mode 100644 index 0000000000..7072139a1a --- /dev/null +++ b/crates/openshell-container-runtime/src/resource_admission.rs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Operator-owned external resource admission. GPU attachments are temporarily +//! exempt; this policy must not be interpreted as permission to attach host paths. + +/// Reserved driver-owned runtime metadata; caller labels must never override it. +pub const CONFIG_USED_LABEL: &str = "openshell.ai/caller-driver-config-used"; + +pub const IDENTITIES_LABEL: &str = "openshell.ai/resource-admission-identities"; + +pub fn check_config_provenance(allowed: bool, recorded: Option<&str>) -> Result<(), tonic::Status> { + match recorded { + Some("false") => Ok(()), + Some("true") if allowed => Ok(()), + _ => Err(tonic::Status::failed_precondition( + "sandbox uses forbidden driver config or lacks admission provenance; recreate it", + )), + } +} diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index aa83e10f86..1ff78b9837 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -15,6 +15,7 @@ openshell-crypto = { workspace = true, features = ["tls-tonic"] } async-trait = "0.1" openshell-extension-core = { path = "../openshell-extension-core" } openshell-policy-schema = { path = "../openshell-policy-schema" } +openshell-driver-sdk = { path = "../openshell-driver-sdk" } glob = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } @@ -34,12 +35,8 @@ uuid = { workspace = true } ipnet = "2" rustls = { workspace = true } base64 = { workspace = true } -jsonwebtoken = { workspace = true, optional = true } -zeroize = { workspace = true, optional = true } chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true } reqwest = { workspace = true, features = ["blocking", "rustls-tls-native-roots-no-provider"], optional = true } -tar = { version = "0.4", optional = true } -tempfile = { version = "3", optional = true } openshell-otel = { path = "../openshell-otel", optional = true } clap = { workspace = true, optional = true } @@ -53,16 +50,15 @@ default = ["telemetry"] ## Compile in anonymous telemetry emission support. On by default; disable with ## `--no-default-features` (plus any other features you need) for a build that ## contains no telemetry endpoint, no HTTP client, and no emission code at all. -driver-extraction = ["dep:tar", "dep:tempfile"] -telemetry = ["dep:reqwest", "dep:chrono", "reqwest?/blocking"] +telemetry = ["dep:reqwest", "dep:chrono", "reqwest?/blocking", "openshell-driver-sdk/telemetry"] ## OAuth2 token request helpers used by supervisor and gateway. oauth = ["dep:reqwest"] ## Strict Ed25519 JWT issuance and verification for authenticated sandbox sessions. -jwt = ["dep:jsonwebtoken", "dep:zeroize"] +jwt = ["openshell-driver-sdk/jwt"] ## Conversions to `openshell-otel` types. -otel = ["dep:openshell-otel"] +otel = ["dep:openshell-otel", "openshell-driver-sdk/otel"] ## Command-line flags shared by compute drivers. -clap = ["dep:clap"] +clap = ["dep:clap", "openshell-driver-sdk/clap"] ## Propagate the active W3C trace context on supervisor gateway calls. trace-context = ["otel"] diff --git a/crates/openshell-core/README.md b/crates/openshell-core/README.md index b3d9a50349..d0bdedf459 100644 --- a/crates/openshell-core/README.md +++ b/crates/openshell-core/README.md @@ -3,6 +3,13 @@ Shared types, constants, configuration, and helpers used across OpenShell crates. +## Driver SDK + +Items shared with the compute drivers live in `openshell-driver-sdk`, and +`openshell-core` re-exports them, so each has one definition. Code used only by +the desktop drivers lives in `openshell-container-runtime`. Add new items to the +SDK only when something that stays in core also uses them. + ## Isolation registrations `isolation_registration` owns the operator registry's backend names, byte-stream diff --git a/crates/openshell-core/build.rs b/crates/openshell-core/build.rs index 2779e78fb9..b6d2e25ebe 100644 --- a/crates/openshell-core/build.rs +++ b/crates/openshell-core/build.rs @@ -4,8 +4,6 @@ use std::env; use std::path::{Path, PathBuf}; -mod build_version; - const PROTO_REL: &str = "../../proto"; fn main() -> Result<(), Box> { @@ -30,19 +28,6 @@ fn main() -> Result<(), Box> { return Ok(()); } - // --- Git-derived version --- - // Compute a version from tags and commit metadata for local builds. In - // Docker/CI builds where .git is absent, this silently does nothing and - // the binary falls back to CARGO_PKG_VERSION (which is already sed-patched - // by the build pipeline). - for path in git_rerun_paths() { - println!("cargo:rerun-if-changed={}", path.display()); - } - - if let Some(version) = git_version() { - println!("cargo:rustc-env=OPENSHELL_GIT_VERSION={version}"); - } - // --- Protobuf compilation --- // Re-run when anything under proto/ changes (including newly added .proto files). println!("cargo:rerun-if-changed={PROTO_REL}"); @@ -52,6 +37,19 @@ fn main() -> Result<(), Box> { let mut proto_config = tonic_prost_build::Config::new(); // Keep optional grant evidence off sandbox lifecycle futures' stacks. proto_config.boxed(".openshell.v1.SandboxStatus.last_token_grant_failure"); + // The compute-driver contract and its imports are generated by + // `openshell-driver-sdk`; refer to those types rather than regenerating them. + for (package, module) in [ + (".openshell.datamodel.v1", "datamodel"), + (".openshell.sandbox.v1", "sandbox"), + (".openshell.compute.v1", "compute"), + (".openshell.extension.v1", "extension"), + ] { + proto_config.extern_path( + package, + format!("::openshell_driver_sdk::proto::{module}::v1"), + ); + } let proto_include = if use_vendored_protoc { proto_config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); Some(protoc_bin_vendored::include_path()?) @@ -73,10 +71,6 @@ fn main() -> Result<(), Box> { // Configure tonic/prost protobuf code generation. tonic_prost_build::configure() .btree_map(".openshell.sandbox.protocol.v1") - .type_attribute( - ".openshell.sandbox.v1.SupervisorMiddlewareBinding", - r#"#[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")]"#, - ) .build_server(true) .build_client(true) .include_file("openshell.rs") @@ -104,74 +98,3 @@ fn collect_proto_files(dir: &Path, out: &mut Vec) -> std::io::Result<() } Ok(()) } - -/// Git metadata files whose changes affect [`git_version`]. -/// -/// In a linked worktree `.git` is a file, so `HEAD` and `logs/HEAD` live in the -/// per-worktree git dir while branch and tag refs live in the common git dir. Cargo treats a -/// missing `rerun-if-changed` path as always stale, so only existing paths are -/// returned. Without git metadata nothing is returned. -fn git_rerun_paths() -> Vec { - let resolve = |args: &[&str]| { - let dir = PathBuf::from(git_output(args)?); - // Relative output is relative to the build script's working directory. - Some(env::current_dir().ok()?.join(dir)) - }; - let mut paths = Vec::new(); - if let Some(git_dir) = resolve(&["rev-parse", "--git-dir"]) { - paths.push(git_dir.join("HEAD")); - paths.push(git_dir.join("logs/HEAD")); - } - if let Some(common_dir) = resolve(&["rev-parse", "--git-common-dir"]) { - paths.push(common_dir.join("refs/tags")); - paths.push(common_dir.join("packed-refs")); - // A new worktree has no `logs/HEAD` until its first ref update, so also - // watch the checked-out branch ref, which moves on every commit. - if let Some(branch) = git_output(&["symbolic-ref", "-q", "HEAD"]) { - paths.push(common_dir.join(branch)); - } - } - paths.retain(|path| path.exists()); - paths -} - -/// Derive the release or development version from git metadata. -/// -/// Implements the "guess-next-dev" convention used by the release pipeline -/// (`tasks/scripts/release.py`): exact stable and prerelease tags retain their -/// version. Otherwise, the latest merged stable release gets a patch bump and -/// `-dev.+g` is appended. -/// -/// Examples: -/// on tag v0.1.0-pre.1 → "0.1.0-pre.1" -/// 3 commits past v0.0.3 → "0.0.4-dev.3+g2bf9969ab" -/// -/// Returns `None` when git metadata cannot be read. -fn git_version() -> Option { - let exact_tags = git_output(&["tag", "--points-at", "HEAD"])?; - if let Some(version) = build_version::exact_release_version(exact_tags.lines()) { - return Some(version); - } - - let merged_tags = git_output(&["tag", "--merged", "HEAD", "--list", "v*.*.*"])?; - let latest_tag = build_version::latest_stable_tag(merged_tags.lines()); - let revision_range = latest_tag - .as_deref() - .map_or_else(|| "HEAD".to_string(), |tag| format!("{tag}..HEAD")); - let distance = git_output(&["rev-list", "--count", &revision_range])? - .parse() - .ok()?; - let sha = git_output(&["rev-parse", "--short=9", "HEAD"])?; - - build_version::next_dev_version(latest_tag.as_deref(), distance, &sha) -} - -fn git_output(args: &[&str]) -> Option { - let output = std::process::Command::new("git").args(args).output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout) - .ok() - .map(|output| output.trim().to_string()) -} diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 20f6a334b1..f203cfdd5a 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -3,43 +3,21 @@ //! Configuration management for `OpenShell` components. +/// The binding policy shared by gateway interceptors and supervisor middleware. +pub use GatewayInterceptorBindingPolicy as ExtensionBindingPolicy; +pub use openshell_driver_sdk::config::*; use serde::{Deserialize, Deserializer, Serialize, de}; use std::borrow::Cow; use std::collections::BTreeMap; -use std::fmt; use std::net::SocketAddr; -use std::num::{NonZeroI64, NonZeroU64}; +use std::num::NonZeroU64; use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; -// ── Public default constants ──────────────────────────────────────────── -// -// Canonical source for default values used across multiple crates. -// Clap `default_value_t` annotations and runtime fallbacks should -// reference these constants instead of hardcoding literals. - -/// Default SSH port inside sandbox containers. -pub const DEFAULT_SSH_PORT: u16 = 2222; - -/// Default gateway server port. -pub const DEFAULT_SERVER_PORT: u16 = 17670; - /// Default operator-facing name for a gateway installation. pub const DEFAULT_GATEWAY_NAME: &str = "openshell"; -/// Default container stop timeout in seconds (SIGTERM → SIGKILL). -pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; - -/// Default cgroup PID limit for local container sandboxes. -pub const DEFAULT_SANDBOX_PIDS_LIMIT: i64 = 2048; - -/// Typed default cgroup PID limit for local container sandboxes. -#[must_use] -pub fn default_sandbox_pids_limit() -> Option { - NonZeroI64::new(DEFAULT_SANDBOX_PIDS_LIMIT) -} - /// Default Docker bridge network name for local sandboxes. pub const DEFAULT_DOCKER_NETWORK_NAME: &str = "openshell-docker"; @@ -110,80 +88,6 @@ impl FromStr for PolicyValidationFailureMode { } } -/// Default OCI repository for the supervisor image (no tag). -pub const DEFAULT_SUPERVISOR_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/supervisor"; - -/// Default OCI repository for the sandbox runtime image (no tag). -pub const DEFAULT_SANDBOX_RUNTIME_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/sandbox"; - -/// Process-level default for the trusted sandbox runtime image. -pub const SANDBOX_RUNTIME_IMAGE_ENV: &str = "OPENSHELL_SANDBOX_RUNTIME_IMAGE"; - -/// Process-level default for the trusted supervisor image. -pub const SUPERVISOR_IMAGE_ENV: &str = "OPENSHELL_SUPERVISOR_IMAGE"; - -fn compiled_sandbox_runtime_image() -> String { - format!( - "{DEFAULT_SANDBOX_RUNTIME_IMAGE_REPO}:{}", - default_supervisor_image_tag() - ) -} - -fn compiled_supervisor_image() -> String { - format!( - "{DEFAULT_SUPERVISOR_IMAGE_REPO}:{}", - default_supervisor_image_tag() - ) -} - -fn runtime_image_default(environment_value: Option, compiled_default: String) -> String { - environment_value.unwrap_or(compiled_default) -} - -/// Return the process-configured sandbox runtime image, or the compiled default. -#[must_use] -pub fn default_sandbox_runtime_image() -> String { - runtime_image_default( - std::env::var(SANDBOX_RUNTIME_IMAGE_ENV).ok(), - compiled_sandbox_runtime_image(), - ) -} - -/// Return the process-configured supervisor image, or the compiled default. -#[must_use] -pub fn default_supervisor_image() -> String { - runtime_image_default( - std::env::var(SUPERVISOR_IMAGE_ENV).ok(), - compiled_supervisor_image(), - ) -} - -fn default_supervisor_image_tag() -> String { - resolve_supervisor_image_tag(&[ - option_env!("OPENSHELL_IMAGE_TAG").unwrap_or(""), - option_env!("IMAGE_TAG").unwrap_or(""), - env!("CARGO_PKG_VERSION"), - ]) -} - -/// Resolve the supervisor image tag from an ordered list of candidates. -/// -/// Returns the first non-empty, non-`"0.0.0"` candidate, falling back to -/// `"dev"` when none qualifies. Replaces `+` with `-` for OCI tag -/// compatibility. -#[must_use] -pub fn resolve_supervisor_image_tag(candidates: &[&str]) -> String { - candidates - .iter() - .copied() - .find(|t| !t.is_empty() && *t != "0.0.0") - .unwrap_or("dev") - .replace('+', "-") -} - -/// CDI device identifier for requesting all NVIDIA GPUs. -pub const CDI_GPU_DEVICE_ALL: &str = "nvidia.com/gpu=all"; - /// Normalize a configured compute driver name. /// /// Built-in driver names and custom remote driver names share the same @@ -650,9 +554,6 @@ pub enum GatewayInterceptorBindingPolicy { Exact, } -/// The binding policy shared by gateway interceptors and supervisor middleware. -pub use GatewayInterceptorBindingPolicy as ExtensionBindingPolicy; - /// One configured source in the gateway's effective provider-profile catalog. /// /// Deserialization is hand-written so that the removed `builtin` source is @@ -743,225 +644,6 @@ const fn default_jwks_ttl_secs() -> u64 { 3600 } -/// Canonical policy controlling when a driver pulls a sandbox image. -/// -/// Backends translate this shared vocabulary to their runtime API. `newer` is -/// supported only by Podman; other backends reject it during configuration. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ImagePullPolicy { - /// Always pull, even if a local image is available. - Always, - /// Pull only when a local image is unavailable. - #[default] - IfNotPresent, - /// Never pull; fail when a local image is unavailable. - Never, - /// Pull only when the registry image is newer than the local copy. - Newer, -} - -impl ImagePullPolicy { - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Always => "always", - Self::IfNotPresent => "if_not_present", - Self::Never => "never", - Self::Newer => "newer", - } - } -} - -impl fmt::Display for ImagePullPolicy { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl FromStr for ImagePullPolicy { - type Err = String; - - fn from_str(value: &str) -> Result { - match value { - "always" => Ok(Self::Always), - "if_not_present" => Ok(Self::IfNotPresent), - "never" => Ok(Self::Never), - "newer" => Ok(Self::Newer), - other => Err(format!( - "invalid image pull policy '{other}'; expected one of: always, if_not_present, never, newer" - )), - } - } -} - -/// Canonical `AppArmor` confinement requested for a sandbox container. -/// -/// Drivers translate this model to their runtime API. An omitted value leaves -/// the runtime default unchanged; `Unconfined` is explicit because the -/// supervisor needs mount operations that the default Docker/Podman profile -/// commonly denies. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AppArmorProfile { - RuntimeDefault, - Unconfined, - Localhost(String), -} - -impl AppArmorProfile { - #[must_use] - pub const fn kubernetes_type(&self) -> &'static str { - match self { - Self::RuntimeDefault => "RuntimeDefault", - Self::Unconfined => "Unconfined", - Self::Localhost(_) => "Localhost", - } - } - - #[must_use] - pub fn localhost_profile(&self) -> Option<&str> { - match self { - Self::Localhost(profile) => Some(profile), - Self::RuntimeDefault | Self::Unconfined => None, - } - } - - /// Translate to the OCI `apparmor=` security option. - /// - /// `RuntimeDefault` deliberately returns `None`: omitting an OCI option - /// asks Docker/Podman to apply their runtime default profile. - #[must_use] - pub fn oci_security_opt(&self) -> Option { - match self { - Self::RuntimeDefault => None, - Self::Unconfined => Some("apparmor=unconfined".to_string()), - Self::Localhost(profile) => Some(format!("apparmor={profile}")), - } - } -} - -impl fmt::Display for AppArmorProfile { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::RuntimeDefault => f.write_str("RuntimeDefault"), - Self::Unconfined => f.write_str("Unconfined"), - Self::Localhost(profile) => write!(f, "Localhost/{profile}"), - } - } -} - -impl FromStr for AppArmorProfile { - type Err = String; - - fn from_str(value: &str) -> Result { - match value { - "RuntimeDefault" => Ok(Self::RuntimeDefault), - "Unconfined" => Ok(Self::Unconfined), - other => match other.strip_prefix("Localhost/") { - Some("") => Err( - "invalid AppArmor profile 'Localhost/'; expected non-empty profile name" - .to_string(), - ), - Some(profile) if !profile.contains(char::is_whitespace) => { - Ok(Self::Localhost(profile.to_string())) - } - Some(_) => { - Err("invalid AppArmor localhost profile; whitespace is not allowed".to_string()) - } - None => Err(format!( - "unknown AppArmor profile '{other}'; expected 'RuntimeDefault', 'Unconfined', or 'Localhost/'" - )), - }, - } - } -} - -impl Serialize for AppArmorProfile { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for AppArmorProfile { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::from_str(&value).map_err(de::Error::custom) - } -} - -/// Common local-driver corporate forward-proxy settings. -/// -/// This type is `flatten`ed by local compute-driver tables, preserving the -/// established TOML field names while keeping their safety contract shared. -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(default, deny_unknown_fields)] -pub struct UpstreamProxyConfig { - pub https_proxy: Option, - pub no_proxy: Option, - pub proxy_auth_file: Option, - pub proxy_auth_allow_insecure: Option, - pub proxy_connect_by_hostname: Option, -} - -impl UpstreamProxyConfig { - /// Validate relationships that are independent of the container backend. - /// Credential contents are intentionally not read here and are never put - /// in an error message; drivers validate and stage them per sandbox. - pub fn validate(&self) -> Result<(), String> { - use crate::driver_utils::{UpstreamProxyUrlError, parse_upstream_proxy_url}; - - let proxy_secure = if let Some(url) = self.https_proxy.as_deref() { - parse_upstream_proxy_url(url) - .map_err(|err| match err { - UpstreamProxyUrlError::Empty => "https_proxy must not be empty when set".to_string(), - UpstreamProxyUrlError::InlineCredentials => "https_proxy must not embed credentials; supply them with proxy_auth_file so they are not stored in configuration or runtime metadata".to_string(), - err => format!("https_proxy {err}"), - })? - .secure - } else { - false - }; - - if self - .no_proxy - .as_deref() - .is_some_and(|value| value.trim().is_empty()) - { - return Err("no_proxy must not be empty when set; omit it instead".to_string()); - } - if self.no_proxy.is_some() && self.https_proxy.is_none() { - return Err("no_proxy is set but no https_proxy is configured".to_string()); - } - if let Some(path) = self.proxy_auth_file.as_ref() { - if path.as_os_str().is_empty() { - return Err("proxy_auth_file must not be empty when set".to_string()); - } - if self.https_proxy.is_none() { - return Err("proxy_auth_file is set but no https_proxy is configured".to_string()); - } - if !proxy_secure && self.proxy_auth_allow_insecure != Some(true) { - return Err("proxy_auth_file sends a cleartext Basic credential to an http:// proxy; set proxy_auth_allow_insecure = true to acknowledge that exposure".to_string()); - } - } else if self.proxy_auth_allow_insecure.is_some() { - return Err( - "proxy_auth_allow_insecure is set but no proxy_auth_file is configured".to_string(), - ); - } - if self.proxy_connect_by_hostname.is_some() && self.https_proxy.is_none() { - return Err( - "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), - ); - } - Ok(()) - } -} - /// Gateway-minted sandbox JWT configuration. /// /// Points the gateway at the Ed25519 signing key (produced by `certgen`) @@ -1359,13 +1041,14 @@ const fn default_ssh_session_ttl_secs() -> u64 { #[cfg(test)] mod tests { use super::{ - AppArmorProfile, Config, ConfigDeliveryMode, DEFAULT_SERVICE_ROUTING_DOMAIN, + Config, ConfigDeliveryMode, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayJwtConfig, GatewayProviderProfileSourceConfig, ImagePullPolicy, MetricsTlsConfig, - PolicyValidationFailureMode, ServiceHostnameFormat, UpstreamProxyConfig, - default_sandbox_pids_limit, normalize_compute_driver_name, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, MetricsTlsConfig, + PolicyValidationFailureMode, ServiceHostnameFormat, normalize_compute_driver_name, }; + use std::time::Duration; + use std::{net::SocketAddr, path::PathBuf}; #[test] @@ -1534,189 +1217,6 @@ mod tests { assert!(error.to_string().contains("invalid value: integer `0`")); } - #[test] - fn image_pull_policy_uses_canonical_vocabulary() { - for (value, expected) in [ - ("always", ImagePullPolicy::Always), - ("if_not_present", ImagePullPolicy::IfNotPresent), - ("never", ImagePullPolicy::Never), - ("newer", ImagePullPolicy::Newer), - ] { - assert_eq!(value.parse::(), Ok(expected)); - assert_eq!(expected.to_string(), value); - assert_eq!(serde_json::to_value(expected).unwrap(), value); - } - assert!("missing".parse::().is_err()); - assert!("IfNotPresent".parse::().is_err()); - } - - #[test] - fn app_armor_profiles_round_trip_and_translate_for_each_backend() { - for (value, expected, kubernetes_type, localhost_profile, oci_security_opt) in [ - ( - "RuntimeDefault", - AppArmorProfile::RuntimeDefault, - "RuntimeDefault", - None, - None, - ), - ( - "Unconfined", - AppArmorProfile::Unconfined, - "Unconfined", - None, - Some("apparmor=unconfined"), - ), - ( - "Localhost/openshell-supervisor", - AppArmorProfile::Localhost("openshell-supervisor".to_string()), - "Localhost", - Some("openshell-supervisor"), - Some("apparmor=openshell-supervisor"), - ), - ] { - let parsed = value.parse::().expect("valid profile"); - assert_eq!(parsed, expected); - assert_eq!(parsed.to_string(), value); - assert_eq!(parsed.kubernetes_type(), kubernetes_type); - assert_eq!(parsed.localhost_profile(), localhost_profile); - assert_eq!(parsed.oci_security_opt().as_deref(), oci_security_opt); - - let json = serde_json::to_value(&parsed).expect("profile serializes"); - assert_eq!(json, value); - assert_eq!( - serde_json::from_value::(json).expect("profile deserializes"), - parsed - ); - } - - for invalid in [ - "Localhost/", - "Localhost/openshell profile", - "runtimeDefault", - "unconfined", - "Unknown", - ] { - assert!( - invalid.parse::().is_err(), - "{invalid} should be rejected" - ); - } - } - - #[test] - fn upstream_proxy_validation_enforces_cross_field_contract() { - let auth_file = Some("/run/secrets/proxy-auth".into()); - let cases = [ - ("default", UpstreamProxyConfig::default(), None), - ( - "https auth", - UpstreamProxyConfig { - https_proxy: Some("https://proxy.example:8443".to_string()), - proxy_auth_file: auth_file.clone(), - ..Default::default() - }, - None, - ), - ( - "acknowledged http auth", - UpstreamProxyConfig { - https_proxy: Some("http://proxy.example:8080".to_string()), - proxy_auth_file: auth_file.clone(), - proxy_auth_allow_insecure: Some(true), - ..Default::default() - }, - None, - ), - ( - "unacknowledged http auth", - UpstreamProxyConfig { - https_proxy: Some("http://proxy.example:8080".to_string()), - proxy_auth_file: auth_file.clone(), - ..Default::default() - }, - Some("proxy_auth_allow_insecure"), - ), - ( - "no_proxy without proxy", - UpstreamProxyConfig { - no_proxy: Some("localhost".to_string()), - ..Default::default() - }, - Some("no_proxy"), - ), - ( - "blank no_proxy", - UpstreamProxyConfig { - https_proxy: Some("https://proxy.example:8443".to_string()), - no_proxy: Some(" ".to_string()), - ..Default::default() - }, - Some("no_proxy"), - ), - ( - "auth file without proxy", - UpstreamProxyConfig { - proxy_auth_file: auth_file, - ..Default::default() - }, - Some("proxy_auth_file"), - ), - ( - "ack without auth file", - UpstreamProxyConfig { - https_proxy: Some("http://proxy.example:8080".to_string()), - proxy_auth_allow_insecure: Some(true), - ..Default::default() - }, - Some("proxy_auth_allow_insecure"), - ), - ( - "hostname mode without proxy", - UpstreamProxyConfig { - proxy_connect_by_hostname: Some(true), - ..Default::default() - }, - Some("proxy_connect_by_hostname"), - ), - ( - "empty proxy", - UpstreamProxyConfig { - https_proxy: Some(String::new()), - ..Default::default() - }, - Some("https_proxy"), - ), - ( - "inline credentials", - UpstreamProxyConfig { - https_proxy: Some( - "https://secret-user:secret-password@proxy.example:8443".to_string(), - ), - ..Default::default() - }, - Some("must not embed credentials"), - ), - ]; - - for (name, config, expected_error) in cases { - match expected_error { - None => config - .validate() - .unwrap_or_else(|error| panic!("{name}: {error}")), - Some(expected) => { - let error = match config.validate() { - Ok(()) => panic!("{name} should fail validation"), - Err(error) => error, - }; - assert!(error.contains(expected), "{name}: {error}"); - assert!(!error.contains("secret-user"), "{name}: {error}"); - assert!(!error.contains("secret-password"), "{name}: {error}"); - } - } - } - } - #[test] fn config_defaults_and_builder_use_singular_compute_driver() { let config = Config::new(None); @@ -1730,14 +1230,6 @@ mod tests { ); } - #[test] - fn typed_sandbox_pids_default_matches_positive_constant() { - assert_eq!( - default_sandbox_pids_limit().map(std::num::NonZeroI64::get), - Some(super::DEFAULT_SANDBOX_PIDS_LIMIT) - ); - } - #[test] fn name_defaults_and_can_be_overridden() { assert_eq!(Config::new(None).name, "openshell"); @@ -1950,62 +1442,4 @@ mod tests { Some(tls) ); } - - #[test] - fn supervisor_image_tag_prefers_explicit_build_tags() { - use super::resolve_supervisor_image_tag; - assert_eq!( - resolve_supervisor_image_tag(&["1.2.3", "sha", "0.0.0"]), - "1.2.3" - ); - assert_eq!(resolve_supervisor_image_tag(&["", "sha", "0.0.0"]), "sha"); - assert_eq!(resolve_supervisor_image_tag(&["", "", "1.2.3"]), "1.2.3"); - assert_eq!(resolve_supervisor_image_tag(&["", "", "0.0.0"]), "dev"); - assert_eq!( - resolve_supervisor_image_tag(&["latest", "", "1.2.3"]), - "latest" - ); - } - - #[test] - fn supervisor_image_tag_sanitizes_build_metadata_for_oci() { - use super::resolve_supervisor_image_tag; - assert_eq!( - resolve_supervisor_image_tag(&["", "", "0.0.37-dev.156+g1d3b741ee"]), - "0.0.37-dev.156-g1d3b741ee", - ); - assert_eq!( - resolve_supervisor_image_tag(&["0.0.37-dev.156+g1d3b741ee", "", "0.0.0"]), - "0.0.37-dev.156-g1d3b741ee", - ); - } - - #[test] - fn default_supervisor_image_is_version_pinned() { - use super::{compiled_sandbox_runtime_image, compiled_supervisor_image}; - let image = compiled_supervisor_image(); - assert!(image.starts_with("ghcr.io/nvidia/openshell/supervisor:")); - let tag = image.rsplit_once(':').unwrap().1; - assert!(!tag.is_empty()); - - let sandbox_image = compiled_sandbox_runtime_image(); - assert!(sandbox_image.starts_with("ghcr.io/nvidia/openshell/sandbox:")); - let sandbox_tag = sandbox_image.rsplit_once(':').unwrap().1; - assert!(!sandbox_tag.is_empty()); - } - - #[test] - fn runtime_image_environment_value_replaces_compiled_default() { - use super::runtime_image_default; - - let digest = format!("registry.example.com/sandbox@sha256:{}", "a".repeat(64)); - assert_eq!( - runtime_image_default(Some(digest.clone()), "compiled:default".to_string()), - digest - ); - assert_eq!( - runtime_image_default(None, "compiled:default".to_string()), - "compiled:default" - ); - } } diff --git a/crates/openshell-core/src/gpu.rs b/crates/openshell-core/src/gpu.rs index 09b8337f05..30ed91c76d 100644 --- a/crates/openshell-core/src/gpu.rs +++ b/crates/openshell-core/src/gpu.rs @@ -3,17 +3,9 @@ //! Shared GPU resource requirement helpers. -use std::collections::HashSet; -use std::fmt; -use std::sync::RwLock; -use std::sync::atomic::{AtomicUsize, Ordering}; +pub use openshell_driver_sdk::gpu::*; -use crate::config::CDI_GPU_DEVICE_ALL; use crate::proto::ResourceRequirements as SandboxResourceRequirements; -use crate::proto::compute::v1::{ - GpuResourceRequirements as DriverGpuResourceRequirements, - ResourceRequirements as DriverResourceRequirements, -}; /// Return whether sandbox resource requirements request a GPU. #[must_use] @@ -30,755 +22,3 @@ pub fn sandbox_gpu_count(resources: Option<&SandboxResourceRequirements>) -> Opt .and_then(|resources| resources.gpu.as_ref()) .and_then(|gpu| gpu.count) } - -/// Return the effective compute-driver GPU count. -/// -/// A present GPU requirement with an omitted count requests one GPU. -/// -/// # Errors -/// Returns an error when a GPU requirement explicitly requests zero GPUs. -pub fn effective_driver_gpu_count( - gpu: Option<&DriverGpuResourceRequirements>, -) -> Result, String> { - let Some(gpu) = gpu else { - return Ok(None); - }; - let count = gpu.count.unwrap_or(1); - if count == 0 { - return Err("gpu count must be greater than 0".to_string()); - } - Ok(Some(count)) -} - -/// Return the requested compute-driver GPU requirements, if present. -#[must_use] -pub fn driver_gpu_requirements( - resources: Option<&DriverResourceRequirements>, -) -> Option<&DriverGpuResourceRequirements> { - resources.and_then(|resources| resources.gpu.as_ref()) -} - -const CDI_NVIDIA_GPU_PREFIX: &str = "nvidia.com/gpu="; -const CDI_NVIDIA_GPU_ALL_SUFFIX: &str = "all"; - -/// Normalized CDI GPU inventory used by local container drivers. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CdiGpuInventory { - device_ids: Vec, -} - -impl CdiGpuInventory { - /// Build a normalized inventory from runtime-reported CDI device IDs. - #[must_use] - pub fn new(device_ids: impl IntoIterator>) -> Self { - let mut device_ids = device_ids - .into_iter() - .filter_map(|id| { - let id = id.as_ref().trim(); - id.starts_with(CDI_NVIDIA_GPU_PREFIX) - .then(|| id.to_string()) - }) - .collect::>(); - device_ids.sort(); - device_ids.dedup(); - Self { device_ids } - } - - #[must_use] - pub fn as_slice(&self) -> &[String] { - &self.device_ids - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.device_ids.is_empty() - } - - fn default_device_family( - &self, - allow_all_devices: bool, - ) -> Result, CdiGpuSelectionError> { - let mut indexed = self - .device_ids - .iter() - .filter_map(|id| { - let suffix = cdi_nvidia_gpu_suffix(id)?; - let index = suffix.parse::().ok()?; - Some((index, id.clone())) - }) - .collect::>(); - if !indexed.is_empty() { - indexed.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); - return Ok(indexed.into_iter().map(|(_, id)| id).collect()); - } - - let mut named = self - .device_ids - .iter() - .filter_map(|id| { - let suffix = cdi_nvidia_gpu_suffix(id)?; - (suffix != CDI_NVIDIA_GPU_ALL_SUFFIX).then(|| id.clone()) - }) - .collect::>(); - if !named.is_empty() { - named.sort(); - return Ok(named); - } - - if self.device_ids.iter().any(|id| id == CDI_GPU_DEVICE_ALL) { - if !allow_all_devices { - return Err(CdiGpuSelectionError::AllDevicesDefaultUnsupported); - } - return Ok(vec![CDI_GPU_DEVICE_ALL.to_string()]); - } - - Err(CdiGpuSelectionError::NoAvailableDevices) - } -} - -#[derive(Debug)] -struct CdiGpuSelectorState { - inventory: CdiGpuInventory, - allow_all_devices: bool, -} - -/// Concurrency-safe default CDI GPU selector used by local container drivers. -#[derive(Debug)] -pub struct CdiGpuDefaultSelector { - state: RwLock, - round_robin: CdiGpuRoundRobin, -} - -impl CdiGpuDefaultSelector { - /// Create a selector with an initial discovered CDI GPU inventory snapshot. - #[must_use] - pub fn new(inventory: CdiGpuInventory, allow_all_devices: bool) -> Self { - Self { - state: RwLock::new(CdiGpuSelectorState { - inventory, - allow_all_devices, - }), - round_robin: CdiGpuRoundRobin::new(), - } - } - - /// Replace the cached inventory snapshot without resetting the cursor. - pub fn refresh(&self, inventory: CdiGpuInventory, allow_all_devices: bool) { - let mut state = self - .state - .write() - .expect("CDI GPU selector state lock poisoned"); - state.inventory = inventory; - state.allow_all_devices = allow_all_devices; - } - - /// Return the cached normalized inventory snapshot. - #[must_use] - pub fn device_ids(&self) -> Vec { - self.state - .read() - .expect("CDI GPU selector state lock poisoned") - .inventory - .as_slice() - .to_vec() - } - - /// Return the current default device IDs without advancing the cursor. - pub fn peek_device_ids(&self, count: u32) -> Result, CdiGpuSelectionError> { - self.selected_device_ids(count, false) - } - - /// Return the next default device IDs and advance the cursor. - pub fn next_device_ids(&self, count: u32) -> Result, CdiGpuSelectionError> { - self.selected_device_ids(count, true) - } - - fn selected_device_ids( - &self, - count: u32, - consume: bool, - ) -> Result, CdiGpuSelectionError> { - let state = self - .state - .read() - .expect("CDI GPU selector state lock poisoned"); - self.round_robin.selected_default_device_ids( - &state.inventory, - count, - consume, - state.allow_all_devices, - ) - } -} - -/// Concurrency-safe round-robin cursor for default CDI GPU selection. -#[derive(Debug, Default)] -struct CdiGpuRoundRobin { - next: AtomicUsize, -} - -impl CdiGpuRoundRobin { - #[must_use] - pub const fn new() -> Self { - Self { - next: AtomicUsize::new(0), - } - } - - fn selected_default_device_ids( - &self, - inventory: &CdiGpuInventory, - count: u32, - consume: bool, - allow_all_devices: bool, - ) -> Result, CdiGpuSelectionError> { - let devices = inventory.default_device_family(allow_all_devices)?; - let count = - usize::try_from(count).map_err(|_| CdiGpuSelectionError::InsufficientDevices { - requested: count, - available: u32::try_from(devices.len()).unwrap_or(u32::MAX), - })?; - let available = devices.len(); - if count > available { - return Err(CdiGpuSelectionError::InsufficientDevices { - requested: u32::try_from(count).unwrap_or(u32::MAX), - available: u32::try_from(available).unwrap_or(u32::MAX), - }); - } - let base = if consume { - self.next.fetch_add(count, Ordering::Relaxed) - } else { - self.next.load(Ordering::Relaxed) - }; - Ok((0..count) - .map(|offset| devices[(base + offset) % available].clone()) - .collect()) - } -} - -/// CDI GPU selection failed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CdiGpuSelectionError { - NoAvailableDevices, - AllDevicesDefaultUnsupported, - InsufficientDevices { requested: u32, available: u32 }, -} - -impl fmt::Display for CdiGpuSelectionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NoAvailableDevices => f.write_str("no NVIDIA CDI GPU devices were discovered"), - Self::AllDevicesDefaultUnsupported => f.write_str( - "default GPU request resolved only to nvidia.com/gpu=all, which is not allowed on this platform; set driver_config.cdi_devices to [\"nvidia.com/gpu=all\"] explicitly to request all GPUs", - ), - Self::InsufficientDevices { - requested, - available: 0, - } => write!( - f, - "GPU sandbox requested {requested} GPUs, but no selectable NVIDIA CDI GPU devices are available" - ), - Self::InsufficientDevices { - requested, - available: 1, - } => write!( - f, - "GPU sandbox requested {requested} GPUs, but only 1 selectable NVIDIA CDI GPU device is available" - ), - Self::InsufficientDevices { - requested, - available, - } => write!( - f, - "GPU sandbox requested {requested} GPUs, but only {available} selectable NVIDIA CDI GPU devices are available" - ), - } - } -} - -impl std::error::Error for CdiGpuSelectionError {} - -fn cdi_nvidia_gpu_suffix(id: &str) -> Option<&str> { - id.strip_prefix(CDI_NVIDIA_GPU_PREFIX) -} - -// Local equivalent of the upstream CDI parser until its public API is released: -// https://github.com/cncf-tags/container-device-interface-rs/pull/177 -fn is_qualified_name(device: &str) -> bool { - let Some((kind, name)) = device.split_once('=') else { - return false; - }; - let Some((vendor, class)) = kind.split_once('/') else { - return false; - }; - let valid_component = |value: &str, device_name: bool| { - let bytes = value.as_bytes(); - bytes.first().is_some_and(|byte| { - if device_name { - byte.is_ascii_alphanumeric() - } else { - byte.is_ascii_alphabetic() - } - }) && bytes.last().is_some_and(u8::is_ascii_alphanumeric) - && bytes.iter().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!(byte, b'_' | b'-' | b'.') - || (device_name && *byte == b':') - }) - }; - valid_component(vendor, false) && valid_component(class, false) && valid_component(name, true) -} - -/// Validate vendor-agnostic CDI qualified names without resolving or authorizing devices. -/// -/// # Errors -/// Returns an error for host paths or malformed `/=` selectors. -pub fn validate_cdi_device_names(devices: &[String], field: &str) -> Result<(), String> { - if devices.iter().any(|device| !is_qualified_name(device)) { - return Err(format!( - "{field} must contain CDI qualified names (/=)" - )); - } - Ok(()) -} - -/// Validate explicit CDI selectors and their GPU request requirements. -/// -/// Docker and Podman use this helper; drivers with other identifier formats use -/// [`validate_specific_gpu_device_request`] directly. -/// -/// # Errors -/// Returns an error for malformed CDI names or invalid GPU request requirements. -pub fn validate_cdi_gpu_device_request( - gpu: Option<&DriverGpuResourceRequirements>, - devices: &[String], - field: &str, -) -> Result<(), String> { - validate_cdi_device_names(devices, field)?; - validate_specific_gpu_device_request(gpu, devices, field) -} - -/// Validate a compute-driver GPU request against driver-owned specific devices. -/// -/// Drivers call this when a sandbox request combines portable GPU requirements -/// with exact device identifiers in `driver_config`. -/// -/// # Errors -/// Returns an error when the sandbox GPU request is absent, when `gpu.count` -/// is zero, when device IDs are duplicated, or when the effective GPU count -/// does not equal the number of specific devices. -pub fn validate_specific_gpu_device_request( - gpu: Option<&DriverGpuResourceRequirements>, - specific_devices: &[String], - driver_config_field: &str, -) -> Result<(), String> { - let device_count = specific_devices.len(); - if device_count == 0 { - return Ok(()); - } - - let mut seen = HashSet::with_capacity(device_count); - for device in specific_devices { - if !seen.insert(device.as_str()) { - return Err(format!( - "{driver_config_field} contains duplicate device ID '{device}'" - )); - } - } - - let Some(count) = effective_driver_gpu_count(gpu)? else { - return Err(format!("{driver_config_field} requires a gpu request")); - }; - - if usize::try_from(count).ok() != Some(device_count) { - return Err(format!( - "gpu count ({count}) must match {driver_config_field} length ({device_count})" - )); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn cdi_device_names_accept_vendor_agnostic_selectors() { - for device in [ - "nvidia.com/gpu=0", - "nvidia.com/gpu=all", - "nvidia.com/gpu=GPU-5b2d", - "nvidia.com/gpu=MIG-5b2d", - "nvidia.com/gpu=0:1", - "example.com/accelerator=card_0.1", - "intel.com/gpu=0", - "amd.com/gpu=0", - "v/c=0", - ] { - let devices = vec![device.to_string()]; - validate_cdi_device_names(&devices, "driver_config.cdi_devices") - .unwrap_or_else(|error| panic!("{device:?}: {error}")); - } - } - - #[test] - fn cdi_device_names_reject_host_paths_and_malformed_selectors() { - for device in [ - "", - "/dev/sda", - "/dev", - "/dev/sda:/dev/sda:rwm", - "dev/sda", - "../dev/sda", - "vendor/gpu", - "/gpu=0", - "vendor/=0", - "vendor/gpu=", - "vendor/gpu=/dev/sda", - "vendor/gpu=0/1", - "vendor/gpu=0=1", - "vendor/gpu=0,1", - "vendor/gpu=-0", - "vendor/gpu=0:", - "vendor/gpu=0 ", - " vendor/gpu=0", - "vendor/gpu=0\n", - "vendor/gpu=0\0", - "vendor/gpu=é", - "véndor/gpu=0", - "vendor/gpü=0", - "0vendor/gpu=0", - "vendor/0gpu=0", - "vendor./gpu=0", - "vendor/gpu-=0", - "vendor/gpu/other=0", - ] { - let error = - validate_cdi_device_names(&[device.to_string()], "driver_config.cdi_devices") - .unwrap_err(); - assert!(error.contains("CDI qualified names"), "{device:?}: {error}"); - } - } - - #[test] - fn effective_driver_gpu_count_normalizes_missing_count() { - let gpu = DriverGpuResourceRequirements { count: None }; - - assert_eq!(effective_driver_gpu_count(Some(&gpu)), Ok(Some(1))); - assert_eq!(effective_driver_gpu_count(None), Ok(None)); - } - - #[test] - fn effective_driver_gpu_count_rejects_zero_count() { - let gpu = DriverGpuResourceRequirements { count: Some(0) }; - - assert_eq!( - effective_driver_gpu_count(Some(&gpu)), - Err("gpu count must be greater than 0".to_string()) - ); - } - - #[test] - fn inventory_filters_and_deduplicates_nvidia_gpu_ids() { - let inventory = CdiGpuInventory::new([ - "nvidia.com/gpu=1", - "vendor.example/device=0", - "nvidia.com/gpu=1", - " nvidia.com/gpu=0 ", - ]); - - assert_eq!( - inventory.as_slice(), - &vec![ - "nvidia.com/gpu=0".to_string(), - "nvidia.com/gpu=1".to_string() - ] - ); - } - - #[test] - fn selector_prefers_indexed_family_and_sorts_numerically() { - let inventory = CdiGpuInventory::new([ - "nvidia.com/gpu=10", - "nvidia.com/gpu=UUID-b", - "nvidia.com/gpu=2", - "nvidia.com/gpu=all", - ]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=2".to_string()]) - ); - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=10".to_string()]) - ); - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=2".to_string()]) - ); - } - - #[test] - fn selector_uses_named_family_when_no_indexed_ids_exist() { - let inventory = CdiGpuInventory::new(["nvidia.com/gpu=UUID-b", "nvidia.com/gpu=UUID-a"]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=UUID-a".to_string()]) - ); - } - - #[test] - fn selector_uses_all_only_inventory_when_allowed() { - let inventory = CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]); - let selector = CdiGpuDefaultSelector::new(inventory, true); - - assert_eq!( - selector.next_device_ids(1), - Ok(vec![CDI_GPU_DEVICE_ALL.to_string()]) - ); - } - - #[test] - fn selector_rejects_all_only_inventory_when_not_allowed() { - let inventory = CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.next_device_ids(1), - Err(CdiGpuSelectionError::AllDevicesDefaultUnsupported) - ); - } - - #[test] - fn selector_rejects_empty_inventory() { - let inventory = CdiGpuInventory::new(["vendor.example/device=0"]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.next_device_ids(1), - Err(CdiGpuSelectionError::NoAvailableDevices) - ); - } - - #[test] - fn peek_does_not_advance_round_robin_cursor() { - let inventory = CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1"]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.peek_device_ids(1), - Ok(vec!["nvidia.com/gpu=0".to_string()]) - ); - assert_eq!( - selector.peek_device_ids(1), - Ok(vec!["nvidia.com/gpu=0".to_string()]) - ); - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=0".to_string()]) - ); - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=1".to_string()]) - ); - } - - #[test] - fn selector_selects_multiple_distinct_devices_in_cursor_order() { - let inventory = - CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1", "nvidia.com/gpu=2"]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.next_device_ids(2), - Ok(vec![ - "nvidia.com/gpu=0".to_string(), - "nvidia.com/gpu=1".to_string() - ]) - ); - assert_eq!( - selector.next_device_ids(2), - Ok(vec![ - "nvidia.com/gpu=2".to_string(), - "nvidia.com/gpu=0".to_string() - ]) - ); - } - - #[test] - fn selector_rejects_count_larger_than_selectable_family_without_advancing() { - let inventory = CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1"]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.next_device_ids(3), - Err(CdiGpuSelectionError::InsufficientDevices { - requested: 3, - available: 2 - }) - ); - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=0".to_string()]) - ); - } - - #[test] - fn selector_treats_all_only_inventory_as_one_selectable_device() { - let inventory = CdiGpuInventory::new([CDI_GPU_DEVICE_ALL]); - let selector = CdiGpuDefaultSelector::new(inventory, true); - - assert_eq!( - selector.next_device_ids(2), - Err(CdiGpuSelectionError::InsufficientDevices { - requested: 2, - available: 1 - }) - ); - } - - #[test] - fn selector_refreshes_inventory_without_resetting_cursor() { - let inventory = CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1"]); - let selector = CdiGpuDefaultSelector::new(inventory, false); - - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=0".to_string()]) - ); - selector.refresh( - CdiGpuInventory::new(["nvidia.com/gpu=0", "nvidia.com/gpu=1", "nvidia.com/gpu=2"]), - false, - ); - assert_eq!( - selector.next_device_ids(1), - Ok(vec!["nvidia.com/gpu=1".to_string()]) - ); - } - - #[test] - fn specific_gpu_device_request_preserves_non_cdi_identifiers() { - let gpu = DriverGpuResourceRequirements { count: Some(1) }; - let devices = vec!["0000:2d:00.0".to_string()]; - validate_specific_gpu_device_request(Some(&gpu), &devices, "driver_config.gpu_device_ids") - .expect("generic GPU request validation must accept PCI device identifiers"); - let error = - validate_cdi_gpu_device_request(Some(&gpu), &devices, "driver_config.cdi_devices") - .expect_err("CDI drivers must reject non-CDI identifiers"); - assert!(error.contains("CDI qualified names")); - } - - #[test] - fn validate_specific_gpu_device_request_ignores_empty_devices() { - validate_specific_gpu_device_request(None, &[], "driver_config.cdi_devices") - .expect("empty exact device lists should not be validated"); - } - - #[test] - fn validate_specific_gpu_device_request_accepts_matching_count() { - let gpu = DriverGpuResourceRequirements { count: Some(2) }; - let specific_devices = vec![ - "nvidia.com/gpu=0".to_string(), - "nvidia.com/gpu=1".to_string(), - ]; - - validate_specific_gpu_device_request( - Some(&gpu), - &specific_devices, - "driver_config.cdi_devices", - ) - .expect("matching count should be accepted"); - } - - #[test] - fn validate_specific_gpu_device_request_accepts_missing_count_for_one_device() { - let gpu = DriverGpuResourceRequirements { count: None }; - let specific_devices = vec!["nvidia.com/gpu=0".to_string()]; - - validate_specific_gpu_device_request( - Some(&gpu), - &specific_devices, - "driver_config.cdi_devices", - ) - .expect("single exact device should be compatible with a default GPU request"); - } - - #[test] - fn validate_specific_gpu_device_request_rejects_missing_gpu_request() { - let specific_devices = vec!["nvidia.com/gpu=0".to_string()]; - - let err = validate_specific_gpu_device_request( - None, - &specific_devices, - "driver_config.cdi_devices", - ) - .expect_err("missing GPU request should be rejected"); - - assert_eq!(err, "driver_config.cdi_devices requires a gpu request"); - } - - #[test] - fn validate_specific_gpu_device_request_rejects_missing_count_for_multiple_devices() { - let gpu = DriverGpuResourceRequirements { count: None }; - let specific_devices = vec![ - "nvidia.com/gpu=0".to_string(), - "nvidia.com/gpu=1".to_string(), - ]; - - let err = validate_specific_gpu_device_request( - Some(&gpu), - &specific_devices, - "driver_config.cdi_devices", - ) - .expect_err("missing count should be rejected for multiple devices"); - - assert_eq!( - err, - "gpu count (1) must match driver_config.cdi_devices length (2)" - ); - } - - #[test] - fn validate_specific_gpu_device_request_rejects_mismatch() { - let gpu = DriverGpuResourceRequirements { count: Some(2) }; - let specific_devices = vec!["nvidia.com/gpu=0".to_string()]; - - let err = validate_specific_gpu_device_request( - Some(&gpu), - &specific_devices, - "driver_config.cdi_devices", - ) - .expect_err("mismatched count should be rejected"); - - assert_eq!( - err, - "gpu count (2) must match driver_config.cdi_devices length (1)" - ); - } - - #[test] - fn validate_specific_gpu_device_request_rejects_duplicate_ids() { - let gpu = DriverGpuResourceRequirements { count: Some(2) }; - let specific_devices = vec![ - "nvidia.com/gpu=0".to_string(), - "nvidia.com/gpu=0".to_string(), - ]; - - let err = validate_specific_gpu_device_request( - Some(&gpu), - &specific_devices, - "driver_config.cdi_devices", - ) - .expect_err("duplicates should be rejected"); - - assert_eq!( - err, - "driver_config.cdi_devices contains duplicate device ID 'nvidia.com/gpu=0'" - ); - } -} diff --git a/crates/openshell-core/src/jwt.rs b/crates/openshell-core/src/jwt.rs index 84ffe98ec0..2e1c139e0a 100644 --- a/crates/openshell-core/src/jwt.rs +++ b/crates/openshell-core/src/jwt.rs @@ -30,1394 +30,7 @@ pub fn parse_exp_secs(token: &str) -> Option { } #[cfg(feature = "jwt")] -mod session { - use std::collections::BTreeMap; - use std::fmt; - use std::sync::Arc; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode_header}; - use openshell_crypto::jwt::{decode, encode}; - use serde::{Deserialize, Serialize}; - use uuid::Uuid; - use zeroize::Zeroizing; - - use crate::SandboxSessionId; - use crate::sandbox_generation::SandboxGenerationId; - - pub const GATEWAY_SESSION_JWT_TYPE: &str = "openshell-gateway-session+jwt"; - pub const SANDBOX_SESSION_JWT_TYPE: &str = "openshell-sandbox-session+jwt"; - /// JOSE `typ` of the supervisor token: the credential of the gateway's - /// shared supervisor process for the assignment stream, and nothing else. - pub const SUPERVISOR_JWT_TYPE: &str = "openshell-supervisor+jwt"; - /// JOSE `typ` of a supervisor takeover grant. - /// - /// The grant is the gateway's authorization for one supervisor process to - /// replace the owner of one runtime generation at a strictly higher - /// credential epoch. Only the sandbox runtime verifies it, and only while - /// staging a replacement attach. - pub const SUPERVISOR_TAKEOVER_JWT_TYPE: &str = "openshell-supervisor-takeover+jwt"; - /// Longest lifetime of a takeover grant. Replay safety comes from the - /// strictly increasing epoch it names; the bound only limits how long an - /// unused grant stays usable. - pub const MAX_SUPERVISOR_TAKEOVER_TTL: Duration = Duration::from_mins(10); - pub const SANDBOX_SESSION_AUDIENCE: &str = "openshell-sandbox"; - pub const DEFAULT_SESSION_TOKEN_TTL: Duration = Duration::from_hours(1); - pub const MIN_SESSION_TOKEN_TTL: Duration = Duration::from_mins(1); - pub const MAX_SESSION_TOKEN_TTL: Duration = Duration::from_hours(1); - pub const MAX_SESSION_CLOCK_LEEWAY: Duration = Duration::from_secs(30); - - const GATEWAY_ISSUER_PREFIX: &str = "openshell-gateway:"; - const SANDBOX_SUBJECT_PREFIX: &str = "spiffe://openshell/sandbox/"; - const SUPERVISOR_SUBJECT_PREFIX: &str = "openshell-supervisor:"; - - /// Canonical sandbox identity carried by both session-token profiles. - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] - #[serde(transparent)] - pub struct SandboxId(String); - - impl SandboxId { - pub fn parse(value: impl Into) -> Result { - let value = value.into(); - if value.is_empty() || value.trim() != value || value.chars().any(char::is_whitespace) { - return Err(SessionJwtError::InvalidSandboxId); - } - Ok(Self(value)) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } - } - - impl fmt::Display for SandboxId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } - } - - /// Monotonic order for authenticated Sandbox Protocol connections. - #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] - #[serde(transparent)] - pub struct CredentialEpoch(u64); - - impl CredentialEpoch { - pub fn new(value: u64) -> Result { - if value == 0 { - return Err(SessionJwtError::InvalidCredentialEpoch); - } - Ok(Self(value)) - } - - #[must_use] - pub const fn get(self) -> u64 { - self.0 - } - } - - /// Monotonic identity for Sandbox Protocol attachment state. - /// - /// This is a protocol coordination value, not part of JWT authorization. - #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] - #[serde(transparent)] - pub struct SessionRotation(u64); - - impl SessionRotation { - pub fn new(value: u64) -> Result { - if value == 0 { - return Err(SessionJwtError::InvalidSessionRotation); - } - Ok(Self(value)) - } - - #[must_use] - pub const fn get(self) -> u64 { - self.0 - } - - pub fn successor(self) -> Result { - self.0 - .checked_add(1) - .ok_or(SessionJwtError::SessionRotationOverflow) - .and_then(Self::new) - } - } - - /// The only component authorized by either sandbox-session token profile. - #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] - pub enum SessionComponent { - #[serde(rename = "openshell-supervisor")] - OpenShellSupervisor, - } - - /// Exact token profile. The profile chooses both the JOSE `typ` and JWT `aud`. - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - pub enum SessionTokenProfile { - Gateway, - Sandbox, - } - - impl SessionTokenProfile { - #[must_use] - pub const fn token_type(self) -> &'static str { - match self { - Self::Gateway => GATEWAY_SESSION_JWT_TYPE, - Self::Sandbox => SANDBOX_SESSION_JWT_TYPE, - } - } - - fn audience(self, issuer: &str) -> &str { - match self { - Self::Gateway => issuer, - Self::Sandbox => SANDBOX_SESSION_AUDIENCE, - } - } - } - - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - struct SessionClaims { - iss: String, - sub: String, - aud: String, - iat: i64, - exp: i64, - jti: String, - sandbox_id: SandboxId, - runtime_generation: SandboxGenerationId, - auth_epoch: CredentialEpoch, - component: SessionComponent, - } - - /// Claims of the supervisor token. It names no sandbox, generation, or - /// credential epoch: the shared supervisor acts for sandboxes only with the - /// per-instance credentials each assignment carries. - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - struct SupervisorClaims { - iss: String, - sub: String, - aud: String, - iat: i64, - exp: i64, - jti: String, - component: SessionComponent, - } - - /// Claims of a supervisor takeover grant. The subject and audience are the - /// sandbox's, like its Sandbox Protocol session token, but the JOSE `typ` - /// keeps the two from ever being accepted for each other. - #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - struct SupervisorTakeoverClaims { - iss: String, - sub: String, - aud: String, - iat: i64, - exp: i64, - jti: String, - sandbox_id: SandboxId, - runtime_generation: SandboxGenerationId, - auth_epoch: CredentialEpoch, - supervisor_instance: String, - owner: String, - component: SessionComponent, - } - - /// The replacement of the supervisor that owns one runtime generation. - /// - /// The gateway grants it only after durably advancing the sandbox's - /// credential epoch to `auth_epoch`; ordinary credential refresh keeps the - /// epoch and never produces a grant. - #[derive(Clone, Debug, PartialEq, Eq)] - pub struct SupervisorTakeover { - pub sandbox_id: SandboxId, - pub runtime_generation: SandboxGenerationId, - /// Ownership term the grant starts; it must exceed the term the - /// runtime currently accepts. - pub auth_epoch: CredentialEpoch, - /// Supervisor process identity the runtime pins once the replacement - /// is confirmed (a canonical UUID chosen by the gateway). - pub supervisor_instance: String, - /// Supervisor pool member that owns the new term, for audit. - pub owner: String, - } - - /// A takeover grant whose signature, issuer, audience, and lifetime were - /// verified. - #[derive(Clone, Debug, PartialEq, Eq)] - pub struct VerifiedSupervisorTakeover { - pub takeover: SupervisorTakeover, - pub token_id: Uuid, - pub expires_at: i64, - } - - /// Durable identity shared by every short-lived token for one sandbox runtime. - #[derive(Clone, Debug, PartialEq, Eq)] - pub struct SandboxRuntimeIdentity { - pub sandbox_id: SandboxId, - pub runtime_generation: SandboxGenerationId, - pub auth_epoch: CredentialEpoch, - } - - /// A JWT whose contents are deliberately omitted from `Debug` output and - /// zeroed when its final owner is dropped. - #[derive(Clone)] - pub struct SecretJwt(Zeroizing); - - impl SecretJwt { - pub fn parse(value: impl Into) -> Result { - let value = value.into(); - if value.is_empty() || value.chars().any(char::is_whitespace) { - return Err(SessionJwtError::InvalidTokenEncoding); - } - Ok(Self(Zeroizing::new(value))) - } - - #[must_use] - pub fn expose_secret(&self) -> &str { - self.0.as_str() - } - } - - impl fmt::Debug for SecretJwt { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("SecretJwt([REDACTED])") - } - } - - impl Serialize for SecretJwt { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(self.expose_secret()) - } - } - - impl<'de> Deserialize<'de> for SecretJwt { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - Self::parse(value).map_err(serde::de::Error::custom) - } - } - - /// Private SSH key material is redacted from diagnostics and cleared on drop. - #[derive(Clone)] - pub struct SecretSshHostKey(Zeroizing); - - impl SecretSshHostKey { - #[must_use] - pub fn new(value: String) -> Self { - Self(Zeroizing::new(value)) - } - - #[must_use] - pub fn expose_secret(&self) -> &str { - &self.0 - } - } - - impl fmt::Debug for SecretSshHostKey { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("SecretSshHostKey([REDACTED])") - } - } - - impl Serialize for SecretSshHostKey { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(self.expose_secret()) - } - } - - impl<'de> Deserialize<'de> for SecretSshHostKey { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - String::deserialize(deserializer).map(Self::new) - } - } - - /// OTLP collector request headers, such as an authorization token, in - /// the `OTEL_EXPORTER_OTLP_HEADERS` format. Redacted from diagnostics and - /// cleared on drop. - #[derive(Clone)] - pub struct SecretOtlpHeaders(Zeroizing); - - impl SecretOtlpHeaders { - #[must_use] - pub fn new(value: String) -> Self { - Self(Zeroizing::new(value)) - } - - #[must_use] - pub fn expose_secret(&self) -> &str { - &self.0 - } - } - - impl fmt::Debug for SecretOtlpHeaders { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("SecretOtlpHeaders([REDACTED])") - } - } - - impl Serialize for SecretOtlpHeaders { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(self.expose_secret()) - } - } - - impl<'de> Deserialize<'de> for SecretOtlpHeaders { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - String::deserialize(deserializer).map(Self::new) - } - } - - /// A PEM private key. Redacted from diagnostics and cleared on drop. - #[derive(Clone)] - pub struct SecretPrivateKeyPem(Zeroizing); - - impl SecretPrivateKeyPem { - #[must_use] - pub fn new(value: String) -> Self { - Self(Zeroizing::new(value)) - } - - #[must_use] - pub fn expose_secret(&self) -> &str { - &self.0 - } - } - - impl fmt::Debug for SecretPrivateKeyPem { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("SecretPrivateKeyPem([REDACTED])") - } - } - - impl Serialize for SecretPrivateKeyPem { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(self.expose_secret()) - } - } - - impl<'de> Deserialize<'de> for SecretPrivateKeyPem { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - String::deserialize(deserializer).map(Self::new) - } - } - - /// TLS material for one OTLP collector connection, as PEM. The private - /// key is redacted from diagnostics and cleared on drop. - #[derive(Clone, Default, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub struct OtlpTlsMaterial { - /// Certificates trusted to verify the collector instead of the - /// platform roots. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ca_pem: Option, - /// Client certificate presented to the collector. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client: Option, - } - - /// The headers and TLS material one supervisor export sends its - /// collector. - #[derive(Clone, Copy, Debug, Default)] - pub struct OtlpExportCredentials<'a> { - pub headers: Option<&'a SecretOtlpHeaders>, - pub tls: Option<&'a OtlpTlsMaterial>, - } - - /// A PEM client certificate chain and its private key. - #[derive(Clone, Debug, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub struct OtlpClientIdentity { - pub cert_pem: String, - pub key_pem: SecretPrivateKeyPem, - } - - #[cfg(feature = "otel")] - impl OtlpTlsMaterial { - /// The material as exporter TLS settings. - pub fn otlp_tls(&self) -> Result { - openshell_otel::OtlpTls::from_pem( - self.ca_pem.as_deref().map(str::as_bytes), - self.client.as_ref().map(|client| { - ( - client.cert_pem.as_bytes(), - client.key_pem.expose_secret().as_bytes(), - ) - }), - ) - } - } - - impl fmt::Debug for OtlpTlsMaterial { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("OtlpTlsMaterial") - .field("ca_pem", &self.ca_pem.is_some()) - .field("client", &self.client) - .finish() - } - } - - /// Trusted launch input delivered only to `openshell-supervisor`. - #[derive(Clone, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub struct SupervisorAuthBundle { - /// Correlates the two ends of one Sandbox Protocol launch. This value - /// is not carried in JWTs and is not an authorization identity. - pub session_id: SandboxSessionId, - pub runtime_generation: SandboxGenerationId, - /// Orders protocol attachment replacement, independently of auth. - pub session_rotation: SessionRotation, - pub auth_epoch: CredentialEpoch, - pub gateway_token: SecretJwt, - pub gateway_expires_at: i64, - pub sandbox_token: SecretJwt, - pub sandbox_expires_at: i64, - /// Never delivered to the workload. Missing only in older bundles. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ssh_host_private_key: Option, - /// Headers for the supervisor's trace collector, present only when - /// the gateway has them. Never delivered to the workload. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub traces_otlp_headers: Option, - /// Headers for the OCSF OTLP collector, present only when the gateway - /// has them. Never delivered to the workload. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ocsf_otlp_headers: Option, - /// Headers for the agent trace relay's collector, present only when - /// the gateway configures them. Never delivered to the workload. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_otlp_headers: Option, - /// TLS material for the supervisor's collectors, each distinct - /// material once, present only when the gateway configures it. - /// Never delivered to the workload. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub otlp_tls: Vec, - /// Position in `otlp_tls` of each collector's material. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub traces_otlp_tls: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ocsf_otlp_tls: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent_otlp_tls: Option, - } - - /// Gateway-created authentication input trusted by a compute driver. - /// - /// Drivers split this structure: the supervisor receives `supervisor`, - /// while the sandbox receives only the gateway identity and public keys. - #[derive(Clone, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub struct SandboxLaunchAuthentication { - pub supervisor: SupervisorAuthBundle, - pub gateway_id: String, - pub verification_keys: Vec, - } - - impl SandboxLaunchAuthentication { - pub fn validate(&self) -> Result<(), SessionJwtError> { - self.supervisor.validate()?; - validate_gateway_id(&self.gateway_id)?; - if self.verification_keys.is_empty() { - return Err(SessionJwtError::NoVerificationKeys); - } - let mut key_ids = std::collections::BTreeSet::new(); - for key in &self.verification_keys { - validate_key_id(key.key_id.clone())?; - if key.public_key_pem.is_empty() { - return Err(SessionJwtError::InvalidVerificationKey); - } - if !key_ids.insert(key.key_id.as_str()) { - return Err(SessionJwtError::DuplicateKeyId); - } - } - Ok(()) - } - } - - impl fmt::Debug for SandboxLaunchAuthentication { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SandboxLaunchAuthentication") - .field("supervisor", &self.supervisor) - .field("gateway_id", &self.gateway_id) - .field( - "verification_key_ids", - &self - .verification_keys - .iter() - .map(|key| key.key_id.as_str()) - .collect::>(), - ) - .finish() - } - } - - impl SupervisorAuthBundle { - pub fn validate(&self) -> Result<(), SessionJwtError> { - if self.gateway_expires_at < 0 || self.sandbox_expires_at < 0 { - return Err(SessionJwtError::InvalidLifetime); - } - SandboxGenerationId::parse(self.runtime_generation.to_string()) - .map_err(|_| SessionJwtError::InvalidRuntimeIdentity)?; - SessionRotation::new(self.session_rotation.get())?; - let references = [ - self.traces_otlp_tls, - self.ocsf_otlp_tls, - self.agent_otlp_tls, - ]; - if references - .into_iter() - .flatten() - .any(|index| index >= self.otlp_tls.len()) - { - return Err(SessionJwtError::InvalidOtlpTlsReference); - } - Ok(()) - } - - /// What the supervisor's trace export sends its collector. - pub fn traces_otlp(&self) -> OtlpExportCredentials<'_> { - self.otlp_credentials(self.traces_otlp_headers.as_ref(), self.traces_otlp_tls) - } - - /// What the supervisor's OCSF export sends its collector. - pub fn ocsf_otlp(&self) -> OtlpExportCredentials<'_> { - self.otlp_credentials(self.ocsf_otlp_headers.as_ref(), self.ocsf_otlp_tls) - } - - /// What the agent trace relay sends its collector. - pub fn agent_otlp(&self) -> OtlpExportCredentials<'_> { - self.otlp_credentials(self.agent_otlp_headers.as_ref(), self.agent_otlp_tls) - } - - /// [`Self::validate`] rejects a `tls` index outside `otlp_tls`. - fn otlp_credentials<'a>( - &'a self, - headers: Option<&'a SecretOtlpHeaders>, - tls: Option, - ) -> OtlpExportCredentials<'a> { - OtlpExportCredentials { - headers, - tls: tls.and_then(|index| self.otlp_tls.get(index)), - } - } - - pub fn sandbox_bearer_slot(&self) -> Result { - SessionBearerTokenSlot::new( - self.sandbox_token.clone(), - self.sandbox_expires_at, - self.auth_epoch, - ) - } - } - - impl fmt::Debug for SupervisorAuthBundle { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SupervisorAuthBundle") - .field("session_id", &self.session_id) - .field("runtime_generation", &self.runtime_generation) - .field("session_rotation", &self.session_rotation) - .field("auth_epoch", &self.auth_epoch) - .field("gateway_token", &"[REDACTED]") - .field("gateway_expires_at", &self.gateway_expires_at) - .field("sandbox_token", &"[REDACTED]") - .field("sandbox_expires_at", &self.sandbox_expires_at) - .field("ssh_host_private_key", &self.ssh_host_private_key) - .field("traces_otlp_headers", &self.traces_otlp_headers) - .field("ocsf_otlp_headers", &self.ocsf_otlp_headers) - .field("agent_otlp_headers", &self.agent_otlp_headers) - .field("otlp_tls", &self.otlp_tls) - .field("traces_otlp_tls", &self.traces_otlp_tls) - .field("ocsf_otlp_tls", &self.ocsf_otlp_tls) - .field("agent_otlp_tls", &self.agent_otlp_tls) - .finish() - } - } - - #[derive(Clone, Debug)] - pub struct MintedSessionToken { - pub token: SecretJwt, - pub expires_at: i64, - pub token_id: Uuid, - } - - #[derive(Clone, Debug)] - pub struct MintedSessionTokenPair { - pub gateway: MintedSessionToken, - pub sandbox: MintedSessionToken, - pub auth_epoch: CredentialEpoch, - } - - /// Refreshable Sandbox Protocol bearer credential shared by all streams on - /// the supervisor's current HTTP/2 connection. - #[derive(Clone)] - pub struct SessionBearerTokenSlot { - inner: Arc>>, - } - - #[derive(Clone)] - struct StoredBearer { - token: SecretJwt, - expires_at: i64, - credential_epoch: CredentialEpoch, - } - - impl SessionBearerTokenSlot { - #[must_use] - pub fn empty() -> Self { - Self { - inner: Arc::new(std::sync::RwLock::new(None)), - } - } - - pub fn new( - token: SecretJwt, - expires_at: i64, - credential_epoch: CredentialEpoch, - ) -> Result { - let slot = Self::empty(); - slot.update(token, expires_at, credential_epoch)?; - Ok(slot) - } - - pub fn update( - &self, - token: SecretJwt, - expires_at: i64, - credential_epoch: CredentialEpoch, - ) -> Result<(), SessionJwtError> { - if expires_at < 0 { - return Err(SessionJwtError::InvalidLifetime); - } - let mut stored = self - .inner - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if stored - .as_ref() - .is_some_and(|current| credential_epoch < current.credential_epoch) - { - return Err(SessionJwtError::StaleCredentialEpoch); - } - *stored = Some(StoredBearer { - token, - expires_at, - credential_epoch, - }); - Ok(()) - } - - pub fn clear(&self) { - *self - .inner - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - } - - #[must_use] - pub fn expires_at(&self) -> Option { - self.inner - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .map(|stored| stored.expires_at) - } - - #[must_use] - pub fn credential_epoch(&self) -> Option { - self.inner - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .map(|stored| stored.credential_epoch) - } - - pub fn authorization_metadata( - &self, - ) -> Result { - let stored = self - .inner - .read() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let stored = stored.as_ref().ok_or(SessionJwtError::TokenUnavailable)?; - if stored.expires_at > 0 && stored.expires_at <= SystemJwtClock.now_unix_seconds() { - return Err(SessionJwtError::Expired); - } - format!("Bearer {}", stored.token.expose_secret()) - .parse() - .map_err(|_| SessionJwtError::InvalidTokenEncoding) - } - } - - impl Default for SessionBearerTokenSlot { - fn default() -> Self { - Self::empty() - } - } - - impl fmt::Debug for SessionBearerTokenSlot { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SessionBearerTokenSlot") - .field("expires_at", &self.expires_at()) - .field("credential_epoch", &self.credential_epoch()) - .finish_non_exhaustive() - } - } - - pub trait JwtClock: Send + Sync { - fn now_unix_seconds(&self) -> i64; - } - - #[derive(Debug)] - pub struct SystemJwtClock; - - impl JwtClock for SystemJwtClock { - fn now_unix_seconds(&self) -> i64 { - i64::try_from( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()), - ) - .unwrap_or(i64::MAX) - } - } - - /// Gateway-side issuer shared by both token profiles. - pub struct SessionJwtIssuer { - encoding_key: EncodingKey, - key_id: String, - issuer: String, - ttl: Option, - clock: Arc, - } - - impl fmt::Debug for SessionJwtIssuer { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SessionJwtIssuer") - .field("key_id", &self.key_id) - .field("issuer", &self.issuer) - .field("ttl", &self.ttl) - .finish_non_exhaustive() - } - } - - impl SessionJwtIssuer { - pub fn from_ed25519_pem( - signing_key_pem: &[u8], - key_id: impl Into, - gateway_id: &str, - ttl: Option, - clock: Arc, - ) -> Result { - install_crypto_provider(); - if let Some(ttl) = ttl { - validate_ttl(ttl)?; - } - let key_id = validate_key_id(key_id.into())?; - let gateway_id = validate_gateway_id(gateway_id)?; - let encoding_key = EncodingKey::from_ed_pem(signing_key_pem) - .map_err(|_| SessionJwtError::InvalidSigningKey)?; - Ok(Self { - encoding_key, - key_id, - issuer: format!("{GATEWAY_ISSUER_PREFIX}{gateway_id}"), - ttl, - clock, - }) - } - - pub fn mint_pair( - &self, - identity: &SandboxRuntimeIdentity, - ) -> Result { - self.mint_pair_with_gateway_token_id(identity, Uuid::new_v4()) - } - - /// Mint a token pair whose gateway-facing credential has a caller-owned - /// lineage identifier. - /// - /// The gateway persists this identifier before returning the token so - /// refresh can reject every superseded bearer across all replicas. The - /// Sandbox Protocol credential remains independently identified. - pub fn mint_pair_with_gateway_token_id( - &self, - identity: &SandboxRuntimeIdentity, - gateway_token_id: Uuid, - ) -> Result { - self.mint_pair_with_token_metadata( - identity, - gateway_token_id, - Uuid::new_v4(), - self.clock.now_unix_seconds(), - ) - } - - /// Mint a reproducible token pair for a persisted refresh successor. - pub fn mint_pair_with_token_metadata( - &self, - identity: &SandboxRuntimeIdentity, - gateway_token_id: Uuid, - sandbox_token_id: Uuid, - issued_at: i64, - ) -> Result { - Ok(MintedSessionTokenPair { - gateway: self.mint( - SessionTokenProfile::Gateway, - identity, - gateway_token_id, - issued_at, - )?, - sandbox: self.mint( - SessionTokenProfile::Sandbox, - identity, - sandbox_token_id, - issued_at, - )?, - auth_epoch: identity.auth_epoch, - }) - } - - /// Mint the shared supervisor's token for the gateway's assignment - /// stream with its own bounded lifetime, independent of the session - /// token lifetime (which may be non-expiring). - pub fn mint_supervisor_token( - &self, - ttl: Duration, - ) -> Result { - validate_ttl(ttl)?; - let issued_at = self.clock.now_unix_seconds(); - let expires_at = - issued_at.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX)); - let token_id = Uuid::new_v4(); - let gateway_id = &self.issuer[GATEWAY_ISSUER_PREFIX.len()..]; - let claims = SupervisorClaims { - iss: self.issuer.clone(), - sub: format!("{SUPERVISOR_SUBJECT_PREFIX}{gateway_id}"), - aud: self.issuer.clone(), - iat: issued_at, - exp: expires_at, - jti: token_id.to_string(), - component: SessionComponent::OpenShellSupervisor, - }; - let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some(self.key_id.clone()); - header.typ = Some(SUPERVISOR_JWT_TYPE.to_string()); - let token = encode(&header, &claims, &self.encoding_key) - .map_err(|_| SessionJwtError::SigningFailed)?; - Ok(MintedSessionToken { - token: SecretJwt::parse(token)?, - expires_at, - token_id, - }) - } - - /// Mint a takeover grant for `takeover`, valid for `ttl` (at most - /// [`MAX_SUPERVISOR_TAKEOVER_TTL`]). The caller must already have - /// advanced the sandbox's persisted credential epoch to - /// `takeover.auth_epoch`. - pub fn mint_supervisor_takeover( - &self, - takeover: &SupervisorTakeover, - ttl: Duration, - ) -> Result { - if ttl.is_zero() || ttl > MAX_SUPERVISOR_TAKEOVER_TTL { - return Err(SessionJwtError::InvalidLifetime); - } - validate_takeover_identity(&takeover.supervisor_instance, &takeover.owner)?; - let issued_at = self.clock.now_unix_seconds(); - let expires_at = - issued_at.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX)); - let token_id = Uuid::new_v4(); - let claims = SupervisorTakeoverClaims { - iss: self.issuer.clone(), - sub: format!("{SANDBOX_SUBJECT_PREFIX}{}", takeover.sandbox_id), - aud: SANDBOX_SESSION_AUDIENCE.to_string(), - iat: issued_at, - exp: expires_at, - jti: token_id.to_string(), - sandbox_id: takeover.sandbox_id.clone(), - runtime_generation: takeover.runtime_generation.clone(), - auth_epoch: takeover.auth_epoch, - supervisor_instance: takeover.supervisor_instance.clone(), - owner: takeover.owner.clone(), - component: SessionComponent::OpenShellSupervisor, - }; - let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some(self.key_id.clone()); - header.typ = Some(SUPERVISOR_TAKEOVER_JWT_TYPE.to_string()); - let token = encode(&header, &claims, &self.encoding_key) - .map_err(|_| SessionJwtError::SigningFailed)?; - Ok(MintedSessionToken { - token: SecretJwt::parse(token)?, - expires_at, - token_id, - }) - } - - fn mint( - &self, - profile: SessionTokenProfile, - identity: &SandboxRuntimeIdentity, - token_id: Uuid, - issued_at: i64, - ) -> Result { - let expires_at = self.ttl.map_or(0, |ttl| { - issued_at.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX)) - }); - let claims = SessionClaims { - iss: self.issuer.clone(), - sub: format!("{SANDBOX_SUBJECT_PREFIX}{}", identity.sandbox_id), - aud: profile.audience(&self.issuer).to_string(), - iat: issued_at, - exp: expires_at, - jti: token_id.to_string(), - sandbox_id: identity.sandbox_id.clone(), - runtime_generation: identity.runtime_generation.clone(), - auth_epoch: identity.auth_epoch, - component: SessionComponent::OpenShellSupervisor, - }; - let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some(self.key_id.clone()); - header.typ = Some(profile.token_type().to_string()); - let token = encode(&header, &claims, &self.encoding_key) - .map_err(|_| SessionJwtError::SigningFailed)?; - Ok(MintedSessionToken { - token: SecretJwt::parse(token)?, - expires_at, - token_id, - }) - } - } - - /// One accepted public key from the immutable sandbox verification bundle. - #[derive(Clone, Serialize, Deserialize)] - #[serde(deny_unknown_fields)] - pub struct SessionVerificationKey { - pub key_id: String, - pub public_key_pem: Vec, - } - - /// Strict verifier used by either the gateway or the Sandbox Protocol. - pub struct SessionJwtVerifier { - keys: BTreeMap, - issuer: String, - profile: SessionTokenProfile, - clock: Arc, - } - - impl fmt::Debug for SessionJwtVerifier { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SessionJwtVerifier") - .field("key_ids", &self.keys.keys().collect::>()) - .field("issuer", &self.issuer) - .field("profile", &self.profile) - .finish_non_exhaustive() - } - } - - #[derive(Clone, Debug, PartialEq, Eq)] - pub struct AuthenticatedSandboxSession { - pub sandbox_id: SandboxId, - pub runtime_generation: SandboxGenerationId, - pub auth_epoch: CredentialEpoch, - pub token_id: Uuid, - pub issued_at: i64, - pub expires_at: i64, - } - - impl SessionJwtVerifier { - pub fn new( - gateway_id: &str, - profile: SessionTokenProfile, - keys: impl IntoIterator, - clock: Arc, - ) -> Result { - install_crypto_provider(); - let gateway_id = validate_gateway_id(gateway_id)?; - Ok(Self { - keys: parse_verification_keys(keys)?, - issuer: format!("{GATEWAY_ISSUER_PREFIX}{gateway_id}"), - profile, - clock, - }) - } - - pub fn verify(&self, token: &str) -> Result { - let claims = decode_signed::( - &self.keys, - token, - self.profile.token_type(), - &["iss", "aud", "iat", "exp", "sub", "jti"], - )?; - self.validate_claims(claims) - } - - /// Verify a supervisor takeover grant. Only the Sandbox profile, which - /// the runtime uses, verifies grants; the gateway profile refuses them. - pub fn verify_supervisor_takeover( - &self, - token: &str, - ) -> Result { - if self.profile != SessionTokenProfile::Sandbox { - return Err(SessionJwtError::WrongTokenType); - } - let claims = decode_signed::( - &self.keys, - token, - SUPERVISOR_TAKEOVER_JWT_TYPE, - &["iss", "aud", "iat", "exp", "sub", "jti"], - )?; - if claims.iss != self.issuer { - return Err(SessionJwtError::WrongIssuer); - } - if claims.aud != SANDBOX_SESSION_AUDIENCE { - return Err(SessionJwtError::WrongAudience); - } - if claims.sub != format!("{SANDBOX_SUBJECT_PREFIX}{}", claims.sandbox_id) { - return Err(SessionJwtError::SubjectMismatch); - } - SandboxGenerationId::parse(claims.runtime_generation.to_string()) - .map_err(|_| SessionJwtError::InvalidRuntimeIdentity)?; - validate_takeover_identity(&claims.supervisor_instance, &claims.owner)?; - let token_id = Uuid::parse_str(&claims.jti).map_err(|_| SessionJwtError::InvalidJti)?; - // A grant must expire, within its own bound. - let max_lifetime = - i64::try_from(MAX_SUPERVISOR_TAKEOVER_TTL.as_secs()).unwrap_or(i64::MAX); - if claims.exp == 0 - || claims.exp <= claims.iat - || claims.exp.saturating_sub(claims.iat) > max_lifetime - { - return Err(SessionJwtError::InvalidLifetime); - } - validate_lifetime(claims.iat, claims.exp, self.clock.now_unix_seconds())?; - Ok(VerifiedSupervisorTakeover { - takeover: SupervisorTakeover { - sandbox_id: claims.sandbox_id, - runtime_generation: claims.runtime_generation, - auth_epoch: claims.auth_epoch, - supervisor_instance: claims.supervisor_instance, - owner: claims.owner, - }, - token_id, - expires_at: claims.exp, - }) - } - - fn validate_claims( - &self, - claims: SessionClaims, - ) -> Result { - if claims.iss != self.issuer { - return Err(SessionJwtError::WrongIssuer); - } - if claims.aud != self.profile.audience(&self.issuer) { - return Err(SessionJwtError::WrongAudience); - } - if claims.sub != format!("{SANDBOX_SUBJECT_PREFIX}{}", claims.sandbox_id) { - return Err(SessionJwtError::SubjectMismatch); - } - SandboxGenerationId::parse(claims.runtime_generation.to_string()) - .map_err(|_| SessionJwtError::InvalidRuntimeIdentity)?; - let token_id = Uuid::parse_str(&claims.jti).map_err(|_| SessionJwtError::InvalidJti)?; - validate_lifetime(claims.iat, claims.exp, self.clock.now_unix_seconds())?; - Ok(AuthenticatedSandboxSession { - sandbox_id: claims.sandbox_id, - runtime_generation: claims.runtime_generation, - auth_epoch: claims.auth_epoch, - token_id, - issued_at: claims.iat, - expires_at: claims.exp, - }) - } - } - - /// A verified supervisor token. - #[derive(Clone, Debug, PartialEq, Eq)] - pub struct AuthenticatedSupervisor { - /// `openshell-supervisor:`. - pub subject: String, - pub token_id: Uuid, - pub issued_at: i64, - pub expires_at: i64, - } - - /// Strict gateway-side verifier of supervisor tokens. Accepts only the - /// supervisor profile signed by this gateway for this gateway. - pub struct SupervisorJwtVerifier { - keys: BTreeMap, - issuer: String, - subject: String, - clock: Arc, - } - - impl fmt::Debug for SupervisorJwtVerifier { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("SupervisorJwtVerifier") - .field("key_ids", &self.keys.keys().collect::>()) - .field("issuer", &self.issuer) - .finish_non_exhaustive() - } - } - - impl SupervisorJwtVerifier { - pub fn new( - gateway_id: &str, - keys: impl IntoIterator, - clock: Arc, - ) -> Result { - install_crypto_provider(); - let gateway_id = validate_gateway_id(gateway_id)?; - Ok(Self { - keys: parse_verification_keys(keys)?, - issuer: format!("{GATEWAY_ISSUER_PREFIX}{gateway_id}"), - subject: format!("{SUPERVISOR_SUBJECT_PREFIX}{gateway_id}"), - clock, - }) - } - - pub fn verify(&self, token: &str) -> Result { - let claims = decode_signed::( - &self.keys, - token, - SUPERVISOR_JWT_TYPE, - &["iss", "aud", "iat", "exp", "sub", "jti"], - )?; - if claims.iss != self.issuer { - return Err(SessionJwtError::WrongIssuer); - } - if claims.aud != self.issuer { - return Err(SessionJwtError::WrongAudience); - } - if claims.sub != self.subject { - return Err(SessionJwtError::SubjectMismatch); - } - let token_id = Uuid::parse_str(&claims.jti).map_err(|_| SessionJwtError::InvalidJti)?; - // Supervisor tokens must expire; a non-expiring one is refused. - if claims.exp == 0 { - return Err(SessionJwtError::InvalidLifetime); - } - validate_lifetime(claims.iat, claims.exp, self.clock.now_unix_seconds())?; - Ok(AuthenticatedSupervisor { - subject: claims.sub, - token_id, - issued_at: claims.iat, - expires_at: claims.exp, - }) - } - } - - fn parse_verification_keys( - keys: impl IntoIterator, - ) -> Result, SessionJwtError> { - let mut parsed = BTreeMap::new(); - for key in keys { - let key_id = validate_key_id(key.key_id)?; - let decoding_key = DecodingKey::from_ed_pem(&key.public_key_pem) - .map_err(|_| SessionJwtError::InvalidVerificationKey)?; - if parsed.insert(key_id, decoding_key).is_some() { - return Err(SessionJwtError::DuplicateKeyId); - } - } - if parsed.is_empty() { - return Err(SessionJwtError::NoVerificationKeys); - } - Ok(parsed) - } - - /// Check the header (`EdDSA` only, exact `typ`, known `kid`) and the - /// signature, then decode the claims. Time and audience are validated by - /// the caller against its own clock and profile. - fn decode_signed( - keys: &BTreeMap, - token: &str, - token_type: &str, - required_claims: &[&str], - ) -> Result { - install_crypto_provider(); - let header = decode_header(token).map_err(|_| SessionJwtError::InvalidToken)?; - if header.alg != Algorithm::EdDSA { - return Err(SessionJwtError::WrongAlgorithm); - } - if header.typ.as_deref() != Some(token_type) { - return Err(SessionJwtError::WrongTokenType); - } - let key_id = header.kid.ok_or(SessionJwtError::MissingKeyId)?; - let key = keys.get(&key_id).ok_or(SessionJwtError::UnknownKeyId)?; - let mut validation = Validation::new(Algorithm::EdDSA); - validation.algorithms = vec![Algorithm::EdDSA]; - validation.validate_exp = false; - validation.validate_aud = false; - validation.set_required_spec_claims(required_claims); - Ok(decode::(token, key, &validation) - .map_err(|_| SessionJwtError::InvalidToken)? - .claims) - } - - /// Shared lifetime rules: not issued in the future, and when expiring, - /// a positive lifetime within the session bound that has not passed. - fn validate_lifetime(issued_at: i64, expires_at: i64, now: i64) -> Result<(), SessionJwtError> { - let leeway = i64::try_from(MAX_SESSION_CLOCK_LEEWAY.as_secs()).unwrap_or(30); - if issued_at > now.saturating_add(leeway) { - return Err(SessionJwtError::IssuedInFuture); - } - if expires_at != 0 { - if expires_at <= issued_at { - return Err(SessionJwtError::InvalidLifetime); - } - let lifetime = expires_at.saturating_sub(issued_at); - if lifetime > i64::try_from(MAX_SESSION_TOKEN_TTL.as_secs()).unwrap_or(i64::MAX) { - return Err(SessionJwtError::InvalidLifetime); - } - if expires_at < now.saturating_sub(leeway) { - return Err(SessionJwtError::Expired); - } - } - Ok(()) - } - - #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] - pub enum SessionJwtError { - #[error("sandbox ID is invalid")] - InvalidSandboxId, - #[error("gateway ID is invalid")] - InvalidGatewayId, - #[error("credential epoch must be positive")] - InvalidCredentialEpoch, - #[error("credential epoch does not advance the active credential")] - StaleCredentialEpoch, - #[error("session rotation must be positive")] - InvalidSessionRotation, - #[error("session rotation overflow")] - SessionRotationOverflow, - #[error("sandbox runtime identity is missing")] - MissingRuntimeIdentity, - #[error("sandbox runtime identity is invalid")] - InvalidRuntimeIdentity, - #[error("OTLP TLS reference is outside the bundle's TLS material")] - InvalidOtlpTlsReference, - #[error("key ID is invalid")] - InvalidKeyId, - #[error("verification key IDs must be unique")] - DuplicateKeyId, - #[error("at least one verification key is required")] - NoVerificationKeys, - #[error("Ed25519 signing key is invalid")] - InvalidSigningKey, - #[error("Ed25519 verification key is invalid")] - InvalidVerificationKey, - #[error("session token lifetime must be non-expiring or between 60 and 3600 seconds")] - InvalidLifetime, - #[error("session token profile does not match its claims")] - ProfileMismatch, - #[error("session token could not be signed")] - SigningFailed, - #[error("session token encoding is invalid")] - InvalidTokenEncoding, - #[error("session token is unavailable")] - TokenUnavailable, - #[error("session token is invalid")] - InvalidToken, - #[error("session token algorithm must be EdDSA")] - WrongAlgorithm, - #[error("session token type is invalid")] - WrongTokenType, - #[error("session token key ID is missing")] - MissingKeyId, - #[error("session token key ID is unknown")] - UnknownKeyId, - #[error("session token issuer is invalid")] - WrongIssuer, - #[error("session token audience is invalid")] - WrongAudience, - #[error("session token subject does not match its sandbox ID")] - SubjectMismatch, - #[error("session token ID is not a UUID")] - InvalidJti, - #[error("session token was issued in the future")] - IssuedInFuture, - #[error("session token has expired")] - Expired, - } - - fn install_crypto_provider() { - openshell_crypto::install_jwt_provider(); - } - - fn validate_ttl(ttl: Duration) -> Result<(), SessionJwtError> { - if !(MIN_SESSION_TOKEN_TTL..=MAX_SESSION_TOKEN_TTL).contains(&ttl) { - return Err(SessionJwtError::InvalidLifetime); - } - Ok(()) - } - - fn validate_gateway_id(gateway_id: &str) -> Result<&str, SessionJwtError> { - if gateway_id.is_empty() - || gateway_id.trim() != gateway_id - || gateway_id.chars().any(char::is_whitespace) - { - return Err(SessionJwtError::InvalidGatewayId); - } - Ok(gateway_id) - } - - /// A takeover names a non-blank supervisor instance and owner without - /// whitespace; the runtime compares the instance with its attach request. - fn validate_takeover_identity( - supervisor_instance: &str, - owner: &str, - ) -> Result<(), SessionJwtError> { - let valid = |value: &str| { - !value.is_empty() && value.len() <= 253 && !value.chars().any(char::is_whitespace) - }; - if valid(supervisor_instance) && valid(owner) { - Ok(()) - } else { - Err(SessionJwtError::InvalidRuntimeIdentity) - } - } - - fn validate_key_id(key_id: String) -> Result { - if key_id.is_empty() || key_id.trim() != key_id || key_id.chars().any(char::is_whitespace) { - return Err(SessionJwtError::InvalidKeyId); - } - Ok(key_id) - } -} - -#[cfg(feature = "jwt")] -pub use session::*; +pub use openshell_driver_sdk::jwt::*; #[cfg(test)] mod tests { @@ -1471,662 +84,4 @@ mod tests { assert_eq!(parse_exp_secs("not-a-jwt"), None); assert_eq!(parse_exp_secs(""), None); } - - #[cfg(feature = "jwt")] - mod session_tests { - use std::sync::Arc; - - use jsonwebtoken::{Algorithm, EncodingKey, Header}; - use openshell_crypto::jwt::encode; - use rcgen::PKCS_ED25519; - use serde::Serialize; - - use super::super::session::*; - use crate::SandboxSessionId; - use crate::sandbox_generation::SandboxGenerationId; - - #[derive(Debug)] - struct FixedClock(i64); - - impl JwtClock for FixedClock { - fn now_unix_seconds(&self) -> i64 { - self.0 - } - } - - fn fixture() -> ( - SessionJwtIssuer, - SessionJwtVerifier, - SessionJwtVerifier, - SandboxRuntimeIdentity, - ) { - let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519) - .expect("generate Ed25519 key"); - let public_key_pem = key.public_key_pem().into_bytes(); - let clock: Arc = Arc::new(FixedClock(1_900_000_000)); - let issuer = SessionJwtIssuer::from_ed25519_pem( - key.serialize_pem().unwrap().as_bytes(), - "current", - "test", - Some(DEFAULT_SESSION_TOKEN_TTL), - clock.clone(), - ) - .expect("issuer"); - let key = || SessionVerificationKey { - key_id: "current".to_string(), - public_key_pem: public_key_pem.clone(), - }; - let gateway = SessionJwtVerifier::new( - "test", - SessionTokenProfile::Gateway, - [key()], - clock.clone(), - ) - .expect("gateway verifier"); - let sandbox = - SessionJwtVerifier::new("test", SessionTokenProfile::Sandbox, [key()], clock) - .expect("sandbox verifier"); - let identity = SandboxRuntimeIdentity { - sandbox_id: SandboxId::parse("sandbox-a").expect("sandbox ID"), - runtime_generation: SandboxGenerationId::parse("generation-1") - .expect("runtime generation"), - auth_epoch: CredentialEpoch::new(1).expect("auth epoch"), - }; - (issuer, gateway, sandbox, identity) - } - - #[test] - fn token_profiles_are_not_interchangeable() { - let (issuer, gateway, sandbox, identity) = fixture(); - let pair = issuer.mint_pair(&identity).expect("token pair"); - - let gateway_session = gateway - .verify(pair.gateway.token.expose_secret()) - .expect("gateway token"); - assert_eq!(gateway_session.auth_epoch, identity.auth_epoch); - - let sandbox_session = sandbox - .verify(pair.sandbox.token.expose_secret()) - .expect("sandbox token"); - assert_eq!(sandbox_session.auth_epoch, identity.auth_epoch); - - assert_eq!( - gateway.verify(pair.sandbox.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - assert_eq!( - sandbox.verify(pair.gateway.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - } - - #[test] - fn supervisor_tokens_are_a_separate_profile_bound_to_the_gateway() { - let (issuer, gateway, sandbox, _identity) = fixture(); - let minted = issuer - .mint_supervisor_token(std::time::Duration::from_mins(15)) - .expect("supervisor token"); - assert_eq!(minted.expires_at, 1_900_000_000 + 900); - - let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519).unwrap(); - let public_key_pem = key.public_key_pem().into_bytes(); - let clock: Arc = Arc::new(FixedClock(1_900_000_000)); - // A verifier holding a different key under the same key ID sees a - // signature failure, not a key lookup failure. - let verification_key = |pem: Vec| SessionVerificationKey { - key_id: "current".to_string(), - public_key_pem: pem, - }; - let wrong_key = SupervisorJwtVerifier::new( - "test", - [verification_key(public_key_pem)], - clock.clone(), - ) - .expect("verifier"); - assert_eq!( - wrong_key.verify(minted.token.expose_secret()), - Err(SessionJwtError::InvalidToken) - ); - - // Session verifiers refuse the supervisor profile and the supervisor - // verifier refuses session tokens, whatever key signed them. - assert_eq!( - gateway.verify(minted.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - assert_eq!( - sandbox.verify(minted.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - let identity = SandboxRuntimeIdentity { - sandbox_id: SandboxId::parse("sandbox-a").unwrap(), - runtime_generation: SandboxGenerationId::parse("generation-1").unwrap(), - auth_epoch: CredentialEpoch::new(1).unwrap(), - }; - let pair = issuer.mint_pair(&identity).unwrap(); - assert_eq!( - wrong_key.verify(pair.gateway.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - - // The supervisor lifetime is bounded on its own, whatever the - // session lifetime is. - drop(clock); - for ttl in [ - std::time::Duration::from_secs(59), - std::time::Duration::from_secs(3_601), - ] { - assert_eq!( - issuer.mint_supervisor_token(ttl).unwrap_err(), - SessionJwtError::InvalidLifetime - ); - } - } - - #[test] - fn supervisor_tokens_verify_only_for_their_gateway_and_lifetime() { - let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519).unwrap(); - let public_key_pem = key.public_key_pem().into_bytes(); - let signing_pem = key.serialize_pem().unwrap(); - let verification_key = || SessionVerificationKey { - key_id: "current".to_string(), - public_key_pem: public_key_pem.clone(), - }; - let issuer = |gateway_id: &str, now: i64| { - SessionJwtIssuer::from_ed25519_pem( - signing_pem.as_bytes(), - "current", - gateway_id, - Some(std::time::Duration::from_mins(15)), - Arc::new(FixedClock(now)), - ) - .unwrap() - }; - let verifier = |gateway_id: &str, now: i64| { - SupervisorJwtVerifier::new( - gateway_id, - [verification_key()], - Arc::new(FixedClock(now)), - ) - .unwrap() - }; - - let minted = issuer("gw-a", 1_900_000_000) - .mint_supervisor_token(std::time::Duration::from_mins(15)) - .unwrap(); - let authenticated = verifier("gw-a", 1_900_000_100) - .verify(minted.token.expose_secret()) - .expect("valid supervisor token"); - assert_eq!(authenticated.subject, "openshell-supervisor:gw-a"); - assert_eq!(authenticated.token_id, minted.token_id); - assert_eq!(authenticated.expires_at, 1_900_000_900); - - // Another gateway's token is refused by issuer, audience, and subject. - assert_eq!( - verifier("gw-b", 1_900_000_100).verify(minted.token.expose_secret()), - Err(SessionJwtError::WrongIssuer) - ); - // Expired tokens are refused past the leeway. - assert_eq!( - verifier("gw-a", 1_900_001_000).verify(minted.token.expose_secret()), - Err(SessionJwtError::Expired) - ); - assert_eq!( - verifier("gw-a", 1_899_999_000).verify(minted.token.expose_secret()), - Err(SessionJwtError::IssuedInFuture) - ); - assert_eq!( - verifier("gw-a", 1_900_000_100).verify("not-a-token"), - Err(SessionJwtError::InvalidToken) - ); - } - - #[derive(Serialize)] - struct NonExpiringSupervisor<'a> { - iss: &'a str, - sub: &'a str, - aud: &'a str, - iat: i64, - exp: i64, - jti: String, - component: SessionComponent, - } - - #[test] - fn non_expiring_session_tokens_remain_usable() { - let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519) - .expect("generate Ed25519 key"); - let public_key_pem = key.public_key_pem().into_bytes(); - let issuer = SessionJwtIssuer::from_ed25519_pem( - key.serialize_pem().unwrap().as_bytes(), - "current", - "test", - None, - Arc::new(FixedClock(1_900_000_000)), - ) - .expect("issuer"); - let verifier = SessionJwtVerifier::new( - "test", - SessionTokenProfile::Sandbox, - [SessionVerificationKey { - key_id: "current".to_string(), - public_key_pem, - }], - Arc::new(FixedClock(2_000_000_000)), - ) - .expect("verifier"); - let identity = SandboxRuntimeIdentity { - sandbox_id: SandboxId::parse("sandbox-a").expect("sandbox ID"), - runtime_generation: SandboxGenerationId::parse("generation-1") - .expect("runtime generation"), - auth_epoch: CredentialEpoch::new(1).expect("auth epoch"), - }; - let pair = issuer.mint_pair(&identity).expect("token pair"); - - assert_eq!(pair.gateway.expires_at, 0); - assert_eq!(pair.sandbox.expires_at, 0); - verifier - .verify(pair.sandbox.token.expose_secret()) - .expect("non-expiring token remains valid"); - - // The default (non-expiring) session issuer still mints expiring - // supervisor tokens, and a supervisor token must expire. - let supervisor = issuer - .mint_supervisor_token(std::time::Duration::from_mins(15)) - .expect("supervisor token from a non-expiring issuer"); - assert_eq!(supervisor.expires_at, 1_900_000_000 + 900); - let supervisor_verifier = SupervisorJwtVerifier::new( - "test", - [SessionVerificationKey { - key_id: "current".to_string(), - public_key_pem: key.public_key_pem().into_bytes(), - }], - Arc::new(FixedClock(1_900_000_100)), - ) - .unwrap(); - supervisor_verifier - .verify(supervisor.token.expose_secret()) - .expect("expiring supervisor token"); - let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some("current".to_string()); - header.typ = Some(SUPERVISOR_JWT_TYPE.to_string()); - let forever = encode( - &header, - &NonExpiringSupervisor { - iss: "openshell-gateway:test", - sub: "openshell-supervisor:test", - aud: "openshell-gateway:test", - iat: 1_900_000_000, - exp: 0, - jti: uuid::Uuid::new_v4().to_string(), - component: SessionComponent::OpenShellSupervisor, - }, - &EncodingKey::from_ed_pem(key.serialize_pem().unwrap().as_bytes()).unwrap(), - ) - .unwrap(); - assert_eq!( - supervisor_verifier.verify(&forever), - Err(SessionJwtError::InvalidLifetime) - ); - - let slot = SessionBearerTokenSlot::new( - pair.sandbox.token.clone(), - pair.sandbox.expires_at, - pair.auth_epoch, - ) - .expect("non-expiring bearer slot"); - slot.authorization_metadata() - .expect("non-expiring bearer remains available"); - - let bundle = SupervisorAuthBundle { - session_id: SandboxSessionId::new(), - session_rotation: SessionRotation::new(1).expect("session rotation"), - runtime_generation: identity.runtime_generation, - auth_epoch: pair.auth_epoch, - gateway_token: pair.gateway.token, - gateway_expires_at: pair.gateway.expires_at, - sandbox_token: pair.sandbox.token, - sandbox_expires_at: pair.sandbox.expires_at, - ssh_host_private_key: None, - traces_otlp_headers: None, - ocsf_otlp_headers: None, - agent_otlp_headers: None, - otlp_tls: Vec::new(), - traces_otlp_tls: None, - ocsf_otlp_tls: None, - agent_otlp_tls: None, - }; - bundle.validate().expect("non-expiring auth bundle"); - } - - fn takeover(epoch: u64) -> SupervisorTakeover { - SupervisorTakeover { - sandbox_id: SandboxId::parse("sandbox-a").expect("sandbox ID"), - runtime_generation: SandboxGenerationId::parse("generation-1") - .expect("runtime generation"), - auth_epoch: CredentialEpoch::new(epoch).expect("auth epoch"), - supervisor_instance: "6a8f1f4e-8c0e-4d55-9d8c-1d6e2f3a4b5c".to_string(), - owner: "supervisor-1".to_string(), - } - } - - #[test] - fn takeover_grants_are_their_own_profile_verified_only_by_the_runtime() { - let (issuer, gateway, sandbox, identity) = fixture(); - let expected = takeover(2); - let minted = issuer - .mint_supervisor_takeover(&expected, std::time::Duration::from_mins(5)) - .expect("mint takeover grant"); - let verified = sandbox - .verify_supervisor_takeover(minted.token.expose_secret()) - .expect("runtime verifies the grant"); - assert_eq!(verified.takeover, expected); - assert_eq!(verified.token_id, minted.token_id); - assert_eq!(verified.expires_at, 1_900_000_000 + 300); - - // A grant is never a session credential, and no other credential is - // a grant: neither session profile nor the shared supervisor token. - assert_eq!( - sandbox.verify(minted.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - assert_eq!( - gateway.verify_supervisor_takeover(minted.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - let pair = issuer.mint_pair(&identity).expect("token pair"); - for token in [&pair.sandbox.token, &pair.gateway.token] { - assert_eq!( - sandbox.verify_supervisor_takeover(token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - } - let host = issuer - .mint_supervisor_token(std::time::Duration::from_mins(15)) - .expect("supervisor token"); - assert_eq!( - sandbox.verify_supervisor_takeover(host.token.expose_secret()), - Err(SessionJwtError::WrongTokenType) - ); - } - - #[test] - fn takeover_grants_are_bounded_named_and_expire() { - let (issuer, _gateway, _sandbox, _identity) = fixture(); - for ttl in [ - std::time::Duration::ZERO, - MAX_SUPERVISOR_TAKEOVER_TTL + std::time::Duration::from_secs(1), - ] { - assert_eq!( - issuer.mint_supervisor_takeover(&takeover(2), ttl).err(), - Some(SessionJwtError::InvalidLifetime) - ); - } - for (instance, owner) in [("", "member"), ("instance", ""), ("in stance", "m")] { - let mut unnamed = takeover(2); - unnamed.supervisor_instance = instance.to_string(); - unnamed.owner = owner.to_string(); - assert_eq!( - issuer - .mint_supervisor_takeover(&unnamed, std::time::Duration::from_mins(5)) - .err(), - Some(SessionJwtError::InvalidRuntimeIdentity) - ); - } - - let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519).unwrap(); - let issuer = SessionJwtIssuer::from_ed25519_pem( - key.serialize_pem().unwrap().as_bytes(), - "current", - "test", - None, - Arc::new(FixedClock(1_900_000_000)), - ) - .unwrap(); - let verifier_at = |now| { - SessionJwtVerifier::new( - "test", - SessionTokenProfile::Sandbox, - [SessionVerificationKey { - key_id: "current".to_string(), - public_key_pem: key.public_key_pem().into_bytes(), - }], - Arc::new(FixedClock(now)), - ) - .unwrap() - }; - // A non-expiring session issuer still mints expiring grants. - let minted = issuer - .mint_supervisor_takeover(&takeover(3), std::time::Duration::from_mins(1)) - .unwrap(); - verifier_at(1_900_000_030) - .verify_supervisor_takeover(minted.token.expose_secret()) - .expect("grant within its lifetime"); - assert_eq!( - verifier_at(1_900_000_200).verify_supervisor_takeover(minted.token.expose_secret()), - Err(SessionJwtError::Expired) - ); - } - - #[test] - fn token_debug_is_redacted() { - let (issuer, _gateway, _sandbox, identity) = fixture(); - let pair = issuer.mint_pair(&identity).expect("token pair"); - let debug = format!("{:?}", pair.sandbox.token); - assert_eq!(debug, "SecretJwt([REDACTED])"); - assert!(!debug.contains(pair.sandbox.token.expose_secret())); - } - - #[test] - fn each_supervisor_export_reads_its_own_credentials() { - let (issuer, _gateway, _sandbox, identity) = fixture(); - let pair = issuer.mint_pair(&identity).expect("token pair"); - let headers = |lane: &str| Some(SecretOtlpHeaders::new(format!("lane={lane}"))); - let tls = |lane: &str| OtlpTlsMaterial { - ca_pem: Some(format!("{lane}-ca")), - client: None, - }; - let bundle = SupervisorAuthBundle { - session_id: SandboxSessionId::new(), - session_rotation: SessionRotation::new(1).expect("session rotation"), - runtime_generation: identity.runtime_generation.clone(), - auth_epoch: identity.auth_epoch, - gateway_token: pair.gateway.token, - gateway_expires_at: pair.gateway.expires_at, - sandbox_token: pair.sandbox.token, - sandbox_expires_at: pair.sandbox.expires_at, - ssh_host_private_key: None, - traces_otlp_headers: headers("traces"), - ocsf_otlp_headers: headers("ocsf"), - agent_otlp_headers: headers("agent"), - otlp_tls: vec![tls("agent"), tls("ocsf"), tls("traces")], - traces_otlp_tls: Some(2), - ocsf_otlp_tls: Some(1), - agent_otlp_tls: Some(0), - }; - - for (lane, credentials) in [ - ("traces", bundle.traces_otlp()), - ("ocsf", bundle.ocsf_otlp()), - ("agent", bundle.agent_otlp()), - ] { - assert_eq!( - credentials.headers.map(SecretOtlpHeaders::expose_secret), - Some(format!("lane={lane}").as_str()) - ); - assert_eq!( - credentials.tls.and_then(|tls| tls.ca_pem.as_deref()), - Some(format!("{lane}-ca").as_str()) - ); - } - } - - #[test] - fn supervisor_auth_bundle_round_trips_without_exposing_secrets_in_debug() { - let (issuer, _gateway, _sandbox, identity) = fixture(); - let pair = issuer.mint_pair(&identity).expect("token pair"); - let bundle = SupervisorAuthBundle { - session_id: SandboxSessionId::new(), - session_rotation: SessionRotation::new(1).expect("session rotation"), - runtime_generation: identity.runtime_generation.clone(), - auth_epoch: identity.auth_epoch, - gateway_token: pair.gateway.token, - gateway_expires_at: pair.gateway.expires_at, - sandbox_token: pair.sandbox.token, - sandbox_expires_at: pair.sandbox.expires_at, - ssh_host_private_key: Some(SecretSshHostKey::new( - "private-ssh-host-key".to_string(), - )), - traces_otlp_headers: Some(SecretOtlpHeaders::new( - "authorization=traces-token".to_string(), - )), - ocsf_otlp_headers: Some(SecretOtlpHeaders::new( - "authorization=collector-token".to_string(), - )), - agent_otlp_headers: Some(SecretOtlpHeaders::new( - "authorization=agent-token".to_string(), - )), - otlp_tls: vec![OtlpTlsMaterial { - ca_pem: Some("agent-ca".to_string()), - client: Some(OtlpClientIdentity { - cert_pem: "agent-cert".to_string(), - key_pem: SecretPrivateKeyPem::new("agent-private-key".to_string()), - }), - }], - traces_otlp_tls: None, - ocsf_otlp_tls: None, - agent_otlp_tls: Some(0), - }; - - let encoded = serde_json::to_vec(&bundle).expect("serialize auth bundle"); - let decoded: SupervisorAuthBundle = - serde_json::from_slice(&encoded).expect("deserialize auth bundle"); - assert_eq!(decoded.auth_epoch, bundle.auth_epoch); - assert_eq!( - decoded.gateway_token.expose_secret(), - bundle.gateway_token.expose_secret() - ); - assert_eq!( - decoded.sandbox_token.expose_secret(), - bundle.sandbox_token.expose_secret() - ); - - let debug = format!("{bundle:?}"); - assert!(!debug.contains(bundle.gateway_token.expose_secret())); - assert!(!debug.contains(bundle.sandbox_token.expose_secret())); - assert!(!debug.contains("private-ssh-host-key")); - assert!(!debug.contains("collector-token")); - assert!(!debug.contains("agent-token")); - assert!(!debug.contains("traces-token")); - assert!(!debug.contains("agent-private-key")); - assert_eq!(debug.matches("[REDACTED]").count(), 7); - assert_eq!( - decoded - .ssh_host_private_key - .as_ref() - .unwrap() - .expose_secret(), - "private-ssh-host-key" - ); - assert_eq!( - decoded - .traces_otlp_headers - .as_ref() - .unwrap() - .expose_secret(), - "authorization=traces-token" - ); - assert_eq!( - decoded.ocsf_otlp_headers.as_ref().unwrap().expose_secret(), - "authorization=collector-token" - ); - assert_eq!( - decoded.agent_otlp_headers.as_ref().unwrap().expose_secret(), - "authorization=agent-token" - ); - let agent_tls = decoded.agent_otlp().tls.expect("agent collector TLS"); - assert_eq!(agent_tls.ca_pem.as_deref(), Some("agent-ca")); - let client = agent_tls.client.as_ref().unwrap(); - assert_eq!(client.cert_pem, "agent-cert"); - assert_eq!(client.key_pem.expose_secret(), "agent-private-key"); - let mut legacy = serde_json::to_value(&bundle).unwrap(); - let fields = legacy.as_object_mut().unwrap(); - fields.remove("ssh_host_private_key"); - fields.remove("traces_otlp_headers"); - fields.remove("ocsf_otlp_headers"); - fields.remove("agent_otlp_headers"); - fields.remove("otlp_tls"); - fields.remove("agent_otlp_tls"); - let legacy: SupervisorAuthBundle = serde_json::from_value(legacy).unwrap(); - assert!(legacy.ssh_host_private_key.is_none()); - assert!(legacy.traces_otlp_headers.is_none()); - assert!(legacy.ocsf_otlp_headers.is_none()); - assert!(legacy.agent_otlp_headers.is_none()); - assert!(legacy.otlp_tls.is_empty() && legacy.agent_otlp_tls.is_none()); - - decoded - .validate() - .expect("references within the TLS material"); - let mut dangling = decoded.clone(); - dangling.traces_otlp_tls = Some(1); - assert!(matches!( - dangling.validate(), - Err(SessionJwtError::InvalidOtlpTlsReference) - )); - } - - #[derive(Serialize)] - struct AudienceArrayClaims<'a> { - iss: &'a str, - sub: &'a str, - aud: [&'a str; 1], - iat: i64, - exp: i64, - jti: String, - sandbox_id: &'a str, - runtime_generation: &'a str, - auth_epoch: CredentialEpoch, - component: SessionComponent, - } - - #[test] - fn audience_arrays_are_rejected() { - let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519) - .expect("generate Ed25519 key"); - let clock: Arc = Arc::new(FixedClock(1_900_000_000)); - let verifier = SessionJwtVerifier::new( - "test", - SessionTokenProfile::Gateway, - [SessionVerificationKey { - key_id: "current".to_string(), - public_key_pem: key.public_key_pem().into_bytes(), - }], - clock, - ) - .expect("verifier"); - let claims = AudienceArrayClaims { - iss: "openshell-gateway:test", - sub: "spiffe://openshell/sandbox/sandbox-a", - aud: ["openshell-gateway:test"], - iat: 1_900_000_000, - exp: 1_900_003_600, - jti: uuid::Uuid::new_v4().to_string(), - sandbox_id: "sandbox-a", - runtime_generation: "generation-1", - auth_epoch: CredentialEpoch::new(1).expect("auth epoch"), - component: SessionComponent::OpenShellSupervisor, - }; - let mut header = Header::new(Algorithm::EdDSA); - header.kid = Some("current".to_string()); - header.typ = Some(GATEWAY_SESSION_JWT_TYPE.to_string()); - let token = encode( - &header, - &claims, - &EncodingKey::from_ed_pem(key.serialize_pem().unwrap().as_bytes()) - .expect("encoding key"), - ) - .expect("token"); - assert_eq!(verifier.verify(&token), Err(SessionJwtError::InvalidToken)); - } - } } diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 12d6cd26ff..90804521b6 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -12,61 +12,60 @@ pub mod activity; pub mod app_listener; pub mod auth; -pub mod compute; +pub use openshell_driver_sdk::compute; pub mod config; -pub mod container_paths; +pub use openshell_driver_sdk::container_paths; pub mod denial; -pub mod driver_mounts; -pub mod driver_utils; +pub use openshell_driver_sdk::driver_mounts; +pub use openshell_driver_sdk::driver_utils; pub mod dynamic_credential_key; pub mod dynamic_string_allowlist; #[cfg(unix)] -pub mod e2fsprogs; +pub use openshell_driver_sdk::e2fsprogs; pub mod endpoint_failure; pub mod endpoint_path; pub mod endpoint_status; -pub mod error; -pub mod extension_protocol; +pub use openshell_driver_sdk::error; +pub use openshell_driver_sdk::extension_protocol; #[cfg(unix)] -pub mod external_driver_socket; +pub use openshell_driver_sdk::external_driver_socket; pub mod forward; pub mod google_cloud; pub mod gpu; pub mod grpc_client; pub mod host_pattern; -pub mod image; -pub mod isolation_registration; +pub use openshell_driver_sdk::image; +pub use openshell_driver_sdk::isolation_registration; pub mod jwt; -pub mod local_api_socket; pub mod mcp; pub mod metadata; pub mod middleware; pub mod net; -pub mod network_trust; +pub use openshell_driver_sdk::network_trust; #[cfg(feature = "oauth")] pub mod oauth; pub mod paths; -pub mod policy; +pub use openshell_driver_sdk::policy; pub use openshell_policy_schema::yaml; pub mod policy_identity; -pub mod progress; +pub use openshell_driver_sdk::progress; pub mod proposals; pub mod proto; -pub mod proto_struct; +pub use openshell_driver_sdk::proto_struct; pub mod provider_credentials; pub mod replica_routing; -pub mod resource_admission; +pub use openshell_driver_sdk::resource_admission; pub mod rpc_error; -pub mod sandbox_env; -pub mod sandbox_generation; -pub mod sandbox_session; +pub use openshell_driver_sdk::sandbox_env; +pub use openshell_driver_sdk::sandbox_generation; +pub use openshell_driver_sdk::sandbox_session; pub mod sandbox_update; pub mod secrets; pub mod settings; pub mod shell; pub mod spiffe; pub mod telemetry; -pub mod time; +pub use openshell_driver_sdk::time; pub mod token_grant_failure; pub mod transport_errors; pub mod upload; @@ -86,20 +85,7 @@ pub use metadata::{ pub use network_trust::NetworkSupervisorTrustBundle; pub use sandbox_session::{SandboxSessionId, SandboxSessionIdError}; -/// Build version string derived from git metadata. -/// -/// For local builds this is computed by `build.rs` from the exact release tag -/// or the latest merged stable tag using the guess-next-dev scheme (e.g. -/// `0.0.4-dev.6+g2bf9969ab`). In Docker/CI builds where `.git` is absent, it -/// falls back to `CARGO_PKG_VERSION`, which the build pipeline already stamps. -pub const VERSION: &str = match option_env!("OPENSHELL_GIT_VERSION") { - Some(v) => v, - None => env!("CARGO_PKG_VERSION"), -}; - -#[cfg(test)] -#[path = "../build_version.rs"] -mod build_version; +pub use openshell_driver_sdk::VERSION; /// Encoded protobuf `FileDescriptorSet` for every proto in `proto/`. /// diff --git a/crates/openshell-core/src/net.rs b/crates/openshell-core/src/net.rs index a9bbc23217..e7b699ab6c 100644 --- a/crates/openshell-core/src/net.rs +++ b/crates/openshell-core/src/net.rs @@ -16,8 +16,8 @@ //! use ipnet::{IpNet, Ipv4Net, Ipv6Net}; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -use tokio::net::TcpStream; +pub use openshell_driver_sdk::net::{connect_tcp_nodelay_best_effort, set_tcp_nodelay_best_effort}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; /// Check if a hostname is a known cloud metadata hostname that resolves to an /// always-blocked metadata service. @@ -163,15 +163,25 @@ pub fn is_always_blocked_net(net: IpNet) -> bool { } const RFC1918_10_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(10, 0, 0, 0), 8); + const RFC1918_172_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(172, 16, 0, 0), 12); + const RFC1918_192_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 168, 0, 0), 16); + const CGNAT_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(100, 64, 0, 0), 10); + const IETF_PROTOCOL_ASSIGNMENTS_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 0, 0), 24); + const TEST_NET_1: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(192, 0, 2, 0), 24); + const BENCHMARKING_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(198, 18, 0, 0), 15); + const TEST_NET_2: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(198, 51, 100, 0), 24); + const TEST_NET_3: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::new(203, 0, 113, 0), 24); + const LIMITED_BROADCAST_NET: Ipv4Net = Ipv4Net::new_assert(Ipv4Addr::BROADCAST, 32); + const IPV6_ULA_NET: Ipv6Net = Ipv6Net::new_assert(Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7); const NON_HARD_INTERNAL_V4_NETS: [Ipv4Net; 10] = [ @@ -285,27 +295,6 @@ fn is_internal_v4(v4: Ipv4Addr) -> bool { false } -/// Enable `TCP_NODELAY` on a stream, logging (not returning) any failure. -/// -/// Disabling Nagle's algorithm keeps small writes from waiting on delayed ACKs. -/// It's a latency optimization: if it fails the connection still works, just a -/// bit slower, so there is nothing for the caller to act on — we log and move on. -pub fn set_tcp_nodelay_best_effort(stream: &TcpStream) { - if let Err(e) = stream.set_nodelay(true) { - tracing::debug!(error = %e, "failed to set TCP_NODELAY"); - } -} - -/// Connect to `addrs`, then enable `TCP_NODELAY` on a best-effort basis, propagating -/// any errors from `TcpStream::connect`. -/// -/// The returned stream is not *guaranteed* to have `TCP_NODELAY` set. -pub async fn connect_tcp_nodelay_best_effort(addrs: &[SocketAddr]) -> std::io::Result { - let stream = TcpStream::connect(addrs).await?; - set_tcp_nodelay_best_effort(&stream); - Ok(stream) -} - #[cfg(test)] mod tests { use super::*; @@ -726,31 +715,4 @@ mod tests { let v6 = Ipv4Addr::new(100, 64, 0, 1).to_ipv6_mapped(); assert!(is_internal_ip(IpAddr::V6(v6))); } - - // -- tcp_nodelay helpers -- - - #[tokio::test] - async fn set_tcp_nodelay_best_effort_enables_nodelay() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local addr"); - let stream = TcpStream::connect(addr).await.expect("connect"); - - set_tcp_nodelay_best_effort(&stream); - assert!(stream.nodelay().expect("query TCP_NODELAY")); - } - - #[tokio::test] - async fn connect_tcp_nodelay_best_effort_sets_nodelay() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind"); - let addr = listener.local_addr().expect("local addr"); - - let stream = connect_tcp_nodelay_best_effort(&[addr]) - .await - .expect("connect"); - assert!(stream.nodelay().expect("query TCP_NODELAY")); - } } diff --git a/crates/openshell-core/src/paths.rs b/crates/openshell-core/src/paths.rs index 0470ab6764..4edab3f468 100644 --- a/crates/openshell-core/src/paths.rs +++ b/crates/openshell-core/src/paths.rs @@ -11,6 +11,7 @@ //! lexical normalization (no filesystem access, no symlink resolution). use miette::{IntoDiagnostic, Result, WrapErr}; +pub use openshell_driver_sdk::paths::*; use std::path::{Path, PathBuf}; /// Treat an empty XDG base-directory value as unset. @@ -21,6 +22,9 @@ use std::path::{Path, PathBuf}; fn non_empty_xdg_base(value: Option) -> Option { value.filter(|v| !v.is_empty()).map(PathBuf::from) } +/// Compatibility re-export; authored policy path normalization is owned by +/// `openshell-policy-schema`. +pub use openshell_policy_schema::normalize_path; /// Resolve the XDG config base directory. /// @@ -44,23 +48,6 @@ pub fn openshell_config_dir() -> Result { Ok(xdg_config_dir()?.join("openshell")) } -/// Resolve the XDG state base directory. -/// -/// Returns `$XDG_STATE_HOME` if set, otherwise `$HOME/.local/state`. -pub fn xdg_state_dir() -> Result { - if let Ok(path) = std::env::var("XDG_STATE_HOME") { - return Ok(PathBuf::from(path)); - } - #[cfg(target_os = "windows")] - if let Ok(path) = std::env::var("LOCALAPPDATA") { - return Ok(PathBuf::from(path)); - } - let home = std::env::var("HOME") - .into_diagnostic() - .wrap_err("HOME is not set")?; - Ok(PathBuf::from(home).join(".local").join("state")) -} - /// The top-level `OpenShell` state directory: `$XDG_STATE_HOME/openshell/`. pub fn openshell_state_dir() -> Result { Ok(xdg_state_dir()?.join("openshell")) @@ -84,68 +71,6 @@ pub fn gateway_audit_log_dir() -> PathBuf { } } -/// Resolve the XDG data base directory. -/// -/// Returns `$XDG_DATA_HOME` if set, otherwise `$HOME/.local/share`. -pub fn xdg_data_dir() -> Result { - if let Ok(path) = std::env::var("XDG_DATA_HOME") { - return Ok(PathBuf::from(path)); - } - #[cfg(target_os = "windows")] - if let Ok(path) = std::env::var("LOCALAPPDATA") { - return Ok(PathBuf::from(path)); - } - let home = std::env::var("HOME") - .into_diagnostic() - .wrap_err("HOME is not set")?; - Ok(PathBuf::from(home).join(".local").join("share")) -} - -/// Create a directory (and parents) with owner-only permissions (`0o700`) on -/// Unix. On non-Unix platforms, falls back to default permissions. -/// -/// This should be used for any directory that contains sensitive material -/// (tokens, private keys, certificates). -pub fn create_dir_restricted(path: &Path) -> Result<()> { - std::fs::create_dir_all(path) - .into_diagnostic() - .wrap_err_with(|| format!("failed to create {}", path.display()))?; - set_dir_owner_only(path)?; - Ok(()) -} - -/// Set a directory to owner-only access (`0o700`) on Unix. -/// -/// No-op on non-Unix platforms. -pub fn set_dir_owner_only(path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) - .into_diagnostic() - .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; - } - #[cfg(not(unix))] - let _ = path; - Ok(()) -} - -/// Set a file to owner-only read/write (`0o600`) on Unix. -/// -/// No-op on non-Unix platforms. -pub fn set_file_owner_only(path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .into_diagnostic() - .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; - } - #[cfg(not(unix))] - let _ = path; - Ok(()) -} - /// Ensure the parent directory of `path` exists with restricted permissions. /// /// Equivalent to `create_dir_restricted(path.parent())` but handles the case @@ -167,37 +92,10 @@ pub fn is_file_permissions_too_open(path: &Path) -> bool { std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o077 != 0) } -/// Whether `path` is absolute under POSIX rules, whatever the host platform. -pub fn is_posix_absolute(path: &Path) -> bool { - path.to_str().is_some_and(|path| path.starts_with('/')) -} - -/// Compatibility re-export; authored policy path normalization is owned by -/// `openshell-policy-schema`. -pub use openshell_policy_schema::normalize_path; - #[cfg(test)] mod tests { use super::*; - #[test] - fn is_posix_absolute_ignores_host_path_syntax() { - assert!(is_posix_absolute(Path::new("/run/backend.sock"))); - assert!(is_posix_absolute(Path::new("/"))); - for path in ["", "relative", "./run", r"\run", r"C:\run", "C:/run"] { - assert!(!is_posix_absolute(Path::new(path)), "{path}"); - } - } - - #[test] - fn xdg_config_dir_respects_env() { - // This test checks the logic — actual env var mutation is unsafe so - // we rely on the integration tests in openshell-bootstrap for full - // round-trip testing. - let result = xdg_config_dir(); - assert!(result.is_ok()); - } - #[test] fn non_empty_xdg_base_treats_empty_as_unset() { assert_eq!(non_empty_xdg_base(None), None); @@ -216,6 +114,15 @@ mod tests { ); } + #[test] + fn xdg_config_dir_respects_env() { + // This test checks the logic — actual env var mutation is unsafe so + // we rely on the integration tests in openshell-bootstrap for full + // round-trip testing. + let result = xdg_config_dir(); + assert!(result.is_ok()); + } + #[test] fn openshell_config_dir_appends_openshell() { let dir = openshell_config_dir().unwrap(); @@ -234,29 +141,6 @@ mod tests { ); } - #[cfg(unix)] - #[test] - fn create_dir_restricted_sets_0o700() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let dir = tmp.path().join("restricted"); - create_dir_restricted(&dir).unwrap(); - let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o700, "expected 0700, got {mode:04o}"); - } - - #[cfg(unix)] - #[test] - fn set_file_owner_only_sets_0o600() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("secret"); - std::fs::write(&file, "secret-data").unwrap(); - set_file_owner_only(&file).unwrap(); - let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "expected 0600, got {mode:04o}"); - } - #[cfg(unix)] #[test] fn is_file_permissions_too_open_detects_world_readable() { diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index fb200cadac..605b4b2802 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -29,25 +29,25 @@ pub mod v1 { } pub mod datamodel { - pub use super::generated::openshell::datamodel::v1; + pub use openshell_driver_sdk::proto::datamodel::v1; } pub mod sandbox { pub mod backend { pub use super::super::generated::openshell::sandbox::backend::v1; } - pub use super::generated::openshell::sandbox::v1; + pub use openshell_driver_sdk::proto::sandbox::v1; pub mod protocol { pub use super::super::generated::openshell::sandbox::protocol::v1; } } pub mod compute { - pub use super::generated::openshell::compute::v1; + pub use openshell_driver_sdk::proto::compute::v1; } pub mod extension { - pub use super::generated::openshell::extension::v1; + pub use openshell_driver_sdk::proto::extension::v1; } #[allow( diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index f819d8ab38..96bbd386ce 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -3,6 +3,8 @@ //! Registry for sandbox runtime settings keys and value kinds. +pub use openshell_driver_sdk::settings::parse_bool_like; + /// Supported value kinds for registered sandbox settings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingValueKind { @@ -154,21 +156,11 @@ pub fn registered_keys_csv() -> String { .join(", ") } -/// Parse common bool-like string values. -#[must_use] -pub fn parse_bool_like(raw: &str) -> Option { - match raw.trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "y" | "on" => Some(true), - "0" | "false" | "no" | "n" | "off" => Some(false), - _ => None, - } -} - #[cfg(test)] mod tests { use super::{ PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, REGISTERED_SETTINGS, - RegisteredSetting, SettingValueKind, parse_bool_like, registered_keys_csv, setting_for_key, + RegisteredSetting, SettingValueKind, registered_keys_csv, setting_for_key, }; #[test] @@ -245,53 +237,6 @@ mod tests { } } - // ---- parse_bool_like ---- - - #[test] - fn parse_bool_like_accepts_expected_spellings() { - for raw in ["1", "true", "yes", "on", "Y"] { - assert_eq!(parse_bool_like(raw), Some(true), "expected true for {raw}"); - } - for raw in ["0", "false", "no", "off", "N"] { - assert_eq!( - parse_bool_like(raw), - Some(false), - "expected false for {raw}" - ); - } - } - - #[test] - fn parse_bool_like_case_insensitive() { - assert_eq!(parse_bool_like("TRUE"), Some(true)); - assert_eq!(parse_bool_like("True"), Some(true)); - assert_eq!(parse_bool_like("FALSE"), Some(false)); - assert_eq!(parse_bool_like("False"), Some(false)); - assert_eq!(parse_bool_like("YES"), Some(true)); - assert_eq!(parse_bool_like("NO"), Some(false)); - assert_eq!(parse_bool_like("On"), Some(true)); - assert_eq!(parse_bool_like("Off"), Some(false)); - } - - #[test] - fn parse_bool_like_trims_whitespace() { - assert_eq!(parse_bool_like(" true "), Some(true)); - assert_eq!(parse_bool_like("\tfalse\t"), Some(false)); - assert_eq!(parse_bool_like(" 1 "), Some(true)); - assert_eq!(parse_bool_like(" 0 "), Some(false)); - } - - #[test] - fn parse_bool_like_rejects_unrecognized_values() { - assert_eq!(parse_bool_like("maybe"), None); - assert_eq!(parse_bool_like(""), None); - assert_eq!(parse_bool_like("2"), None); - assert_eq!(parse_bool_like("nope"), None); - assert_eq!(parse_bool_like("yep"), None); - assert_eq!(parse_bool_like("enabled"), None); - assert_eq!(parse_bool_like("disabled"), None); - } - // ---- REGISTERED_SETTINGS entries ---- #[test] diff --git a/crates/openshell-core/src/telemetry.rs b/crates/openshell-core/src/telemetry.rs index 2aca36f92b..55d5a4461a 100644 --- a/crates/openshell-core/src/telemetry.rs +++ b/crates/openshell-core/src/telemetry.rs @@ -5,6 +5,7 @@ #[cfg(feature = "telemetry")] use chrono::{SecondsFormat, Utc}; +pub use openshell_driver_sdk::telemetry::enabled_env_value; #[cfg(feature = "telemetry")] use reqwest::blocking::Client; use serde_json::{Value, json}; @@ -17,22 +18,30 @@ use std::thread; use std::time::Duration; const MAX_TELEMETRY_INTEGER: u64 = 9_223_372_036_854_775_807; + const SOURCE: TelemetrySource = TelemetrySource::OpenShell; #[cfg(feature = "telemetry")] const TELEMETRY_EVENT_QUEUE_CAPACITY: usize = 1024; + #[cfg(feature = "telemetry")] const CLIENT_ID: &str = "415437562476676"; + #[cfg(feature = "telemetry")] const DEFAULT_ENDPOINT: &str = "https://events.telemetry.data.nvidia.com/v1.1/events/json"; + #[cfg(feature = "telemetry")] const EVENT_SCHEMA_VERSION: &str = "4.0"; + #[cfg(feature = "telemetry")] const EVENT_PROTOCOL_VERSION: &str = "1.6"; + #[cfg(feature = "telemetry")] const EVENT_SYSTEM_VERSION: &str = "openshell-telemetry/1.0"; + #[cfg(feature = "telemetry")] const HTTP_TIMEOUT: Duration = Duration::from_secs(5); + #[cfg(feature = "telemetry")] static TELEMETRY_SENDER: OnceLock>> = OnceLock::new(); @@ -295,7 +304,7 @@ impl DenyGroup { #[cfg(feature = "telemetry")] pub fn enabled() -> bool { - telemetry_enabled_from(std::env::var("OPENSHELL_TELEMETRY_ENABLED").ok().as_deref()) + enabled_env_value() == "true" } /// Telemetry support is compiled out: always disabled. @@ -304,36 +313,6 @@ pub fn enabled() -> bool { false } -#[cfg(feature = "telemetry")] -pub fn enabled_env_value() -> &'static str { - enabled_env_value_from(std::env::var("OPENSHELL_TELEMETRY_ENABLED").ok().as_deref()) -} - -/// Telemetry support is compiled out: report disabled so sandbox supervisors -/// inherit the disabled state and skip activity collection. -#[cfg(not(feature = "telemetry"))] -pub fn enabled_env_value() -> &'static str { - "false" -} - -#[cfg(feature = "telemetry")] -fn enabled_env_value_from(value: Option<&str>) -> &'static str { - if telemetry_enabled_from(value) { - "true" - } else { - "false" - } -} - -#[cfg(feature = "telemetry")] -fn telemetry_enabled_from(value: Option<&str>) -> bool { - let value = value.unwrap_or("true"); - !matches!( - value.trim().to_ascii_lowercase().as_str(), - "0" | "false" | "no" | "off" - ) -} - #[cfg(feature = "telemetry")] fn telemetry_endpoint() -> Option { telemetry_endpoint_from( @@ -611,29 +590,50 @@ where Some(sanitized) } -#[cfg(all(test, feature = "telemetry"))] -mod tests { +#[cfg(all(test, not(feature = "telemetry")))] +mod disabled_tests { use super::*; #[test] - fn telemetry_enabled_defaults_true() { - assert!(telemetry_enabled_from(None)); + fn telemetry_reports_disabled_when_compiled_out() { + assert!(!enabled()); + assert_eq!(enabled_env_value(), "false"); } #[test] - fn telemetry_enabled_honors_false_values() { - assert!(!telemetry_enabled_from(Some("off"))); - assert!(!telemetry_enabled_from(Some("false"))); - assert!(telemetry_enabled_from(Some("yes"))); + fn emit_functions_are_no_ops_when_compiled_out() { + // These must remain callable so dependent crates compile unchanged; + // with telemetry compiled out they do nothing and never panic. + emit_lifecycle( + LifecycleResource::Sandbox, + LifecycleOperation::Create, + TelemetryOutcome::Success, + ); + emit_provider_lifecycle( + LifecycleOperation::Create, + TelemetryOutcome::Success, + ProviderProfile::Custom, + ); + emit_sandbox_create( + TelemetryOutcome::Success, + false, + 1, + false, + SandboxTemplateSource::Default, + TelemetryComputeDriver::custom(), + ); + emit_policy_decision( + PolicyDecisionOperation::Approve, + TelemetryOutcome::Success, + 1, + ); + emit_sandbox_activity_summary(0, 0, 0.0, [(DenyGroup::ConnectPolicy, 0)]); } +} - #[test] - fn telemetry_enabled_env_value_is_normalized() { - assert_eq!(enabled_env_value_from(Some("false")), "false"); - assert_eq!(enabled_env_value_from(Some("0")), "false"); - assert_eq!(enabled_env_value_from(None), "true"); - assert_eq!(enabled_env_value_from(Some("yes")), "true"); - } +#[cfg(all(test, feature = "telemetry"))] +mod tests { + use super::*; #[test] fn telemetry_endpoint_empty_disables_publish() { @@ -741,44 +741,3 @@ mod tests { assert_eq!(rows.get(&DenyGroup::Unknown), Some(&3)); } } - -#[cfg(all(test, not(feature = "telemetry")))] -mod disabled_tests { - use super::*; - - #[test] - fn telemetry_reports_disabled_when_compiled_out() { - assert!(!enabled()); - assert_eq!(enabled_env_value(), "false"); - } - - #[test] - fn emit_functions_are_no_ops_when_compiled_out() { - // These must remain callable so dependent crates compile unchanged; - // with telemetry compiled out they do nothing and never panic. - emit_lifecycle( - LifecycleResource::Sandbox, - LifecycleOperation::Create, - TelemetryOutcome::Success, - ); - emit_provider_lifecycle( - LifecycleOperation::Create, - TelemetryOutcome::Success, - ProviderProfile::Custom, - ); - emit_sandbox_create( - TelemetryOutcome::Success, - false, - 1, - false, - SandboxTemplateSource::Default, - TelemetryComputeDriver::custom(), - ); - emit_policy_decision( - PolicyDecisionOperation::Approve, - TelemetryOutcome::Success, - 1, - ); - emit_sandbox_activity_summary(0, 0, 0.0, [(DenyGroup::ConnectPolicy, 0)]); - } -} diff --git a/crates/openshell-crypto/CRYPTO.md b/crates/openshell-crypto/CRYPTO.md index 9eaf928447..043ce0e2ec 100644 --- a/crates/openshell-crypto/CRYPTO.md +++ b/crates/openshell-crypto/CRYPTO.md @@ -108,7 +108,7 @@ is migrated or deleted. | `sha2` | `openshell-core` | Endpoint identities and canonical policy hashes; policy binding still requires migration. | | `sha2` | `openshell-driver-docker`, `openshell-driver-kubernetes-secrets`, `openshell-driver-vault` | Deterministic volume and credential-storage names, not authentication. | | `sha2` | `openshell-driver-kubernetes`, `openshell-driver-podman` | Destination-trust ConfigMap names and operator-bundle generation labels, not authenticity proofs. Trust relies on immutable staging, exact-byte validation, and TLS verification; see the README's destination-trust classification. | -| `sha2` | `openshell-driver-vm`, `openshell-isolation-interface`, `openshell-supervisor-network` | Artifact integrity, outer-fence evidence, and DNS policy binding; security-sensitive migration remains required. | +| `sha2` | `openshell-driver-sdk`, `openshell-driver-vm`, `openshell-supervisor-network` | Artifact integrity, outer-fence evidence, and DNS policy binding; security-sensitive migration remains required. | | `sha2` | `openshell-gateway-interceptors` | Provider-profile snapshot revision hashes; content identity outside backend dispatch. | | `sha2` | `openshell-supervisor-process` | Diagnostic debug-token fingerprints. | | `sha2`, `hmac` | `openshell-server` | Naming, coordination, request matching, and tests, plus security-sensitive bearer-cache keys, launch-session derivation, policy/review bindings, credential handles, and protected mutation fingerprints. The security-sensitive uses must migrate; the HMAC exception is for `grpc::mutation_replay`'s protection against offline guessing. | diff --git a/crates/openshell-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index e4ba6f40c0..0fec49ba63 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -15,7 +15,8 @@ name = "openshell-driver-docker" path = "src/main.rs" [dependencies] -openshell-core = { path = "../openshell-core", default-features = false, features = ["driver-extraction", "otel", "clap"] } +openshell-container-runtime = { path = "../openshell-container-runtime" } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "otel", "clap"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } openshell-otel = { path = "../openshell-otel" } diff --git a/crates/openshell-driver-docker/src/isolation.rs b/crates/openshell-driver-docker/src/isolation.rs index 9935ff0cbe..2644ec5650 100644 --- a/crates/openshell-driver-docker/src/isolation.rs +++ b/crates/openshell-driver-docker/src/isolation.rs @@ -64,9 +64,9 @@ pub fn provisioning_evidence( pub struct DockerBoundarySpec { pub boundary_id: String, pub generation: String, - pub session_id: openshell_core::SandboxSessionId, - pub session_rotation: openshell_core::jwt::SessionRotation, - pub auth_epoch: openshell_core::jwt::CredentialEpoch, + pub session_id: openshell_driver_sdk::SandboxSessionId, + pub session_rotation: openshell_driver_sdk::jwt::SessionRotation, + pub auth_epoch: openshell_driver_sdk::jwt::CredentialEpoch, pub gateway_id: String, pub verification_keys: Vec, pub container_id: String, @@ -166,7 +166,7 @@ mod tests { #[test] fn provisioning_binds_container_and_image_claims() { - let session_id = openshell_core::SandboxSessionId::new(); + let session_id = openshell_driver_sdk::SandboxSessionId::new(); let tls = openshell_sandbox_backend::boundary_protocol::generate_sandbox_tls_material(session_id) .unwrap(); @@ -174,8 +174,8 @@ mod tests { boundary_id: "sandbox-1".to_string(), generation: "generation-1".to_string(), session_id, - session_rotation: openshell_core::jwt::SessionRotation::new(1).unwrap(), - auth_epoch: openshell_core::jwt::CredentialEpoch::new(1).unwrap(), + session_rotation: openshell_driver_sdk::jwt::SessionRotation::new(1).unwrap(), + auth_epoch: openshell_driver_sdk::jwt::CredentialEpoch::new(1).unwrap(), gateway_id: "gateway-1".to_string(), verification_keys: vec![GatewayVerificationKey { key_id: "key-1".to_string(), diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index aa1d7de706..9e8f8da3aa 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -23,25 +23,30 @@ use bollard::query_parameters::{ }; use bytes::Bytes; use futures::{Stream, StreamExt}; -use openshell_core::config::DEFAULT_STOP_TIMEOUT_SECS; -use openshell_core::driver_mounts; -use openshell_core::driver_utils::{ - CONDITION_EXITED, CONDITION_RUNTIME_RESTART, CONDITION_WORKSPACE_VALIDATION_FAILED, - LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, SANDBOX_RUNTIME_IMAGE_BINARY_PATH, - SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED, WORKSPACE_VALIDATION_FAILED_MESSAGE, +use openshell_container_runtime::driver_utils::{ + CONDITION_WORKSPACE_VALIDATION_FAILED, LABEL_SANDBOX_NAMESPACE, + SANDBOX_RUNTIME_IMAGE_BINARY_PATH, WORKSPACE_VALIDATION_FAILED_MESSAGE, extract_first_tar_entry, supervisor_image_should_refresh, temp_extract_container_name, validate_linux_elf_binary, }; -use openshell_core::gpu::{ - CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, - effective_driver_gpu_count, validate_cdi_device_names, validate_cdi_gpu_device_request, +use openshell_container_runtime::gpu::{ + CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, validate_cdi_device_names, + validate_cdi_gpu_device_request, }; -use openshell_core::progress::{ +use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; +use openshell_driver_sdk::config::DEFAULT_STOP_TIMEOUT_SECS; +use openshell_driver_sdk::driver_mounts; +use openshell_driver_sdk::driver_utils::{ + CONDITION_EXITED, CONDITION_RUNTIME_RESTART, LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, + LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, + SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED, +}; +use openshell_driver_sdk::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_driver_sdk::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail, }; -use openshell_core::proto::compute::v1::{ +use openshell_driver_sdk::proto::compute::v1::{ CpuResourceCapabilities, CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition, DriverPlatformEvent, DriverSandbox, DriverSandboxStatus, DriverSandboxTemplate, @@ -54,10 +59,8 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesEvent, WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent, compute_driver_server::ComputeDriver, watch_sandboxes_event, }; -use openshell_core::proto_struct::{ - deserialize_optional_non_empty_string_list, struct_to_json_value, -}; -use openshell_core::{ +use openshell_driver_sdk::proto_struct::struct_to_json_value; +use openshell_driver_sdk::{ AppArmorProfile, Error, ImagePullPolicy, NetworkSupervisorTrustBundle, Result as CoreResult, UpstreamProxyConfig, }; @@ -120,7 +123,7 @@ const SUPERVISOR_PROXY_AUTH_MOUNT_PATH: &str = "/.openshell/supervisor/upstream- const SUPERVISOR_PROXY_CA_BUNDLE_MOUNT_PATH: &str = "/.openshell/supervisor/upstream-proxy-ca-bundle.pem"; const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = - openshell_core::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; + openshell_container_runtime::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; const DRIVER_ADMITTED_BACKEND: &str = openshell_sandbox_backend::BACKEND_NAME; const LABEL_ISOLATION_BACKEND: &str = "openshell.ai/isolation-backend"; const LABEL_ISOLATION_BACKEND_OPEN_SHELL: &str = openshell_sandbox_backend::BACKEND_NAME; @@ -141,7 +144,7 @@ const HOST_OPEN_SHELL_INTERNAL: &str = "host.openshell.internal"; const HOST_DOCKER_INTERNAL: &str = "host.docker.internal"; /// Fixed in-container path for the gateway-owned additional destination trust. /// It is mounted only into the separate trusted supervisor container. -const ADDITIONAL_CA_MOUNT_PATH: &str = openshell_core::driver_utils::ADDITIONAL_CA_MOUNT_PATH; +const ADDITIONAL_CA_MOUNT_PATH: &str = openshell_driver_sdk::driver_utils::ADDITIONAL_CA_MOUNT_PATH; fn provisioning_span( parent: &opentelemetry::Context, @@ -183,7 +186,7 @@ pub struct DockerComputeConfig { /// Permit caller-supplied driver JSON. Does not waive resource admission. pub allow_driver_config: bool, /// Operator-owned external attachment approval policy. - pub resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, + pub resource_admission: openshell_driver_sdk::resource_admission::ResourceAdmissionConfig, /// Docker API Unix socket. When unset, use the socket selected by gateway /// auto-detection, falling back to `/var/run/docker.sock` for an explicitly /// configured Docker driver. @@ -210,7 +213,7 @@ pub struct DockerComputeConfig { /// OTLP exports passed to supervisors. The gateway supplies them from /// `[otlp]`; driver TOML cannot set them. #[serde(skip)] - pub supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp, + pub supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp, /// Gateway name passed to supervisors for their telemetry. The gateway /// supplies its own name; driver TOML cannot set it. @@ -248,7 +251,7 @@ pub struct DockerComputeConfig { /// Omit the field to use `OpenShell`'s default sandbox process limit. /// Explicit zero is invalid. #[serde( - default = "openshell_core::config::default_sandbox_pids_limit", + default = "openshell_container_runtime::config::default_sandbox_pids_limit", skip_serializing_if = "Option::is_none" )] pub sandbox_pids_limit: Option, @@ -292,7 +295,7 @@ impl DockerComputeConfig { validate_docker_proxy_auth_file(&self.upstream_proxy)?; validate_docker_proxy_ca_bundle(self)?; if let Some(socket) = self.provider_spiffe_workload_api_socket.as_deref() { - openshell_core::driver_utils::validate_provider_spiffe_unix_socket(socket) + openshell_container_runtime::driver_utils::validate_provider_spiffe_unix_socket(socket) .map_err(Error::config)?; } if gateway_bind_address.port() == 0 { @@ -310,14 +313,14 @@ impl Default for DockerComputeConfig { socket_path: None, allow_driver_config: false, resource_admission: - openshell_core::resource_admission::ResourceAdmissionConfig::default(), - default_image: openshell_core::image::default_sandbox_image(), + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig::default(), + default_image: openshell_driver_sdk::image::default_sandbox_image(), image_pull_policy: ImagePullPolicy::default(), sandbox_label: "default".to_string(), grpc_endpoint: String::new(), supervisor_network_mode: DockerSupervisorNetworkMode::Host, host_gateway_ip: None, - supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp::default(), + supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp::default(), supervisor_gateway_name: None, sandbox_runtime_image: None, supervisor_bin: None, @@ -325,8 +328,8 @@ impl Default for DockerComputeConfig { guest_tls_ca: None, guest_tls_cert: None, guest_tls_key: None, - ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), - sandbox_pids_limit: openshell_core::config::default_sandbox_pids_limit(), + ssh_socket_path: openshell_driver_sdk::container_paths::SSH_SOCKET_PATH.to_string(), + sandbox_pids_limit: openshell_container_runtime::config::default_sandbox_pids_limit(), enable_bind_mounts: false, upstream_proxy: UpstreamProxyConfig::default(), proxy_ca_bundle: None, @@ -344,7 +347,7 @@ pub(crate) struct DockerGuestTlsPaths { #[derive(Debug, Clone)] struct DockerDriverRuntimeConfig { allow_driver_config: bool, - resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, + resource_admission: openshell_driver_sdk::resource_admission::ResourceAdmissionConfig, default_image: String, image_pull_policy: ImagePullPolicy, sandbox_namespace: String, @@ -357,7 +360,7 @@ struct DockerDriverRuntimeConfig { supervisor_endpoint_explicit: bool, bridge_default_endpoint: Option, host_gateway_ip: Option, - supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp, + supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp, supervisor_gateway_name: Option, ssh_socket_path: String, guest_tls: Option, @@ -706,8 +709,8 @@ fn resolve_docker_identity_from_accounts( // rejecting, matching the Podman driver's USER-less default and the // numeric-identity behavior of the Kubernetes and VM drivers. return ResolvedWorkloadIdentity::new( - openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, - openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_GID, Vec::new(), "default".to_string(), image.id.clone(), @@ -788,7 +791,7 @@ impl DockerSandboxDriverConfig { } } -use openshell_core::driver_mounts::SelinuxLabel; +use openshell_driver_sdk::driver_mounts::SelinuxLabel; #[derive(Debug, Clone, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] @@ -881,11 +884,20 @@ pub fn detect_socket() -> Option { if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR") { candidates.push(PathBuf::from(runtime_dir).join("docker.sock")); } - openshell_core::local_api_socket::first_responsive_socket(&candidates, |response| { - openshell_core::local_api_socket::http_response_is_success(response) - && openshell_core::local_api_socket::contains_ascii(response, b"Api-Version:") - && !openshell_core::local_api_socket::contains_ascii(response, b"Libpod-Api-Version:") - }) + openshell_container_runtime::local_api_socket::first_responsive_socket( + &candidates, + |response| { + openshell_container_runtime::local_api_socket::http_response_is_success(response) + && openshell_container_runtime::local_api_socket::contains_ascii( + response, + b"Api-Version:", + ) + && !openshell_container_runtime::local_api_socket::contains_ascii( + response, + b"Libpod-Api-Version:", + ) + }, + ) } #[must_use] @@ -960,7 +972,7 @@ impl DockerComputeDriver { let supervisor_image = docker_config .supervisor_image .clone() - .unwrap_or_else(openshell_core::config::default_supervisor_image); + .unwrap_or_else(openshell_driver_sdk::config::default_supervisor_image); let supervisor_image_id = ensure_runtime_image(&docker, &supervisor_image, "supervisor").await?; let host_gateway_ip = if let Some(ip) = docker_config.host_gateway_ip { @@ -989,7 +1001,7 @@ impl DockerComputeDriver { let sandbox_runtime_image = docker_config .sandbox_runtime_image .clone() - .unwrap_or_else(openshell_core::config::default_sandbox_runtime_image); + .unwrap_or_else(openshell_driver_sdk::config::default_sandbox_runtime_image); let sandbox_runtime_image_id = ensure_runtime_image(&docker, &sandbox_runtime_image, "sandbox runtime").await?; Arc::new( @@ -1065,13 +1077,14 @@ impl DockerComputeDriver { fn capabilities(&self) -> GetCapabilitiesResponse { GetCapabilitiesResponse { - resource_admission_policy: openshell_core::resource_admission::DriverAdmissionConfig { - allow_driver_config: self.config.allow_driver_config, - resource_admission: self.config.resource_admission.clone(), - } - .acknowledgement(), + resource_admission_policy: + openshell_driver_sdk::resource_admission::DriverAdmissionConfig { + allow_driver_config: self.config.allow_driver_config, + resource_admission: self.config.resource_admission.clone(), + } + .acknowledgement(), driver_name: "docker".to_string(), - driver_version: openshell_core::VERSION.to_string(), + driver_version: openshell_driver_sdk::VERSION.to_string(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, supports_sandbox_authentication: false, @@ -1090,12 +1103,14 @@ impl DockerComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - extension: Some(openshell_core::extension_protocol::extension_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, - "openshell/docker", - openshell_core::VERSION, - [], - )), + extension: Some( + openshell_driver_sdk::extension_protocol::extension_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, + "openshell/docker", + openshell_driver_sdk::VERSION, + [], + ), + ), } } @@ -1112,7 +1127,7 @@ impl DockerComputeDriver { sandbox: &'a DriverSandbox, config: &DockerDriverRuntimeConfig, ) -> Result, Status> { - openshell_core::resource_admission::check_sandbox_driver_config( + openshell_driver_sdk::resource_admission::check_sandbox_driver_config( config.allow_driver_config, sandbox, )?; @@ -1277,10 +1292,10 @@ impl DockerComputeDriver { .as_ref() .and_then(|config| config.labels.as_ref()) .ok_or_else(|| Status::failed_precondition("sandbox lacks admission metadata"))?; - openshell_core::resource_admission::check_config_provenance( + openshell_container_runtime::resource_admission::check_config_provenance( self.config.allow_driver_config, labels - .get(openshell_core::resource_admission::CONFIG_USED_LABEL) + .get(openshell_container_runtime::resource_admission::CONFIG_USED_LABEL) .map(String::as_str), )?; if !self.config.resource_admission.enabled { @@ -1293,7 +1308,7 @@ impl DockerComputeDriver { .get(LABEL_SANDBOX_ID) .ok_or_else(|| Status::failed_precondition("sandbox lacks identity"))?; let expected: std::collections::BTreeMap = labels - .get(openshell_core::resource_admission::IDENTITIES_LABEL) + .get(openshell_container_runtime::resource_admission::IDENTITIES_LABEL) .and_then(|value| serde_json::from_str(value).ok()) .ok_or_else(|| Status::failed_precondition("sandbox lacks resource identity record"))?; let mut actual = std::collections::BTreeMap::new(); @@ -1755,7 +1770,7 @@ impl DockerComputeDriver { .labels .get_or_insert_with(Default::default) .insert( - openshell_core::resource_admission::IDENTITIES_LABEL.into(), + openshell_container_runtime::resource_admission::IDENTITIES_LABEL.into(), serde_json::to_string(&identities).map_err(|error| { DockerProvisioningFailure::new("ResourceAdmissionDenied", error.to_string()) })?, @@ -2468,7 +2483,7 @@ impl DockerComputeDriver { ) -> Result { let span_status = openshell_otel::ErrorStatusGuard::current(); require_sandbox_identifier(sandbox_id, sandbox_name)?; - let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( + let generation = openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( generation_id.to_string(), ) .map_err(|error| Status::invalid_argument(error.to_string()))?; @@ -2491,7 +2506,7 @@ impl DockerComputeDriver { &self, sandbox_id: &str, sandbox_name: &str, - generation: &openshell_core::sandbox_generation::SandboxGenerationId, + generation: &openshell_driver_sdk::sandbox_generation::SandboxGenerationId, launch_authentication: &[u8], _durable_sandbox: Option<&DriverSandbox>, ) -> Result { @@ -2805,8 +2820,8 @@ impl DockerComputeDriver { self.publish_platform_event( sandbox_id.to_string(), DriverPlatformEvent { - event_time: openshell_core::time::timestamp_from_millis( - openshell_core::time::now_ms(), + event_time: openshell_driver_sdk::time::timestamp_from_millis( + openshell_driver_sdk::time::now_ms(), ) .ok(), source: "docker".to_string(), @@ -3140,7 +3155,7 @@ fn validate_docker_outer_fence( Ok(()) } #[tonic::async_trait] -impl openshell_core::compute::ComputeDriver for ComputeDriverService {} +impl openshell_driver_sdk::compute::ComputeDriver for ComputeDriverService {} #[tonic::async_trait] impl ComputeDriver for ComputeDriverService { @@ -3148,9 +3163,11 @@ impl ComputeDriver for ComputeDriverService { async fn authenticate_sandbox( &self, - request: Request, - ) -> Result, Status> - { + request: Request, + ) -> Result< + Response, + Status, + > { self.rpc_tracer .trace( openshell_otel::rpc::AUTHENTICATE_SANDBOX, @@ -3305,15 +3322,17 @@ impl ComputeDriver for ComputeDriverService { } #[tonic::async_trait] -impl openshell_core::compute::ComputeDriver for DockerComputeDriver {} +impl openshell_driver_sdk::compute::ComputeDriver for DockerComputeDriver {} #[tonic::async_trait] impl ComputeDriver for DockerComputeDriver { async fn authenticate_sandbox( &self, - _request: Request, - ) -> Result, Status> - { + _request: Request, + ) -> Result< + Response, + Status, + > { Err(Status::unimplemented( "docker does not authenticate sandbox credentials", )) @@ -3326,8 +3345,8 @@ impl ComputeDriver for DockerComputeDriver { request: Request, ) -> Result, Status> { let capabilities = self.capabilities(); - openshell_core::extension_protocol::validate_gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + openshell_driver_sdk::extension_protocol::validate_gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, "docker", capabilities.extension.as_ref(), request.into_inner().gateway, @@ -3700,8 +3719,10 @@ fn platform_event( message: String, ) -> DriverPlatformEvent { DriverPlatformEvent { - event_time: openshell_core::time::timestamp_from_millis(openshell_core::time::now_ms()) - .ok(), + event_time: openshell_driver_sdk::time::timestamp_from_millis( + openshell_driver_sdk::time::now_ms(), + ) + .ok(), source: source.to_string(), r#type: event_type.to_string(), reason: reason.to_string(), @@ -3729,8 +3750,10 @@ fn docker_pull_progress_event(image: &str, info: &CreateImageInfo) -> Option Result { - openshell_core::driver_utils::sandbox_token_path( + openshell_container_runtime::driver_utils::sandbox_token_path( "docker-sandbox-tokens", Some(&config.sandbox_namespace), sandbox_id, @@ -4341,7 +4364,7 @@ async fn write_sandbox_token_file( return Ok(path.is_file()); } if let Some(parent) = path.parent() { - openshell_core::paths::create_dir_restricted(parent).map_err(|err| { + openshell_driver_sdk::paths::create_dir_restricted(parent).map_err(|err| { Status::internal(format!( "create sandbox token directory {} failed: {err}", parent.display() @@ -4356,7 +4379,7 @@ async fn write_sandbox_token_file( path.display() )) })?; - openshell_core::paths::set_file_owner_only(&path).map_err(|err| { + openshell_driver_sdk::paths::set_file_owner_only(&path).map_err(|err| { Status::internal(format!( "restrict sandbox token file {} failed: {err}", path.display() @@ -4370,7 +4393,7 @@ fn prepare_docker_boundary_state_dir( config: &DockerDriverRuntimeConfig, ) -> Result { let directory = docker_boundary_state_dir(sandbox, config)?; - openshell_core::paths::create_dir_restricted(&directory).map_err(|error| { + openshell_driver_sdk::paths::create_dir_restricted(&directory).map_err(|error| { Status::internal(format!( "create Docker boundary state directory {}: {error}", directory.display() @@ -4386,7 +4409,7 @@ async fn write_docker_boundary_file(path: &Path, contents: &[u8]) -> Result<(), path.display() )) })?; - openshell_core::paths::set_file_owner_only(path).map_err(|error| { + openshell_driver_sdk::paths::set_file_owner_only(path).map_err(|error| { Status::internal(format!( "restrict Docker boundary file {}: {error}", path.display() @@ -4397,7 +4420,7 @@ async fn write_docker_boundary_file(path: &Path, contents: &[u8]) -> Result<(), async fn adopt_or_verify_docker_start_generation( sandbox_id: &str, config: &DockerDriverRuntimeConfig, - requested: &openshell_core::sandbox_generation::SandboxGenerationId, + requested: &openshell_driver_sdk::sandbox_generation::SandboxGenerationId, ) -> Result<(), Status> { let path = docker_boundary_state_dir_by_id(sandbox_id, config)?.join(START_GENERATION_FILE); adopt_or_verify_docker_start_generation_path(&path, requested).await @@ -4405,7 +4428,7 @@ async fn adopt_or_verify_docker_start_generation( async fn adopt_or_verify_docker_start_generation_path( path: &Path, - requested: &openshell_core::sandbox_generation::SandboxGenerationId, + requested: &openshell_driver_sdk::sandbox_generation::SandboxGenerationId, ) -> Result<(), Status> { let active = match tokio::fs::read_to_string(&path).await { Ok(active) => active, @@ -4448,7 +4471,7 @@ async fn adopt_or_verify_docker_start_generation_path( path.display() )) })?; - openshell_core::paths::set_file_owner_only(path).map_err(|error| { + openshell_driver_sdk::paths::set_file_owner_only(path).map_err(|error| { Status::internal(format!( "restrict adopted Docker sandbox generation {}: {error}", path.display() @@ -4468,7 +4491,7 @@ async fn adopt_or_verify_docker_start_generation_path( fn verify_docker_start_generation_value( active: &str, - requested: &openshell_core::sandbox_generation::SandboxGenerationId, + requested: &openshell_driver_sdk::sandbox_generation::SandboxGenerationId, ) -> Result<(), Status> { if active.trim() == requested.as_str() { return Ok(()); @@ -4643,14 +4666,14 @@ async fn stage_docker_sandbox_bundle( /// types before uploading the same filtered archive to the replacement. fn decode_docker_launch_authentication( encoded: &[u8], -) -> Result { +) -> Result { let authentication = - serde_json::from_slice::(encoded) + serde_json::from_slice::(encoded) .map_err(|error| { - Status::failed_precondition(format!( - "decode Docker sandbox launch authentication: {error}" - )) - })?; + Status::failed_precondition(format!( + "decode Docker sandbox launch authentication: {error}" + )) + })?; authentication.validate().map_err(|error| { Status::failed_precondition(format!( "validate Docker sandbox launch authentication: {error}" @@ -4660,7 +4683,7 @@ fn decode_docker_launch_authentication( } fn gateway_verification_keys( - keys: &[openshell_core::jwt::SessionVerificationKey], + keys: &[openshell_driver_sdk::jwt::SessionVerificationKey], ) -> Result, Status> { keys.iter() .map(|key| { @@ -4776,10 +4799,11 @@ async fn prepare_docker_boundary_files( &supervisor_auth, ) .await?; - let main_process_spec = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec( - sandbox.spec.as_ref(), - ) - .map_err(|error| Status::internal(format!("encode Docker main process spec: {error}")))?; + let main_process_spec = + openshell_driver_sdk::sandbox_env::MainProcessConfig::encode_driver_spec( + sandbox.spec.as_ref(), + ) + .map_err(|error| Status::internal(format!("encode Docker main process spec: {error}")))?; write_docker_boundary_file( &directory.join(MAIN_PROCESS_SPEC_FILE), main_process_spec.as_bytes(), @@ -4885,9 +4909,11 @@ fn append_docker_proxy_ca_bundle( let path = path .to_str() .ok_or_else(|| Status::failed_precondition("proxy_ca_bundle must be valid UTF-8"))?; - let contents = - openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file(path, "proxy_ca_bundle") - .map_err(Status::failed_precondition)?; + let contents = openshell_driver_sdk::driver_utils::read_upstream_proxy_ca_bundle_file( + path, + "proxy_ca_bundle", + ) + .map_err(Status::failed_precondition)?; append_docker_archive_file( archive, "upstream-proxy-ca-bundle.pem", @@ -5343,7 +5369,7 @@ async fn docker_workload_image_environment( if let Some(image) = sandbox_image(sandbox) { environment.push(format!( "{}={image}", - openshell_core::sandbox_env::CONTAINER_IMAGE + openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE )); } if let Some(uid) = bootstrap @@ -5353,7 +5379,7 @@ async fn docker_workload_image_environment( { environment.push(format!( "{}={uid}", - openshell_core::sandbox_env::CONTAINER_IMAGE_UID + openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE_UID )); } Ok(environment) @@ -5398,42 +5424,50 @@ async fn spawn_docker_control_process( let mut environment = vec![ format!( "{}={DRIVER_ADMITTED_BACKEND}", - openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND + openshell_driver_sdk::sandbox_env::ADMITTED_ISOLATION_BACKEND ), format!( "{}={main_process_spec}", - openshell_core::sandbox_env::MAIN_PROCESS_SPEC + openshell_driver_sdk::sandbox_env::MAIN_PROCESS_SPEC ), format!( "{}={}", - openshell_core::sandbox_env::ENDPOINT, + openshell_driver_sdk::sandbox_env::ENDPOINT, supervisor_endpoint ), - format!("{}={}", openshell_core::sandbox_env::SANDBOX_ID, sandbox.id), - format!("{}={}", openshell_core::sandbox_env::SANDBOX, sandbox.name), format!( "{}={}", - openshell_core::sandbox_env::SSH_SOCKET_PATH, + openshell_driver_sdk::sandbox_env::SANDBOX_ID, + sandbox.id + ), + format!( + "{}={}", + openshell_driver_sdk::sandbox_env::SANDBOX, + sandbox.name + ), + format!( + "{}={}", + openshell_driver_sdk::sandbox_env::SSH_SOCKET_PATH, config.ssh_socket_path ), format!( "{}=/run/openshell/proxy-tls", - openshell_core::sandbox_env::PROXY_TLS_DIR + openshell_driver_sdk::sandbox_env::PROXY_TLS_DIR ), format!( "{}={}", - openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, - openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY + openshell_driver_sdk::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, + openshell_driver_sdk::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY ), format!( "{}={}", - openshell_core::sandbox_env::LOG_LEVEL, - openshell_core::driver_utils::sandbox_log_level(sandbox, &config.log_level) + openshell_driver_sdk::sandbox_env::LOG_LEVEL, + openshell_driver_sdk::driver_utils::sandbox_log_level(sandbox, &config.log_level) ), format!( "{}={}", - openshell_core::sandbox_env::TELEMETRY_ENABLED, - openshell_core::telemetry::enabled_env_value() + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED, + openshell_driver_sdk::telemetry::enabled_env_value() ), ]; environment.extend(image_environment); @@ -5441,15 +5475,18 @@ async fn spawn_docker_control_process( if config.guest_tls.is_some() { environment.push(format!( "{}={SUPERVISOR_STATE_MOUNT_PATH}/tls/ca.pem", - openshell_core::sandbox_env::TLS_CA + openshell_driver_sdk::sandbox_env::TLS_CA )); } if let Some(socket) = config.provider_spiffe_workload_api_socket.as_ref() { - let projected = openshell_core::driver_utils::projected_provider_spiffe_socket_path(socket) + let projected = + openshell_container_runtime::driver_utils::projected_provider_spiffe_socket_path( + socket, + ) .map_err(Status::failed_precondition)?; environment.push(format!( "{}={projected}", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET + openshell_driver_sdk::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET )); } let supervisor_archive = docker_supervisor_bundle_archive(sandbox, config).await?; @@ -5960,22 +5997,22 @@ fn docker_child_environment(sandbox: &DriverSandbox) -> HashMap environment.extend(spec.environment.clone()); } for protected in [ - openshell_core::sandbox_env::ENDPOINT, - openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME, - openshell_core::sandbox_env::MAIN_PROCESS_SPEC, - openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, - openshell_core::sandbox_env::OCI_IMAGE_USER, - openshell_core::sandbox_env::SANDBOX, - openshell_core::sandbox_env::SANDBOX_GID, - openshell_core::sandbox_env::SANDBOX_ID, - openshell_core::sandbox_env::SANDBOX_TOKEN, - openshell_core::sandbox_env::SANDBOX_TOKEN_FILE, - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SSH_SOCKET_PATH, - openshell_core::sandbox_env::TLS_CA, - openshell_core::sandbox_env::TLS_CERT, - openshell_core::sandbox_env::TLS_KEY, - openshell_core::sandbox_env::USER_ENVIRONMENT, + openshell_driver_sdk::sandbox_env::ENDPOINT, + openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME, + openshell_driver_sdk::sandbox_env::MAIN_PROCESS_SPEC, + openshell_driver_sdk::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, + openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER, + openshell_driver_sdk::sandbox_env::SANDBOX, + openshell_driver_sdk::sandbox_env::SANDBOX_GID, + openshell_driver_sdk::sandbox_env::SANDBOX_ID, + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN, + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN_FILE, + openshell_driver_sdk::sandbox_env::SANDBOX_UID, + openshell_driver_sdk::sandbox_env::SSH_SOCKET_PATH, + openshell_driver_sdk::sandbox_env::TLS_CA, + openshell_driver_sdk::sandbox_env::TLS_CERT, + openshell_driver_sdk::sandbox_env::TLS_KEY, + openshell_driver_sdk::sandbox_env::USER_ENVIRONMENT, ] { environment.remove(protected); } @@ -5992,10 +6029,12 @@ fn supervisor_otlp_environment(config: &DockerDriverRuntimeConfig) -> Vec CoreResult<( let Some(path) = config.proxy_auth_file.as_ref() else { return Ok(()); }; - let raw = openshell_core::driver_utils::read_upstream_proxy_credential_file( + let raw = openshell_driver_sdk::driver_utils::read_upstream_proxy_credential_file( path.to_str() .ok_or_else(|| Error::config("proxy_auth_file must be valid UTF-8"))?, ) .map_err(Error::config)?; - openshell_core::driver_utils::parse_upstream_proxy_credential(&raw) + openshell_driver_sdk::driver_utils::parse_upstream_proxy_credential(&raw) .map_err(|error| Error::config(format!("proxy_auth_file is invalid: {error}")))?; Ok(()) } @@ -6383,7 +6422,7 @@ fn validate_docker_proxy_ca_bundle(config: &DockerComputeConfig) -> CoreResult<( let path = path .to_str() .ok_or_else(|| Error::config("proxy_ca_bundle must be valid UTF-8"))?; - openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file(path, "proxy_ca_bundle") + openshell_driver_sdk::driver_utils::read_upstream_proxy_ca_bundle_file(path, "proxy_ca_bundle") .map_err(Error::config)?; Ok(()) } @@ -6793,7 +6832,7 @@ fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String { /// `--from` reference such as `nicolaka/netshoot` must be resolved to a single /// tag the way `docker pull` (and the Podman driver) do. /// -/// Parsing mirrors [`openshell_core::driver_utils::supervisor_image_tag`]: only +/// Parsing mirrors [`openshell_container_runtime::driver_utils::supervisor_image_tag`]: only /// the final path component may carry a tag, so a registry port such as the /// `:5000` in `registry:5000/team/app` is not mistaken for one, and a /// digest-pinned reference (`...@sha256:...`) already names an exact image and diff --git a/crates/openshell-driver-docker/src/main.rs b/crates/openshell-driver-docker/src/main.rs index 817b840d21..25299880f9 100644 --- a/crates/openshell-driver-docker/src/main.rs +++ b/crates/openshell-driver-docker/src/main.rs @@ -6,10 +6,10 @@ use std::path::PathBuf; use clap::Parser; use miette::{IntoDiagnostic, Result}; -use openshell_core::VERSION; -use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; -use openshell_core::sandbox_env::SupervisorOtlpArgs; use openshell_driver_docker::{ComputeDriverService, DockerComputeConfig, DockerComputeDriver}; +use openshell_driver_sdk::VERSION; +use openshell_driver_sdk::proto::compute::v1::compute_driver_server::ComputeDriverServer; +use openshell_driver_sdk::sandbox_env::SupervisorOtlpArgs; use tracing::info; #[derive(Debug, Parser)] @@ -17,7 +17,7 @@ use tracing::info; struct Args { /// Override the operator admission policy in the driver TOML file. #[arg(long, env = "OPENSHELL_DRIVER_ADMISSION_CONFIG_JSON")] - admission_config_json: Option, + admission_config_json: Option, /// Public compute-driver Unix socket used by the gateway. #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] bind_socket: PathBuf, @@ -27,11 +27,11 @@ struct Args { config: PathBuf, /// OCI image containing the `openshell-sandbox` runtime binary. - #[arg(long, env = openshell_core::config::SANDBOX_RUNTIME_IMAGE_ENV)] + #[arg(long, env = openshell_driver_sdk::config::SANDBOX_RUNTIME_IMAGE_ENV)] sandbox_runtime_image: Option, /// OCI image containing the `openshell-supervisor` control binary. - #[arg(long, env = openshell_core::config::SUPERVISOR_IMAGE_ENV)] + #[arg(long, env = openshell_driver_sdk::config::SUPERVISOR_IMAGE_ENV)] supervisor_image: Option, /// Gateway listener address used to derive the supervisor endpoint. @@ -90,16 +90,16 @@ async fn main() -> Result<()> { .await .into_diagnostic()?; - let listener = openshell_core::external_driver_socket::bind_private(&args.bind_socket) + let listener = openshell_driver_sdk::external_driver_socket::bind_private(&args.bind_socket) .map_err(|err| miette::miette!("{err}"))?; let _cleanup = - openshell_core::external_driver_socket::SocketCleanup::new(args.bind_socket.clone()); + openshell_driver_sdk::external_driver_socket::SocketCleanup::new(args.bind_socket.clone()); info!(socket = %args.bind_socket.display(), "Starting Docker compute driver"); tonic::transport::Server::builder() .layer(openshell_otel::compute_driver_rpc_layer()) .add_service(ComputeDriverServer::new(ComputeDriverService::new(driver))) .serve_with_incoming_shutdown( - openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), + openshell_driver_sdk::external_driver_socket::SameUidUnixIncoming::new(listener), shutdown_signal(), ) .await diff --git a/crates/openshell-driver-docker/src/otel_tracing.rs b/crates/openshell-driver-docker/src/otel_tracing.rs index 03090c2eda..dbe437e1e6 100644 --- a/crates/openshell-driver-docker/src/otel_tracing.rs +++ b/crates/openshell-driver-docker/src/otel_tracing.rs @@ -11,7 +11,7 @@ mod tests { async fn driver_spans_reach_otlp_collector_with_resource_identity() { openshell_otel_test_support::assert_compute_driver_tracing( super::TRACING, - openshell_core::VERSION, + openshell_driver_sdk::VERSION, "docker.schedule_sandbox", ) .await; diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 70fc34f256..1a2293f537 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -2,20 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; -use openshell_core::driver_utils::{ +use openshell_container_runtime::driver_utils::LABEL_SANDBOX_NAMESPACE; +use openshell_driver_sdk::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, }; -use openshell_core::jwt::{ +use openshell_driver_sdk::jwt::{ CredentialEpoch, SandboxLaunchAuthentication, SecretJwt, SessionVerificationKey, SupervisorAuthBundle, }; -use openshell_core::progress::{ +use openshell_driver_sdk::progress::{ PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY, PROGRESS_COMPLETE_STEP_KEY, PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, }; -use openshell_core::proto::compute::v1::{ +use openshell_driver_sdk::proto::compute::v1::{ DriverResourceRequirements, DriverSandboxSpec, DriverSandboxTemplate, GpuResourceRequirements, ResourceRequirements, WorkloadIdentityRequest, }; @@ -52,12 +52,11 @@ fn startup_error_log_tails_fit_grpc_header_budget() { fn test_launch_authentication() -> Vec { serde_json::to_vec(&SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { - session_id: openshell_core::SandboxSessionId::new(), - runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( - "generation-1", - ) - .unwrap(), - session_rotation: openshell_core::jwt::SessionRotation::new(1).unwrap(), + session_id: openshell_driver_sdk::SandboxSessionId::new(), + runtime_generation: + openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse("generation-1") + .unwrap(), + session_rotation: openshell_driver_sdk::jwt::SessionRotation::new(1).unwrap(), auth_epoch: CredentialEpoch::new(1).unwrap(), gateway_token: SecretJwt::parse("gateway.token.value").unwrap(), gateway_expires_at: i64::MAX, @@ -84,7 +83,7 @@ fn test_launch_authentication() -> Vec { #[tokio::test] async fn restart_rotates_bootstrap_descriptor_and_provisioning_evidence_together() { let config = runtime_config(); - let sandbox_id = format!("restart-{}", openshell_core::SandboxSessionId::new()); + let sandbox_id = format!("restart-{}", openshell_driver_sdk::SandboxSessionId::new()); let directory = docker_boundary_state_dir_by_id(&sandbox_id, &config).unwrap(); fs::create_dir_all(&directory).unwrap(); let mut authentication: SandboxLaunchAuthentication = @@ -140,8 +139,9 @@ async fn restart_rotates_bootstrap_descriptor_and_provisioning_evidence_together .unwrap(); let previous_evidence = provisioned.boundary_config.provisioning_evidence; authentication.supervisor.runtime_generation = - openshell_core::sandbox_generation::SandboxGenerationId::parse("generation-2").unwrap(); - authentication.supervisor.session_id = openshell_core::SandboxSessionId::new(); + openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse("generation-2") + .unwrap(); + authentication.supervisor.session_id = openshell_driver_sdk::SandboxSessionId::new(); let encoded = serde_json::to_vec(&authentication).unwrap(); refresh_docker_boundary_authentication(&sandbox_id, &config, &encoded) .await @@ -157,11 +157,11 @@ async fn restart_rotates_bootstrap_descriptor_and_provisioning_evidence_together .unwrap(); assert!(image_env.contains(&format!( "{}=image-test", - openshell_core::sandbox_env::CONTAINER_IMAGE_UID + openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE_UID ))); assert!(image_env.contains(&format!( "{}={}", - openshell_core::sandbox_env::CONTAINER_IMAGE, + openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE, sandbox_image(&sandbox).unwrap() ))); let descriptor = read_docker_runtime_descriptor(&sandbox_id, &config) @@ -245,7 +245,7 @@ fn admission_defaults_reject_even_gpu_driver_json_but_not_public_gpu_requests() let mut config = runtime_config(); config.allow_driver_config = false; config.resource_admission = - openshell_core::resource_admission::ResourceAdmissionConfig::default(); + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig::default(); config.gpu.cdi_supported = true; let mut sandbox = test_sandbox(); let spec = sandbox.spec.as_mut().unwrap(); @@ -270,7 +270,7 @@ fn admission_blocks_unlabelable_bind_mount_even_when_bind_mounts_enabled() { let mut config = runtime_config(); config.enable_bind_mounts = true; config.resource_admission = - openshell_core::resource_admission::ResourceAdmissionConfig::default(); + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig::default(); let mut sandbox = test_sandbox(); sandbox .spec @@ -280,7 +280,7 @@ fn admission_blocks_unlabelable_bind_mount_even_when_bind_mounts_enabled() { .as_mut() .unwrap() .driver_config = Some( - openshell_core::proto_struct::json_object_to_struct( + openshell_driver_sdk::proto_struct::json_object_to_struct( serde_json::json!({"mounts":[{"type":"bind","source":"/srv/data","target":"/data"}]}) .as_object() .unwrap() @@ -333,7 +333,7 @@ fn runtime_config() -> DockerDriverRuntimeConfig { DockerDriverRuntimeConfig { // Existing lifecycle fixtures exercise the explicitly opted-out contract. allow_driver_config: true, - resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { + resource_admission: openshell_driver_sdk::resource_admission::ResourceAdmissionConfig { enabled: false, ..Default::default() }, @@ -349,9 +349,9 @@ fn runtime_config() -> DockerDriverRuntimeConfig { supervisor_endpoint_explicit: true, bridge_default_endpoint: Some("https://host.docker.internal:8443".to_string()), host_gateway_ip: None, - supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp::default(), + supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp::default(), supervisor_gateway_name: None, - ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), + ssh_socket_path: openshell_driver_sdk::container_paths::SSH_SOCKET_PATH.to_string(), guest_tls: Some(DockerGuestTlsPaths { ca: PathBuf::from("/tmp/ca.crt"), }), @@ -359,7 +359,7 @@ fn runtime_config() -> DockerDriverRuntimeConfig { cdi_supported: false, wsl_all_gpu_fallback_enabled: false, }, - sandbox_pids_limit: openshell_core::config::default_sandbox_pids_limit(), + sandbox_pids_limit: openshell_container_runtime::config::default_sandbox_pids_limit(), enable_bind_mounts: false, upstream_proxy: UpstreamProxyConfig::default(), proxy_ca_bundle: None, @@ -370,7 +370,7 @@ fn runtime_config() -> DockerDriverRuntimeConfig { } fn write_test_proxy_ca_bundle(directory: &TempDir) -> PathBuf { - let tls = generate_sandbox_tls_material(openshell_core::SandboxSessionId::new()) + let tls = generate_sandbox_tls_material(openshell_driver_sdk::SandboxSessionId::new()) .expect("generate test proxy CA"); let path = directory.path().join("proxy-ca.pem"); fs::write(&path, tls.trust_anchor_pem).expect("write test proxy CA"); @@ -546,7 +546,7 @@ fn json_struct(value: serde_json::Value) -> prost_types::Struct { let serde_json::Value::Object(object) = value else { panic!("expected JSON object"); }; - openshell_core::proto_struct::json_object_to_struct(object) + openshell_driver_sdk::proto_struct::json_object_to_struct(object) .expect("test JSON must convert to a protobuf Struct") } @@ -631,7 +631,7 @@ async fn live_docker_resource_admission_checks_native_volume_labels() { let suffix = temporary.path().file_name().unwrap().to_str().unwrap(); let mut config = runtime_config(); config.resource_admission = - openshell_core::resource_admission::ResourceAdmissionConfig::default(); + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig::default(); let mut driver = test_driver_with_config(config); driver.docker = Arc::new(Docker::connect_with_local_defaults().unwrap()); for (index, (workspace, approved)) in [ @@ -695,7 +695,7 @@ async fn live_docker_resource_admission_checks_native_volume_labels() { } type TestDriverClient = - openshell_core::proto::compute::v1::compute_driver_client::ComputeDriverClient< + openshell_driver_sdk::proto::compute::v1::compute_driver_client::ComputeDriverClient< tonic::transport::Channel, >; @@ -715,7 +715,7 @@ async fn standalone_traced_client() -> ( oneshot::Sender<()>, JoinHandle>, ) { - use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; + use openshell_driver_sdk::proto::compute::v1::compute_driver_server::ComputeDriverServer; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -757,8 +757,8 @@ async fn tracing_standalone_rpc_layer_propagates_context_and_records_errors() { client .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { - gateway: Some(openshell_core::extension_protocol::gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + gateway: Some(openshell_driver_sdk::extension_protocol::gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, )), })) .await @@ -916,8 +916,8 @@ async fn tracing_in_process_service_preserves_the_driver_rpc_server_boundary() { ComputeDriver::get_capabilities( &service, Request::new(GetCapabilitiesRequest { - gateway: Some(openshell_core::extension_protocol::gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + gateway: Some(openshell_driver_sdk::extension_protocol::gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, )), }), ) @@ -1469,7 +1469,7 @@ fn docker_resource_limits_applies_cpu_and_memory_limits() { #[test] fn docker_pids_limit_uses_driver_default_and_allows_runtime_inherit() { - let default = openshell_core::config::default_sandbox_pids_limit(); + let default = openshell_container_runtime::config::default_sandbox_pids_limit(); assert_eq!( docker_pids_limit(default).unwrap(), default.map(std::num::NonZeroI64::get) @@ -1503,7 +1503,8 @@ fn container_create_body_sets_driver_owned_pids_limit() { let host_config = body.host_config.expect("host config"); assert_eq!( host_config.pids_limit, - openshell_core::config::default_sandbox_pids_limit().map(std::num::NonZeroI64::get) + openshell_container_runtime::config::default_sandbox_pids_limit() + .map(std::num::NonZeroI64::get) ); } @@ -1512,12 +1513,12 @@ fn docker_child_environment_strips_supervisor_control_keys() { let mut sandbox = test_sandbox(); let spec = sandbox.spec.as_mut().unwrap(); for key in [ - openshell_core::sandbox_env::ENDPOINT, - openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME, - openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, - openshell_core::sandbox_env::OCI_IMAGE_USER, - openshell_core::sandbox_env::SANDBOX_TOKEN, - openshell_core::sandbox_env::SANDBOX_TOKEN_FILE, + openshell_driver_sdk::sandbox_env::ENDPOINT, + openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME, + openshell_driver_sdk::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, + openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER, + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN, + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN_FILE, ] { spec.environment .insert(key.to_string(), "spoofed".to_string()); @@ -1535,7 +1536,7 @@ fn docker_child_environment_strips_supervisor_control_keys() { #[test] fn supervisor_otlp_environment_carries_only_configured_signals() { - use openshell_core::sandbox_env::{ + use openshell_driver_sdk::sandbox_env::{ GATEWAY_NAME, OTLP_AGENT_ENDPOINT, OTLP_AGENT_PROTOCOL, OTLP_ENDPOINT, OTLP_OCSF_ENDPOINT, OTLP_OCSF_PROTOCOL, OTLP_PROTOCOL, OtlpExport, OtlpProtocol, }; @@ -1614,7 +1615,7 @@ fn boundary_environment_contains_only_driver_owned_values() { ); assert!(env.iter().any(|entry| entry.starts_with(&format!( "{}=", - openshell_core::sandbox_env::TELEMETRY_ENABLED + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED )))); } @@ -1664,7 +1665,10 @@ fn container_creation_uses_inspected_immutable_image() { ) ); assert!(body.env.unwrap().iter().all(|entry| { - !entry.starts_with(&format!("{}=", openshell_core::sandbox_env::OCI_IMAGE_USER)) + !entry.starts_with(&format!( + "{}=", + openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER + )) })); let host = body.host_config.unwrap(); assert_eq!(host.cap_add, None); @@ -1883,8 +1887,8 @@ fn docker_identity_resolution_uses_numeric_default_for_userless_image() { assert_eq!( (resolved.uid, resolved.gid), ( - openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, - openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_GID, ) ); assert_eq!(resolved.source, "default"); @@ -4137,7 +4141,7 @@ fn docker_guest_tls_paths_allows_plain_http_without_tls_flags() { #[test] fn default_docker_supervisor_image_uses_nvidia_ghcr_repo() { - let image = openshell_core::config::default_supervisor_image(); + let image = openshell_driver_sdk::config::default_supervisor_image(); assert!( image.starts_with("ghcr.io/nvidia/openshell/supervisor:"), "unexpected default image reference: {image}", @@ -4146,7 +4150,7 @@ fn default_docker_supervisor_image_uses_nvidia_ghcr_repo() { #[test] fn docker_supervisor_image_tag_prefers_explicit_build_tags() { - use openshell_core::config::resolve_supervisor_image_tag; + use openshell_driver_sdk::config::resolve_supervisor_image_tag; assert_eq!( resolve_supervisor_image_tag(&["1.2.3", "sha", "0.0.0"]), "1.2.3" @@ -4158,7 +4162,7 @@ fn docker_supervisor_image_tag_prefers_explicit_build_tags() { #[test] fn docker_supervisor_image_tag_sanitizes_build_metadata_for_docker() { - use openshell_core::config::resolve_supervisor_image_tag; + use openshell_driver_sdk::config::resolve_supervisor_image_tag; assert_eq!( resolve_supervisor_image_tag(&["", "", "0.0.37-dev.156+g1d3b741ee"]), "0.0.37-dev.156-g1d3b741ee", @@ -4477,7 +4481,7 @@ fn concurrent_container_removal_is_idempotent() { async fn missing_start_generation_is_adopted() { let directory = TempDir::new().expect("create temporary directory"); let path = directory.path().join(START_GENERATION_FILE); - let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( + let generation = openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( "generation-one".to_string(), ) .expect("valid generation"); @@ -4500,7 +4504,7 @@ async fn different_start_generation_is_rejected() { let directory = TempDir::new().expect("create temporary directory"); let path = directory.path().join(START_GENERATION_FILE); fs::write(&path, "generation-one").expect("write active marker"); - let requested = openshell_core::sandbox_generation::SandboxGenerationId::parse( + let requested = openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( "generation-two".to_string(), ) .expect("valid generation"); diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index 3fb3c51b5d..dcae6dd947 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -15,7 +15,8 @@ name = "openshell-driver-podman" path = "src/main.rs" [dependencies] -openshell-core = { path = "../openshell-core", default-features = false, features = ["driver-extraction", "otel", "clap"] } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "otel", "clap"] } +openshell-container-runtime = { path = "../openshell-container-runtime" } openshell-otel = { path = "../openshell-otel" } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } diff --git a/crates/openshell-driver-podman/src/client.rs b/crates/openshell-driver-podman/src/client.rs index 793b92271c..9552d8f99b 100644 --- a/crates/openshell-driver-podman/src/client.rs +++ b/crates/openshell-driver-podman/src/client.rs @@ -813,11 +813,11 @@ impl PodmanClient { ) -> Result<(), PodmanApiError> { let labels = HashMap::from([ ( - openshell_core::driver_utils::LABEL_SANDBOX_ID.to_string(), + openshell_driver_sdk::driver_utils::LABEL_SANDBOX_ID.to_string(), sandbox_id.to_string(), ), ( - openshell_core::driver_utils::LABEL_SANDBOX_WORKSPACE.to_string(), + openshell_driver_sdk::driver_utils::LABEL_SANDBOX_WORKSPACE.to_string(), workspace.to_string(), ), ]); @@ -858,7 +858,7 @@ impl PodmanClient { Err(PodmanApiError::NotFound(_)) => return Ok(()), Err(error) => return Err(error), }; - let reason = match labels.get(openshell_core::driver_utils::LABEL_SANDBOX_ID) { + let reason = match labels.get(openshell_driver_sdk::driver_utils::LABEL_SANDBOX_ID) { Some(owner) if owner == sandbox_id => return self.remove_volume(name).await, Some(_) => "sandbox ID label names another sandbox", None => "sandbox ID label is missing", diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index a7a22ca686..23d1ca263a 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -5,7 +5,7 @@ use std::net::{IpAddr, Ipv4Addr}; use std::num::{NonZeroI64, NonZeroU64}; use std::path::PathBuf; -use openshell_core::{AppArmorProfile, ImagePullPolicy}; +use openshell_driver_sdk::{AppArmorProfile, ImagePullPolicy}; /// Default Podman bridge network name. pub const DEFAULT_NETWORK_NAME: &str = "openshell"; @@ -30,7 +30,7 @@ pub struct PodmanComputeConfig { /// Permit caller-supplied driver JSON. Does not waive resource admission. pub allow_driver_config: bool, /// Operator-owned external attachment approval policy. - pub resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, + pub resource_admission: openshell_driver_sdk::resource_admission::ResourceAdmissionConfig, /// Podman API Unix socket. When unset, use the socket selected by /// gateway auto-detection. pub socket_path: Option, @@ -46,7 +46,7 @@ pub struct PodmanComputeConfig { /// OTLP exports passed to supervisors. The gateway supplies them from /// `[otlp]`; driver TOML cannot set them. #[serde(skip)] - pub supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp, + pub supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp, /// Gateway name passed to supervisors for their telemetry. The gateway /// supplies its own name; driver TOML cannot set it. @@ -57,7 +57,7 @@ pub struct PodmanComputeConfig { /// Used by the driver's auto-detection fallback when `grpc_endpoint` /// is empty. The server must set this to `config.bind_address.port()` /// so the correct port is used even when `--port` differs from the - /// default. Defaults to [`openshell_core::config::DEFAULT_SERVER_PORT`]. + /// default. Defaults to [`openshell_driver_sdk::config::DEFAULT_SERVER_PORT`]. pub gateway_port: u16, /// Unix socket path the in-container supervisor bridges relay traffic to. pub ssh_socket_path: String, @@ -92,7 +92,7 @@ pub struct PodmanComputeConfig { /// Omit the field to use `OpenShell`'s 2048-process sandbox limit. Explicit /// zero is invalid. #[serde( - default = "openshell_core::config::default_sandbox_pids_limit", + default = "openshell_container_runtime::config::default_sandbox_pids_limit", skip_serializing_if = "Option::is_none" )] pub sandbox_pids_limit: Option, @@ -175,7 +175,7 @@ pub struct PodmanComputeConfig { /// /// A CA certificate is not secret, so the gateway bind-mounts this file /// read-only into the sandbox (at - /// [`PROXY_CA_MOUNT_PATH`](openshell_core::driver_utils::PROXY_CA_MOUNT_PATH)) + /// [`PROXY_CA_MOUNT_PATH`](openshell_container_runtime::driver_utils::PROXY_CA_MOUNT_PATH)) /// and passes its path to the supervisor via `--upstream-proxy-ca-bundle`. /// The supervisor trusts it for the TLS handshake with an `https://` /// proxy and, because a TLS-intercepting proxy re-signs tunneled server @@ -193,7 +193,7 @@ pub struct PodmanComputeConfig { /// workload image, and therefore cannot inherit a private CA baked into /// the workload's system trust store. The gateway bind-mounts this file /// read-only into the supervisor container (at - /// [`ADDITIONAL_CA_MOUNT_PATH`](openshell_core::driver_utils::ADDITIONAL_CA_MOUNT_PATH)) + /// [`ADDITIONAL_CA_MOUNT_PATH`](openshell_driver_sdk::driver_utils::ADDITIONAL_CA_MOUNT_PATH)) /// and passes its path via `--additional-ca-bundle`. The supervisor folds /// it into its upstream TLS trust store alongside (not instead of) the /// system CA bundle. The bundle must exist and contain at least one @@ -278,8 +278,10 @@ impl PodmanComputeConfig { // Preserve pass-through support for an explicitly configured // container-reachable Workload API TCP endpoint. if !raw.starts_with("tcp:") { - openshell_core::driver_utils::validate_provider_spiffe_unix_socket(socket) - .map_err(crate::client::PodmanApiError::InvalidInput)?; + openshell_container_runtime::driver_utils::validate_provider_spiffe_unix_socket( + socket, + ) + .map_err(crate::client::PodmanApiError::InvalidInput)?; } } self.canonicalize_userns()?; @@ -329,7 +331,7 @@ impl PodmanComputeConfig { /// Validate optional corporate proxy configuration. /// /// Shares validation semantics with the in-container supervisor through - /// [`openshell_core::driver_utils::parse_upstream_proxy_url`], so a value + /// [`openshell_driver_sdk::driver_utils::parse_upstream_proxy_url`], so a value /// accepted here can never be rejected by the supervisor at sandbox /// startup (or vice versa). The supervisor supports `http://` and /// `https://` forward proxies, so other schemes (SOCKS, etc.) are rejected @@ -341,7 +343,7 @@ impl PodmanComputeConfig { // Keep the Podman-only CA-bundle behaviour below, but delegate the // shared URL, bypass-list, credential-file, and acknowledgement // contract to openshell-core so Docker and VM cannot drift. - openshell_core::UpstreamProxyConfig { + openshell_driver_sdk::UpstreamProxyConfig { https_proxy: self.https_proxy.clone(), no_proxy: self.no_proxy.clone(), proxy_auth_file: self.proxy_auth_file.as_ref().map(PathBuf::from), @@ -372,7 +374,7 @@ impl PodmanComputeConfig { // error when the driver starts, not a failed/crash-looping sandbox // once the supervisor container starts and tries to read it. if let Some(path) = self.additional_ca_bundle.as_deref() { - openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file( + openshell_driver_sdk::driver_utils::read_upstream_proxy_ca_bundle_file( path, "additional_ca_bundle", ) @@ -539,23 +541,23 @@ impl Default for PodmanComputeConfig { socket_path: None, allow_driver_config: false, resource_admission: - openshell_core::resource_admission::ResourceAdmissionConfig::default(), - default_image: openshell_core::image::default_sandbox_image(), + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig::default(), + default_image: openshell_driver_sdk::image::default_sandbox_image(), image_pull_policy: ImagePullPolicy::default(), grpc_endpoint: String::new(), - supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp::default(), + supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp::default(), supervisor_gateway_name: None, - gateway_port: openshell_core::config::DEFAULT_SERVER_PORT, - ssh_socket_path: openshell_core::container_paths::SSH_SOCKET_PATH.to_string(), + gateway_port: openshell_driver_sdk::config::DEFAULT_SERVER_PORT, + ssh_socket_path: openshell_driver_sdk::container_paths::SSH_SOCKET_PATH.to_string(), network_name: DEFAULT_NETWORK_NAME.to_string(), host_gateway_ip: Self::default_host_gateway_ip(), stop_timeout_secs: DEFAULT_PODMAN_STOP_TIMEOUT_SECS, - sandbox_runtime_image: openshell_core::config::default_sandbox_runtime_image(), - supervisor_image: openshell_core::config::default_supervisor_image(), + sandbox_runtime_image: openshell_driver_sdk::config::default_sandbox_runtime_image(), + supervisor_image: openshell_driver_sdk::config::default_supervisor_image(), guest_tls_ca: None, guest_tls_cert: None, guest_tls_key: None, - sandbox_pids_limit: openshell_core::config::default_sandbox_pids_limit(), + sandbox_pids_limit: openshell_container_runtime::config::default_sandbox_pids_limit(), enable_bind_mounts: false, provider_spiffe_workload_api_socket: None, app_armor_profile: None, @@ -707,7 +709,7 @@ mod tests { let cfg = PodmanComputeConfig::default(); assert_eq!( cfg.sandbox_pids_limit.map(NonZeroI64::get), - Some(openshell_core::config::DEFAULT_SANDBOX_PIDS_LIMIT) + Some(openshell_container_runtime::config::DEFAULT_SANDBOX_PIDS_LIMIT) ); assert!(!cfg.enable_bind_mounts); } @@ -718,7 +720,7 @@ mod tests { .expect("default Podman config should deserialize"); assert_eq!( cfg.sandbox_pids_limit.map(NonZeroI64::get), - Some(openshell_core::config::DEFAULT_SANDBOX_PIDS_LIMIT) + Some(openshell_container_runtime::config::DEFAULT_SANDBOX_PIDS_LIMIT) ); } diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index 9943d7a0b6..590c5b8a9a 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -5,17 +5,19 @@ use crate::client::ImageInspect; use crate::config::PodmanComputeConfig; -use openshell_core::ComputeDriverError; -use openshell_core::driver_mounts::SelinuxLabel; -use openshell_core::gpu::validate_cdi_device_names; +use openshell_container_runtime::gpu::validate_cdi_device_names; #[cfg(test)] -use openshell_core::gpu::{driver_gpu_requirements, validate_cdi_gpu_device_request}; -use openshell_core::network_trust::{ +use openshell_container_runtime::gpu::validate_cdi_gpu_device_request; +use openshell_container_runtime::network_trust::{ NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, }; -use openshell_core::proto::compute::v1::{DriverSandbox, DriverSandboxTemplate}; -use openshell_core::proto_struct::deserialize_optional_non_empty_string_list; -use openshell_core::{NetworkSupervisorTrustBundle, driver_mounts, proto_struct}; +use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; +use openshell_driver_sdk::ComputeDriverError; +use openshell_driver_sdk::driver_mounts::SelinuxLabel; +#[cfg(test)] +use openshell_driver_sdk::gpu::driver_gpu_requirements; +use openshell_driver_sdk::proto::compute::v1::{DriverSandbox, DriverSandboxTemplate}; +use openshell_driver_sdk::{NetworkSupervisorTrustBundle, driver_mounts, proto_struct}; use serde::Serialize; use serde_json::Value; use std::collections::{BTreeMap, HashSet}; @@ -40,8 +42,9 @@ fn is_selinux_enabled() -> bool { false } -pub use openshell_core::driver_utils::{ - LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, +pub use openshell_container_runtime::driver_utils::LABEL_SANDBOX_NAMESPACE; +pub use openshell_driver_sdk::driver_utils::{ + LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, }; /// Label applied to all managed containers. @@ -66,20 +69,21 @@ const RESOLVER_SECRET_PREFIX: &str = "openshell-resolver-"; const TLS_CA_SECRET_PREFIX: &str = "openshell-tls-ca-"; /// Container-side mount paths for client TLS materials and the sandbox token. -const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH; -const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; +const TLS_CA_MOUNT_PATH: &str = openshell_driver_sdk::driver_utils::TLS_CA_MOUNT_PATH; +const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_driver_sdk::driver_utils::SANDBOX_TOKEN_MOUNT_PATH; const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = - openshell_core::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH; + openshell_driver_sdk::driver_utils::UPSTREAM_PROXY_AUTH_MOUNT_PATH; const RESOLV_CONF_PATH: &str = "/etc/resolv.conf"; -const PROXY_CA_MOUNT_PATH: &str = openshell_core::driver_utils::PROXY_CA_MOUNT_PATH; -const ADDITIONAL_CA_MOUNT_PATH: &str = openshell_core::driver_utils::ADDITIONAL_CA_MOUNT_PATH; +const PROXY_CA_MOUNT_PATH: &str = openshell_container_runtime::driver_utils::PROXY_CA_MOUNT_PATH; +const ADDITIONAL_CA_MOUNT_PATH: &str = openshell_driver_sdk::driver_utils::ADDITIONAL_CA_MOUNT_PATH; const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = - openshell_core::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; + openshell_container_runtime::driver_utils::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR; /// Directory inside sandbox containers where the supervisor binary is mounted. -const SUPERVISOR_MOUNT_DIR: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_DIR; +const SUPERVISOR_MOUNT_DIR: &str = openshell_driver_sdk::driver_utils::SUPERVISOR_CONTAINER_DIR; /// Full path to the supervisor binary inside sandbox containers. -const SUPERVISOR_BINARY_PATH: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_BINARY; +const SUPERVISOR_BINARY_PATH: &str = + openshell_driver_sdk::driver_utils::SUPERVISOR_CONTAINER_BINARY; #[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(default, deny_unknown_fields)] @@ -95,7 +99,7 @@ pub struct PodmanSandboxDriverConfig { impl PodmanSandboxDriverConfig { pub(crate) fn admit_mount_types( &self, - policy: &openshell_core::resource_admission::ResourceAdmissionConfig, + policy: &openshell_driver_sdk::resource_admission::ResourceAdmissionConfig, ) -> Result<(), ComputeDriverError> { for mount in &self.mounts { if matches!( @@ -685,7 +689,7 @@ fn build_env( if let Some(s) = spec { if !s.log_level.is_empty() { env.insert( - openshell_core::sandbox_env::LOG_LEVEL.into(), + openshell_driver_sdk::sandbox_env::LOG_LEVEL.into(), s.log_level.clone(), ); } @@ -698,7 +702,10 @@ fn build_env( if !user_env.is_empty() { let json = serde_json::to_string(&user_env) .map_err(|error| ComputeDriverError::Precondition(error.to_string()))?; - env.insert(openshell_core::sandbox_env::USER_ENVIRONMENT.into(), json); + env.insert( + openshell_driver_sdk::sandbox_env::USER_ENVIRONMENT.into(), + json, + ); } // 2. Required driver vars (highest priority -- always overwrite). @@ -709,34 +716,35 @@ fn build_env( // and image ENV cannot influence. env.insert( - openshell_core::sandbox_env::SANDBOX.into(), + openshell_driver_sdk::sandbox_env::SANDBOX.into(), sandbox.name.clone(), ); env.insert( - openshell_core::sandbox_env::SANDBOX_ID.into(), + openshell_driver_sdk::sandbox_env::SANDBOX_ID.into(), sandbox.id.clone(), ); env.insert( - openshell_core::sandbox_env::ENDPOINT.into(), + openshell_driver_sdk::sandbox_env::ENDPOINT.into(), config.grpc_endpoint.clone(), ); env.insert( - openshell_core::sandbox_env::SSH_SOCKET_PATH.into(), + openshell_driver_sdk::sandbox_env::SSH_SOCKET_PATH.into(), config.ssh_socket_path.clone(), ); env.insert( - openshell_core::sandbox_env::CONTAINER_IMAGE.into(), + openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE.into(), image.to_string(), ); - let main_process = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec(spec) - .map_err(|error| ComputeDriverError::Precondition(error.to_string()))?; + let main_process = + openshell_driver_sdk::sandbox_env::MainProcessConfig::encode_driver_spec(spec) + .map_err(|error| ComputeDriverError::Precondition(error.to_string()))?; env.insert( - openshell_core::sandbox_env::MAIN_PROCESS_SPEC.into(), + openshell_driver_sdk::sandbox_env::MAIN_PROCESS_SPEC.into(), main_process, ); env.insert( - openshell_core::sandbox_env::TELEMETRY_ENABLED.into(), - openshell_core::telemetry::enabled_env_value().into(), + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED.into(), + openshell_driver_sdk::telemetry::enabled_env_value().into(), ); env.extend( config @@ -754,67 +762,67 @@ fn build_env( } if let Some(name) = &config.supervisor_gateway_name { env.insert( - openshell_core::sandbox_env::GATEWAY_NAME.into(), + openshell_driver_sdk::sandbox_env::GATEWAY_NAME.into(), name.clone(), ); } // Runtime capabilities are driver-owned. Override image/user input with // only the substrate that this driver configures for the supervisor. env.insert( - openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.into(), - openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.into(), + openshell_driver_sdk::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.into(), + openshell_driver_sdk::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY.into(), ); // 3. Gateway CA path (when TLS is enabled). if config.tls_enabled() { env.insert( - openshell_core::sandbox_env::TLS_CA.into(), + openshell_driver_sdk::sandbox_env::TLS_CA.into(), TLS_CA_MOUNT_PATH.into(), ); } if let Some(socket_path) = provider_spiffe_workload_api_socket_env_value(config) { env.insert( - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET.into(), + openshell_driver_sdk::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET.into(), socket_path, ); } - env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN); - env.remove(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE); + env.remove(openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN); + env.remove(openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN_FILE); // Prevent user-supplied environment from overriding the TLS server name // the supervisor verifies — a sandbox user who can redirect the gateway // hostname could otherwise present a certificate for a name they control // and intercept the sandbox JWT. - env.remove(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME); + env.remove(openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME); if oci_user.is_empty() { // The image declares no OCI USER (for example, a minimal base image). Assign a // numeric non-root identity like the Kubernetes and VM drivers so the // supervisor synthesizes the account instead of rejecting the image. env.insert( - openshell_core::sandbox_env::OCI_IMAGE_USER.into(), + openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER.into(), String::new(), ); env.insert( - openshell_core::sandbox_env::SANDBOX_UID.into(), - openshell_core::sandbox_env::DEFAULT_SANDBOX_UID.to_string(), + openshell_driver_sdk::sandbox_env::SANDBOX_UID.into(), + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_UID.to_string(), ); env.insert( - openshell_core::sandbox_env::SANDBOX_GID.into(), - openshell_core::sandbox_env::DEFAULT_SANDBOX_GID.to_string(), + openshell_driver_sdk::sandbox_env::SANDBOX_GID.into(), + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_GID.to_string(), ); } else { // The image declares a USER; preserve the OCI resolution path. env.insert( - openshell_core::sandbox_env::OCI_IMAGE_USER.into(), + openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER.into(), oci_user.to_string(), ); env.insert( - openshell_core::sandbox_env::SANDBOX_UID.into(), + openshell_driver_sdk::sandbox_env::SANDBOX_UID.into(), String::new(), ); env.insert( - openshell_core::sandbox_env::SANDBOX_GID.into(), + openshell_driver_sdk::sandbox_env::SANDBOX_GID.into(), String::new(), ); } @@ -825,7 +833,7 @@ fn build_env( && !s.sandbox_token.is_empty() { env.insert( - openshell_core::sandbox_env::SANDBOX_TOKEN_FILE.into(), + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN_FILE.into(), SANDBOX_TOKEN_MOUNT_PATH.into(), ); } @@ -848,7 +856,7 @@ fn build_labels(sandbox: &DriverSandbox) -> BTreeMap { } // Managed labels (highest priority -- always overwrite). labels.insert( - openshell_core::resource_admission::CONFIG_USED_LABEL.into(), + openshell_container_runtime::resource_admission::CONFIG_USED_LABEL.into(), template .and_then(|t| t.driver_config.as_ref()) .is_some_and(|config| !config.fields.is_empty()) @@ -1382,7 +1390,7 @@ fn build_base_spec( format!( "test -e /var/run/openshell-ssh-ready || test -S {} || ss -tlnp | grep -q :{}", config.ssh_socket_path, - openshell_core::config::DEFAULT_SSH_PORT + openshell_container_runtime::config::DEFAULT_SSH_PORT ), ], interval: config @@ -1460,7 +1468,7 @@ fn build_base_spec( let mut m = vec![Mount { kind: "tmpfs".into(), source: "tmpfs".into(), - destination: openshell_core::container_paths::NETNS_MOUNT_ROOT.into(), + destination: openshell_driver_sdk::container_paths::NETNS_MOUNT_ROOT.into(), options: vec!["rw".into(), "nosuid".into(), "nodev".into()], }]; // Deliver the gateway CA into the container when TLS is @@ -1532,7 +1540,7 @@ fn build_base_spec( // the host, so we must use the published host port on 127.0.0.1 instead. portmappings: vec![PortMapping { host_port: 0, - container_port: openshell_core::config::DEFAULT_SSH_PORT, + container_port: openshell_container_runtime::config::DEFAULT_SSH_PORT, protocol: "tcp".into(), }], userns: config.userns.as_deref().map(|raw| { @@ -1597,7 +1605,7 @@ impl IsolationSpecs { identities: &BTreeMap, ) -> Result<(), ComputeDriverError> { self.workload.labels.insert( - openshell_core::resource_admission::IDENTITIES_LABEL.into(), + openshell_container_runtime::resource_admission::IDENTITIES_LABEL.into(), serde_json::to_string(identities) .map_err(|error| ComputeDriverError::Message(error.to_string()))?, ); @@ -1651,7 +1659,7 @@ pub fn build_isolation_specs( .config .app_armor_profile .as_ref() - .and_then(openshell_core::config::AppArmorProfile::oci_security_opt) + .and_then(openshell_driver_sdk::config::AppArmorProfile::oci_security_opt) .and_then(|option| option.strip_prefix("apparmor=").map(str::to_string)); workload.seccomp_profile_path.clear(); workload @@ -1716,7 +1724,7 @@ pub fn build_isolation_specs( ); supervisor.image.clone_from(&input.config.supervisor_image); supervisor.env.insert( - openshell_core::sandbox_env::CONTAINER_IMAGE_UID.into(), + openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE_UID.into(), input.image.id.clone(), ); supervisor.entrypoint = vec!["/openshell-supervisor".into()]; @@ -1735,14 +1743,14 @@ pub fn build_isolation_specs( supervisor.mounts.push(network_trust_mount(network_trust)?); } supervisor.env.insert( - openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND.into(), + openshell_driver_sdk::sandbox_env::ADMITTED_ISOLATION_BACKEND.into(), openshell_sandbox_backend::BACKEND_NAME.into(), ); // The supervisor runs as the resolved non-root workload identity. Keep // generated interception CA material under its writable /run tmpfs rather // than the root-owned default at /etc/openshell-tls. supervisor.env.insert( - openshell_core::sandbox_env::PROXY_TLS_DIR.into(), + openshell_driver_sdk::sandbox_env::PROXY_TLS_DIR.into(), "/run/openshell/proxy-tls".into(), ); supervisor.user = user; @@ -1782,7 +1790,7 @@ pub fn build_isolation_specs( }); supervisor.mounts.retain(|mount| { trusted_mount(&mount.destination) - && mount.destination != openshell_core::container_paths::NETNS_MOUNT_ROOT + && mount.destination != openshell_driver_sdk::container_paths::NETNS_MOUNT_ROOT }); for destination in ["/run", "/var/log", "/tmp"] { supervisor.mounts.push(Mount { @@ -1828,7 +1836,7 @@ fn trusted_mount(destination: &str) -> bool { | PROXY_CA_MOUNT_PATH | ADDITIONAL_CA_MOUNT_PATH | PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR - ) || destination == openshell_core::container_paths::NETNS_MOUNT_ROOT + ) || destination == openshell_driver_sdk::container_paths::NETNS_MOUNT_ROOT } fn provider_spiffe_workload_api_socket_env_value(config: &PodmanComputeConfig) -> Option { @@ -1931,7 +1939,7 @@ fn parse_memory_to_bytes(quantity: &str) -> Option { mod tests { use super::*; use crate::client::ImageConfig; - use openshell_core::proto::compute::v1::{GpuResourceRequirements, ResourceRequirements}; + use openshell_driver_sdk::proto::compute::v1::{GpuResourceRequirements, ResourceRequirements}; static ENV_LOCK: std::sync::LazyLock> = std::sync::LazyLock::new(|| std::sync::Mutex::new(())); @@ -2080,7 +2088,7 @@ mod tests { ..Default::default() }; let mut config = PodmanComputeConfig::default(); - config.app_armor_profile = Some(openshell_core::config::AppArmorProfile::Localhost( + config.app_armor_profile = Some(openshell_driver_sdk::config::AppArmorProfile::Localhost( "openshell-sandbox".into(), )); config.userns = Some("auto".into()); @@ -2129,7 +2137,7 @@ mod tests { specs .supervisor .env - .get(openshell_core::sandbox_env::CONTAINER_IMAGE_UID) + .get(openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE_UID) .map(String::as_str), Some("sha256:image") ); @@ -2137,7 +2145,7 @@ mod tests { specs .supervisor .env - .get(openshell_core::sandbox_env::CONTAINER_IMAGE) + .get(openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE) .map(String::as_str), Some("image:latest") ); @@ -2145,7 +2153,7 @@ mod tests { !specs .workload .env - .contains_key(openshell_core::sandbox_env::CONTAINER_IMAGE_UID) + .contains_key(openshell_driver_sdk::sandbox_env::CONTAINER_IMAGE_UID) ); assert_eq!(specs.supervisor.groups, vec!["2000"]); assert!(specs.supervisor.cap_add.is_empty()); @@ -2272,7 +2280,7 @@ mod tests { specs .supervisor .env - .get(openshell_core::sandbox_env::PROXY_TLS_DIR) + .get(openshell_driver_sdk::sandbox_env::PROXY_TLS_DIR) .map(String::as_str), Some("/run/openshell/proxy-tls") ); @@ -2379,7 +2387,7 @@ mod tests { #[test] fn container_spec_applies_cpu_and_memory_limits() { - use openshell_core::proto::compute::v1::{ + use openshell_driver_sdk::proto::compute::v1::{ DriverResourceRequirements, DriverSandboxSpec, DriverSandboxTemplate, }; @@ -2402,7 +2410,8 @@ mod tests { assert_eq!(limits.memory.limit, 2 * 1024 * 1024 * 1024); assert_eq!( limits.pids.as_ref().map(|pids| pids.limit), - openshell_core::config::default_sandbox_pids_limit().map(std::num::NonZeroI64::get) + openshell_container_runtime::config::default_sandbox_pids_limit() + .map(std::num::NonZeroI64::get) ); let serialized = serde_json::to_string(&limits).unwrap(); assert!(serialized.contains("\"pids\":{\"limit\":2048}")); @@ -2432,9 +2441,9 @@ mod tests { let mut sandbox = test_sandbox("test-id", "test-name"); let spec = sandbox.spec.get_or_insert_default(); for (key, value) in [ - (openshell_core::sandbox_env::OCI_IMAGE_USER, "spoofed"), - (openshell_core::sandbox_env::SANDBOX_UID, "9999"), - (openshell_core::sandbox_env::SANDBOX_GID, "9999"), + (openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER, "spoofed"), + (openshell_driver_sdk::sandbox_env::SANDBOX_UID, "9999"), + (openshell_driver_sdk::sandbox_env::SANDBOX_GID, "9999"), ] { spec.environment.insert(key.to_string(), value.to_string()); } @@ -2473,15 +2482,15 @@ mod tests { ); assert!(container["volumes"].as_array().is_some_and(Vec::is_empty)); assert_eq!( - container["env"][openshell_core::sandbox_env::OCI_IMAGE_USER].as_str(), + container["env"][openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER].as_str(), Some("app:staff") ); assert_eq!( - container["env"][openshell_core::sandbox_env::SANDBOX_UID].as_str(), + container["env"][openshell_driver_sdk::sandbox_env::SANDBOX_UID].as_str(), Some("") ); assert_eq!( - container["env"][openshell_core::sandbox_env::SANDBOX_GID].as_str(), + container["env"][openshell_driver_sdk::sandbox_env::SANDBOX_GID].as_str(), Some("") ); } @@ -2491,14 +2500,14 @@ mod tests { let mut sandbox = test_sandbox("test-id", "test-name"); let spec = sandbox.spec.get_or_insert_default(); spec.environment.insert( - openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME.to_string(), + openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME.to_string(), "evil.attacker.example.com".to_string(), ); let container = build_container_spec(&sandbox, &test_config()); assert_eq!( - container["env"].get(openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME), + container["env"].get(openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME), None, "GATEWAY_TLS_SERVER_NAME must be stripped from the supervisor environment" ); @@ -2529,7 +2538,7 @@ mod tests { #[test] fn container_spec_maps_empty_gpu_request_to_selected_default_cdi_device() { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2554,7 +2563,7 @@ mod tests { #[test] fn container_spec_omits_devices_without_resolved_default_cdi_devices() { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2571,7 +2580,7 @@ mod tests { #[test] fn container_spec_accepts_non_nvidia_explicit_and_resolved_cdi_devices() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { resource_requirements: Some(gpu_resources(None)), @@ -2606,7 +2615,7 @@ mod tests { #[test] fn container_spec_rejects_host_paths_in_explicit_and_resolved_cdi_devices() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { resource_requirements: Some(gpu_resources(None)), @@ -2648,7 +2657,7 @@ mod tests { #[test] fn container_spec_passes_explicit_cdi_device_id_through() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2670,7 +2679,7 @@ mod tests { #[test] fn container_spec_accepts_gpu_count_matching_cdi_devices() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2700,7 +2709,7 @@ mod tests { #[test] fn container_spec_rejects_gpu_count_mismatched_cdi_devices() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2723,7 +2732,7 @@ mod tests { #[test] fn container_spec_rejects_cdi_devices_without_gpu_request() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2742,7 +2751,7 @@ mod tests { #[test] fn container_spec_rejects_empty_cdi_devices() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2762,7 +2771,7 @@ mod tests { #[test] fn container_spec_rejects_unknown_driver_config_fields() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -2782,7 +2791,7 @@ mod tests { #[test] fn sandbox_driver_config_rejects_trusted_runtime_image_overrides() { - use openshell_core::proto::compute::v1::DriverSandboxTemplate; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxTemplate; for field in ["sandbox_runtime_image", "supervisor_image"] { let template = DriverSandboxTemplate { @@ -2820,7 +2829,7 @@ mod tests { let env_map = spec["env"].as_object().expect("env should be an object"); assert_eq!( env_map - .get(openshell_core::sandbox_env::SANDBOX) + .get(openshell_driver_sdk::sandbox_env::SANDBOX) .and_then(|v| v.as_str()), Some("my-sandbox"), ); @@ -2875,7 +2884,7 @@ mod tests { #[test] fn container_spec_required_vars_cannot_be_overridden() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "legit-name"); let mut env_overrides = std::collections::HashMap::new(); @@ -2920,7 +2929,7 @@ mod tests { #[test] fn container_spec_passes_the_gateway_otlp_endpoint_to_the_supervisor() { - use openshell_core::sandbox_env::{ + use openshell_driver_sdk::sandbox_env::{ OTLP_AGENT_ENDPOINT, OTLP_AGENT_PROTOCOL, OTLP_ENDPOINT, OTLP_OCSF_ENDPOINT, OTLP_OCSF_PROTOCOL, OTLP_PROTOCOL, OtlpExport, OtlpProtocol, SupervisorOtlp, }; @@ -3007,26 +3016,26 @@ mod tests { }; let spec = build_container_spec(&sandbox, &config); assert_eq!( - spec["env"][openshell_core::sandbox_env::GATEWAY_NAME], + spec["env"][openshell_driver_sdk::sandbox_env::GATEWAY_NAME], "prod-west" ); } #[test] fn container_spec_telemetry_toggle_comes_from_driver_env() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let _guard = ENV_LOCK.lock().unwrap(); temp_env::with_vars( [( - openshell_core::sandbox_env::TELEMETRY_ENABLED, + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED, Some("false"), )], || { let mut sandbox = test_sandbox("test-id", "legit-name"); sandbox.spec = Some(DriverSandboxSpec { environment: std::collections::HashMap::from([( - openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(), + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED.to_string(), "true".to_string(), )]), template: Some(DriverSandboxTemplate::default()), @@ -3038,7 +3047,7 @@ mod tests { assert_eq!( env_map - .get(openshell_core::sandbox_env::TELEMETRY_ENABLED) + .get(openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED) .and_then(|v| v.as_str()), Some("false"), "telemetry toggle must come from the deployment environment" @@ -3049,12 +3058,12 @@ mod tests { #[test] fn container_spec_keeps_network_capabilities_driver_controlled() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "legit-name"); sandbox.spec = Some(DriverSandboxSpec { environment: std::collections::HashMap::from([( - openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), + openshell_driver_sdk::sandbox_env::NETWORK_RUNTIME_CAPABILITIES.to_string(), "spoofed".to_string(), )]), template: Some(DriverSandboxTemplate::default()), @@ -3062,8 +3071,10 @@ mod tests { }); let spec = build_container_spec(&sandbox, &test_config()); assert_eq!( - spec["env"][openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES], - serde_json::json!(openshell_core::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY) + spec["env"][openshell_driver_sdk::sandbox_env::NETWORK_RUNTIME_CAPABILITIES], + serde_json::json!( + openshell_driver_sdk::sandbox_env::POLICY_DNS_TRANSPARENT_TCP_CAPABILITY + ) ); } @@ -3203,7 +3214,7 @@ mod tests { #[test] fn container_spec_sandbox_env_cannot_influence_proxy_argv() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; // A sandbox creator tries to steer the egress boundary through spec // and template environment (image-baked ENV behaves the same at the @@ -3255,7 +3266,7 @@ mod tests { #[test] fn container_spec_required_labels_cannot_be_overridden() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("real-id", "real-name"); sandbox.namespace = "real-namespace".to_string(); @@ -3419,7 +3430,7 @@ mod tests { let vol = &image_volumes[0]; assert_eq!( vol["source"].as_str(), - Some(openshell_core::config::default_sandbox_runtime_image().as_str()), + Some(openshell_driver_sdk::config::default_sandbox_runtime_image().as_str()), "image volume source should be the sandbox runtime image" ); assert_eq!( @@ -3436,7 +3447,7 @@ mod tests { #[test] fn container_spec_includes_driver_config_mounts() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3505,7 +3516,8 @@ mod tests { let image_volumes = spec["image_volumes"] .as_array() .expect("image_volumes should be an array"); - let expected_sandbox_runtime = openshell_core::config::default_sandbox_runtime_image(); + let expected_sandbox_runtime = + openshell_driver_sdk::config::default_sandbox_runtime_image(); assert!(image_volumes.iter().any(|volume| { volume["source"].as_str() == Some(expected_sandbox_runtime.as_str()) && volume["destination"].as_str() == Some("/opt/openshell/bin") @@ -3519,7 +3531,7 @@ mod tests { #[test] fn container_spec_defaults_volume_mounts_to_read_only() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3553,7 +3565,7 @@ mod tests { #[test] fn container_spec_allows_explicit_writable_volume_mounts() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3588,7 +3600,7 @@ mod tests { #[test] fn driver_config_rejects_duplicate_mount_targets() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3669,7 +3681,7 @@ mod tests { #[test] fn driver_config_rejects_bind_mounts_unless_enabled() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3694,7 +3706,7 @@ mod tests { #[test] fn container_spec_includes_bind_mounts_when_enabled() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3734,7 +3746,7 @@ mod tests { #[test] fn container_spec_defaults_enabled_bind_mounts_to_read_only() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3773,7 +3785,7 @@ mod tests { #[test] fn driver_config_rejects_relative_bind_sources_when_enabled() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3802,7 +3814,7 @@ mod tests { #[test] fn container_spec_bind_mount_selinux_shared_label() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3841,7 +3853,7 @@ mod tests { #[test] fn container_spec_bind_mount_selinux_private_label() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let mut sandbox = test_sandbox("test-id", "test-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -3880,7 +3892,7 @@ mod tests { #[test] fn driver_config_rejects_reserved_mount_targets() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; for target in ["/etc/openshell/tls/client", ADDITIONAL_CA_MOUNT_PATH] { let mut sandbox = test_sandbox("test-id", "test-name"); @@ -4014,7 +4026,7 @@ mod tests { #[test] fn container_spec_uses_token_secret_mount_without_raw_token_env() { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let mut sandbox = test_sandbox("token-id", "token-name"); sandbox.spec = Some(DriverSandboxSpec { @@ -4029,13 +4041,13 @@ mod tests { let env_map = spec["env"].as_object().expect("env should be an object"); assert_eq!( env_map - .get(openshell_core::sandbox_env::SANDBOX_TOKEN) + .get(openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN) .and_then(|v| v.as_str()), None ); assert_eq!( env_map - .get(openshell_core::sandbox_env::SANDBOX_TOKEN_FILE) + .get(openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN_FILE) .and_then(|v| v.as_str()), Some("/etc/openshell/auth/sandbox.jwt") ); @@ -4167,7 +4179,7 @@ mod tests { let env_map = spec["env"].as_object().expect("env should be an object"); assert_eq!( env_map - .get(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) + .get(openshell_driver_sdk::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) .and_then(|v| v.as_str()), Some("/spiffe-workload-api/spire-agent.sock"), ); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 6bea5b7f16..5435577544 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -10,23 +10,23 @@ use crate::watcher::{ self, LifecycleEventFences, WatchStream, driver_sandbox_from_inspect, driver_sandbox_from_list_entry, }; -use openshell_core::config::CDI_GPU_DEVICE_ALL; -use openshell_core::driver_utils::{ +use openshell_container_runtime::config::CDI_GPU_DEVICE_ALL; +use openshell_container_runtime::driver_utils::{ SANDBOX_RUNTIME_IMAGE_BINARY_PATH, extract_first_tar_entry, supervisor_image_should_refresh, temp_extract_container_name, validate_linux_elf_binary, write_cache_binary_atomic, }; -use openshell_core::gpu::{ - CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, - effective_driver_gpu_count, validate_cdi_gpu_device_request, +use openshell_container_runtime::gpu::{ + CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, validate_cdi_gpu_device_request, }; -use openshell_core::network_trust::{ +use openshell_container_runtime::network_trust::{ NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, }; -use openshell_core::proto::compute::v1::{ +use openshell_driver_sdk::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_driver_sdk::proto::compute::v1::{ CpuResourceCapabilities, DriverSandbox, GetCapabilitiesResponse, GpuResourceCapabilities, GpuResourceRequirements, MemoryResourceCapabilities, ResourceCapabilities, }; -use openshell_core::{ComputeDriverError, NetworkSupervisorTrustBundle}; +use openshell_driver_sdk::{ComputeDriverError, NetworkSupervisorTrustBundle}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -83,9 +83,9 @@ fn select_grpc_endpoint( } fn select_supervisor_otlp( - otlp: openshell_core::sandbox_env::SupervisorOtlp, + otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp, environment: PodmanEndpointEnvironment, -) -> openshell_core::sandbox_env::SupervisorOtlp { +) -> openshell_driver_sdk::sandbox_env::SupervisorOtlp { otlp.map_endpoints(|endpoint| supervisor_collector_endpoint(&endpoint, environment)) } @@ -117,14 +117,14 @@ fn supervisor_collector_endpoint(endpoint: &str, environment: PodmanEndpointEnvi fn decode_launch_authentication( encoded: &[u8], -) -> Result { +) -> Result { let authentication = - serde_json::from_slice::(encoded) + serde_json::from_slice::(encoded) .map_err(|error| { - ComputeDriverError::Precondition(format!( - "decode Podman sandbox launch authentication: {error}" - )) - })?; + ComputeDriverError::Precondition(format!( + "decode Podman sandbox launch authentication: {error}" + )) + })?; authentication.validate().map_err(|error| { ComputeDriverError::Precondition(format!( "validate Podman sandbox launch authentication: {error}" @@ -270,7 +270,7 @@ async fn cleanup_sandbox_resolver_secret(client: &PodmanClient, secret_name: &st /// Fails closed: when `proxy_auth_file` is configured but cannot be read or /// does not hold a valid `user:pass` credential, the sandbox is not created. /// Credential validation is shared with the in-container supervisor through -/// [`openshell_core::driver_utils::parse_upstream_proxy_credential`], so a +/// [`openshell_driver_sdk::driver_utils::parse_upstream_proxy_credential`], so a /// credential staged here can never be rejected at supervisor startup. async fn create_sandbox_proxy_auth_secret( client: &PodmanClient, @@ -286,13 +286,13 @@ async fn create_sandbox_proxy_auth_secret( // exhaust gateway memory. let path_owned = path.to_string(); let raw = tokio::task::spawn_blocking(move || { - openshell_core::driver_utils::read_upstream_proxy_credential_file(&path_owned) + openshell_driver_sdk::driver_utils::read_upstream_proxy_credential_file(&path_owned) }) .await .map_err(|e| ComputeDriverError::Message(format!("proxy_auth_file read task failed: {e}")))? .map_err(ComputeDriverError::Message)?; - let credential = - openshell_core::driver_utils::parse_upstream_proxy_credential(&raw).map_err(|err| { + let credential = openshell_driver_sdk::driver_utils::parse_upstream_proxy_credential(&raw) + .map_err(|err| { ComputeDriverError::InvalidArgument(format!("proxy_auth_file '{path}': {err}")) })?; @@ -477,7 +477,7 @@ fn resolve_socket_path( fn operator_network_trust( path: &str, ) -> Result<(NetworkSupervisorTrustBundle, tempfile::TempDir), PodmanApiError> { - let pem = openshell_core::driver_utils::read_upstream_proxy_ca_bundle_file( + let pem = openshell_driver_sdk::driver_utils::read_upstream_proxy_ca_bundle_file( path, "additional_ca_bundle", ) @@ -715,13 +715,14 @@ impl PodmanComputeDriver { /// Report driver capabilities. pub fn capabilities(&self) -> Result { Ok(GetCapabilitiesResponse { - resource_admission_policy: openshell_core::resource_admission::DriverAdmissionConfig { - allow_driver_config: self.config.allow_driver_config, - resource_admission: self.config.resource_admission.clone(), - } - .acknowledgement(), + resource_admission_policy: + openshell_driver_sdk::resource_admission::DriverAdmissionConfig { + allow_driver_config: self.config.allow_driver_config, + resource_admission: self.config.resource_admission.clone(), + } + .acknowledgement(), driver_name: "podman".to_string(), - driver_version: openshell_core::VERSION.to_string(), + driver_version: openshell_driver_sdk::VERSION.to_string(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, supports_sandbox_authentication: false, @@ -740,12 +741,14 @@ impl PodmanComputeDriver { }), rootfs_tar_staging_dir: String::new(), rootfs_tar_max_bytes: 0, - extension: Some(openshell_core::extension_protocol::extension_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, - "openshell/podman", - openshell_core::VERSION, - [], - )), + extension: Some( + openshell_driver_sdk::extension_protocol::extension_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, + "openshell/podman", + openshell_driver_sdk::VERSION, + [], + ), + ), }) } @@ -767,7 +770,7 @@ impl PodmanComputeDriver { &self, sandbox: &'a DriverSandbox, ) -> Result, ComputeDriverError> { - openshell_core::resource_admission::check_sandbox_driver_config( + openshell_driver_sdk::resource_admission::check_sandbox_driver_config( self.config.allow_driver_config, sandbox, ) @@ -901,10 +904,10 @@ impl PodmanComputeDriver { let labels = &inspect.config.labels; let precondition = |error: tonic::Status| ComputeDriverError::Precondition(error.message().into()); - openshell_core::resource_admission::check_config_provenance( + openshell_container_runtime::resource_admission::check_config_provenance( self.config.allow_driver_config, labels - .get(openshell_core::resource_admission::CONFIG_USED_LABEL) + .get(openshell_container_runtime::resource_admission::CONFIG_USED_LABEL) .map(String::as_str), ) .map_err(precondition)?; @@ -919,7 +922,7 @@ impl PodmanComputeDriver { let sandbox_id = labels.get(LABEL_SANDBOX_ID).ok_or_else(missing)?; let mounts = inspect.mounts.as_ref().ok_or_else(missing)?; let expected: std::collections::BTreeMap = labels - .get(openshell_core::resource_admission::IDENTITIES_LABEL) + .get(openshell_container_runtime::resource_admission::IDENTITIES_LABEL) .and_then(|value| serde_json::from_str(value).ok()) .ok_or_else(missing)?; let mut actual = std::collections::BTreeMap::new(); @@ -989,7 +992,9 @@ impl PodmanComputeDriver { Some("tmpfs") => {} Some("bind") if mount["Destination"].as_str() - == Some(openshell_core::driver_utils::SUPERVISOR_CONTAINER_BINARY) + == Some( + openshell_driver_sdk::driver_utils::SUPERVISOR_CONTAINER_BINARY, + ) && mount["RW"].as_bool() == Some(false) && labels .get("openshell.ai/runtime-binary-source") @@ -1872,7 +1877,7 @@ impl PodmanComputeDriver { &self, old_workload: &ContainerListEntry, durable_sandbox: &DriverSandbox, - generation: &openshell_core::sandbox_generation::SandboxGenerationId, + generation: &openshell_driver_sdk::sandbox_generation::SandboxGenerationId, encoded_authentication: &[u8], ) -> Result<(), ComputeDriverError> { if durable_sandbox.id @@ -2028,7 +2033,7 @@ impl PodmanComputeDriver { // The durable snapshot is used only if a stopped generation must be // reconciled; it never comes from Podman's mutable inspect response. self.verify_network_trust()?; - let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( + let generation = openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( generation_id.to_string(), ) .map_err(|error| ComputeDriverError::InvalidArgument(error.to_string()))?; @@ -2455,14 +2460,14 @@ impl PodmanComputeDriver { } fn validate_apparmor_support( - profile: Option<&openshell_core::AppArmorProfile>, + profile: Option<&openshell_driver_sdk::AppArmorProfile>, apparmor_enabled: bool, ) -> Result<(), PodmanApiError> { let requires_apparmor = matches!( profile, Some( - openshell_core::AppArmorProfile::RuntimeDefault - | openshell_core::AppArmorProfile::Localhost(_) + openshell_driver_sdk::AppArmorProfile::RuntimeDefault + | openshell_driver_sdk::AppArmorProfile::Localhost(_) ) ); if requires_apparmor && !apparmor_enabled { @@ -2567,8 +2572,9 @@ async fn extract_sandbox_bin( &inspect.id }; - let cache_path = openshell_core::driver_utils::supervisor_cache_path("podman-sandbox", digest) - .map_err(ComputeDriverError::Precondition)?; + let cache_path = + openshell_container_runtime::driver_utils::supervisor_cache_path("podman-sandbox", digest) + .map_err(ComputeDriverError::Precondition)?; // Unit tests use ordered Podman API stubs and intentionally exercise the // extraction path on every create. Production reuses the immutable cache. #[cfg(not(test))] @@ -2672,11 +2678,11 @@ mod tests { use super::*; use crate::test_utils::{StubResponse, spawn_podman_stub}; use hyper::StatusCode; - use openshell_core::jwt::{ + use openshell_driver_sdk::jwt::{ CredentialEpoch, SandboxLaunchAuthentication, SecretJwt, SessionVerificationKey, SupervisorAuthBundle, }; - use openshell_core::proto::compute::v1::{ + use openshell_driver_sdk::proto::compute::v1::{ DriverSandboxSpec, DriverSandboxTemplate, ResourceRequirements, }; use std::collections::HashMap; @@ -2686,12 +2692,13 @@ mod tests { fn launch_authentication() -> SandboxLaunchAuthentication { SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { - session_id: openshell_core::SandboxSessionId::new(), - runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( - "generation-1", - ) - .unwrap(), - session_rotation: openshell_core::jwt::SessionRotation::new(1).unwrap(), + session_id: openshell_driver_sdk::SandboxSessionId::new(), + runtime_generation: + openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( + "generation-1", + ) + .unwrap(), + session_rotation: openshell_driver_sdk::jwt::SessionRotation::new(1).unwrap(), auth_epoch: CredentialEpoch::new(1).unwrap(), gateway_token: SecretJwt::parse("gateway.token.value").unwrap(), gateway_expires_at: i64::MAX, @@ -2830,9 +2837,9 @@ mod tests { ) -> ( ContainerListEntry, DriverSandbox, - openshell_core::sandbox_generation::SandboxGenerationId, + openshell_driver_sdk::sandbox_generation::SandboxGenerationId, ) { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let old_workload = ContainerListEntry { id: format!("workload-{sandbox_id}"), names: vec![format!("openshell-default--{sandbox_id}")], @@ -2851,7 +2858,7 @@ mod tests { workspace: String::new(), }; let generation = - openshell_core::sandbox_generation::SandboxGenerationId::parse("generation-1") + openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse("generation-1") .expect("valid generation"); (old_workload, durable, generation) } @@ -3537,8 +3544,8 @@ mod tests { ); } - fn supervisor_otlp(endpoint: &str) -> openshell_core::sandbox_env::SupervisorOtlp { - use openshell_core::sandbox_env::{OtlpExport, OtlpProtocol, SupervisorOtlp}; + fn supervisor_otlp(endpoint: &str) -> openshell_driver_sdk::sandbox_env::SupervisorOtlp { + use openshell_driver_sdk::sandbox_env::{OtlpExport, OtlpProtocol, SupervisorOtlp}; let export = |protocol| { Some(OtlpExport { endpoint: endpoint.to_string(), @@ -3556,7 +3563,7 @@ mod tests { fn supervisor_otlp_stays_empty_without_gateway_export() { assert!( select_supervisor_otlp( - openshell_core::sandbox_env::SupervisorOtlp::default(), + openshell_driver_sdk::sandbox_env::SupervisorOtlp::default(), PodmanEndpointEnvironment::PodmanMachine ) .is_empty() @@ -3629,7 +3636,7 @@ mod tests { #[test] fn confined_apparmor_profiles_follow_podman_capability() { - use openshell_core::AppArmorProfile; + use openshell_driver_sdk::AppArmorProfile; for profile in [ AppArmorProfile::RuntimeDefault, @@ -3698,7 +3705,7 @@ mod tests { #[tokio::test] async fn validate_sandbox_create_accepts_default_gpu_with_inventory() { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let driver = PodmanComputeDriver::for_tests_with_gpu_inventory( PodmanComputeConfig::default(), @@ -3717,7 +3724,7 @@ mod tests { #[tokio::test] async fn validate_sandbox_create_accepts_all_only_inventory_when_dxg_fallback_allowed() { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let driver = PodmanComputeDriver::for_tests_with_gpu_inventory_and_all_fallback( PodmanComputeConfig::default(), @@ -3737,7 +3744,7 @@ mod tests { #[tokio::test] async fn validate_sandbox_create_rejects_all_only_inventory_without_dxg_fallback() { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let driver = PodmanComputeDriver::for_tests_with_gpu_inventory( PodmanComputeConfig::default(), @@ -3758,7 +3765,7 @@ mod tests { #[tokio::test] async fn validate_sandbox_create_rejects_host_device_paths() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig { allow_driver_config: true, @@ -3784,7 +3791,7 @@ mod tests { #[tokio::test] async fn validate_sandbox_create_passes_explicit_cdi_device_id_without_inventory() { - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig { allow_driver_config: true, @@ -3807,7 +3814,7 @@ mod tests { #[test] fn driver_default_gpu_selection_consumes_distinct_devices_for_creates() { - use openshell_core::proto::compute::v1::DriverSandboxSpec; + use openshell_driver_sdk::proto::compute::v1::DriverSandboxSpec; let driver = PodmanComputeDriver::for_tests_with_gpu_inventory( PodmanComputeConfig::default(), @@ -3897,7 +3904,7 @@ mod tests { fn test_driver(socket_path: PathBuf) -> PodmanComputeDriver { let config = PodmanComputeConfig { allow_driver_config: true, - resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { + resource_admission: openshell_driver_sdk::resource_admission::ResourceAdmissionConfig { enabled: false, ..Default::default() }, @@ -3918,7 +3925,7 @@ mod tests { let serde_json::Value::Object(object) = value else { panic!("expected JSON object"); }; - openshell_core::proto_struct::json_object_to_struct(object) + openshell_driver_sdk::proto_struct::json_object_to_struct(object) .expect("test JSON must convert to a protobuf Struct") } @@ -4052,8 +4059,8 @@ mod tests { "Labels": { LABEL_SANDBOX_ID: sandbox_id, container::LABEL_SANDBOX_WORKSPACE: "team-a", - openshell_core::resource_admission::CONFIG_USED_LABEL: "false", - openshell_core::resource_admission::IDENTITIES_LABEL: "{}", + openshell_container_runtime::resource_admission::CONFIG_USED_LABEL: "false", + openshell_container_runtime::resource_admission::IDENTITIES_LABEL: "{}", } }, "Mounts": [ @@ -4071,10 +4078,11 @@ mod tests { ); let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig { socket_path: Some(socket.clone()), - resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { - enabled: true, - ..Default::default() - }, + resource_admission: + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig { + enabled: true, + ..Default::default() + }, ..Default::default() }); let result = driver.admit_container_resources("workload").await; @@ -4096,10 +4104,11 @@ mod tests { async fn admission_driver_config_denial_does_not_contact_podman() { for enabled in [true, false] { let driver = PodmanComputeDriver::for_tests(PodmanComputeConfig { - resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig { - enabled, - ..Default::default() - }, + resource_admission: + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig { + enabled, + ..Default::default() + }, ..Default::default() }); let error = driver diff --git a/crates/openshell-driver-podman/src/grpc.rs b/crates/openshell-driver-podman/src/grpc.rs index 5318b3f833..d2c0977708 100644 --- a/crates/openshell-driver-podman/src/grpc.rs +++ b/crates/openshell-driver-podman/src/grpc.rs @@ -4,7 +4,7 @@ #![allow(clippy::result_large_err)] // gRPC handlers return Result<_, tonic::Status> use futures::{Stream, StreamExt}; -use openshell_core::proto::compute::v1::{ +use openshell_driver_sdk::proto::compute::v1::{ CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, EnsureWorkspaceRequest, EnsureWorkspaceResponse, GetCapabilitiesRequest, GetCapabilitiesResponse, GetSandboxRequest, @@ -49,15 +49,17 @@ impl ComputeDriverService { } #[tonic::async_trait] -impl openshell_core::compute::ComputeDriver for ComputeDriverService {} +impl openshell_driver_sdk::compute::ComputeDriver for ComputeDriverService {} #[tonic::async_trait] impl ComputeDriver for ComputeDriverService { async fn authenticate_sandbox( &self, - _request: Request, - ) -> Result, Status> - { + _request: Request, + ) -> Result< + Response, + Status, + > { self.rpc_tracer .trace(openshell_otel::rpc::AUTHENTICATE_SANDBOX, async { Err(Status::unimplemented( @@ -74,8 +76,8 @@ impl ComputeDriver for ComputeDriverService { self.rpc_tracer .trace(openshell_otel::rpc::GET_CAPABILITIES, async { let capabilities = self.driver.capabilities().map_err(Status::from)?; - openshell_core::extension_protocol::validate_gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + openshell_driver_sdk::extension_protocol::validate_gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, "podman", capabilities.extension.as_ref(), request.into_inner().gateway, @@ -279,11 +281,11 @@ mod tests { use crate::container; use crate::test_utils::{StubResponse, spawn_podman_stub, unique_socket_path}; use hyper::StatusCode; - use openshell_core::ComputeDriverError; + use openshell_driver_sdk::ComputeDriverError; use std::path::PathBuf; type TestDriverClient = - openshell_core::proto::compute::v1::compute_driver_client::ComputeDriverClient< + openshell_driver_sdk::proto::compute::v1::compute_driver_client::ComputeDriverClient< tonic::transport::Channel, >; @@ -303,7 +305,7 @@ mod tests { tokio::sync::oneshot::Sender<()>, tokio::task::JoinHandle>, ) { - use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; + use openshell_driver_sdk::proto::compute::v1::compute_driver_server::ComputeDriverServer; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -381,8 +383,8 @@ mod tests { ComputeDriver::get_capabilities( &service, Request::new(GetCapabilitiesRequest { - gateway: Some(openshell_core::extension_protocol::gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + gateway: Some(openshell_driver_sdk::extension_protocol::gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, )), }), ) @@ -449,8 +451,8 @@ mod tests { client .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { - gateway: Some(openshell_core::extension_protocol::gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + gateway: Some(openshell_driver_sdk::extension_protocol::gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, )), })) .await diff --git a/crates/openshell-driver-podman/src/isolation.rs b/crates/openshell-driver-podman/src/isolation.rs index f9fc790588..fa837aef8b 100644 --- a/crates/openshell-driver-podman/src/isolation.rs +++ b/crates/openshell-driver-podman/src/isolation.rs @@ -8,8 +8,8 @@ use std::collections::{BTreeMap, HashMap}; use std::io::Read; use std::path::PathBuf; -use openshell_core::ComputeDriverError; -use openshell_core::proto::compute::v1::DriverSandbox; +use openshell_driver_sdk::ComputeDriverError; +use openshell_driver_sdk::proto::compute::v1::DriverSandbox; use openshell_isolation_interface::contract::{ProvisioningEvidence, ResolvedWorkloadIdentity}; use openshell_sandbox_backend::ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM; use openshell_sandbox_backend::boundary_protocol::{ @@ -127,8 +127,8 @@ pub fn resolve_identity( // policy requested no identity. Synthesize a numeric non-root identity // instead of rejecting the image, matching Docker, Kubernetes, and VM. return ResolvedWorkloadIdentity::new( - openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, - openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_GID, Vec::new(), "default".into(), image_id.into(), @@ -199,7 +199,7 @@ pub struct BootstrapArchivesInput<'a> { pub identity: &'a ResolvedWorkloadIdentity, pub allow_extra_supplementary_groups: bool, pub child_env: HashMap, - pub launch_authentication: &'a openshell_core::jwt::SandboxLaunchAuthentication, + pub launch_authentication: &'a openshell_driver_sdk::jwt::SandboxLaunchAuthentication, } /// The shared volume contains only sandbox credentials. Supervisor credentials, @@ -427,7 +427,7 @@ impl<'a> Archive<'a> { #[cfg(test)] mod tests { use super::*; - use openshell_core::jwt::{ + use openshell_driver_sdk::jwt::{ CredentialEpoch, SandboxLaunchAuthentication, SecretJwt, SessionVerificationKey, SupervisorAuthBundle, }; @@ -459,12 +459,13 @@ mod tests { fn authentication() -> SandboxLaunchAuthentication { SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { - session_id: openshell_core::SandboxSessionId::new(), - runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( - "generation-1", - ) - .unwrap(), - session_rotation: openshell_core::jwt::SessionRotation::new(1).unwrap(), + session_id: openshell_driver_sdk::SandboxSessionId::new(), + runtime_generation: + openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( + "generation-1", + ) + .unwrap(), + session_rotation: openshell_driver_sdk::jwt::SessionRotation::new(1).unwrap(), auth_epoch: CredentialEpoch::new(1).unwrap(), gateway_token: SecretJwt::parse("gateway.token.value").unwrap(), gateway_expires_at: i64::MAX, @@ -515,8 +516,8 @@ mod tests { assert_eq!( (identity.uid, identity.gid), ( - openshell_core::sandbox_env::DEFAULT_SANDBOX_UID, - openshell_core::sandbox_env::DEFAULT_SANDBOX_GID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_UID, + openshell_driver_sdk::sandbox_env::DEFAULT_SANDBOX_GID, ) ); assert_eq!(identity.source, "default"); diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index cc0ebe1a20..f1e67ad78c 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -9,11 +9,11 @@ use std::num::{NonZeroI64, NonZeroU64}; use std::path::PathBuf; use tracing::info; -use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; -use openshell_core::sandbox_env::SupervisorOtlpArgs; -use openshell_core::{AppArmorProfile, ImagePullPolicy, VERSION}; use openshell_driver_podman::config::{DEFAULT_NETWORK_NAME, DEFAULT_PODMAN_STOP_TIMEOUT_SECS}; use openshell_driver_podman::{ComputeDriverService, PodmanComputeConfig, PodmanComputeDriver}; +use openshell_driver_sdk::proto::compute::v1::compute_driver_server::ComputeDriverServer; +use openshell_driver_sdk::sandbox_env::SupervisorOtlpArgs; +use openshell_driver_sdk::{AppArmorProfile, ImagePullPolicy, VERSION}; #[derive(Parser)] #[command(name = "openshell-driver-podman")] @@ -25,7 +25,7 @@ struct Args { env = "OPENSHELL_DRIVER_ADMISSION_CONFIG_JSON", default_value = "{}" )] - admission_config_json: openshell_core::resource_admission::DriverAdmissionConfig, + admission_config_json: openshell_driver_sdk::resource_admission::DriverAdmissionConfig, /// Public compute-driver Unix socket used by an external gateway. #[arg(long, env = "OPENSHELL_COMPUTE_DRIVER_SOCKET")] bind_socket: Option, @@ -70,7 +70,7 @@ struct Args { #[arg( long, env = "OPENSHELL_GATEWAY_PORT", - default_value_t = openshell_core::config::DEFAULT_SERVER_PORT + default_value_t = openshell_driver_sdk::config::DEFAULT_SERVER_PORT )] gateway_port: u16, @@ -84,7 +84,7 @@ struct Args { #[arg( long, env = "OPENSHELL_SANDBOX_SSH_SOCKET_PATH", - default_value = openshell_core::container_paths::SSH_SOCKET_PATH + default_value = openshell_driver_sdk::container_paths::SSH_SOCKET_PATH )] sandbox_ssh_socket_path: String, @@ -111,11 +111,11 @@ struct Args { health_check_interval_secs: Option, /// OCI image containing the `openshell-sandbox` runtime binary. - #[arg(long, env = openshell_core::config::SANDBOX_RUNTIME_IMAGE_ENV)] + #[arg(long, env = openshell_driver_sdk::config::SANDBOX_RUNTIME_IMAGE_ENV)] sandbox_runtime_image: Option, /// OCI image containing the `openshell-supervisor` control binary. - #[arg(long, env = openshell_core::config::SUPERVISOR_IMAGE_ENV)] + #[arg(long, env = openshell_driver_sdk::config::SUPERVISOR_IMAGE_ENV)] supervisor_image: Option, /// Host path to the CA certificate for supervisor-to-gateway TLS. @@ -243,10 +243,10 @@ async fn main() -> Result<()> { stop_timeout_secs: args.stop_timeout, sandbox_runtime_image: args .sandbox_runtime_image - .unwrap_or_else(openshell_core::config::default_sandbox_runtime_image), + .unwrap_or_else(openshell_driver_sdk::config::default_sandbox_runtime_image), supervisor_image: args .supervisor_image - .unwrap_or_else(openshell_core::config::default_supervisor_image), + .unwrap_or_else(openshell_driver_sdk::config::default_supervisor_image), guest_tls_ca: args.podman_tls_ca, guest_tls_cert: args.podman_tls_cert, guest_tls_key: args.podman_tls_key, @@ -273,16 +273,16 @@ async fn main() -> Result<()> { let service = ComputeDriverServer::new(ComputeDriverService::new(driver)); if let Some(socket_path) = args.bind_socket { - let listener = openshell_core::external_driver_socket::bind_private(&socket_path) + let listener = openshell_driver_sdk::external_driver_socket::bind_private(&socket_path) .map_err(|err| miette::miette!("{err}"))?; let _cleanup = - openshell_core::external_driver_socket::SocketCleanup::new(socket_path.clone()); + openshell_driver_sdk::external_driver_socket::SocketCleanup::new(socket_path.clone()); info!(socket = %socket_path.display(), "Starting Podman compute driver"); tonic::transport::Server::builder() .layer(openshell_otel::compute_driver_rpc_layer()) .add_service(service) .serve_with_incoming_shutdown( - openshell_core::external_driver_socket::SameUidUnixIncoming::new(listener), + openshell_driver_sdk::external_driver_socket::SameUidUnixIncoming::new(listener), shutdown_signal(), ) .await @@ -373,7 +373,7 @@ mod tests { ); assert_eq!( defaults.sandbox_pids_limit.map(NonZeroI64::get), - Some(openshell_core::config::DEFAULT_SANDBOX_PIDS_LIMIT) + Some(openshell_container_runtime::config::DEFAULT_SANDBOX_PIDS_LIMIT) ); for flag in ["--sandbox-pids-limit", "--health-check-interval-secs"] { diff --git a/crates/openshell-driver-podman/src/otel_tracing.rs b/crates/openshell-driver-podman/src/otel_tracing.rs index 8d3314f821..7719a60a3c 100644 --- a/crates/openshell-driver-podman/src/otel_tracing.rs +++ b/crates/openshell-driver-podman/src/otel_tracing.rs @@ -11,7 +11,7 @@ mod tests { async fn driver_spans_reach_otlp_collector_with_resource_identity() { openshell_otel_test_support::assert_compute_driver_tracing( super::TRACING, - openshell_core::VERSION, + openshell_driver_sdk::VERSION, "podman.create", ) .await; diff --git a/crates/openshell-driver-podman/src/socket_discovery.rs b/crates/openshell-driver-podman/src/socket_discovery.rs index e7a6c79e74..383bfc0f90 100644 --- a/crates/openshell-driver-podman/src/socket_discovery.rs +++ b/crates/openshell-driver-podman/src/socket_discovery.rs @@ -13,10 +13,16 @@ const DISCOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); /// Return the first responsive well-known socket, then ask the Podman CLI for /// the active native or machine-backed connection. pub fn detect_socket() -> Option { - openshell_core::local_api_socket::first_responsive_socket(&socket_candidates(), |response| { - openshell_core::local_api_socket::http_response_is_success(response) - && openshell_core::local_api_socket::contains_ascii(response, b"Libpod-Api-Version:") - }) + openshell_container_runtime::local_api_socket::first_responsive_socket( + &socket_candidates(), + |response| { + openshell_container_runtime::local_api_socket::http_response_is_success(response) + && openshell_container_runtime::local_api_socket::contains_ascii( + response, + b"Libpod-Api-Version:", + ) + }, + ) .or_else(discover_socket) } diff --git a/crates/openshell-driver-podman/src/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index 9a508e52c3..6c86dc75ab 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -11,8 +11,8 @@ use crate::container::{ LABEL_MANAGED_FILTER, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, short_id, }; use futures::Stream; -use openshell_core::ComputeDriverError; -use openshell_core::proto::compute::v1::{ +use openshell_driver_sdk::ComputeDriverError; +use openshell_driver_sdk::proto::compute::v1::{ DriverCondition, DriverSandbox, DriverSandboxStatus, WatchSandboxesDeletedEvent, WatchSandboxesEvent, WatchSandboxesSandboxEvent, watch_sandboxes_event, }; @@ -26,10 +26,12 @@ use tracing::{debug, info, warn}; // Condition reason constants shared across event-building paths. const CONDITION_RUNNING: &str = "ContainerRunning"; const CONDITION_STARTING: &str = "ContainerStarting"; -use openshell_core::driver_utils::{ +use openshell_container_runtime::driver_utils::{ + CONDITION_WORKSPACE_VALIDATION_FAILED, WORKSPACE_VALIDATION_FAILED_MESSAGE, +}; +use openshell_driver_sdk::driver_utils::{ CONDITION_EXITED, CONDITION_RUNTIME_RESTART, CONDITION_STOPPED, - CONDITION_WORKSPACE_VALIDATION_FAILED, SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED, - WORKSPACE_VALIDATION_FAILED_MESSAGE, + SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED, }; pub type WatchStream = diff --git a/crates/openshell-driver-sdk/Cargo.toml b/crates/openshell-driver-sdk/Cargo.toml new file mode 100644 index 0000000000..03277eea37 --- /dev/null +++ b/crates/openshell-driver-sdk/Cargo.toml @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-driver-sdk" +description = "Narrow shared contract for OpenShell compute drivers" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-policy-schema = { path = "../openshell-policy-schema" } +openshell-crypto = { workspace = true, optional = true } +openshell-otel = { path = "../openshell-otel", optional = true } +base64 = { workspace = true } +clap = { workspace = true, optional = true } +jsonwebtoken = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } +prost = { workspace = true } +prost-types = { workspace = true } +tonic = { workspace = true, features = ["channel"] } +tonic-prost = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tracing = { workspace = true } +sha2 = { workspace = true } +rustls = { workspace = true } +miette = { workspace = true } +url = { workspace = true } + +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true } +nix = { workspace = true } +libc = "0.2" + +[dev-dependencies] +openshell-crypto = { workspace = true } +rcgen = { workspace = true } +base64 = { workspace = true } +tempfile = "3" + +[features] +default = [] +## Report anonymous telemetry as enabled unless the environment disables it. When +## off, drivers always report it disabled. +telemetry = [] +## Strict Ed25519 JWT issuance and verification for authenticated sandbox sessions. +jwt = ["dep:openshell-crypto", "dep:jsonwebtoken", "dep:zeroize"] +## Conversions to `openshell-otel` types. +otel = ["dep:openshell-otel"] +## Command-line flags shared by compute drivers. +clap = ["dep:clap"] + +[build-dependencies] +tonic-prost-build = { workspace = true } +protoc-bin-vendored = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-driver-sdk/README.md b/crates/openshell-driver-sdk/README.md new file mode 100644 index 0000000000..cb558b2961 --- /dev/null +++ b/crates/openshell-driver-sdk/README.md @@ -0,0 +1,32 @@ +# openshell-driver-sdk + +The narrow contract that compute drivers share with `OpenShell` core. It holds +the items that both a crate that stays in core (the gateway, the supervisor, the +Kubernetes driver) and the desktop drivers use, so each constant and type has +one definition. + +`openshell-core` re-exports every module and item defined here, so existing +`openshell_core::...` paths keep resolving to the same definitions. Add an item +to this crate only when something that stays in core also needs it. Code used +only by the Docker, Podman, and VM drivers belongs in +`openshell-container-runtime`. + +## Contents + +- `proto`: the generated compute-driver contract (`compute_driver.proto` and the + protos it imports) and the git-derived build `VERSION`. +- `sandbox_env`, `container_paths`, `driver_mounts`, `driver_utils`, + `resource_admission`, `gpu`, `config`: the sandbox environment protocol and + the validation shared by drivers and the supervisor. +- `jwt` (feature `jwt`), `extension_protocol`, `network_trust`, + `isolation_interface`, `isolation_registration`, `policy`: tokens, extension + negotiation, destination-trust bundles, and the Isolation Backend contract + types. +- `error`, `paths`, `net`, `settings`, `telemetry`, `time`, `progress`, + `image`, `sandbox_session`, `sandbox_generation`, `proto_struct`, + `external_driver_socket`, `e2fsprogs`: small helpers. + +## Dependencies + +The crate depends only on workspace crates and crates.io, and never on +`openshell-core`. diff --git a/crates/openshell-driver-sdk/build.rs b/crates/openshell-driver-sdk/build.rs new file mode 100644 index 0000000000..f09c103bf7 --- /dev/null +++ b/crates/openshell-driver-sdk/build.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::env; +use std::path::PathBuf; + +mod build_version; + +const PROTO_REL: &str = "../../proto"; + +/// Proto files that make up the compute-driver contract. Their transitive +/// imports are `options`, `extension`, `datamodel`, and `sandbox`; no other +/// proto file is needed. +const PROTO_FILES: &[&str] = &[ + "options.proto", + "extension.proto", + "datamodel.proto", + "sandbox.proto", + "compute_driver.proto", +]; + +fn main() -> Result<(), Box> { + // A caller-supplied PROTOC (for example a distro package's system + // compiler) is used as-is and prost resolves its own includes. Without it, + // use the vendored compiler and include tree. + println!("cargo:rerun-if-env-changed=PROTOC"); + println!("cargo:rerun-if-env-changed=PROTOC_INCLUDE"); + let use_vendored_protoc = env::var_os("PROTOC").is_none(); + + // In vendored mode, preserve the vendored include-tree guarantee even when + // the caller has PROTOC_INCLUDE set: prost validates that ambient path + // independently of explicit includes. Re-enter with a child-only + // environment override. + if use_vendored_protoc && env::var_os("PROTOC_INCLUDE").is_some() { + let status = std::process::Command::new(env::current_exe()?) + .env_remove("PROTOC_INCLUDE") + .status()?; + if !status.success() { + return Err(format!("vendored protobuf build failed: {status}").into()); + } + return Ok(()); + } + + // --- Git-derived version --- + // Compute a version from tags and commit metadata for local builds. In + // Docker/CI builds where .git is absent, this silently does nothing and + // the binary falls back to CARGO_PKG_VERSION (which is already sed-patched + // by the build pipeline). + for path in git_rerun_paths() { + println!("cargo:rerun-if-changed={}", path.display()); + } + + if let Some(version) = git_version() { + println!("cargo:rustc-env=OPENSHELL_GIT_VERSION={version}"); + } + + // --- Protobuf compilation --- + println!("cargo:rerun-if-changed={PROTO_REL}"); + let mut proto_config = tonic_prost_build::Config::new(); + let proto_include = if use_vendored_protoc { + proto_config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?); + Some(protoc_bin_vendored::include_path()?) + } else { + None + }; + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let proto_root = manifest_dir.join(PROTO_REL); + let proto_files: Vec = PROTO_FILES.iter().map(|f| proto_root.join(f)).collect(); + + let mut include_dirs = vec![proto_root]; + include_dirs.extend(proto_include); + + tonic_prost_build::configure() + .type_attribute( + ".openshell.sandbox.v1.SupervisorMiddlewareBinding", + r#"#[derive(serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")]"#, + ) + .build_server(true) + .build_client(true) + .include_file("openshell.rs") + .compile_with_config(proto_config, &proto_files, &include_dirs)?; + + Ok(()) +} + +/// Git metadata files whose changes affect [`git_version`]. +/// +/// In a linked worktree `.git` is a file, so `HEAD` and `logs/HEAD` live in the +/// per-worktree git dir while branch and tag refs live in the common git dir. Cargo treats a +/// missing `rerun-if-changed` path as always stale, so only existing paths are +/// returned. Without git metadata nothing is returned. +fn git_rerun_paths() -> Vec { + let resolve = |args: &[&str]| { + let dir = PathBuf::from(git_output(args)?); + // Relative output is relative to the build script's working directory. + Some(env::current_dir().ok()?.join(dir)) + }; + let mut paths = Vec::new(); + if let Some(git_dir) = resolve(&["rev-parse", "--git-dir"]) { + paths.push(git_dir.join("HEAD")); + paths.push(git_dir.join("logs/HEAD")); + } + if let Some(common_dir) = resolve(&["rev-parse", "--git-common-dir"]) { + paths.push(common_dir.join("refs/tags")); + paths.push(common_dir.join("packed-refs")); + // A new worktree has no `logs/HEAD` until its first ref update, so also + // watch the checked-out branch ref, which moves on every commit. + if let Some(branch) = git_output(&["symbolic-ref", "-q", "HEAD"]) { + paths.push(common_dir.join(branch)); + } + } + paths.retain(|path| path.exists()); + paths +} + +/// Derive the release or development version from git metadata. +/// +/// Implements the "guess-next-dev" convention used by the release pipeline +/// (`tasks/scripts/release.py`): exact stable and prerelease tags retain their +/// version. Otherwise, the latest merged stable release gets a patch bump and +/// `-dev.+g` is appended. +/// +/// Examples: +/// on tag v0.1.0-pre.1 → "0.1.0-pre.1" +/// 3 commits past v0.0.3 → "0.0.4-dev.3+g2bf9969ab" +/// +/// Returns `None` when git metadata cannot be read. +fn git_version() -> Option { + let exact_tags = git_output(&["tag", "--points-at", "HEAD"])?; + if let Some(version) = build_version::exact_release_version(exact_tags.lines()) { + return Some(version); + } + + let merged_tags = git_output(&["tag", "--merged", "HEAD", "--list", "v*.*.*"])?; + let latest_tag = build_version::latest_stable_tag(merged_tags.lines()); + let revision_range = latest_tag + .as_deref() + .map_or_else(|| "HEAD".to_string(), |tag| format!("{tag}..HEAD")); + let distance = git_output(&["rev-list", "--count", &revision_range])? + .parse() + .ok()?; + let sha = git_output(&["rev-parse", "--short=9", "HEAD"])?; + + build_version::next_dev_version(latest_tag.as_deref(), distance, &sha) +} + +fn git_output(args: &[&str]) -> Option { + let output = std::process::Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|output| output.trim().to_string()) +} diff --git a/crates/openshell-core/build_version.rs b/crates/openshell-driver-sdk/build_version.rs similarity index 100% rename from crates/openshell-core/build_version.rs rename to crates/openshell-driver-sdk/build_version.rs diff --git a/crates/openshell-core/src/compute.rs b/crates/openshell-driver-sdk/src/compute.rs similarity index 100% rename from crates/openshell-core/src/compute.rs rename to crates/openshell-driver-sdk/src/compute.rs diff --git a/crates/openshell-driver-sdk/src/config.rs b/crates/openshell-driver-sdk/src/config.rs new file mode 100644 index 0000000000..d819b5e101 --- /dev/null +++ b/crates/openshell-driver-sdk/src/config.rs @@ -0,0 +1,551 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Configuration types and defaults shared by the gateway, the supervisor, and +//! the compute drivers. + +use serde::{Deserialize, Deserializer, Serialize, de}; +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; +/// Default gateway server port. +pub const DEFAULT_SERVER_PORT: u16 = 17670; + +/// Default container stop timeout in seconds (SIGTERM → SIGKILL). +pub const DEFAULT_STOP_TIMEOUT_SECS: u32 = 10; + +/// Default OCI repository for the supervisor image (no tag). +pub const DEFAULT_SUPERVISOR_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/supervisor"; + +/// Default OCI repository for the sandbox runtime image (no tag). +pub const DEFAULT_SANDBOX_RUNTIME_IMAGE_REPO: &str = "ghcr.io/nvidia/openshell/sandbox"; + +/// Process-level default for the trusted sandbox runtime image. +pub const SANDBOX_RUNTIME_IMAGE_ENV: &str = "OPENSHELL_SANDBOX_RUNTIME_IMAGE"; + +/// Process-level default for the trusted supervisor image. +pub const SUPERVISOR_IMAGE_ENV: &str = "OPENSHELL_SUPERVISOR_IMAGE"; + +fn compiled_sandbox_runtime_image() -> String { + format!( + "{DEFAULT_SANDBOX_RUNTIME_IMAGE_REPO}:{}", + default_supervisor_image_tag() + ) +} + +fn compiled_supervisor_image() -> String { + format!( + "{DEFAULT_SUPERVISOR_IMAGE_REPO}:{}", + default_supervisor_image_tag() + ) +} + +fn runtime_image_default(environment_value: Option, compiled_default: String) -> String { + environment_value.unwrap_or(compiled_default) +} + +/// Return the process-configured sandbox runtime image, or the compiled default. +#[must_use] +pub fn default_sandbox_runtime_image() -> String { + runtime_image_default( + std::env::var(SANDBOX_RUNTIME_IMAGE_ENV).ok(), + compiled_sandbox_runtime_image(), + ) +} + +/// Return the process-configured supervisor image, or the compiled default. +#[must_use] +pub fn default_supervisor_image() -> String { + runtime_image_default( + std::env::var(SUPERVISOR_IMAGE_ENV).ok(), + compiled_supervisor_image(), + ) +} + +fn default_supervisor_image_tag() -> String { + resolve_supervisor_image_tag(&[ + option_env!("OPENSHELL_IMAGE_TAG").unwrap_or(""), + option_env!("IMAGE_TAG").unwrap_or(""), + env!("CARGO_PKG_VERSION"), + ]) +} + +/// Resolve the supervisor image tag from an ordered list of candidates. +/// +/// Returns the first non-empty, non-`"0.0.0"` candidate, falling back to +/// `"dev"` when none qualifies. Replaces `+` with `-` for OCI tag +/// compatibility. +#[must_use] +pub fn resolve_supervisor_image_tag(candidates: &[&str]) -> String { + candidates + .iter() + .copied() + .find(|t| !t.is_empty() && *t != "0.0.0") + .unwrap_or("dev") + .replace('+', "-") +} + +/// Canonical policy controlling when a driver pulls a sandbox image. +/// +/// Backends translate this shared vocabulary to their runtime API. `newer` is +/// supported only by Podman; other backends reject it during configuration. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ImagePullPolicy { + /// Always pull, even if a local image is available. + Always, + /// Pull only when a local image is unavailable. + #[default] + IfNotPresent, + /// Never pull; fail when a local image is unavailable. + Never, + /// Pull only when the registry image is newer than the local copy. + Newer, +} + +impl ImagePullPolicy { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Always => "always", + Self::IfNotPresent => "if_not_present", + Self::Never => "never", + Self::Newer => "newer", + } + } +} + +impl fmt::Display for ImagePullPolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ImagePullPolicy { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "always" => Ok(Self::Always), + "if_not_present" => Ok(Self::IfNotPresent), + "never" => Ok(Self::Never), + "newer" => Ok(Self::Newer), + other => Err(format!( + "invalid image pull policy '{other}'; expected one of: always, if_not_present, never, newer" + )), + } + } +} + +/// Canonical `AppArmor` confinement requested for a sandbox container. +/// +/// Drivers translate this model to their runtime API. An omitted value leaves +/// the runtime default unchanged; `Unconfined` is explicit because the +/// supervisor needs mount operations that the default Docker/Podman profile +/// commonly denies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppArmorProfile { + RuntimeDefault, + Unconfined, + Localhost(String), +} + +impl AppArmorProfile { + #[must_use] + pub const fn kubernetes_type(&self) -> &'static str { + match self { + Self::RuntimeDefault => "RuntimeDefault", + Self::Unconfined => "Unconfined", + Self::Localhost(_) => "Localhost", + } + } + + #[must_use] + pub fn localhost_profile(&self) -> Option<&str> { + match self { + Self::Localhost(profile) => Some(profile), + Self::RuntimeDefault | Self::Unconfined => None, + } + } + + /// Translate to the OCI `apparmor=` security option. + /// + /// `RuntimeDefault` deliberately returns `None`: omitting an OCI option + /// asks Docker/Podman to apply their runtime default profile. + #[must_use] + pub fn oci_security_opt(&self) -> Option { + match self { + Self::RuntimeDefault => None, + Self::Unconfined => Some("apparmor=unconfined".to_string()), + Self::Localhost(profile) => Some(format!("apparmor={profile}")), + } + } +} + +impl fmt::Display for AppArmorProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RuntimeDefault => f.write_str("RuntimeDefault"), + Self::Unconfined => f.write_str("Unconfined"), + Self::Localhost(profile) => write!(f, "Localhost/{profile}"), + } + } +} + +impl FromStr for AppArmorProfile { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "RuntimeDefault" => Ok(Self::RuntimeDefault), + "Unconfined" => Ok(Self::Unconfined), + other => match other.strip_prefix("Localhost/") { + Some("") => Err( + "invalid AppArmor profile 'Localhost/'; expected non-empty profile name" + .to_string(), + ), + Some(profile) if !profile.contains(char::is_whitespace) => { + Ok(Self::Localhost(profile.to_string())) + } + Some(_) => { + Err("invalid AppArmor localhost profile; whitespace is not allowed".to_string()) + } + None => Err(format!( + "unknown AppArmor profile '{other}'; expected 'RuntimeDefault', 'Unconfined', or 'Localhost/'" + )), + }, + } + } +} + +impl Serialize for AppArmorProfile { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +/// Common local-driver corporate forward-proxy settings. +/// +/// This type is `flatten`ed by local compute-driver tables, preserving the +/// established TOML field names while keeping their safety contract shared. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct UpstreamProxyConfig { + pub https_proxy: Option, + pub no_proxy: Option, + pub proxy_auth_file: Option, + pub proxy_auth_allow_insecure: Option, + pub proxy_connect_by_hostname: Option, +} + +impl UpstreamProxyConfig { + /// Validate relationships that are independent of the container backend. + /// Credential contents are intentionally not read here and are never put + /// in an error message; drivers validate and stage them per sandbox. + pub fn validate(&self) -> Result<(), String> { + use crate::driver_utils::{UpstreamProxyUrlError, parse_upstream_proxy_url}; + + let proxy_secure = if let Some(url) = self.https_proxy.as_deref() { + parse_upstream_proxy_url(url) + .map_err(|err| match err { + UpstreamProxyUrlError::Empty => "https_proxy must not be empty when set".to_string(), + UpstreamProxyUrlError::InlineCredentials => "https_proxy must not embed credentials; supply them with proxy_auth_file so they are not stored in configuration or runtime metadata".to_string(), + err => format!("https_proxy {err}"), + })? + .secure + } else { + false + }; + + if self + .no_proxy + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err("no_proxy must not be empty when set; omit it instead".to_string()); + } + if self.no_proxy.is_some() && self.https_proxy.is_none() { + return Err("no_proxy is set but no https_proxy is configured".to_string()); + } + if let Some(path) = self.proxy_auth_file.as_ref() { + if path.as_os_str().is_empty() { + return Err("proxy_auth_file must not be empty when set".to_string()); + } + if self.https_proxy.is_none() { + return Err("proxy_auth_file is set but no https_proxy is configured".to_string()); + } + if !proxy_secure && self.proxy_auth_allow_insecure != Some(true) { + return Err("proxy_auth_file sends a cleartext Basic credential to an http:// proxy; set proxy_auth_allow_insecure = true to acknowledge that exposure".to_string()); + } + } else if self.proxy_auth_allow_insecure.is_some() { + return Err( + "proxy_auth_allow_insecure is set but no proxy_auth_file is configured".to_string(), + ); + } + if self.proxy_connect_by_hostname.is_some() && self.https_proxy.is_none() { + return Err( + "proxy_connect_by_hostname is set but no https_proxy is configured".to_string(), + ); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for AppArmorProfile { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::from_str(&value).map_err(de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_pull_policy_uses_canonical_vocabulary() { + for (value, expected) in [ + ("always", ImagePullPolicy::Always), + ("if_not_present", ImagePullPolicy::IfNotPresent), + ("never", ImagePullPolicy::Never), + ("newer", ImagePullPolicy::Newer), + ] { + assert_eq!(value.parse::(), Ok(expected)); + assert_eq!(expected.to_string(), value); + assert_eq!(serde_json::to_value(expected).unwrap(), value); + } + assert!("missing".parse::().is_err()); + assert!("IfNotPresent".parse::().is_err()); + } + + #[test] + fn app_armor_profiles_round_trip_and_translate_for_each_backend() { + for (value, expected, kubernetes_type, localhost_profile, oci_security_opt) in [ + ( + "RuntimeDefault", + AppArmorProfile::RuntimeDefault, + "RuntimeDefault", + None, + None, + ), + ( + "Unconfined", + AppArmorProfile::Unconfined, + "Unconfined", + None, + Some("apparmor=unconfined"), + ), + ( + "Localhost/openshell-supervisor", + AppArmorProfile::Localhost("openshell-supervisor".to_string()), + "Localhost", + Some("openshell-supervisor"), + Some("apparmor=openshell-supervisor"), + ), + ] { + let parsed = value.parse::().expect("valid profile"); + assert_eq!(parsed, expected); + assert_eq!(parsed.to_string(), value); + assert_eq!(parsed.kubernetes_type(), kubernetes_type); + assert_eq!(parsed.localhost_profile(), localhost_profile); + assert_eq!(parsed.oci_security_opt().as_deref(), oci_security_opt); + + let json = serde_json::to_value(&parsed).expect("profile serializes"); + assert_eq!(json, value); + assert_eq!( + serde_json::from_value::(json).expect("profile deserializes"), + parsed + ); + } + + for invalid in [ + "Localhost/", + "Localhost/openshell profile", + "runtimeDefault", + "unconfined", + "Unknown", + ] { + assert!( + invalid.parse::().is_err(), + "{invalid} should be rejected" + ); + } + } + + #[test] + fn upstream_proxy_validation_enforces_cross_field_contract() { + let auth_file = Some("/run/secrets/proxy-auth".into()); + let cases = [ + ("default", UpstreamProxyConfig::default(), None), + ( + "https auth", + UpstreamProxyConfig { + https_proxy: Some("https://proxy.example:8443".to_string()), + proxy_auth_file: auth_file.clone(), + ..Default::default() + }, + None, + ), + ( + "acknowledged http auth", + UpstreamProxyConfig { + https_proxy: Some("http://proxy.example:8080".to_string()), + proxy_auth_file: auth_file.clone(), + proxy_auth_allow_insecure: Some(true), + ..Default::default() + }, + None, + ), + ( + "unacknowledged http auth", + UpstreamProxyConfig { + https_proxy: Some("http://proxy.example:8080".to_string()), + proxy_auth_file: auth_file.clone(), + ..Default::default() + }, + Some("proxy_auth_allow_insecure"), + ), + ( + "no_proxy without proxy", + UpstreamProxyConfig { + no_proxy: Some("localhost".to_string()), + ..Default::default() + }, + Some("no_proxy"), + ), + ( + "blank no_proxy", + UpstreamProxyConfig { + https_proxy: Some("https://proxy.example:8443".to_string()), + no_proxy: Some(" ".to_string()), + ..Default::default() + }, + Some("no_proxy"), + ), + ( + "auth file without proxy", + UpstreamProxyConfig { + proxy_auth_file: auth_file, + ..Default::default() + }, + Some("proxy_auth_file"), + ), + ( + "ack without auth file", + UpstreamProxyConfig { + https_proxy: Some("http://proxy.example:8080".to_string()), + proxy_auth_allow_insecure: Some(true), + ..Default::default() + }, + Some("proxy_auth_allow_insecure"), + ), + ( + "hostname mode without proxy", + UpstreamProxyConfig { + proxy_connect_by_hostname: Some(true), + ..Default::default() + }, + Some("proxy_connect_by_hostname"), + ), + ( + "empty proxy", + UpstreamProxyConfig { + https_proxy: Some(String::new()), + ..Default::default() + }, + Some("https_proxy"), + ), + ( + "inline credentials", + UpstreamProxyConfig { + https_proxy: Some( + "https://secret-user:secret-password@proxy.example:8443".to_string(), + ), + ..Default::default() + }, + Some("must not embed credentials"), + ), + ]; + + for (name, config, expected_error) in cases { + match expected_error { + None => config + .validate() + .unwrap_or_else(|error| panic!("{name}: {error}")), + Some(expected) => { + let error = match config.validate() { + Ok(()) => panic!("{name} should fail validation"), + Err(error) => error, + }; + assert!(error.contains(expected), "{name}: {error}"); + assert!(!error.contains("secret-user"), "{name}: {error}"); + assert!(!error.contains("secret-password"), "{name}: {error}"); + } + } + } + } + + #[test] + fn supervisor_image_tag_prefers_explicit_build_tags() { + use super::resolve_supervisor_image_tag; + assert_eq!( + resolve_supervisor_image_tag(&["1.2.3", "sha", "0.0.0"]), + "1.2.3" + ); + assert_eq!(resolve_supervisor_image_tag(&["", "sha", "0.0.0"]), "sha"); + assert_eq!(resolve_supervisor_image_tag(&["", "", "1.2.3"]), "1.2.3"); + assert_eq!(resolve_supervisor_image_tag(&["", "", "0.0.0"]), "dev"); + assert_eq!( + resolve_supervisor_image_tag(&["latest", "", "1.2.3"]), + "latest" + ); + } + + #[test] + fn supervisor_image_tag_sanitizes_build_metadata_for_oci() { + use super::resolve_supervisor_image_tag; + assert_eq!( + resolve_supervisor_image_tag(&["", "", "0.0.37-dev.156+g1d3b741ee"]), + "0.0.37-dev.156-g1d3b741ee", + ); + assert_eq!( + resolve_supervisor_image_tag(&["0.0.37-dev.156+g1d3b741ee", "", "0.0.0"]), + "0.0.37-dev.156-g1d3b741ee", + ); + } + + #[test] + fn default_supervisor_image_is_version_pinned() { + use super::{compiled_sandbox_runtime_image, compiled_supervisor_image}; + let image = compiled_supervisor_image(); + assert!(image.starts_with("ghcr.io/nvidia/openshell/supervisor:")); + let tag = image.rsplit_once(':').unwrap().1; + assert!(!tag.is_empty()); + + let sandbox_image = compiled_sandbox_runtime_image(); + assert!(sandbox_image.starts_with("ghcr.io/nvidia/openshell/sandbox:")); + let sandbox_tag = sandbox_image.rsplit_once(':').unwrap().1; + assert!(!sandbox_tag.is_empty()); + } + + #[test] + fn runtime_image_environment_value_replaces_compiled_default() { + use super::runtime_image_default; + + let digest = format!("registry.example.com/sandbox@sha256:{}", "a".repeat(64)); + assert_eq!( + runtime_image_default(Some(digest.clone()), "compiled:default".to_string()), + digest + ); + assert_eq!( + runtime_image_default(None, "compiled:default".to_string()), + "compiled:default" + ); + } +} diff --git a/crates/openshell-core/src/container_paths.rs b/crates/openshell-driver-sdk/src/container_paths.rs similarity index 100% rename from crates/openshell-core/src/container_paths.rs rename to crates/openshell-driver-sdk/src/container_paths.rs diff --git a/crates/openshell-core/src/driver_mounts.rs b/crates/openshell-driver-sdk/src/driver_mounts.rs similarity index 100% rename from crates/openshell-core/src/driver_mounts.rs rename to crates/openshell-driver-sdk/src/driver_mounts.rs diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-driver-sdk/src/driver_utils.rs similarity index 81% rename from crates/openshell-core/src/driver_utils.rs rename to crates/openshell-driver-sdk/src/driver_utils.rs index 5a04659a9f..51761edd0d 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-driver-sdk/src/driver_utils.rs @@ -3,10 +3,8 @@ //! Utility helpers shared across compute-driver crates. -use rustls::pki_types::{CertificateDer, pem::PemObject}; -use std::path::{Path, PathBuf}; - use crate::proto::compute::v1::DriverSandbox; +use rustls::pki_types::{CertificateDer, pem::PemObject}; // --------------------------------------------------------------------------- // Sandbox container/pod label keys (openshell.ai/ namespace) @@ -25,9 +23,6 @@ pub const LABEL_SANDBOX_ID: &str = "openshell.ai/sandbox-id"; /// Container/pod label carrying the sandbox name. pub const LABEL_SANDBOX_NAME: &str = "openshell.ai/sandbox-name"; -/// Container/pod label carrying the sandbox namespace. -pub const LABEL_SANDBOX_NAMESPACE: &str = "openshell.ai/sandbox-namespace"; - /// Container/pod label carrying the sandbox workspace. pub const LABEL_SANDBOX_WORKSPACE: &str = "openshell.ai/sandbox-workspace"; @@ -53,10 +48,6 @@ pub fn openshell_sandbox_label_selector() -> String { /// being relaunched. pub const CONDITION_EXITED: &str = "ContainerExited"; -/// Ready-condition reason when the supervisor rejects an image-provided OCI -/// working directory because the sandbox identity lacks the required access. -pub const CONDITION_WORKSPACE_VALIDATION_FAILED: &str = "WorkspaceValidationFailed"; - /// Supervisor exit status reserved for OCI workspace validation failures. /// /// Local container drivers translate a supervisor exit with this status into @@ -70,11 +61,6 @@ pub const SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED: i32 = 78; /// [`SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED`]. pub const WORKSPACE_VALIDATION_ERROR_CONTEXT: &str = "image workspace validation failed"; -/// Driver condition message for [`CONDITION_WORKSPACE_VALIDATION_FAILED`]. -/// Fixed text, because supervisor output may contain secrets. -pub const WORKSPACE_VALIDATION_FAILED_MESSAGE: &str = - "OCI WorkingDir is not usable by the sandbox identity"; - /// Ready-condition reason when a container was terminated by an external signal. /// /// SIGKILL/SIGTERM (exit 137/143) is what a Podman/Docker machine or daemon @@ -87,18 +73,6 @@ pub const CONDITION_RUNTIME_RESTART: &str = "ContainerRuntimeRestart"; /// runtime API (e.g. `podman stop`, gateway-initiated shutdown). pub const CONDITION_STOPPED: &str = "ContainerStopped"; -// --------------------------------------------------------------------------- - -/// Path to the sandbox supervisor binary inside the container image. -/// -/// All compute drivers must launch this binary as the container entrypoint to -/// start the sandboxed environment. The value must be kept in sync with the -/// path used when building the `openshell-sandbox` image layer. -pub const SANDBOX_RUNTIME_IMAGE_BINARY_PATH: &str = "/openshell-sandbox"; - -/// Legacy name for [`SANDBOX_RUNTIME_IMAGE_BINARY_PATH`]. -pub const SUPERVISOR_IMAGE_BINARY_PATH: &str = SANDBOX_RUNTIME_IMAGE_BINARY_PATH; - /// Directory inside sandbox containers where the supervisor binary is mounted. /// /// Compute drivers that side-load the supervisor into a shared volume mount @@ -135,17 +109,6 @@ pub const SANDBOX_TOKEN_MOUNT_PATH: &str = "/etc/openshell/auth/sandbox.jwt"; /// mount so the credential never appears in container environment/metadata. pub const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = "/etc/openshell/auth/upstream-proxy"; -/// Container-side mount path for the corporate proxy CA bundle. -/// -/// Drivers with a `proxy_ca_bundle` operator setting bind-mount the host PEM -/// file here (read-only) and pass the path on the supervisor's argv via -/// `--upstream-proxy-ca-bundle`. The supervisor trusts it for the TLS -/// handshake with an `https://` corporate egress proxy and for server -/// certificates re-signed by a TLS-intercepting proxy. Unlike the proxy -/// credential, a CA certificate is not secret, so a plain read-only bind -/// mount is used rather than a driver secret. -pub const PROXY_CA_MOUNT_PATH: &str = "/etc/openshell/tls/proxy/ca-bundle.pem"; - /// Container-side mount path for an operator-supplied additional destination /// CA bundle. /// @@ -703,44 +666,6 @@ pub fn validate_upstream_proxy_settings( Ok(()) } -/// Container-side directory where the provider SPIFFE Workload API socket is mounted. -pub const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = "/spiffe-workload-api"; - -/// Validate a host UNIX socket selected for provider SPIFFE projection. -/// -/// Local container drivers bind-mount the socket's dedicated parent directory, -/// not a broad host root. TCP endpoints are deliberately rejected here: a -/// container projection must be a filesystem socket, while VM guest TCP -/// exposure has its own explicit acknowledgement contract. -pub fn validate_provider_spiffe_unix_socket(path: &Path) -> Result<(), String> { - let raw = path - .to_str() - .ok_or_else(|| "provider_spiffe_workload_api_socket must be valid UTF-8".to_string())?; - if raw.trim() != raw || raw.is_empty() { - return Err("provider_spiffe_workload_api_socket must not be empty or contain surrounding whitespace".to_string()); - } - if raw.starts_with("tcp:") || raw.starts_with("unix:") { - return Err("provider_spiffe_workload_api_socket must be an absolute host UNIX socket path, not a URI".to_string()); - } - if !path.is_absolute() || path.parent().is_none_or(|parent| parent == Path::new("/")) { - return Err("provider_spiffe_workload_api_socket must be an absolute UNIX socket path below a dedicated parent directory".to_string()); - } - Ok(()) -} - -/// Return the guest/container path for a projected provider SPIFFE socket. -pub fn projected_provider_spiffe_socket_path(path: &Path) -> Result { - validate_provider_spiffe_unix_socket(path)?; - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .ok_or_else(|| "provider_spiffe_workload_api_socket must name a socket file".to_string())?; - Ok(format!( - "{PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR}/{file_name}" - )) -} - /// Validate an explicitly operator-acknowledged guest-reachable SPIFFE TCP endpoint. /// /// The `tcp:` spelling is the SPIFFE Workload API endpoint grammar accepted by @@ -768,30 +693,6 @@ pub fn validate_guest_spiffe_tcp_endpoint( Ok(()) } -/// Return the XDG state path for a driver's sandbox JWT token file. -/// -/// The resulting path is `$XDG_STATE_HOME/openshell/[/]//sandbox.jwt`. -/// -/// `driver_subdir` is driver-specific. When `namespace` is `Some`, it is -/// appended as an additional path component (with `/` and `\` replaced by -/// `-`). -/// -/// # Errors -/// Returns an error if the XDG state directory cannot be resolved. -pub fn sandbox_token_path( - driver_subdir: &str, - namespace: Option<&str>, - sandbox_id: &str, -) -> miette::Result { - let mut path = crate::paths::xdg_state_dir()? - .join("openshell") - .join(driver_subdir); - if let Some(ns) = namespace { - path = path.join(ns.replace(['/', '\\'], "-")); - } - Ok(path.join(sandbox_id).join("sandbox.jwt")) -} - /// Return the effective log level for a sandbox. /// /// Uses the level from the sandbox spec when non-empty, falling back to @@ -806,182 +707,17 @@ pub fn sandbox_log_level(sandbox: &DriverSandbox, default_level: &str) -> String .to_string() } -// --------------------------------------------------------------------------- -// Supervisor image helpers shared by container-backed drivers -// --------------------------------------------------------------------------- - -/// Return the tag portion of a supervisor image reference, or `None` if the -/// reference uses a digest (`@sha256:...`). -/// -/// Examples: -/// - `"ghcr.io/org/image:1.2.3"` → `Some("1.2.3")` -/// - `"ghcr.io/org/image:latest"` → `Some("latest")` -/// - `"ghcr.io/org/image"` → `Some("latest")` (implied tag) -/// - `"ghcr.io/org/image@sha256:abc"` → `None` (pinned by digest) -/// - `"ghcr.io/org/image:"` → `None` (empty tag) -pub fn supervisor_image_tag(image: &str) -> Option<&str> { - if image.contains('@') { - return None; - } - - let image_name = image.rsplit('/').next().unwrap_or(image); - image_name - .rsplit_once(':') - .map_or(Some("latest"), |(_, tag)| { - if tag.is_empty() { None } else { Some(tag) } - }) -} - -/// Return `true` if the supervisor image should be refreshed before each use. -/// -/// Mutable tags (`dev`, `latest`) are always re-pulled so that the running -/// container tracks the latest pushed version. Digest-pinned references and -/// all other versioned tags are treated as immutable and pulled at most once. -pub fn supervisor_image_should_refresh(image: &str) -> bool { - matches!(supervisor_image_tag(image), Some("dev" | "latest")) -} - -// --------------------------------------------------------------------------- -// Supervisor binary extraction helpers shared by container-backed drivers -// --------------------------------------------------------------------------- - -#[cfg(feature = "driver-extraction")] -/// Extract the payload of the first regular-file entry in a tar archive. -/// -/// Container archive endpoints return a single-file tar when `path` points to -/// a file, so only the first entry is consumed. Returns an error when the -/// archive is empty, the first entry is not a regular file, or the payload is -/// empty. -pub fn extract_first_tar_entry(tar_bytes: &[u8]) -> Result, String> { - let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes)); - let mut entries = archive - .entries() - .map_err(|err| format!("open tar archive: {err}"))?; - let mut entry = entries - .next() - .ok_or_else(|| "tar archive was empty".to_string())? - .map_err(|err| format!("read tar entry: {err}"))?; - let kind = entry.header().entry_type(); - if !kind.is_file() { - return Err(format!( - "expected a regular file in tar archive, got type {kind:?}" - )); - } - let mut bytes = Vec::new(); - std::io::Read::read_to_end(&mut entry, &mut bytes) - .map_err(|err| format!("read tar entry payload: {err}"))?; - if bytes.is_empty() { - return Err("tar entry payload was empty".to_string()); - } - Ok(bytes) -} - -#[cfg(feature = "driver-extraction")] -/// Atomically write `bytes` to `final_path` via a sibling temp file. -/// -/// Creates parent directories as needed. The temp file is synced, `chmod 755` -/// (on Unix), and renamed into place so concurrent readers never observe a -/// partial write. Returns a human-readable error string on failure. -pub fn write_cache_binary_atomic(final_path: &Path, bytes: &[u8]) -> Result<(), String> { - let dir = final_path - .parent() - .ok_or_else(|| format!("cache path '{}' has no parent", final_path.display()))?; - std::fs::create_dir_all(dir) - .map_err(|err| format!("failed to create cache dir '{}': {err}", dir.display()))?; - - let mut temp = tempfile::Builder::new() - .prefix(".openshell-sandbox-") - .tempfile_in(dir) - .map_err(|err| format!("failed to create temp file in '{}': {err}", dir.display()))?; - std::io::Write::write_all(&mut temp, bytes) - .map_err(|err| format!("failed to write supervisor binary: {err}"))?; - temp.as_file() - .sync_all() - .map_err(|err| format!("failed to sync supervisor binary: {err}"))?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)) - .map_err(|err| format!("failed to chmod supervisor binary: {err}"))?; - } - - temp.persist(final_path).map_err(|err| { - format!( - "failed to persist supervisor binary to '{}': {}", - final_path.display(), - err.error, - ) - })?; - Ok(()) -} - -/// Return the host-side cache path for an extracted supervisor binary. -/// -/// The path is `$XDG_DATA_HOME/openshell///openshell-sandbox`. -/// `driver_subdir` distinguishes caches across drivers. -pub fn supervisor_cache_path(driver_subdir: &str, digest: &str) -> Result { - let base = crate::paths::xdg_data_dir() - .map_err(|err| format!("failed to resolve XDG data dir: {err}"))?; - Ok(supervisor_cache_path_with_base( - &base, - driver_subdir, - digest, - )) -} - -/// [`supervisor_cache_path`] with an explicit base directory (for testing). -pub fn supervisor_cache_path_with_base(base: &Path, driver_subdir: &str, digest: &str) -> PathBuf { - let sanitized = digest.replace(':', "-"); - base.join("openshell") - .join(driver_subdir) - .join(sanitized) - .join("openshell-sandbox") -} - -/// Generate a unique container name for supervisor binary extraction. -/// -/// Uses the process ID and an atomic counter to avoid collisions across -/// concurrent gateway starts. -pub fn temp_extract_container_name() -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static SEQ: AtomicU64 = AtomicU64::new(0); - let pid = std::process::id(); - let seq = SEQ.fetch_add(1, Ordering::Relaxed); - format!("openshell-supervisor-extract-{pid}-{seq}") -} - -/// Validate that the file at `path` starts with the ELF magic bytes (`\x7fELF`). -/// -/// Returns a human-readable error when the file cannot be read or is not a -/// Linux ELF binary. -pub fn validate_linux_elf_binary(path: &Path) -> Result<(), String> { - use std::io::Read; - let mut file = std::fs::File::open(path).map_err(|err| { - format!( - "failed to open supervisor binary '{}': {err}", - path.display() - ) - })?; - let mut magic = [0u8; 4]; - file.read_exact(&mut magic).map_err(|err| { - format!( - "failed to read supervisor binary '{}': {err}", - path.display() - ) - })?; - if magic != [0x7f, b'E', b'L', b'F'] { - return Err(format!( - "supervisor binary '{}' is not a Linux ELF executable", - path.display(), - )); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; + use std::path::Path; + + fn proxy_settings(url: Option<&str>) -> UpstreamProxySettings<'_> { + UpstreamProxySettings { + url, + ..UpstreamProxySettings::default() + } + } #[test] fn upstream_proxy_url_accepts_http_with_port() { @@ -1205,21 +941,6 @@ mod tests { assert!(err.contains("open proxy auth file"), "{err}"); } - #[cfg(unix)] - #[test] - fn projected_spiffe_socket_requires_dedicated_absolute_unix_path() { - assert_eq!( - projected_provider_spiffe_socket_path(Path::new("/run/spire/agent.sock")).unwrap(), - "/spiffe-workload-api/agent.sock" - ); - for path in ["relative.sock", "/agent.sock", "tcp:127.0.0.1:8081"] { - assert!( - validate_provider_spiffe_unix_socket(Path::new(path)).is_err(), - "{path}" - ); - } - } - #[test] fn guest_spiffe_tcp_requires_acknowledgement_and_concrete_endpoint() { assert!(validate_guest_spiffe_tcp_endpoint("tcp:192.0.2.10:8081", true).is_ok()); @@ -1330,13 +1051,6 @@ mod tests { assert!(err.contains("no usable trust anchors"), "{err}"); } - fn proxy_settings(url: Option<&str>) -> UpstreamProxySettings<'_> { - UpstreamProxySettings { - url, - ..UpstreamProxySettings::default() - } - } - #[test] fn upstream_proxy_settings_accept_a_bare_proxy_url() { validate_upstream_proxy_settings(&proxy_settings(Some("http://proxy.corp.com:3128"))) diff --git a/crates/openshell-core/src/e2fsprogs.rs b/crates/openshell-driver-sdk/src/e2fsprogs.rs similarity index 100% rename from crates/openshell-core/src/e2fsprogs.rs rename to crates/openshell-driver-sdk/src/e2fsprogs.rs diff --git a/crates/openshell-core/src/error.rs b/crates/openshell-driver-sdk/src/error.rs similarity index 100% rename from crates/openshell-core/src/error.rs rename to crates/openshell-driver-sdk/src/error.rs diff --git a/crates/openshell-core/src/extension_protocol.rs b/crates/openshell-driver-sdk/src/extension_protocol.rs similarity index 99% rename from crates/openshell-core/src/extension_protocol.rs rename to crates/openshell-driver-sdk/src/extension_protocol.rs index d6d93418d8..805c75bf32 100644 --- a/crates/openshell-core/src/extension_protocol.rs +++ b/crates/openshell-driver-sdk/src/extension_protocol.rs @@ -15,7 +15,7 @@ pub const PROTOCOL_MINOR: u32 = 0; /// Capability for compute drivers that require gateway-minted launch credentials. /// /// Drivers require this capability when every launch needs a -/// [`crate::jwt::SandboxLaunchAuthentication`] bundle. The gateway checks its +/// `SandboxLaunchAuthentication` bundle. The gateway checks its /// configured signer before accepting sandbox creation. pub const COMPUTE_LAUNCH_AUTHENTICATION: &str = "openshell.compute.launch-authentication"; diff --git a/crates/openshell-core/src/external_driver_socket.rs b/crates/openshell-driver-sdk/src/external_driver_socket.rs similarity index 100% rename from crates/openshell-core/src/external_driver_socket.rs rename to crates/openshell-driver-sdk/src/external_driver_socket.rs diff --git a/crates/openshell-driver-sdk/src/gpu.rs b/crates/openshell-driver-sdk/src/gpu.rs new file mode 100644 index 0000000000..5b2eb51e74 --- /dev/null +++ b/crates/openshell-driver-sdk/src/gpu.rs @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared GPU resource requirement helpers. + +use crate::proto::compute::v1::{ + GpuResourceRequirements as DriverGpuResourceRequirements, + ResourceRequirements as DriverResourceRequirements, +}; + +/// Return the effective compute-driver GPU count. +/// +/// A present GPU requirement with an omitted count requests one GPU. +/// +/// # Errors +/// Returns an error when a GPU requirement explicitly requests zero GPUs. +pub fn effective_driver_gpu_count( + gpu: Option<&DriverGpuResourceRequirements>, +) -> Result, String> { + let Some(gpu) = gpu else { + return Ok(None); + }; + let count = gpu.count.unwrap_or(1); + if count == 0 { + return Err("gpu count must be greater than 0".to_string()); + } + Ok(Some(count)) +} + +/// Return the requested compute-driver GPU requirements, if present. +#[must_use] +pub fn driver_gpu_requirements( + resources: Option<&DriverResourceRequirements>, +) -> Option<&DriverGpuResourceRequirements> { + resources.and_then(|resources| resources.gpu.as_ref()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effective_driver_gpu_count_normalizes_missing_count() { + let gpu = DriverGpuResourceRequirements { count: None }; + + assert_eq!(effective_driver_gpu_count(Some(&gpu)), Ok(Some(1))); + assert_eq!(effective_driver_gpu_count(None), Ok(None)); + } + + #[test] + fn effective_driver_gpu_count_rejects_zero_count() { + let gpu = DriverGpuResourceRequirements { count: Some(0) }; + + assert_eq!( + effective_driver_gpu_count(Some(&gpu)), + Err("gpu count must be greater than 0".to_string()) + ); + } +} diff --git a/crates/openshell-core/src/image.rs b/crates/openshell-driver-sdk/src/image.rs similarity index 100% rename from crates/openshell-core/src/image.rs rename to crates/openshell-driver-sdk/src/image.rs diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-driver-sdk/src/isolation_interface/contract.rs similarity index 99% rename from crates/openshell-isolation-interface/src/contract.rs rename to crates/openshell-driver-sdk/src/isolation_interface/contract.rs index bd328fc0a1..84ea4a9ad1 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-driver-sdk/src/isolation_interface/contract.rs @@ -18,8 +18,8 @@ use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::oneshot; -pub use openshell_core::SandboxSessionId; -pub use openshell_core::policy::SandboxPolicy; +pub use crate::policy::SandboxPolicy; +pub use crate::sandbox_session::SandboxSessionId; // ============================================================================ // Errors @@ -242,7 +242,7 @@ pub struct SandboxContext { } /// The agent workload to run inside the boundary. -use crate::AgentSpec; +use super::AgentSpec; // ============================================================================ // Lifecycle states diff --git a/crates/openshell-driver-sdk/src/isolation_interface/mod.rs b/crates/openshell-driver-sdk/src/isolation_interface/mod.rs new file mode 100644 index 0000000000..9b1235e3d7 --- /dev/null +++ b/crates/openshell-driver-sdk/src/isolation_interface/mod.rs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared data and validation for the Isolation Backend RPC contracts. +//! +//! The backend extension interface is defined by `proto/isolation_backend.proto` +//! and `proto/sandbox_protocol.proto`. [`contract`] retains validated workload +//! identities, confirmation evidence, process specifications, and local +//! byte-stream types shared by client and server. Confirmation and generation +//! checks remain mandatory before launch. + +/// The agent workload to run inside the boundary. +/// +/// Carried by [`contract::SandboxContext`] so a backend's `start_agent` takes no +/// spec; the bound boundary already carries what runs inside it. +#[derive(Debug, Clone)] +pub struct AgentSpec { + /// Entrypoint program. + pub program: String, + /// Entrypoint arguments. + pub args: Vec, + /// Working directory for the entrypoint, if any. + pub workdir: Option, + /// Wall-clock timeout for the entrypoint in seconds (0 = no timeout). + pub timeout_secs: u64, + /// Whether the entrypoint runs interactively (inherits the parent pgrp). + pub interactive: bool, + /// Whether the supervisor serves the OTLP relay for this sandbox. The + /// boundary points workload SDKs at the relay only while it is active. + pub otlp_relay_active: bool, +} + +pub mod contract; + +/// Stable isolation backend name implemented by `openshell-sandbox`. +pub const BACKEND_NAME: &str = crate::isolation_registration::BUILTIN_BACKEND_NAME; + +/// Resource claim set by compute drivers when the workload requests GPU access. +pub const GPU_RESOURCE_CLAIM: &str = "openshell.gpu"; + +/// Resource claim set when the runtime may retain supplementary groups in +/// addition to the image-derived workload identity. +pub const ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM: &str = + "openshell.identity.allow_extra_supplementary_groups"; + +/// Memory-backed parent used for supervisor CA material. +pub const SUPERVISOR_CA_RUNTIME_ROOT: &str = "/run/openshell-supervisor-ca"; + +/// Workload-visible directory for the supervisor's public HTTPS interception +/// certificate and combined trust bundle. +/// +/// Keeping the material below the mount root lets runtimes that cannot assign +/// tmpfs ownership create this child as the unprivileged sandbox identity. +pub const SUPERVISOR_CA_RUNTIME_DIR: &str = "/run/openshell-supervisor-ca/material"; diff --git a/crates/openshell-core/src/isolation_registration.rs b/crates/openshell-driver-sdk/src/isolation_registration.rs similarity index 100% rename from crates/openshell-core/src/isolation_registration.rs rename to crates/openshell-driver-sdk/src/isolation_registration.rs diff --git a/crates/openshell-driver-sdk/src/jwt.rs b/crates/openshell-driver-sdk/src/jwt.rs new file mode 100644 index 0000000000..62b21a1e97 --- /dev/null +++ b/crates/openshell-driver-sdk/src/jwt.rs @@ -0,0 +1,2036 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Strict Ed25519 JWT issuance and verification for authenticated sandbox sessions. + +mod session { + use std::collections::BTreeMap; + use std::fmt; + use std::sync::Arc; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode_header}; + use openshell_crypto::jwt::{decode, encode}; + use serde::{Deserialize, Serialize}; + use uuid::Uuid; + use zeroize::Zeroizing; + + use crate::sandbox_generation::SandboxGenerationId; + use crate::sandbox_session::SandboxSessionId; + + pub const GATEWAY_SESSION_JWT_TYPE: &str = "openshell-gateway-session+jwt"; + pub const SANDBOX_SESSION_JWT_TYPE: &str = "openshell-sandbox-session+jwt"; + /// JOSE `typ` of the supervisor token: the credential of the gateway's + /// shared supervisor process for the assignment stream, and nothing else. + pub const SUPERVISOR_JWT_TYPE: &str = "openshell-supervisor+jwt"; + /// JOSE `typ` of a supervisor takeover grant. + /// + /// The grant is the gateway's authorization for one supervisor process to + /// replace the owner of one runtime generation at a strictly higher + /// credential epoch. Only the sandbox runtime verifies it, and only while + /// staging a replacement attach. + pub const SUPERVISOR_TAKEOVER_JWT_TYPE: &str = "openshell-supervisor-takeover+jwt"; + /// Longest lifetime of a takeover grant. Replay safety comes from the + /// strictly increasing epoch it names; the bound only limits how long an + /// unused grant stays usable. + pub const MAX_SUPERVISOR_TAKEOVER_TTL: Duration = Duration::from_mins(10); + pub const SANDBOX_SESSION_AUDIENCE: &str = "openshell-sandbox"; + pub const DEFAULT_SESSION_TOKEN_TTL: Duration = Duration::from_hours(1); + pub const MIN_SESSION_TOKEN_TTL: Duration = Duration::from_mins(1); + pub const MAX_SESSION_TOKEN_TTL: Duration = Duration::from_hours(1); + pub const MAX_SESSION_CLOCK_LEEWAY: Duration = Duration::from_secs(30); + + const GATEWAY_ISSUER_PREFIX: &str = "openshell-gateway:"; + const SANDBOX_SUBJECT_PREFIX: &str = "spiffe://openshell/sandbox/"; + const SUPERVISOR_SUBJECT_PREFIX: &str = "openshell-supervisor:"; + + /// Canonical sandbox identity carried by both session-token profiles. + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct SandboxId(String); + + impl SandboxId { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.trim() != value || value.chars().any(char::is_whitespace) { + return Err(SessionJwtError::InvalidSandboxId); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for SandboxId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + + /// Monotonic order for authenticated Sandbox Protocol connections. + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(transparent)] + pub struct CredentialEpoch(u64); + + impl CredentialEpoch { + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(SessionJwtError::InvalidCredentialEpoch); + } + Ok(Self(value)) + } + + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + } + + /// Monotonic identity for Sandbox Protocol attachment state. + /// + /// This is a protocol coordination value, not part of JWT authorization. + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(transparent)] + pub struct SessionRotation(u64); + + impl SessionRotation { + pub fn new(value: u64) -> Result { + if value == 0 { + return Err(SessionJwtError::InvalidSessionRotation); + } + Ok(Self(value)) + } + + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + + pub fn successor(self) -> Result { + self.0 + .checked_add(1) + .ok_or(SessionJwtError::SessionRotationOverflow) + .and_then(Self::new) + } + } + + /// The only component authorized by either sandbox-session token profile. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] + pub enum SessionComponent { + #[serde(rename = "openshell-supervisor")] + OpenShellSupervisor, + } + + /// Exact token profile. The profile chooses both the JOSE `typ` and JWT `aud`. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum SessionTokenProfile { + Gateway, + Sandbox, + } + + impl SessionTokenProfile { + #[must_use] + pub const fn token_type(self) -> &'static str { + match self { + Self::Gateway => GATEWAY_SESSION_JWT_TYPE, + Self::Sandbox => SANDBOX_SESSION_JWT_TYPE, + } + } + + fn audience(self, issuer: &str) -> &str { + match self { + Self::Gateway => issuer, + Self::Sandbox => SANDBOX_SESSION_AUDIENCE, + } + } + } + + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct SessionClaims { + iss: String, + sub: String, + aud: String, + iat: i64, + exp: i64, + jti: String, + sandbox_id: SandboxId, + runtime_generation: SandboxGenerationId, + auth_epoch: CredentialEpoch, + component: SessionComponent, + } + + /// Claims of the supervisor token. It names no sandbox, generation, or + /// credential epoch: the shared supervisor acts for sandboxes only with the + /// per-instance credentials each assignment carries. + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct SupervisorClaims { + iss: String, + sub: String, + aud: String, + iat: i64, + exp: i64, + jti: String, + component: SessionComponent, + } + + /// Claims of a supervisor takeover grant. The subject and audience are the + /// sandbox's, like its Sandbox Protocol session token, but the JOSE `typ` + /// keeps the two from ever being accepted for each other. + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + struct SupervisorTakeoverClaims { + iss: String, + sub: String, + aud: String, + iat: i64, + exp: i64, + jti: String, + sandbox_id: SandboxId, + runtime_generation: SandboxGenerationId, + auth_epoch: CredentialEpoch, + supervisor_instance: String, + owner: String, + component: SessionComponent, + } + + /// The replacement of the supervisor that owns one runtime generation. + /// + /// The gateway grants it only after durably advancing the sandbox's + /// credential epoch to `auth_epoch`; ordinary credential refresh keeps the + /// epoch and never produces a grant. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct SupervisorTakeover { + pub sandbox_id: SandboxId, + pub runtime_generation: SandboxGenerationId, + /// Ownership term the grant starts; it must exceed the term the + /// runtime currently accepts. + pub auth_epoch: CredentialEpoch, + /// Supervisor process identity the runtime pins once the replacement + /// is confirmed (a canonical UUID chosen by the gateway). + pub supervisor_instance: String, + /// Supervisor pool member that owns the new term, for audit. + pub owner: String, + } + + /// A takeover grant whose signature, issuer, audience, and lifetime were + /// verified. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct VerifiedSupervisorTakeover { + pub takeover: SupervisorTakeover, + pub token_id: Uuid, + pub expires_at: i64, + } + + /// Durable identity shared by every short-lived token for one sandbox runtime. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct SandboxRuntimeIdentity { + pub sandbox_id: SandboxId, + pub runtime_generation: SandboxGenerationId, + pub auth_epoch: CredentialEpoch, + } + + /// A JWT whose contents are deliberately omitted from `Debug` output and + /// zeroed when its final owner is dropped. + #[derive(Clone)] + pub struct SecretJwt(Zeroizing); + + impl SecretJwt { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.chars().any(char::is_whitespace) { + return Err(SessionJwtError::InvalidTokenEncoding); + } + Ok(Self(Zeroizing::new(value))) + } + + #[must_use] + pub fn expose_secret(&self) -> &str { + self.0.as_str() + } + } + + impl fmt::Debug for SecretJwt { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretJwt([REDACTED])") + } + } + + impl Serialize for SecretJwt { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.expose_secret()) + } + } + + impl<'de> Deserialize<'de> for SecretJwt { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } + } + + /// Private SSH key material is redacted from diagnostics and cleared on drop. + #[derive(Clone)] + pub struct SecretSshHostKey(Zeroizing); + + impl SecretSshHostKey { + #[must_use] + pub fn new(value: String) -> Self { + Self(Zeroizing::new(value)) + } + + #[must_use] + pub fn expose_secret(&self) -> &str { + &self.0 + } + } + + impl fmt::Debug for SecretSshHostKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretSshHostKey([REDACTED])") + } + } + + impl Serialize for SecretSshHostKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.expose_secret()) + } + } + + impl<'de> Deserialize<'de> for SecretSshHostKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer).map(Self::new) + } + } + + /// OTLP collector request headers, such as an authorization token, in + /// the `OTEL_EXPORTER_OTLP_HEADERS` format. Redacted from diagnostics and + /// cleared on drop. + #[derive(Clone)] + pub struct SecretOtlpHeaders(Zeroizing); + + impl SecretOtlpHeaders { + #[must_use] + pub fn new(value: String) -> Self { + Self(Zeroizing::new(value)) + } + + #[must_use] + pub fn expose_secret(&self) -> &str { + &self.0 + } + } + + impl fmt::Debug for SecretOtlpHeaders { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretOtlpHeaders([REDACTED])") + } + } + + impl Serialize for SecretOtlpHeaders { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.expose_secret()) + } + } + + impl<'de> Deserialize<'de> for SecretOtlpHeaders { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer).map(Self::new) + } + } + + /// A PEM private key. Redacted from diagnostics and cleared on drop. + #[derive(Clone)] + pub struct SecretPrivateKeyPem(Zeroizing); + + impl SecretPrivateKeyPem { + #[must_use] + pub fn new(value: String) -> Self { + Self(Zeroizing::new(value)) + } + + #[must_use] + pub fn expose_secret(&self) -> &str { + &self.0 + } + } + + impl fmt::Debug for SecretPrivateKeyPem { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretPrivateKeyPem([REDACTED])") + } + } + + impl Serialize for SecretPrivateKeyPem { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.expose_secret()) + } + } + + impl<'de> Deserialize<'de> for SecretPrivateKeyPem { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer).map(Self::new) + } + } + + /// TLS material for one OTLP collector connection, as PEM. The private + /// key is redacted from diagnostics and cleared on drop. + #[derive(Clone, Default, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct OtlpTlsMaterial { + /// Certificates trusted to verify the collector instead of the + /// platform roots. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_pem: Option, + /// Client certificate presented to the collector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, + } + + /// The headers and TLS material one supervisor export sends its + /// collector. + #[derive(Clone, Copy, Debug, Default)] + pub struct OtlpExportCredentials<'a> { + pub headers: Option<&'a SecretOtlpHeaders>, + pub tls: Option<&'a OtlpTlsMaterial>, + } + + /// A PEM client certificate chain and its private key. + #[derive(Clone, Debug, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct OtlpClientIdentity { + pub cert_pem: String, + pub key_pem: SecretPrivateKeyPem, + } + + #[cfg(feature = "otel")] + impl OtlpTlsMaterial { + /// The material as exporter TLS settings. + pub fn otlp_tls(&self) -> Result { + openshell_otel::OtlpTls::from_pem( + self.ca_pem.as_deref().map(str::as_bytes), + self.client.as_ref().map(|client| { + ( + client.cert_pem.as_bytes(), + client.key_pem.expose_secret().as_bytes(), + ) + }), + ) + } + } + + impl fmt::Debug for OtlpTlsMaterial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OtlpTlsMaterial") + .field("ca_pem", &self.ca_pem.is_some()) + .field("client", &self.client) + .finish() + } + } + + /// Trusted launch input delivered only to `openshell-supervisor`. + #[derive(Clone, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct SupervisorAuthBundle { + /// Correlates the two ends of one Sandbox Protocol launch. This value + /// is not carried in JWTs and is not an authorization identity. + pub session_id: SandboxSessionId, + pub runtime_generation: SandboxGenerationId, + /// Orders protocol attachment replacement, independently of auth. + pub session_rotation: SessionRotation, + pub auth_epoch: CredentialEpoch, + pub gateway_token: SecretJwt, + pub gateway_expires_at: i64, + pub sandbox_token: SecretJwt, + pub sandbox_expires_at: i64, + /// Never delivered to the workload. Missing only in older bundles. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh_host_private_key: Option, + /// Headers for the supervisor's trace collector, present only when + /// the gateway has them. Never delivered to the workload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub traces_otlp_headers: Option, + /// Headers for the OCSF OTLP collector, present only when the gateway + /// has them. Never delivered to the workload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ocsf_otlp_headers: Option, + /// Headers for the agent trace relay's collector, present only when + /// the gateway configures them. Never delivered to the workload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_otlp_headers: Option, + /// TLS material for the supervisor's collectors, each distinct + /// material once, present only when the gateway configures it. + /// Never delivered to the workload. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub otlp_tls: Vec, + /// Position in `otlp_tls` of each collector's material. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub traces_otlp_tls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ocsf_otlp_tls: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_otlp_tls: Option, + } + + /// Gateway-created authentication input trusted by a compute driver. + /// + /// Drivers split this structure: the supervisor receives `supervisor`, + /// while the sandbox receives only the gateway identity and public keys. + #[derive(Clone, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct SandboxLaunchAuthentication { + pub supervisor: SupervisorAuthBundle, + pub gateway_id: String, + pub verification_keys: Vec, + } + + impl SandboxLaunchAuthentication { + pub fn validate(&self) -> Result<(), SessionJwtError> { + self.supervisor.validate()?; + validate_gateway_id(&self.gateway_id)?; + if self.verification_keys.is_empty() { + return Err(SessionJwtError::NoVerificationKeys); + } + let mut key_ids = std::collections::BTreeSet::new(); + for key in &self.verification_keys { + validate_key_id(key.key_id.clone())?; + if key.public_key_pem.is_empty() { + return Err(SessionJwtError::InvalidVerificationKey); + } + if !key_ids.insert(key.key_id.as_str()) { + return Err(SessionJwtError::DuplicateKeyId); + } + } + Ok(()) + } + } + + impl fmt::Debug for SandboxLaunchAuthentication { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SandboxLaunchAuthentication") + .field("supervisor", &self.supervisor) + .field("gateway_id", &self.gateway_id) + .field( + "verification_key_ids", + &self + .verification_keys + .iter() + .map(|key| key.key_id.as_str()) + .collect::>(), + ) + .finish() + } + } + + impl SupervisorAuthBundle { + pub fn validate(&self) -> Result<(), SessionJwtError> { + if self.gateway_expires_at < 0 || self.sandbox_expires_at < 0 { + return Err(SessionJwtError::InvalidLifetime); + } + SandboxGenerationId::parse(self.runtime_generation.to_string()) + .map_err(|_| SessionJwtError::InvalidRuntimeIdentity)?; + SessionRotation::new(self.session_rotation.get())?; + let references = [ + self.traces_otlp_tls, + self.ocsf_otlp_tls, + self.agent_otlp_tls, + ]; + if references + .into_iter() + .flatten() + .any(|index| index >= self.otlp_tls.len()) + { + return Err(SessionJwtError::InvalidOtlpTlsReference); + } + Ok(()) + } + + /// What the supervisor's trace export sends its collector. + pub fn traces_otlp(&self) -> OtlpExportCredentials<'_> { + self.otlp_credentials(self.traces_otlp_headers.as_ref(), self.traces_otlp_tls) + } + + /// What the supervisor's OCSF export sends its collector. + pub fn ocsf_otlp(&self) -> OtlpExportCredentials<'_> { + self.otlp_credentials(self.ocsf_otlp_headers.as_ref(), self.ocsf_otlp_tls) + } + + /// What the agent trace relay sends its collector. + pub fn agent_otlp(&self) -> OtlpExportCredentials<'_> { + self.otlp_credentials(self.agent_otlp_headers.as_ref(), self.agent_otlp_tls) + } + + /// [`Self::validate`] rejects a `tls` index outside `otlp_tls`. + fn otlp_credentials<'a>( + &'a self, + headers: Option<&'a SecretOtlpHeaders>, + tls: Option, + ) -> OtlpExportCredentials<'a> { + OtlpExportCredentials { + headers, + tls: tls.and_then(|index| self.otlp_tls.get(index)), + } + } + + pub fn sandbox_bearer_slot(&self) -> Result { + SessionBearerTokenSlot::new( + self.sandbox_token.clone(), + self.sandbox_expires_at, + self.auth_epoch, + ) + } + } + + impl fmt::Debug for SupervisorAuthBundle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SupervisorAuthBundle") + .field("session_id", &self.session_id) + .field("runtime_generation", &self.runtime_generation) + .field("session_rotation", &self.session_rotation) + .field("auth_epoch", &self.auth_epoch) + .field("gateway_token", &"[REDACTED]") + .field("gateway_expires_at", &self.gateway_expires_at) + .field("sandbox_token", &"[REDACTED]") + .field("sandbox_expires_at", &self.sandbox_expires_at) + .field("ssh_host_private_key", &self.ssh_host_private_key) + .field("traces_otlp_headers", &self.traces_otlp_headers) + .field("ocsf_otlp_headers", &self.ocsf_otlp_headers) + .field("agent_otlp_headers", &self.agent_otlp_headers) + .field("otlp_tls", &self.otlp_tls) + .field("traces_otlp_tls", &self.traces_otlp_tls) + .field("ocsf_otlp_tls", &self.ocsf_otlp_tls) + .field("agent_otlp_tls", &self.agent_otlp_tls) + .finish() + } + } + + #[derive(Clone, Debug)] + pub struct MintedSessionToken { + pub token: SecretJwt, + pub expires_at: i64, + pub token_id: Uuid, + } + + #[derive(Clone, Debug)] + pub struct MintedSessionTokenPair { + pub gateway: MintedSessionToken, + pub sandbox: MintedSessionToken, + pub auth_epoch: CredentialEpoch, + } + + /// Refreshable Sandbox Protocol bearer credential shared by all streams on + /// the supervisor's current HTTP/2 connection. + #[derive(Clone)] + pub struct SessionBearerTokenSlot { + inner: Arc>>, + } + + #[derive(Clone)] + struct StoredBearer { + token: SecretJwt, + expires_at: i64, + credential_epoch: CredentialEpoch, + } + + impl SessionBearerTokenSlot { + #[must_use] + pub fn empty() -> Self { + Self { + inner: Arc::new(std::sync::RwLock::new(None)), + } + } + + pub fn new( + token: SecretJwt, + expires_at: i64, + credential_epoch: CredentialEpoch, + ) -> Result { + let slot = Self::empty(); + slot.update(token, expires_at, credential_epoch)?; + Ok(slot) + } + + pub fn update( + &self, + token: SecretJwt, + expires_at: i64, + credential_epoch: CredentialEpoch, + ) -> Result<(), SessionJwtError> { + if expires_at < 0 { + return Err(SessionJwtError::InvalidLifetime); + } + let mut stored = self + .inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if stored + .as_ref() + .is_some_and(|current| credential_epoch < current.credential_epoch) + { + return Err(SessionJwtError::StaleCredentialEpoch); + } + *stored = Some(StoredBearer { + token, + expires_at, + credential_epoch, + }); + Ok(()) + } + + pub fn clear(&self) { + *self + .inner + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + } + + #[must_use] + pub fn expires_at(&self) -> Option { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|stored| stored.expires_at) + } + + #[must_use] + pub fn credential_epoch(&self) -> Option { + self.inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|stored| stored.credential_epoch) + } + + pub fn authorization_metadata( + &self, + ) -> Result { + let stored = self + .inner + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let stored = stored.as_ref().ok_or(SessionJwtError::TokenUnavailable)?; + if stored.expires_at > 0 && stored.expires_at <= SystemJwtClock.now_unix_seconds() { + return Err(SessionJwtError::Expired); + } + format!("Bearer {}", stored.token.expose_secret()) + .parse() + .map_err(|_| SessionJwtError::InvalidTokenEncoding) + } + } + + impl Default for SessionBearerTokenSlot { + fn default() -> Self { + Self::empty() + } + } + + impl fmt::Debug for SessionBearerTokenSlot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SessionBearerTokenSlot") + .field("expires_at", &self.expires_at()) + .field("credential_epoch", &self.credential_epoch()) + .finish_non_exhaustive() + } + } + + pub trait JwtClock: Send + Sync { + fn now_unix_seconds(&self) -> i64; + } + + #[derive(Debug)] + pub struct SystemJwtClock; + + impl JwtClock for SystemJwtClock { + fn now_unix_seconds(&self) -> i64 { + i64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()), + ) + .unwrap_or(i64::MAX) + } + } + + /// Gateway-side issuer shared by both token profiles. + pub struct SessionJwtIssuer { + encoding_key: EncodingKey, + key_id: String, + issuer: String, + ttl: Option, + clock: Arc, + } + + impl fmt::Debug for SessionJwtIssuer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SessionJwtIssuer") + .field("key_id", &self.key_id) + .field("issuer", &self.issuer) + .field("ttl", &self.ttl) + .finish_non_exhaustive() + } + } + + impl SessionJwtIssuer { + pub fn from_ed25519_pem( + signing_key_pem: &[u8], + key_id: impl Into, + gateway_id: &str, + ttl: Option, + clock: Arc, + ) -> Result { + install_crypto_provider(); + if let Some(ttl) = ttl { + validate_ttl(ttl)?; + } + let key_id = validate_key_id(key_id.into())?; + let gateway_id = validate_gateway_id(gateway_id)?; + let encoding_key = EncodingKey::from_ed_pem(signing_key_pem) + .map_err(|_| SessionJwtError::InvalidSigningKey)?; + Ok(Self { + encoding_key, + key_id, + issuer: format!("{GATEWAY_ISSUER_PREFIX}{gateway_id}"), + ttl, + clock, + }) + } + + pub fn mint_pair( + &self, + identity: &SandboxRuntimeIdentity, + ) -> Result { + self.mint_pair_with_gateway_token_id(identity, Uuid::new_v4()) + } + + /// Mint a token pair whose gateway-facing credential has a caller-owned + /// lineage identifier. + /// + /// The gateway persists this identifier before returning the token so + /// refresh can reject every superseded bearer across all replicas. The + /// Sandbox Protocol credential remains independently identified. + pub fn mint_pair_with_gateway_token_id( + &self, + identity: &SandboxRuntimeIdentity, + gateway_token_id: Uuid, + ) -> Result { + self.mint_pair_with_token_metadata( + identity, + gateway_token_id, + Uuid::new_v4(), + self.clock.now_unix_seconds(), + ) + } + + /// Mint a reproducible token pair for a persisted refresh successor. + pub fn mint_pair_with_token_metadata( + &self, + identity: &SandboxRuntimeIdentity, + gateway_token_id: Uuid, + sandbox_token_id: Uuid, + issued_at: i64, + ) -> Result { + Ok(MintedSessionTokenPair { + gateway: self.mint( + SessionTokenProfile::Gateway, + identity, + gateway_token_id, + issued_at, + )?, + sandbox: self.mint( + SessionTokenProfile::Sandbox, + identity, + sandbox_token_id, + issued_at, + )?, + auth_epoch: identity.auth_epoch, + }) + } + + /// Mint the shared supervisor's token for the gateway's assignment + /// stream with its own bounded lifetime, independent of the session + /// token lifetime (which may be non-expiring). + pub fn mint_supervisor_token( + &self, + ttl: Duration, + ) -> Result { + validate_ttl(ttl)?; + let issued_at = self.clock.now_unix_seconds(); + let expires_at = + issued_at.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX)); + let token_id = Uuid::new_v4(); + let gateway_id = &self.issuer[GATEWAY_ISSUER_PREFIX.len()..]; + let claims = SupervisorClaims { + iss: self.issuer.clone(), + sub: format!("{SUPERVISOR_SUBJECT_PREFIX}{gateway_id}"), + aud: self.issuer.clone(), + iat: issued_at, + exp: expires_at, + jti: token_id.to_string(), + component: SessionComponent::OpenShellSupervisor, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.key_id.clone()); + header.typ = Some(SUPERVISOR_JWT_TYPE.to_string()); + let token = encode(&header, &claims, &self.encoding_key) + .map_err(|_| SessionJwtError::SigningFailed)?; + Ok(MintedSessionToken { + token: SecretJwt::parse(token)?, + expires_at, + token_id, + }) + } + + /// Mint a takeover grant for `takeover`, valid for `ttl` (at most + /// [`MAX_SUPERVISOR_TAKEOVER_TTL`]). The caller must already have + /// advanced the sandbox's persisted credential epoch to + /// `takeover.auth_epoch`. + pub fn mint_supervisor_takeover( + &self, + takeover: &SupervisorTakeover, + ttl: Duration, + ) -> Result { + if ttl.is_zero() || ttl > MAX_SUPERVISOR_TAKEOVER_TTL { + return Err(SessionJwtError::InvalidLifetime); + } + validate_takeover_identity(&takeover.supervisor_instance, &takeover.owner)?; + let issued_at = self.clock.now_unix_seconds(); + let expires_at = + issued_at.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX)); + let token_id = Uuid::new_v4(); + let claims = SupervisorTakeoverClaims { + iss: self.issuer.clone(), + sub: format!("{SANDBOX_SUBJECT_PREFIX}{}", takeover.sandbox_id), + aud: SANDBOX_SESSION_AUDIENCE.to_string(), + iat: issued_at, + exp: expires_at, + jti: token_id.to_string(), + sandbox_id: takeover.sandbox_id.clone(), + runtime_generation: takeover.runtime_generation.clone(), + auth_epoch: takeover.auth_epoch, + supervisor_instance: takeover.supervisor_instance.clone(), + owner: takeover.owner.clone(), + component: SessionComponent::OpenShellSupervisor, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.key_id.clone()); + header.typ = Some(SUPERVISOR_TAKEOVER_JWT_TYPE.to_string()); + let token = encode(&header, &claims, &self.encoding_key) + .map_err(|_| SessionJwtError::SigningFailed)?; + Ok(MintedSessionToken { + token: SecretJwt::parse(token)?, + expires_at, + token_id, + }) + } + + fn mint( + &self, + profile: SessionTokenProfile, + identity: &SandboxRuntimeIdentity, + token_id: Uuid, + issued_at: i64, + ) -> Result { + let expires_at = self.ttl.map_or(0, |ttl| { + issued_at.saturating_add(i64::try_from(ttl.as_secs()).unwrap_or(i64::MAX)) + }); + let claims = SessionClaims { + iss: self.issuer.clone(), + sub: format!("{SANDBOX_SUBJECT_PREFIX}{}", identity.sandbox_id), + aud: profile.audience(&self.issuer).to_string(), + iat: issued_at, + exp: expires_at, + jti: token_id.to_string(), + sandbox_id: identity.sandbox_id.clone(), + runtime_generation: identity.runtime_generation.clone(), + auth_epoch: identity.auth_epoch, + component: SessionComponent::OpenShellSupervisor, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some(self.key_id.clone()); + header.typ = Some(profile.token_type().to_string()); + let token = encode(&header, &claims, &self.encoding_key) + .map_err(|_| SessionJwtError::SigningFailed)?; + Ok(MintedSessionToken { + token: SecretJwt::parse(token)?, + expires_at, + token_id, + }) + } + } + + /// One accepted public key from the immutable sandbox verification bundle. + #[derive(Clone, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct SessionVerificationKey { + pub key_id: String, + pub public_key_pem: Vec, + } + + /// Strict verifier used by either the gateway or the Sandbox Protocol. + pub struct SessionJwtVerifier { + keys: BTreeMap, + issuer: String, + profile: SessionTokenProfile, + clock: Arc, + } + + impl fmt::Debug for SessionJwtVerifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SessionJwtVerifier") + .field("key_ids", &self.keys.keys().collect::>()) + .field("issuer", &self.issuer) + .field("profile", &self.profile) + .finish_non_exhaustive() + } + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct AuthenticatedSandboxSession { + pub sandbox_id: SandboxId, + pub runtime_generation: SandboxGenerationId, + pub auth_epoch: CredentialEpoch, + pub token_id: Uuid, + pub issued_at: i64, + pub expires_at: i64, + } + + impl SessionJwtVerifier { + pub fn new( + gateway_id: &str, + profile: SessionTokenProfile, + keys: impl IntoIterator, + clock: Arc, + ) -> Result { + install_crypto_provider(); + let gateway_id = validate_gateway_id(gateway_id)?; + Ok(Self { + keys: parse_verification_keys(keys)?, + issuer: format!("{GATEWAY_ISSUER_PREFIX}{gateway_id}"), + profile, + clock, + }) + } + + pub fn verify(&self, token: &str) -> Result { + let claims = decode_signed::( + &self.keys, + token, + self.profile.token_type(), + &["iss", "aud", "iat", "exp", "sub", "jti"], + )?; + self.validate_claims(claims) + } + + /// Verify a supervisor takeover grant. Only the Sandbox profile, which + /// the runtime uses, verifies grants; the gateway profile refuses them. + pub fn verify_supervisor_takeover( + &self, + token: &str, + ) -> Result { + if self.profile != SessionTokenProfile::Sandbox { + return Err(SessionJwtError::WrongTokenType); + } + let claims = decode_signed::( + &self.keys, + token, + SUPERVISOR_TAKEOVER_JWT_TYPE, + &["iss", "aud", "iat", "exp", "sub", "jti"], + )?; + if claims.iss != self.issuer { + return Err(SessionJwtError::WrongIssuer); + } + if claims.aud != SANDBOX_SESSION_AUDIENCE { + return Err(SessionJwtError::WrongAudience); + } + if claims.sub != format!("{SANDBOX_SUBJECT_PREFIX}{}", claims.sandbox_id) { + return Err(SessionJwtError::SubjectMismatch); + } + SandboxGenerationId::parse(claims.runtime_generation.to_string()) + .map_err(|_| SessionJwtError::InvalidRuntimeIdentity)?; + validate_takeover_identity(&claims.supervisor_instance, &claims.owner)?; + let token_id = Uuid::parse_str(&claims.jti).map_err(|_| SessionJwtError::InvalidJti)?; + // A grant must expire, within its own bound. + let max_lifetime = + i64::try_from(MAX_SUPERVISOR_TAKEOVER_TTL.as_secs()).unwrap_or(i64::MAX); + if claims.exp == 0 + || claims.exp <= claims.iat + || claims.exp.saturating_sub(claims.iat) > max_lifetime + { + return Err(SessionJwtError::InvalidLifetime); + } + validate_lifetime(claims.iat, claims.exp, self.clock.now_unix_seconds())?; + Ok(VerifiedSupervisorTakeover { + takeover: SupervisorTakeover { + sandbox_id: claims.sandbox_id, + runtime_generation: claims.runtime_generation, + auth_epoch: claims.auth_epoch, + supervisor_instance: claims.supervisor_instance, + owner: claims.owner, + }, + token_id, + expires_at: claims.exp, + }) + } + + fn validate_claims( + &self, + claims: SessionClaims, + ) -> Result { + if claims.iss != self.issuer { + return Err(SessionJwtError::WrongIssuer); + } + if claims.aud != self.profile.audience(&self.issuer) { + return Err(SessionJwtError::WrongAudience); + } + if claims.sub != format!("{SANDBOX_SUBJECT_PREFIX}{}", claims.sandbox_id) { + return Err(SessionJwtError::SubjectMismatch); + } + SandboxGenerationId::parse(claims.runtime_generation.to_string()) + .map_err(|_| SessionJwtError::InvalidRuntimeIdentity)?; + let token_id = Uuid::parse_str(&claims.jti).map_err(|_| SessionJwtError::InvalidJti)?; + validate_lifetime(claims.iat, claims.exp, self.clock.now_unix_seconds())?; + Ok(AuthenticatedSandboxSession { + sandbox_id: claims.sandbox_id, + runtime_generation: claims.runtime_generation, + auth_epoch: claims.auth_epoch, + token_id, + issued_at: claims.iat, + expires_at: claims.exp, + }) + } + } + + /// A verified supervisor token. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct AuthenticatedSupervisor { + /// `openshell-supervisor:`. + pub subject: String, + pub token_id: Uuid, + pub issued_at: i64, + pub expires_at: i64, + } + + /// Strict gateway-side verifier of supervisor tokens. Accepts only the + /// supervisor profile signed by this gateway for this gateway. + pub struct SupervisorJwtVerifier { + keys: BTreeMap, + issuer: String, + subject: String, + clock: Arc, + } + + impl fmt::Debug for SupervisorJwtVerifier { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SupervisorJwtVerifier") + .field("key_ids", &self.keys.keys().collect::>()) + .field("issuer", &self.issuer) + .finish_non_exhaustive() + } + } + + impl SupervisorJwtVerifier { + pub fn new( + gateway_id: &str, + keys: impl IntoIterator, + clock: Arc, + ) -> Result { + install_crypto_provider(); + let gateway_id = validate_gateway_id(gateway_id)?; + Ok(Self { + keys: parse_verification_keys(keys)?, + issuer: format!("{GATEWAY_ISSUER_PREFIX}{gateway_id}"), + subject: format!("{SUPERVISOR_SUBJECT_PREFIX}{gateway_id}"), + clock, + }) + } + + pub fn verify(&self, token: &str) -> Result { + let claims = decode_signed::( + &self.keys, + token, + SUPERVISOR_JWT_TYPE, + &["iss", "aud", "iat", "exp", "sub", "jti"], + )?; + if claims.iss != self.issuer { + return Err(SessionJwtError::WrongIssuer); + } + if claims.aud != self.issuer { + return Err(SessionJwtError::WrongAudience); + } + if claims.sub != self.subject { + return Err(SessionJwtError::SubjectMismatch); + } + let token_id = Uuid::parse_str(&claims.jti).map_err(|_| SessionJwtError::InvalidJti)?; + // Supervisor tokens must expire; a non-expiring one is refused. + if claims.exp == 0 { + return Err(SessionJwtError::InvalidLifetime); + } + validate_lifetime(claims.iat, claims.exp, self.clock.now_unix_seconds())?; + Ok(AuthenticatedSupervisor { + subject: claims.sub, + token_id, + issued_at: claims.iat, + expires_at: claims.exp, + }) + } + } + + fn parse_verification_keys( + keys: impl IntoIterator, + ) -> Result, SessionJwtError> { + let mut parsed = BTreeMap::new(); + for key in keys { + let key_id = validate_key_id(key.key_id)?; + let decoding_key = DecodingKey::from_ed_pem(&key.public_key_pem) + .map_err(|_| SessionJwtError::InvalidVerificationKey)?; + if parsed.insert(key_id, decoding_key).is_some() { + return Err(SessionJwtError::DuplicateKeyId); + } + } + if parsed.is_empty() { + return Err(SessionJwtError::NoVerificationKeys); + } + Ok(parsed) + } + + /// Check the header (`EdDSA` only, exact `typ`, known `kid`) and the + /// signature, then decode the claims. Time and audience are validated by + /// the caller against its own clock and profile. + fn decode_signed( + keys: &BTreeMap, + token: &str, + token_type: &str, + required_claims: &[&str], + ) -> Result { + install_crypto_provider(); + let header = decode_header(token).map_err(|_| SessionJwtError::InvalidToken)?; + if header.alg != Algorithm::EdDSA { + return Err(SessionJwtError::WrongAlgorithm); + } + if header.typ.as_deref() != Some(token_type) { + return Err(SessionJwtError::WrongTokenType); + } + let key_id = header.kid.ok_or(SessionJwtError::MissingKeyId)?; + let key = keys.get(&key_id).ok_or(SessionJwtError::UnknownKeyId)?; + let mut validation = Validation::new(Algorithm::EdDSA); + validation.algorithms = vec![Algorithm::EdDSA]; + validation.validate_exp = false; + validation.validate_aud = false; + validation.set_required_spec_claims(required_claims); + Ok(decode::(token, key, &validation) + .map_err(|_| SessionJwtError::InvalidToken)? + .claims) + } + + /// Shared lifetime rules: not issued in the future, and when expiring, + /// a positive lifetime within the session bound that has not passed. + fn validate_lifetime(issued_at: i64, expires_at: i64, now: i64) -> Result<(), SessionJwtError> { + let leeway = i64::try_from(MAX_SESSION_CLOCK_LEEWAY.as_secs()).unwrap_or(30); + if issued_at > now.saturating_add(leeway) { + return Err(SessionJwtError::IssuedInFuture); + } + if expires_at != 0 { + if expires_at <= issued_at { + return Err(SessionJwtError::InvalidLifetime); + } + let lifetime = expires_at.saturating_sub(issued_at); + if lifetime > i64::try_from(MAX_SESSION_TOKEN_TTL.as_secs()).unwrap_or(i64::MAX) { + return Err(SessionJwtError::InvalidLifetime); + } + if expires_at < now.saturating_sub(leeway) { + return Err(SessionJwtError::Expired); + } + } + Ok(()) + } + + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] + pub enum SessionJwtError { + #[error("sandbox ID is invalid")] + InvalidSandboxId, + #[error("gateway ID is invalid")] + InvalidGatewayId, + #[error("credential epoch must be positive")] + InvalidCredentialEpoch, + #[error("credential epoch does not advance the active credential")] + StaleCredentialEpoch, + #[error("session rotation must be positive")] + InvalidSessionRotation, + #[error("session rotation overflow")] + SessionRotationOverflow, + #[error("sandbox runtime identity is missing")] + MissingRuntimeIdentity, + #[error("sandbox runtime identity is invalid")] + InvalidRuntimeIdentity, + #[error("OTLP TLS reference is outside the bundle's TLS material")] + InvalidOtlpTlsReference, + #[error("key ID is invalid")] + InvalidKeyId, + #[error("verification key IDs must be unique")] + DuplicateKeyId, + #[error("at least one verification key is required")] + NoVerificationKeys, + #[error("Ed25519 signing key is invalid")] + InvalidSigningKey, + #[error("Ed25519 verification key is invalid")] + InvalidVerificationKey, + #[error("session token lifetime must be non-expiring or between 60 and 3600 seconds")] + InvalidLifetime, + #[error("session token profile does not match its claims")] + ProfileMismatch, + #[error("session token could not be signed")] + SigningFailed, + #[error("session token encoding is invalid")] + InvalidTokenEncoding, + #[error("session token is unavailable")] + TokenUnavailable, + #[error("session token is invalid")] + InvalidToken, + #[error("session token algorithm must be EdDSA")] + WrongAlgorithm, + #[error("session token type is invalid")] + WrongTokenType, + #[error("session token key ID is missing")] + MissingKeyId, + #[error("session token key ID is unknown")] + UnknownKeyId, + #[error("session token issuer is invalid")] + WrongIssuer, + #[error("session token audience is invalid")] + WrongAudience, + #[error("session token subject does not match its sandbox ID")] + SubjectMismatch, + #[error("session token ID is not a UUID")] + InvalidJti, + #[error("session token was issued in the future")] + IssuedInFuture, + #[error("session token has expired")] + Expired, + } + + fn install_crypto_provider() { + openshell_crypto::install_jwt_provider(); + } + + fn validate_ttl(ttl: Duration) -> Result<(), SessionJwtError> { + if !(MIN_SESSION_TOKEN_TTL..=MAX_SESSION_TOKEN_TTL).contains(&ttl) { + return Err(SessionJwtError::InvalidLifetime); + } + Ok(()) + } + + fn validate_gateway_id(gateway_id: &str) -> Result<&str, SessionJwtError> { + if gateway_id.is_empty() + || gateway_id.trim() != gateway_id + || gateway_id.chars().any(char::is_whitespace) + { + return Err(SessionJwtError::InvalidGatewayId); + } + Ok(gateway_id) + } + + /// A takeover names a non-blank supervisor instance and owner without + /// whitespace; the runtime compares the instance with its attach request. + fn validate_takeover_identity( + supervisor_instance: &str, + owner: &str, + ) -> Result<(), SessionJwtError> { + let valid = |value: &str| { + !value.is_empty() && value.len() <= 253 && !value.chars().any(char::is_whitespace) + }; + if valid(supervisor_instance) && valid(owner) { + Ok(()) + } else { + Err(SessionJwtError::InvalidRuntimeIdentity) + } + } + + fn validate_key_id(key_id: String) -> Result { + if key_id.is_empty() || key_id.trim() != key_id || key_id.chars().any(char::is_whitespace) { + return Err(SessionJwtError::InvalidKeyId); + } + Ok(key_id) + } +} + +pub use session::*; + +#[cfg(test)] +mod session_tests { + use std::sync::Arc; + + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + use openshell_crypto::jwt::encode; + use rcgen::PKCS_ED25519; + use serde::Serialize; + + use super::session::*; + use crate::sandbox_generation::SandboxGenerationId; + use crate::sandbox_session::SandboxSessionId; + + #[derive(Debug)] + struct FixedClock(i64); + + impl JwtClock for FixedClock { + fn now_unix_seconds(&self) -> i64 { + self.0 + } + } + + fn fixture() -> ( + SessionJwtIssuer, + SessionJwtVerifier, + SessionJwtVerifier, + SandboxRuntimeIdentity, + ) { + let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519) + .expect("generate Ed25519 key"); + let public_key_pem = key.public_key_pem().into_bytes(); + let clock: Arc = Arc::new(FixedClock(1_900_000_000)); + let issuer = SessionJwtIssuer::from_ed25519_pem( + key.serialize_pem().unwrap().as_bytes(), + "current", + "test", + Some(DEFAULT_SESSION_TOKEN_TTL), + clock.clone(), + ) + .expect("issuer"); + let key = || SessionVerificationKey { + key_id: "current".to_string(), + public_key_pem: public_key_pem.clone(), + }; + let gateway = + SessionJwtVerifier::new("test", SessionTokenProfile::Gateway, [key()], clock.clone()) + .expect("gateway verifier"); + let sandbox = SessionJwtVerifier::new("test", SessionTokenProfile::Sandbox, [key()], clock) + .expect("sandbox verifier"); + let identity = SandboxRuntimeIdentity { + sandbox_id: SandboxId::parse("sandbox-a").expect("sandbox ID"), + runtime_generation: SandboxGenerationId::parse("generation-1") + .expect("runtime generation"), + auth_epoch: CredentialEpoch::new(1).expect("auth epoch"), + }; + (issuer, gateway, sandbox, identity) + } + + #[test] + fn token_profiles_are_not_interchangeable() { + let (issuer, gateway, sandbox, identity) = fixture(); + let pair = issuer.mint_pair(&identity).expect("token pair"); + + let gateway_session = gateway + .verify(pair.gateway.token.expose_secret()) + .expect("gateway token"); + assert_eq!(gateway_session.auth_epoch, identity.auth_epoch); + + let sandbox_session = sandbox + .verify(pair.sandbox.token.expose_secret()) + .expect("sandbox token"); + assert_eq!(sandbox_session.auth_epoch, identity.auth_epoch); + + assert_eq!( + gateway.verify(pair.sandbox.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + assert_eq!( + sandbox.verify(pair.gateway.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + } + + #[test] + fn supervisor_tokens_are_a_separate_profile_bound_to_the_gateway() { + let (issuer, gateway, sandbox, _identity) = fixture(); + let minted = issuer + .mint_supervisor_token(std::time::Duration::from_mins(15)) + .expect("supervisor token"); + assert_eq!(minted.expires_at, 1_900_000_000 + 900); + + let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519).unwrap(); + let public_key_pem = key.public_key_pem().into_bytes(); + let clock: Arc = Arc::new(FixedClock(1_900_000_000)); + // A verifier holding a different key under the same key ID sees a + // signature failure, not a key lookup failure. + let verification_key = |pem: Vec| SessionVerificationKey { + key_id: "current".to_string(), + public_key_pem: pem, + }; + let wrong_key = + SupervisorJwtVerifier::new("test", [verification_key(public_key_pem)], clock.clone()) + .expect("verifier"); + assert_eq!( + wrong_key.verify(minted.token.expose_secret()), + Err(SessionJwtError::InvalidToken) + ); + + // Session verifiers refuse the supervisor profile and the supervisor + // verifier refuses session tokens, whatever key signed them. + assert_eq!( + gateway.verify(minted.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + assert_eq!( + sandbox.verify(minted.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + let identity = SandboxRuntimeIdentity { + sandbox_id: SandboxId::parse("sandbox-a").unwrap(), + runtime_generation: SandboxGenerationId::parse("generation-1").unwrap(), + auth_epoch: CredentialEpoch::new(1).unwrap(), + }; + let pair = issuer.mint_pair(&identity).unwrap(); + assert_eq!( + wrong_key.verify(pair.gateway.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + + // The supervisor lifetime is bounded on its own, whatever the + // session lifetime is. + drop(clock); + for ttl in [ + std::time::Duration::from_secs(59), + std::time::Duration::from_secs(3_601), + ] { + assert_eq!( + issuer.mint_supervisor_token(ttl).unwrap_err(), + SessionJwtError::InvalidLifetime + ); + } + } + + #[test] + fn supervisor_tokens_verify_only_for_their_gateway_and_lifetime() { + let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519).unwrap(); + let public_key_pem = key.public_key_pem().into_bytes(); + let signing_pem = key.serialize_pem().unwrap(); + let verification_key = || SessionVerificationKey { + key_id: "current".to_string(), + public_key_pem: public_key_pem.clone(), + }; + let issuer = |gateway_id: &str, now: i64| { + SessionJwtIssuer::from_ed25519_pem( + signing_pem.as_bytes(), + "current", + gateway_id, + Some(std::time::Duration::from_mins(15)), + Arc::new(FixedClock(now)), + ) + .unwrap() + }; + let verifier = |gateway_id: &str, now: i64| { + SupervisorJwtVerifier::new(gateway_id, [verification_key()], Arc::new(FixedClock(now))) + .unwrap() + }; + + let minted = issuer("gw-a", 1_900_000_000) + .mint_supervisor_token(std::time::Duration::from_mins(15)) + .unwrap(); + let authenticated = verifier("gw-a", 1_900_000_100) + .verify(minted.token.expose_secret()) + .expect("valid supervisor token"); + assert_eq!(authenticated.subject, "openshell-supervisor:gw-a"); + assert_eq!(authenticated.token_id, minted.token_id); + assert_eq!(authenticated.expires_at, 1_900_000_900); + + // Another gateway's token is refused by issuer, audience, and subject. + assert_eq!( + verifier("gw-b", 1_900_000_100).verify(minted.token.expose_secret()), + Err(SessionJwtError::WrongIssuer) + ); + // Expired tokens are refused past the leeway. + assert_eq!( + verifier("gw-a", 1_900_001_000).verify(minted.token.expose_secret()), + Err(SessionJwtError::Expired) + ); + assert_eq!( + verifier("gw-a", 1_899_999_000).verify(minted.token.expose_secret()), + Err(SessionJwtError::IssuedInFuture) + ); + assert_eq!( + verifier("gw-a", 1_900_000_100).verify("not-a-token"), + Err(SessionJwtError::InvalidToken) + ); + } + + #[derive(Serialize)] + struct NonExpiringSupervisor<'a> { + iss: &'a str, + sub: &'a str, + aud: &'a str, + iat: i64, + exp: i64, + jti: String, + component: SessionComponent, + } + + #[test] + fn non_expiring_session_tokens_remain_usable() { + let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519) + .expect("generate Ed25519 key"); + let public_key_pem = key.public_key_pem().into_bytes(); + let issuer = SessionJwtIssuer::from_ed25519_pem( + key.serialize_pem().unwrap().as_bytes(), + "current", + "test", + None, + Arc::new(FixedClock(1_900_000_000)), + ) + .expect("issuer"); + let verifier = SessionJwtVerifier::new( + "test", + SessionTokenProfile::Sandbox, + [SessionVerificationKey { + key_id: "current".to_string(), + public_key_pem, + }], + Arc::new(FixedClock(2_000_000_000)), + ) + .expect("verifier"); + let identity = SandboxRuntimeIdentity { + sandbox_id: SandboxId::parse("sandbox-a").expect("sandbox ID"), + runtime_generation: SandboxGenerationId::parse("generation-1") + .expect("runtime generation"), + auth_epoch: CredentialEpoch::new(1).expect("auth epoch"), + }; + let pair = issuer.mint_pair(&identity).expect("token pair"); + + assert_eq!(pair.gateway.expires_at, 0); + assert_eq!(pair.sandbox.expires_at, 0); + verifier + .verify(pair.sandbox.token.expose_secret()) + .expect("non-expiring token remains valid"); + + // The default (non-expiring) session issuer still mints expiring + // supervisor tokens, and a supervisor token must expire. + let supervisor = issuer + .mint_supervisor_token(std::time::Duration::from_mins(15)) + .expect("supervisor token from a non-expiring issuer"); + assert_eq!(supervisor.expires_at, 1_900_000_000 + 900); + let supervisor_verifier = SupervisorJwtVerifier::new( + "test", + [SessionVerificationKey { + key_id: "current".to_string(), + public_key_pem: key.public_key_pem().into_bytes(), + }], + Arc::new(FixedClock(1_900_000_100)), + ) + .unwrap(); + supervisor_verifier + .verify(supervisor.token.expose_secret()) + .expect("expiring supervisor token"); + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some("current".to_string()); + header.typ = Some(SUPERVISOR_JWT_TYPE.to_string()); + let forever = encode( + &header, + &NonExpiringSupervisor { + iss: "openshell-gateway:test", + sub: "openshell-supervisor:test", + aud: "openshell-gateway:test", + iat: 1_900_000_000, + exp: 0, + jti: uuid::Uuid::new_v4().to_string(), + component: SessionComponent::OpenShellSupervisor, + }, + &EncodingKey::from_ed_pem(key.serialize_pem().unwrap().as_bytes()).unwrap(), + ) + .unwrap(); + assert_eq!( + supervisor_verifier.verify(&forever), + Err(SessionJwtError::InvalidLifetime) + ); + + let slot = SessionBearerTokenSlot::new( + pair.sandbox.token.clone(), + pair.sandbox.expires_at, + pair.auth_epoch, + ) + .expect("non-expiring bearer slot"); + slot.authorization_metadata() + .expect("non-expiring bearer remains available"); + + let bundle = SupervisorAuthBundle { + session_id: SandboxSessionId::new(), + session_rotation: SessionRotation::new(1).expect("session rotation"), + runtime_generation: identity.runtime_generation, + auth_epoch: pair.auth_epoch, + gateway_token: pair.gateway.token, + gateway_expires_at: pair.gateway.expires_at, + sandbox_token: pair.sandbox.token, + sandbox_expires_at: pair.sandbox.expires_at, + ssh_host_private_key: None, + traces_otlp_headers: None, + ocsf_otlp_headers: None, + agent_otlp_headers: None, + otlp_tls: Vec::new(), + traces_otlp_tls: None, + ocsf_otlp_tls: None, + agent_otlp_tls: None, + }; + bundle.validate().expect("non-expiring auth bundle"); + } + + fn takeover(epoch: u64) -> SupervisorTakeover { + SupervisorTakeover { + sandbox_id: SandboxId::parse("sandbox-a").expect("sandbox ID"), + runtime_generation: SandboxGenerationId::parse("generation-1") + .expect("runtime generation"), + auth_epoch: CredentialEpoch::new(epoch).expect("auth epoch"), + supervisor_instance: "6a8f1f4e-8c0e-4d55-9d8c-1d6e2f3a4b5c".to_string(), + owner: "supervisor-1".to_string(), + } + } + + #[test] + fn takeover_grants_are_their_own_profile_verified_only_by_the_runtime() { + let (issuer, gateway, sandbox, identity) = fixture(); + let expected = takeover(2); + let minted = issuer + .mint_supervisor_takeover(&expected, std::time::Duration::from_mins(5)) + .expect("mint takeover grant"); + let verified = sandbox + .verify_supervisor_takeover(minted.token.expose_secret()) + .expect("runtime verifies the grant"); + assert_eq!(verified.takeover, expected); + assert_eq!(verified.token_id, minted.token_id); + assert_eq!(verified.expires_at, 1_900_000_000 + 300); + + // A grant is never a session credential, and no other credential is + // a grant: neither session profile nor the shared supervisor token. + assert_eq!( + sandbox.verify(minted.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + assert_eq!( + gateway.verify_supervisor_takeover(minted.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + let pair = issuer.mint_pair(&identity).expect("token pair"); + for token in [&pair.sandbox.token, &pair.gateway.token] { + assert_eq!( + sandbox.verify_supervisor_takeover(token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + } + let host = issuer + .mint_supervisor_token(std::time::Duration::from_mins(15)) + .expect("supervisor token"); + assert_eq!( + sandbox.verify_supervisor_takeover(host.token.expose_secret()), + Err(SessionJwtError::WrongTokenType) + ); + } + + #[test] + fn takeover_grants_are_bounded_named_and_expire() { + let (issuer, _gateway, _sandbox, _identity) = fixture(); + for ttl in [ + std::time::Duration::ZERO, + MAX_SUPERVISOR_TAKEOVER_TTL + std::time::Duration::from_secs(1), + ] { + assert_eq!( + issuer.mint_supervisor_takeover(&takeover(2), ttl).err(), + Some(SessionJwtError::InvalidLifetime) + ); + } + for (instance, owner) in [("", "member"), ("instance", ""), ("in stance", "m")] { + let mut unnamed = takeover(2); + unnamed.supervisor_instance = instance.to_string(); + unnamed.owner = owner.to_string(); + assert_eq!( + issuer + .mint_supervisor_takeover(&unnamed, std::time::Duration::from_mins(5)) + .err(), + Some(SessionJwtError::InvalidRuntimeIdentity) + ); + } + + let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519).unwrap(); + let issuer = SessionJwtIssuer::from_ed25519_pem( + key.serialize_pem().unwrap().as_bytes(), + "current", + "test", + None, + Arc::new(FixedClock(1_900_000_000)), + ) + .unwrap(); + let verifier_at = |now| { + SessionJwtVerifier::new( + "test", + SessionTokenProfile::Sandbox, + [SessionVerificationKey { + key_id: "current".to_string(), + public_key_pem: key.public_key_pem().into_bytes(), + }], + Arc::new(FixedClock(now)), + ) + .unwrap() + }; + // A non-expiring session issuer still mints expiring grants. + let minted = issuer + .mint_supervisor_takeover(&takeover(3), std::time::Duration::from_mins(1)) + .unwrap(); + verifier_at(1_900_000_030) + .verify_supervisor_takeover(minted.token.expose_secret()) + .expect("grant within its lifetime"); + assert_eq!( + verifier_at(1_900_000_200).verify_supervisor_takeover(minted.token.expose_secret()), + Err(SessionJwtError::Expired) + ); + } + + #[test] + fn token_debug_is_redacted() { + let (issuer, _gateway, _sandbox, identity) = fixture(); + let pair = issuer.mint_pair(&identity).expect("token pair"); + let debug = format!("{:?}", pair.sandbox.token); + assert_eq!(debug, "SecretJwt([REDACTED])"); + assert!(!debug.contains(pair.sandbox.token.expose_secret())); + } + + #[test] + fn each_supervisor_export_reads_its_own_credentials() { + let (issuer, _gateway, _sandbox, identity) = fixture(); + let pair = issuer.mint_pair(&identity).expect("token pair"); + let headers = |lane: &str| Some(SecretOtlpHeaders::new(format!("lane={lane}"))); + let tls = |lane: &str| OtlpTlsMaterial { + ca_pem: Some(format!("{lane}-ca")), + client: None, + }; + let bundle = SupervisorAuthBundle { + session_id: SandboxSessionId::new(), + session_rotation: SessionRotation::new(1).expect("session rotation"), + runtime_generation: identity.runtime_generation.clone(), + auth_epoch: identity.auth_epoch, + gateway_token: pair.gateway.token, + gateway_expires_at: pair.gateway.expires_at, + sandbox_token: pair.sandbox.token, + sandbox_expires_at: pair.sandbox.expires_at, + ssh_host_private_key: None, + traces_otlp_headers: headers("traces"), + ocsf_otlp_headers: headers("ocsf"), + agent_otlp_headers: headers("agent"), + otlp_tls: vec![tls("agent"), tls("ocsf"), tls("traces")], + traces_otlp_tls: Some(2), + ocsf_otlp_tls: Some(1), + agent_otlp_tls: Some(0), + }; + + for (lane, credentials) in [ + ("traces", bundle.traces_otlp()), + ("ocsf", bundle.ocsf_otlp()), + ("agent", bundle.agent_otlp()), + ] { + assert_eq!( + credentials.headers.map(SecretOtlpHeaders::expose_secret), + Some(format!("lane={lane}").as_str()) + ); + assert_eq!( + credentials.tls.and_then(|tls| tls.ca_pem.as_deref()), + Some(format!("{lane}-ca").as_str()) + ); + } + } + + #[test] + fn supervisor_auth_bundle_round_trips_without_exposing_secrets_in_debug() { + let (issuer, _gateway, _sandbox, identity) = fixture(); + let pair = issuer.mint_pair(&identity).expect("token pair"); + let bundle = SupervisorAuthBundle { + session_id: SandboxSessionId::new(), + session_rotation: SessionRotation::new(1).expect("session rotation"), + runtime_generation: identity.runtime_generation.clone(), + auth_epoch: identity.auth_epoch, + gateway_token: pair.gateway.token, + gateway_expires_at: pair.gateway.expires_at, + sandbox_token: pair.sandbox.token, + sandbox_expires_at: pair.sandbox.expires_at, + ssh_host_private_key: Some(SecretSshHostKey::new("private-ssh-host-key".to_string())), + traces_otlp_headers: Some(SecretOtlpHeaders::new( + "authorization=traces-token".to_string(), + )), + ocsf_otlp_headers: Some(SecretOtlpHeaders::new( + "authorization=collector-token".to_string(), + )), + agent_otlp_headers: Some(SecretOtlpHeaders::new( + "authorization=agent-token".to_string(), + )), + otlp_tls: vec![OtlpTlsMaterial { + ca_pem: Some("agent-ca".to_string()), + client: Some(OtlpClientIdentity { + cert_pem: "agent-cert".to_string(), + key_pem: SecretPrivateKeyPem::new("agent-private-key".to_string()), + }), + }], + traces_otlp_tls: None, + ocsf_otlp_tls: None, + agent_otlp_tls: Some(0), + }; + + let encoded = serde_json::to_vec(&bundle).expect("serialize auth bundle"); + let decoded: SupervisorAuthBundle = + serde_json::from_slice(&encoded).expect("deserialize auth bundle"); + assert_eq!(decoded.auth_epoch, bundle.auth_epoch); + assert_eq!( + decoded.gateway_token.expose_secret(), + bundle.gateway_token.expose_secret() + ); + assert_eq!( + decoded.sandbox_token.expose_secret(), + bundle.sandbox_token.expose_secret() + ); + + let debug = format!("{bundle:?}"); + assert!(!debug.contains(bundle.gateway_token.expose_secret())); + assert!(!debug.contains(bundle.sandbox_token.expose_secret())); + assert!(!debug.contains("private-ssh-host-key")); + assert!(!debug.contains("collector-token")); + assert!(!debug.contains("agent-token")); + assert!(!debug.contains("traces-token")); + assert!(!debug.contains("agent-private-key")); + assert_eq!(debug.matches("[REDACTED]").count(), 7); + assert_eq!( + decoded + .ssh_host_private_key + .as_ref() + .unwrap() + .expose_secret(), + "private-ssh-host-key" + ); + assert_eq!( + decoded + .traces_otlp_headers + .as_ref() + .unwrap() + .expose_secret(), + "authorization=traces-token" + ); + assert_eq!( + decoded.ocsf_otlp_headers.as_ref().unwrap().expose_secret(), + "authorization=collector-token" + ); + assert_eq!( + decoded.agent_otlp_headers.as_ref().unwrap().expose_secret(), + "authorization=agent-token" + ); + let agent_tls = decoded.agent_otlp().tls.expect("agent collector TLS"); + assert_eq!(agent_tls.ca_pem.as_deref(), Some("agent-ca")); + let client = agent_tls.client.as_ref().unwrap(); + assert_eq!(client.cert_pem, "agent-cert"); + assert_eq!(client.key_pem.expose_secret(), "agent-private-key"); + let mut legacy = serde_json::to_value(&bundle).unwrap(); + let fields = legacy.as_object_mut().unwrap(); + fields.remove("ssh_host_private_key"); + fields.remove("traces_otlp_headers"); + fields.remove("ocsf_otlp_headers"); + fields.remove("agent_otlp_headers"); + fields.remove("otlp_tls"); + fields.remove("agent_otlp_tls"); + let legacy: SupervisorAuthBundle = serde_json::from_value(legacy).unwrap(); + assert!(legacy.ssh_host_private_key.is_none()); + assert!(legacy.traces_otlp_headers.is_none()); + assert!(legacy.ocsf_otlp_headers.is_none()); + assert!(legacy.agent_otlp_headers.is_none()); + assert!(legacy.otlp_tls.is_empty() && legacy.agent_otlp_tls.is_none()); + + decoded + .validate() + .expect("references within the TLS material"); + let mut dangling = decoded.clone(); + dangling.traces_otlp_tls = Some(1); + assert!(matches!( + dangling.validate(), + Err(SessionJwtError::InvalidOtlpTlsReference) + )); + } + + #[derive(Serialize)] + struct AudienceArrayClaims<'a> { + iss: &'a str, + sub: &'a str, + aud: [&'a str; 1], + iat: i64, + exp: i64, + jti: String, + sandbox_id: &'a str, + runtime_generation: &'a str, + auth_epoch: CredentialEpoch, + component: SessionComponent, + } + + #[test] + fn audience_arrays_are_rejected() { + let key = openshell_crypto::pki::generate_keypair_for(&PKCS_ED25519) + .expect("generate Ed25519 key"); + let clock: Arc = Arc::new(FixedClock(1_900_000_000)); + let verifier = SessionJwtVerifier::new( + "test", + SessionTokenProfile::Gateway, + [SessionVerificationKey { + key_id: "current".to_string(), + public_key_pem: key.public_key_pem().into_bytes(), + }], + clock, + ) + .expect("verifier"); + let claims = AudienceArrayClaims { + iss: "openshell-gateway:test", + sub: "spiffe://openshell/sandbox/sandbox-a", + aud: ["openshell-gateway:test"], + iat: 1_900_000_000, + exp: 1_900_003_600, + jti: uuid::Uuid::new_v4().to_string(), + sandbox_id: "sandbox-a", + runtime_generation: "generation-1", + auth_epoch: CredentialEpoch::new(1).expect("auth epoch"), + component: SessionComponent::OpenShellSupervisor, + }; + let mut header = Header::new(Algorithm::EdDSA); + header.kid = Some("current".to_string()); + header.typ = Some(GATEWAY_SESSION_JWT_TYPE.to_string()); + let token = encode( + &header, + &claims, + &EncodingKey::from_ed_pem(key.serialize_pem().unwrap().as_bytes()) + .expect("encoding key"), + ) + .expect("token"); + assert_eq!(verifier.verify(&token), Err(SessionJwtError::InvalidToken)); + } +} diff --git a/crates/openshell-driver-sdk/src/lib.rs b/crates/openshell-driver-sdk/src/lib.rs new file mode 100644 index 0000000000..b0bfe59d84 --- /dev/null +++ b/crates/openshell-driver-sdk/src/lib.rs @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Narrow shared contract for `OpenShell` compute drivers. +//! +//! This crate holds the items that both core (the gateway, supervisor, and the +//! Kubernetes driver) and the desktop drivers depend on. `openshell-core` +//! re-exports every item defined here, so each item has exactly one definition. +//! It depends only on workspace crates and crates.io. + +pub mod compute; +pub mod config; +pub mod container_paths; +pub mod driver_mounts; +pub mod driver_utils; +#[cfg(unix)] +pub mod e2fsprogs; +pub mod error; +pub mod extension_protocol; +#[cfg(unix)] +pub mod external_driver_socket; +pub mod gpu; +pub mod image; +pub mod isolation_interface; +pub mod isolation_registration; +#[cfg(feature = "jwt")] +pub mod jwt; +pub mod net; +pub mod network_trust; +pub mod paths; +pub mod policy; +pub mod progress; +pub mod proto; +pub mod proto_struct; +pub mod resource_admission; +pub mod sandbox_env; +pub mod sandbox_generation; +pub mod sandbox_session; +pub mod settings; +pub mod telemetry; +pub mod time; + +pub use config::{AppArmorProfile, ImagePullPolicy, UpstreamProxyConfig}; +pub use error::{ComputeDriverError, Error, Result}; +pub use network_trust::NetworkSupervisorTrustBundle; +pub use sandbox_session::{SandboxSessionId, SandboxSessionIdError}; + +/// Build version string derived from git metadata. +/// +/// For local builds this is computed by `build.rs` from the exact release tag +/// or the latest merged stable tag using the guess-next-dev scheme (e.g. +/// `0.0.4-dev.6+g2bf9969ab`). In Docker/CI builds where `.git` is absent, it +/// falls back to `CARGO_PKG_VERSION`, which the build pipeline already stamps. +pub const VERSION: &str = match option_env!("OPENSHELL_GIT_VERSION") { + Some(v) => v, + None => env!("CARGO_PKG_VERSION"), +}; + +#[cfg(test)] +#[path = "../build_version.rs"] +mod build_version; diff --git a/crates/openshell-driver-sdk/src/net.rs b/crates/openshell-driver-sdk/src/net.rs new file mode 100644 index 0000000000..cfaf09db77 --- /dev/null +++ b/crates/openshell-driver-sdk/src/net.rs @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Socket helpers shared by core and the compute drivers. + +use std::net::SocketAddr; +use tokio::net::TcpStream; + +/// Enable `TCP_NODELAY` on a stream, logging (not returning) any failure. +/// +/// Disabling Nagle's algorithm keeps small writes from waiting on delayed ACKs. +/// It's a latency optimization: if it fails the connection still works, just a +/// bit slower, so there is nothing for the caller to act on — we log and move on. +pub fn set_tcp_nodelay_best_effort(stream: &TcpStream) { + if let Err(e) = stream.set_nodelay(true) { + tracing::debug!(error = %e, "failed to set TCP_NODELAY"); + } +} + +/// Connect to `addrs`, then enable `TCP_NODELAY` on a best-effort basis, propagating +/// any errors from `TcpStream::connect`. +/// +/// The returned stream is not *guaranteed* to have `TCP_NODELAY` set. +pub async fn connect_tcp_nodelay_best_effort(addrs: &[SocketAddr]) -> std::io::Result { + let stream = TcpStream::connect(addrs).await?; + set_tcp_nodelay_best_effort(&stream); + Ok(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- tcp_nodelay helpers -- + + #[tokio::test] + async fn set_tcp_nodelay_best_effort_enables_nodelay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let stream = TcpStream::connect(addr).await.expect("connect"); + + set_tcp_nodelay_best_effort(&stream); + assert!(stream.nodelay().expect("query TCP_NODELAY")); + } + + #[tokio::test] + async fn connect_tcp_nodelay_best_effort_sets_nodelay() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("local addr"); + + let stream = connect_tcp_nodelay_best_effort(&[addr]) + .await + .expect("connect"); + assert!(stream.nodelay().expect("query TCP_NODELAY")); + } +} diff --git a/crates/openshell-core/src/network_trust.rs b/crates/openshell-driver-sdk/src/network_trust.rs similarity index 95% rename from crates/openshell-core/src/network_trust.rs rename to crates/openshell-driver-sdk/src/network_trust.rs index cd0a349eb5..e43ef59f85 100644 --- a/crates/openshell-core/src/network_trust.rs +++ b/crates/openshell-driver-sdk/src/network_trust.rs @@ -20,20 +20,6 @@ use std::sync::Arc; /// supported driver on the same deployable boundary. pub const MAX_NETWORK_SUPERVISOR_TRUST_BUNDLE_BYTES: usize = 1024 * 1024 - "ca.crt".len(); -/// Protected resource metadata key recording the destination-trust generation -/// used to build a sandbox startup artifact. -/// -/// Kubernetes stores this value in an annotation because a `sha256:` -/// digest exceeds the 63-character label-value limit. Local container drivers -/// may store the same protected key in a container label. -pub const NETWORK_SUPERVISOR_TRUST_GENERATION_KEY: &str = "openshell.ai/additional-ca-generation"; - -/// Explicit generation marker for an omitted destination-trust configuration. -/// -/// An explicit value, rather than an absent marker, lets a stopped sandbox -/// distinguish a removal from a resource created by an older gateway. -pub const NETWORK_SUPERVISOR_TRUST_GENERATION_NONE: &str = "none"; - /// Normalized, gateway-owned trust material for sandbox destination TLS. /// /// The PEM bytes contain only canonical X.509 certificate blocks. The type is diff --git a/crates/openshell-driver-sdk/src/paths.rs b/crates/openshell-driver-sdk/src/paths.rs new file mode 100644 index 0000000000..ab98a31037 --- /dev/null +++ b/crates/openshell-driver-sdk/src/paths.rs @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Path utilities shared by the compute drivers: XDG state and data directory +//! resolution and permission helpers that create sensitive files and the +//! directories containing them with restrictive modes. + +use miette::{IntoDiagnostic, Result, WrapErr}; +use std::path::{Path, PathBuf}; +/// Resolve the XDG state base directory. +/// +/// Returns `$XDG_STATE_HOME` if set, otherwise `$HOME/.local/state`. +pub fn xdg_state_dir() -> Result { + if let Ok(path) = std::env::var("XDG_STATE_HOME") { + return Ok(PathBuf::from(path)); + } + #[cfg(target_os = "windows")] + if let Ok(path) = std::env::var("LOCALAPPDATA") { + return Ok(PathBuf::from(path)); + } + let home = std::env::var("HOME") + .into_diagnostic() + .wrap_err("HOME is not set")?; + Ok(PathBuf::from(home).join(".local").join("state")) +} + +/// Resolve the XDG data base directory. +/// +/// Returns `$XDG_DATA_HOME` if set, otherwise `$HOME/.local/share`. +pub fn xdg_data_dir() -> Result { + if let Ok(path) = std::env::var("XDG_DATA_HOME") { + return Ok(PathBuf::from(path)); + } + #[cfg(target_os = "windows")] + if let Ok(path) = std::env::var("LOCALAPPDATA") { + return Ok(PathBuf::from(path)); + } + let home = std::env::var("HOME") + .into_diagnostic() + .wrap_err("HOME is not set")?; + Ok(PathBuf::from(home).join(".local").join("share")) +} + +/// Create a directory (and parents) with owner-only permissions (`0o700`) on +/// Unix. On non-Unix platforms, falls back to default permissions. +/// +/// This should be used for any directory that contains sensitive material +/// (tokens, private keys, certificates). +pub fn create_dir_restricted(path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .into_diagnostic() + .wrap_err_with(|| format!("failed to create {}", path.display()))?; + set_dir_owner_only(path)?; + Ok(()) +} + +/// Set a directory to owner-only access (`0o700`) on Unix. +/// +/// No-op on non-Unix platforms. +pub fn set_dir_owner_only(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .into_diagnostic() + .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +/// Set a file to owner-only read/write (`0o600`) on Unix. +/// +/// No-op on non-Unix platforms. +pub fn set_file_owner_only(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .into_diagnostic() + .wrap_err_with(|| format!("failed to set permissions on {}", path.display()))?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +/// Whether `path` is absolute under POSIX rules, whatever the host platform. +pub fn is_posix_absolute(path: &Path) -> bool { + path.to_str().is_some_and(|path| path.starts_with('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_posix_absolute_ignores_host_path_syntax() { + assert!(is_posix_absolute(Path::new("/run/backend.sock"))); + assert!(is_posix_absolute(Path::new("/"))); + for path in ["", "relative", "./run", r"\run", r"C:\run", "C:/run"] { + assert!(!is_posix_absolute(Path::new(path)), "{path}"); + } + } + + #[cfg(unix)] + #[test] + fn create_dir_restricted_sets_0o700() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("restricted"); + create_dir_restricted(&dir).unwrap(); + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "expected 0700, got {mode:04o}"); + } + + #[cfg(unix)] + #[test] + fn set_file_owner_only_sets_0o600() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("secret"); + std::fs::write(&file, "secret-data").unwrap(); + set_file_owner_only(&file).unwrap(); + let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "expected 0600, got {mode:04o}"); + } +} diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-driver-sdk/src/policy.rs similarity index 92% rename from crates/openshell-core/src/policy.rs rename to crates/openshell-driver-sdk/src/policy.rs index af474325c5..866b02b48c 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-driver-sdk/src/policy.rs @@ -3,11 +3,11 @@ //! Sandbox policy configuration. -use crate::paths::normalize_path; -use crate::proto::{ +use crate::proto::sandbox::v1::{ FilesystemPolicy as ProtoFilesystemPolicy, LandlockPolicy as ProtoLandlockPolicy, ProcessPolicy as ProtoProcessPolicy, SandboxPolicy as ProtoSandboxPolicy, }; +use openshell_policy_schema::normalize_path; use std::net::SocketAddr; use std::path::PathBuf; @@ -189,6 +189,18 @@ impl From for ProcessPolicy { } } +/// Minimum accepted UID/GID for sandbox workload identity. +/// +/// Linux reserves only identity `0` for root. Non-root system identities are +/// valid workload identities when selected explicitly by the operator. +pub const MIN_SANDBOX_UID: u32 = 1; + +/// Maximum accepted UID/GID for sandbox workload identity. +/// +/// `u32::MAX` represents an invalid or unchanged identity in Linux APIs and +/// POSIX ACLs, so the largest usable workload identity is one less. +pub const MAX_SANDBOX_UID: u32 = u32::MAX - 1; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/openshell-core/src/progress.rs b/crates/openshell-driver-sdk/src/progress.rs similarity index 100% rename from crates/openshell-core/src/progress.rs rename to crates/openshell-driver-sdk/src/progress.rs diff --git a/crates/openshell-driver-sdk/src/proto.rs b/crates/openshell-driver-sdk/src/proto.rs new file mode 100644 index 0000000000..87efd3a40d --- /dev/null +++ b/crates/openshell-driver-sdk/src/proto.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generated protocol buffer code for the compute-driver contract. +//! +//! Covers `compute_driver.proto` and the protos it imports. `openshell-core` +//! compiles the remaining protos and refers to these types by extern path. + +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + dead_code, + unused_imports, + unused_qualifications, + rust_2018_idioms +)] +mod generated { + include!(concat!(env!("OUT_DIR"), "/openshell.rs")); +} + +pub mod datamodel { + pub use super::generated::openshell::datamodel::v1; +} + +pub mod sandbox { + pub use super::generated::openshell::sandbox::v1; +} + +pub mod compute { + pub use super::generated::openshell::compute::v1; +} + +pub mod extension { + pub use super::generated::openshell::extension::v1; +} diff --git a/crates/openshell-core/src/proto_struct.rs b/crates/openshell-driver-sdk/src/proto_struct.rs similarity index 71% rename from crates/openshell-core/src/proto_struct.rs rename to crates/openshell-driver-sdk/src/proto_struct.rs index ce95c65f0c..3e75d16ad6 100644 --- a/crates/openshell-core/src/proto_struct.rs +++ b/crates/openshell-driver-sdk/src/proto_struct.rs @@ -3,8 +3,6 @@ //! Helpers for converting `google.protobuf.Struct` values to and from JSON. -use serde::{Deserialize, Deserializer, de::Error as _}; - /// Errors converting JSON values into protobuf well-known types. #[derive(Debug, thiserror::Error)] pub enum ProtoStructError { @@ -105,33 +103,6 @@ pub fn value_to_json(value: &prost_types::Value) -> serde_json::Value { } } -/// Deserialize a present field as a non-empty list of non-empty strings. -/// -/// Use with `#[serde(default, deserialize_with = "...")]` on -/// `Option>` fields. Missing fields use the option default; present -/// fields must be arrays and cannot be empty. -pub fn deserialize_optional_non_empty_string_list<'de, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - let values = Vec::::deserialize(deserializer)?; - if values.is_empty() { - return Err(D::Error::custom("must be a non-empty list of strings")); - } - - for (idx, value) in values.iter().enumerate() { - if value.trim().is_empty() { - return Err(D::Error::custom(format!( - "[{idx}] must be a non-empty string" - ))); - } - } - - Ok(Some(values)) -} - #[cfg(test)] mod tests { use super::*; @@ -176,45 +147,4 @@ mod tests { "protobuf Struct stores all numbers as doubles" ); } - - #[derive(Debug, Default, Deserialize)] - #[serde(default)] - struct TestConfig { - #[serde( - default, - deserialize_with = "deserialize_optional_non_empty_string_list" - )] - devices: Option>, - } - - #[test] - fn optional_non_empty_string_list_defaults_when_absent() { - let config: TestConfig = serde_json::from_value(serde_json::json!({})).unwrap(); - - assert_eq!(config.devices, None); - } - - #[test] - fn optional_non_empty_string_list_parses_present_list() { - let config: TestConfig = - serde_json::from_value(serde_json::json!({"devices": ["nvidia.com/gpu=0"]})).unwrap(); - - assert_eq!(config.devices, Some(vec!["nvidia.com/gpu=0".to_string()])); - } - - #[test] - fn optional_non_empty_string_list_rejects_empty_list() { - let err = - serde_json::from_value::(serde_json::json!({"devices": []})).unwrap_err(); - - assert!(err.to_string().contains("non-empty list")); - } - - #[test] - fn optional_non_empty_string_list_rejects_empty_string() { - let err = - serde_json::from_value::(serde_json::json!({"devices": [""]})).unwrap_err(); - - assert!(err.to_string().contains("non-empty string")); - } } diff --git a/crates/openshell-core/src/resource_admission.rs b/crates/openshell-driver-sdk/src/resource_admission.rs similarity index 95% rename from crates/openshell-core/src/resource_admission.rs rename to crates/openshell-driver-sdk/src/resource_admission.rs index 7d20c405f4..d1a2c58ad3 100644 --- a/crates/openshell-core/src/resource_admission.rs +++ b/crates/openshell-driver-sdk/src/resource_admission.rs @@ -4,13 +4,12 @@ //! Operator-owned external resource admission. GPU attachments are temporarily //! exempt; this policy must not be interpreted as permission to attach host paths. -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - use crate::driver_utils::{LABEL_GATEWAY_ID, LABEL_MANAGED_BY, LABEL_SANDBOX_WORKSPACE}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; const WORKSPACE_PLACEHOLDER: &str = "${workspace}"; + const RESERVED_LABEL_KEYS: [&str; 3] = [LABEL_MANAGED_BY, LABEL_GATEWAY_ID, LABEL_SANDBOX_WORKSPACE]; @@ -90,20 +89,6 @@ impl std::str::FromStr for DriverAdmissionConfig { } } -/// Reserved driver-owned runtime metadata; caller labels must never override it. -pub const CONFIG_USED_LABEL: &str = "openshell.ai/caller-driver-config-used"; -pub const IDENTITIES_LABEL: &str = "openshell.ai/resource-admission-identities"; - -pub fn check_config_provenance(allowed: bool, recorded: Option<&str>) -> Result<(), tonic::Status> { - match recorded { - Some("false") => Ok(()), - Some("true") if allowed => Ok(()), - _ => Err(tonic::Status::failed_precondition( - "sandbox uses forbidden driver config or lacks admission provenance; recreate it", - )), - } -} - /// Check caller config before resource lookups, including direct driver RPCs. pub fn check_driver_config( allowed: bool, diff --git a/crates/openshell-core/src/sandbox_env.rs b/crates/openshell-driver-sdk/src/sandbox_env.rs similarity index 100% rename from crates/openshell-core/src/sandbox_env.rs rename to crates/openshell-driver-sdk/src/sandbox_env.rs diff --git a/crates/openshell-core/src/sandbox_generation.rs b/crates/openshell-driver-sdk/src/sandbox_generation.rs similarity index 100% rename from crates/openshell-core/src/sandbox_generation.rs rename to crates/openshell-driver-sdk/src/sandbox_generation.rs diff --git a/crates/openshell-core/src/sandbox_session.rs b/crates/openshell-driver-sdk/src/sandbox_session.rs similarity index 100% rename from crates/openshell-core/src/sandbox_session.rs rename to crates/openshell-driver-sdk/src/sandbox_session.rs diff --git a/crates/openshell-driver-sdk/src/settings.rs b/crates/openshell-driver-sdk/src/settings.rs new file mode 100644 index 0000000000..162d1325ba --- /dev/null +++ b/crates/openshell-driver-sdk/src/settings.rs @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Setting-value parsing shared by the CLI, the TUI, and the compute drivers. + +/// Parse common bool-like string values. +#[must_use] +pub fn parse_bool_like(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "y" | "on" => Some(true), + "0" | "false" | "no" | "n" | "off" => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::parse_bool_like; + + // ---- parse_bool_like ---- + + #[test] + fn parse_bool_like_accepts_expected_spellings() { + for raw in ["1", "true", "yes", "on", "Y"] { + assert_eq!(parse_bool_like(raw), Some(true), "expected true for {raw}"); + } + for raw in ["0", "false", "no", "off", "N"] { + assert_eq!( + parse_bool_like(raw), + Some(false), + "expected false for {raw}" + ); + } + } + + #[test] + fn parse_bool_like_case_insensitive() { + assert_eq!(parse_bool_like("TRUE"), Some(true)); + assert_eq!(parse_bool_like("True"), Some(true)); + assert_eq!(parse_bool_like("FALSE"), Some(false)); + assert_eq!(parse_bool_like("False"), Some(false)); + assert_eq!(parse_bool_like("YES"), Some(true)); + assert_eq!(parse_bool_like("NO"), Some(false)); + assert_eq!(parse_bool_like("On"), Some(true)); + assert_eq!(parse_bool_like("Off"), Some(false)); + } + + #[test] + fn parse_bool_like_trims_whitespace() { + assert_eq!(parse_bool_like(" true "), Some(true)); + assert_eq!(parse_bool_like("\tfalse\t"), Some(false)); + assert_eq!(parse_bool_like(" 1 "), Some(true)); + assert_eq!(parse_bool_like(" 0 "), Some(false)); + } + + #[test] + fn parse_bool_like_rejects_unrecognized_values() { + assert_eq!(parse_bool_like("maybe"), None); + assert_eq!(parse_bool_like(""), None); + assert_eq!(parse_bool_like("2"), None); + assert_eq!(parse_bool_like("nope"), None); + assert_eq!(parse_bool_like("yep"), None); + assert_eq!(parse_bool_like("enabled"), None); + assert_eq!(parse_bool_like("disabled"), None); + } +} diff --git a/crates/openshell-driver-sdk/src/telemetry.rs b/crates/openshell-driver-sdk/src/telemetry.rs new file mode 100644 index 0000000000..7f54defc55 --- /dev/null +++ b/crates/openshell-driver-sdk/src/telemetry.rs @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Telemetry state that compute drivers propagate to sandbox supervisors. + +/// Whether anonymous telemetry is enabled, as the value drivers hand to sandbox +/// supervisors (`"true"` or `"false"`). +#[cfg(feature = "telemetry")] +pub fn enabled_env_value() -> &'static str { + enabled_env_value_from(std::env::var("OPENSHELL_TELEMETRY_ENABLED").ok().as_deref()) +} + +/// Telemetry support is compiled out: report disabled so sandbox supervisors +/// inherit the disabled state and skip activity collection. +#[cfg(not(feature = "telemetry"))] +pub fn enabled_env_value() -> &'static str { + "false" +} + +#[cfg(feature = "telemetry")] +fn enabled_env_value_from(value: Option<&str>) -> &'static str { + if telemetry_enabled_from(value) { + "true" + } else { + "false" + } +} + +#[cfg(feature = "telemetry")] +fn telemetry_enabled_from(value: Option<&str>) -> bool { + let value = value.unwrap_or("true"); + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" + ) +} + +#[cfg(all(test, feature = "telemetry"))] +mod tests { + use super::*; + + #[test] + fn telemetry_enabled_defaults_true() { + assert!(telemetry_enabled_from(None)); + } + + #[test] + fn telemetry_enabled_honors_false_values() { + assert!(!telemetry_enabled_from(Some("off"))); + assert!(!telemetry_enabled_from(Some("false"))); + assert!(telemetry_enabled_from(Some("yes"))); + } + + #[test] + fn telemetry_enabled_env_value_is_normalized() { + assert_eq!(enabled_env_value_from(Some("false")), "false"); + assert_eq!(enabled_env_value_from(Some("0")), "false"); + assert_eq!(enabled_env_value_from(None), "true"); + assert_eq!(enabled_env_value_from(Some("yes")), "true"); + } +} diff --git a/crates/openshell-core/src/time.rs b/crates/openshell-driver-sdk/src/time.rs similarity index 100% rename from crates/openshell-core/src/time.rs rename to crates/openshell-driver-sdk/src/time.rs diff --git a/crates/openshell-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index 57921d4b9a..4f01454123 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -24,7 +24,8 @@ name = "openshell-vm-init" path = "src/bin/openshell-vm-init.rs" [dependencies] -openshell-core = { path = "../openshell-core", default-features = false, features = ["clap"] } +openshell-container-runtime = { path = "../openshell-container-runtime" } +openshell-driver-sdk = { path = "../openshell-driver-sdk", features = ["jwt", "clap"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } openshell-otel = { path = "../openshell-otel", optional = true } @@ -80,7 +81,7 @@ compute-driver = [ "dep:nix", "dep:oci-client", "dep:openshell-otel", - "openshell-core/otel", + "openshell-driver-sdk/otel", "dep:openshell-policy", "dep:openshell-driver-podman", "dep:openshell-vfio", @@ -101,10 +102,10 @@ compute-driver = [ "dep:url", "dep:zstd", ] -## Compile in telemetry support (forwards to openshell-core/telemetry). On by +## Compile in telemetry support (forwards to openshell-driver-sdk/telemetry). On by ## default; build with `--no-default-features` for a telemetry-free VM driver ## that reports telemetry disabled to the sandboxes it launches. -telemetry = ["openshell-core/telemetry"] +telemetry = ["openshell-driver-sdk/telemetry"] ## Convenience alias: every default feature except `telemetry`. Build a ## telemetry-free VM driver with ## `--no-default-features --features defaults-without-telemetry` and stay diff --git a/crates/openshell-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index f7946c34c2..ed5a10e1a2 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -44,17 +44,17 @@ use oci_client::manifest::{ }; use oci_client::secrets::RegistryAuth; use oci_client::{Reference, RegistryOperation}; -use openshell_core::UpstreamProxyConfig; -use openshell_core::config::DEFAULT_STOP_TIMEOUT_SECS; -use openshell_core::driver_mounts; -use openshell_core::gpu::{ - driver_gpu_requirements, effective_driver_gpu_count, validate_specific_gpu_device_request, -}; -use openshell_core::progress::{ +use openshell_container_runtime::gpu::validate_specific_gpu_device_request; +use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; +use openshell_driver_sdk::UpstreamProxyConfig; +use openshell_driver_sdk::config::DEFAULT_STOP_TIMEOUT_SECS; +use openshell_driver_sdk::driver_mounts; +use openshell_driver_sdk::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; +use openshell_driver_sdk::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail, }; -use openshell_core::proto::compute::v1::{ +use openshell_driver_sdk::proto::compute::v1::{ CpuResourceCapabilities, CreateSandboxRequest, CreateSandboxResponse, DeleteSandboxRequest, DeleteSandboxResponse, DeleteWorkspaceRequest, DeleteWorkspaceResponse, DriverCondition as SandboxCondition, DriverPlatformEvent as PlatformEvent, @@ -69,10 +69,8 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent, WorkloadIdentityRequest, compute_driver_server::ComputeDriver, watch_sandboxes_event, }; -use openshell_core::proto_struct::{ - deserialize_optional_non_empty_string_list, struct_to_json_value, -}; -use openshell_core::{ +use openshell_driver_sdk::proto_struct::struct_to_json_value; +use openshell_driver_sdk::{ NetworkSupervisorTrustBundle, network_trust::MAX_NETWORK_SUPERVISOR_TRUST_BUNDLE_BYTES, }; use openshell_sandbox_backend::boundary_protocol::{ @@ -204,12 +202,13 @@ const HOST_LOOPBACK_ALIASES: &[&str] = &[ "host.docker.internal", ]; #[allow(dead_code)] -const GUEST_SSH_SOCKET_PATH: &str = openshell_core::container_paths::SSH_SOCKET_PATH; +const GUEST_SSH_SOCKET_PATH: &str = openshell_driver_sdk::container_paths::SSH_SOCKET_PATH; #[allow(dead_code)] -const GUEST_TLS_CA_PATH: &str = openshell_core::container_paths::VM_GUEST_TLS_CA_PATH; +const GUEST_TLS_CA_PATH: &str = openshell_driver_sdk::container_paths::VM_GUEST_TLS_CA_PATH; #[allow(dead_code)] -const GUEST_SANDBOX_TOKEN_PATH: &str = openshell_core::container_paths::VM_GUEST_SANDBOX_TOKEN_PATH; -const GUEST_INIT_DROPIN_DIR: &str = openshell_core::container_paths::VM_GUEST_INIT_DROPIN_DIR; +const GUEST_SANDBOX_TOKEN_PATH: &str = + openshell_driver_sdk::container_paths::VM_GUEST_SANDBOX_TOKEN_PATH; +const GUEST_INIT_DROPIN_DIR: &str = openshell_driver_sdk::container_paths::VM_GUEST_INIT_DROPIN_DIR; const GUEST_BOUNDARY_CONFIG_DIR: &str = "/.openshell/state"; const GUEST_BOUNDARY_CONFIG_ENV: &str = "OPENSHELL_VM_SANDBOX_BOOTSTRAP"; const HOST_AUTH_BUNDLE_FILE: &str = "supervisor-auth.json"; @@ -238,7 +237,7 @@ const VM_CONTROL_PORT: u32 = 5500; /// guest image — is ignored. The driver writes this file into the overlay /// upperdir on every launch, so the image cannot forge or shadow it. const GUEST_INIT_DROPIN_MANIFEST: &str = - openshell_core::container_paths::VM_GUEST_INIT_DROPIN_MANIFEST; + openshell_driver_sdk::container_paths::VM_GUEST_INIT_DROPIN_MANIFEST; const IMAGE_CACHE_ROOT_DIR: &str = "images"; const IMAGE_CACHE_ROOTFS_IMAGE: &str = "rootfs.ext4"; const OVERLAY_TEMPLATE_CACHE_DIR: &str = "overlay-templates"; @@ -346,12 +345,12 @@ pub struct VmDriverConfig { pub allow_driver_config: bool, /// Operator-owned external attachment approval policy. #[serde(default)] - pub resource_admission: openshell_core::resource_admission::ResourceAdmissionConfig, + pub resource_admission: openshell_driver_sdk::resource_admission::ResourceAdmissionConfig, pub grpc_endpoint: String, /// OTLP exports passed to supervisors. The gateway supplies them from /// `[otlp]`; driver TOML cannot set them. #[serde(skip)] - pub supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp, + pub supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp, /// Gateway name passed to supervisors for their telemetry. pub supervisor_gateway_name: Option, pub state_dir: PathBuf, @@ -495,11 +494,11 @@ impl Default for VmDriverConfig { fn default() -> Self { Self { grpc_endpoint: String::new(), - supervisor_otlp: openshell_core::sandbox_env::SupervisorOtlp::default(), + supervisor_otlp: openshell_driver_sdk::sandbox_env::SupervisorOtlp::default(), supervisor_gateway_name: None, allow_driver_config: false, resource_admission: - openshell_core::resource_admission::ResourceAdmissionConfig::default(), + openshell_driver_sdk::resource_admission::ResourceAdmissionConfig::default(), state_dir: PathBuf::from("target/openshell-vm-driver"), launcher_bin: None, default_image: String::new(), @@ -554,7 +553,7 @@ impl VmDriverConfig { } } if let Some(endpoint) = self.provider_spiffe_workload_api_tcp_endpoint.as_deref() { - openshell_core::driver_utils::validate_guest_spiffe_tcp_endpoint( + openshell_driver_sdk::driver_utils::validate_guest_spiffe_tcp_endpoint( endpoint, self.provider_spiffe_allow_guest_tcp, )?; @@ -1371,7 +1370,7 @@ impl VmDriver { state_dir: &Path, tls_paths: Option<&VmDriverTlsPaths>, runtime_descriptor: &SandboxRuntimeDescriptor, - auth_bundle: &openshell_core::jwt::SupervisorAuthBundle, + auth_bundle: &openshell_driver_sdk::jwt::SupervisorAuthBundle, sandbox_owner: SandboxOwnerIdentity, ) -> Result<(Child, Option), Status> { // The destination CA is staged on the host before either resolving @@ -1416,10 +1415,11 @@ impl VmDriver { .map_err(|error| { Status::internal(format!("restrict host runtime descriptor: {error}")) })?; - let main_process_spec = openshell_core::sandbox_env::MainProcessConfig::encode_driver_spec( - sandbox.spec.as_ref(), - ) - .map_err(|error| Status::internal(format!("encode main process spec: {error}")))?; + let main_process_spec = + openshell_driver_sdk::sandbox_env::MainProcessConfig::encode_driver_spec( + sandbox.spec.as_ref(), + ) + .map_err(|error| Status::internal(format!("encode main process spec: {error}")))?; let upstream_proxy_args = upstream_proxy_cli_args(&self.config) .map_err(|error| Status::invalid_argument(format!("render upstream proxy: {error}")))?; let mut command = Command::new(&supervisor_binary); @@ -1445,51 +1445,57 @@ impl VmDriver { .args(upstream_proxy_args) .args(network_trust_args) .env( - openshell_core::sandbox_env::ADMITTED_ISOLATION_BACKEND, + openshell_driver_sdk::sandbox_env::ADMITTED_ISOLATION_BACKEND, DRIVER_ADMITTED_BACKEND, ) .env( - openshell_core::sandbox_env::MAIN_PROCESS_SPEC, + openshell_driver_sdk::sandbox_env::MAIN_PROCESS_SPEC, main_process_spec, ) - .env(openshell_core::sandbox_env::ENDPOINT, openshell_endpoint) - .env(openshell_core::sandbox_env::SANDBOX_ID, &sandbox.id) - .env(openshell_core::sandbox_env::SANDBOX, &sandbox.name) .env( - openshell_core::sandbox_env::SSH_SOCKET_PATH, + openshell_driver_sdk::sandbox_env::ENDPOINT, + openshell_endpoint, + ) + .env(openshell_driver_sdk::sandbox_env::SANDBOX_ID, &sandbox.id) + .env(openshell_driver_sdk::sandbox_env::SANDBOX, &sandbox.name) + .env( + openshell_driver_sdk::sandbox_env::SSH_SOCKET_PATH, sandbox_socket_dir(&self.socket_root, &sandbox.id).join("ssh.sock"), ) .env( - openshell_core::sandbox_env::PROXY_TLS_DIR, + openshell_driver_sdk::sandbox_env::PROXY_TLS_DIR, state_dir.join("proxy-tls"), ) .env( - openshell_core::sandbox_env::SANDBOX_UID, + openshell_driver_sdk::sandbox_env::SANDBOX_UID, sandbox_owner.uid.to_string(), ) .env( - openshell_core::sandbox_env::SANDBOX_GID, + openshell_driver_sdk::sandbox_env::SANDBOX_GID, sandbox_owner.gid.to_string(), ) - .env(openshell_core::sandbox_env::OCI_IMAGE_USER, "") + .env(openshell_driver_sdk::sandbox_env::OCI_IMAGE_USER, "") .env( - openshell_core::sandbox_env::LOG_LEVEL, - openshell_core::driver_utils::sandbox_log_level(sandbox, &self.config.log_level), + openshell_driver_sdk::sandbox_env::LOG_LEVEL, + openshell_driver_sdk::driver_utils::sandbox_log_level( + sandbox, + &self.config.log_level, + ), ) .env( - openshell_core::sandbox_env::TELEMETRY_ENABLED, - openshell_core::telemetry::enabled_env_value(), + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED, + openshell_driver_sdk::telemetry::enabled_env_value(), ); command.envs(supervisor_otlp_environment(&self.config)); if let Some(server_name) = gateway_tls_server_name { command.env( - openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME, + openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME, server_name, ); } configure_main_exit_marker(&mut command, state_dir); if let Some(tls) = tls_paths { - command.env(openshell_core::sandbox_env::TLS_CA, &tls.ca); + command.env(openshell_driver_sdk::sandbox_env::TLS_CA, &tls.ca); } #[cfg(unix)] let (liveness_read, liveness_write) = nix::unistd::pipe().map_err(|error| { @@ -1535,23 +1541,24 @@ impl VmDriver { #[must_use] pub fn capabilities(&self) -> GetCapabilitiesResponse { - let mut extension = openshell_core::extension_protocol::extension_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + let mut extension = openshell_driver_sdk::extension_protocol::extension_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, "openshell/vm", - openshell_core::VERSION, + openshell_driver_sdk::VERSION, [], ); - extension - .required_capabilities - .push(openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.to_string()); + extension.required_capabilities.push( + openshell_driver_sdk::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION.to_string(), + ); GetCapabilitiesResponse { - resource_admission_policy: openshell_core::resource_admission::DriverAdmissionConfig { - allow_driver_config: self.config.allow_driver_config, - resource_admission: self.config.resource_admission.clone(), - } - .acknowledgement(), + resource_admission_policy: + openshell_driver_sdk::resource_admission::DriverAdmissionConfig { + allow_driver_config: self.config.allow_driver_config, + resource_admission: self.config.resource_admission.clone(), + } + .acknowledgement(), driver_name: DRIVER_NAME.to_string(), - driver_version: openshell_core::VERSION.to_string(), + driver_version: openshell_driver_sdk::VERSION.to_string(), default_image: self.config.default_image.clone(), gateway_manages_lifecycle: true, supports_sandbox_authentication: false, @@ -1582,7 +1589,7 @@ impl VmDriver { // gRPC API surface; boxing here would diverge from every other handler. #[allow(clippy::result_large_err)] pub fn validate_sandbox(&self, sandbox: &Sandbox) -> Result<(), Status> { - openshell_core::resource_admission::check_sandbox_driver_config( + openshell_driver_sdk::resource_admission::check_sandbox_driver_config( self.config.allow_driver_config, sandbox, )?; @@ -2429,7 +2436,7 @@ impl VmDriver { if !sandbox_id.is_empty() { validate_sandbox_id(sandbox_id)?; } - let generation = openshell_core::sandbox_generation::SandboxGenerationId::parse( + let generation = openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( generation_id.to_string(), ) .map_err(|error| Status::invalid_argument(error.to_string()))?; @@ -5296,15 +5303,17 @@ fn configure_main_exit_marker(command: &mut Command, state_dir: &Path) { } #[tonic::async_trait] -impl openshell_core::compute::ComputeDriver for VmDriver {} +impl openshell_driver_sdk::compute::ComputeDriver for VmDriver {} #[tonic::async_trait] impl ComputeDriver for VmDriver { async fn authenticate_sandbox( &self, - _request: Request, - ) -> Result, Status> - { + _request: Request, + ) -> Result< + Response, + Status, + > { Err(Status::unimplemented( "VM driver does not authenticate sandbox credentials", )) @@ -5315,8 +5324,8 @@ impl ComputeDriver for VmDriver { request: Request, ) -> Result, Status> { let capabilities = self.capabilities(); - openshell_core::extension_protocol::validate_gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + openshell_driver_sdk::extension_protocol::validate_gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, DRIVER_NAME, capabilities.extension.as_ref(), request.into_inner().gateway, @@ -5519,7 +5528,7 @@ fn check_gpu_privileges() -> Result<(), String> { #[allow(clippy::result_large_err)] fn decode_launch_authentication( encoded: &[u8], -) -> Result { +) -> Result { if encoded.is_empty() { return Err(Status::failed_precondition( "VM sandbox launch authentication is required; configure the gateway's \ @@ -5528,11 +5537,10 @@ fn decode_launch_authentication( } // Serde errors may quote an unexpected field or value from the secret // bundle. Return fixed diagnostics rather than forwarding those details. - let authentication = - serde_json::from_slice::(encoded) - .map_err(|_| { - Status::failed_precondition("VM sandbox launch authentication is malformed") - })?; + let authentication = serde_json::from_slice::< + openshell_driver_sdk::jwt::SandboxLaunchAuthentication, + >(encoded) + .map_err(|_| Status::failed_precondition("VM sandbox launch authentication is malformed"))?; authentication.validate().map_err(|_| { Status::failed_precondition("VM sandbox launch authentication has invalid fields") })?; @@ -6740,20 +6748,20 @@ fn build_guest_environment(sandbox: &Sandbox, config: &VmDriverConfig) -> Vec>(); pairs.sort_by(|left, right| left.0.cmp(&right.0)); @@ -7277,7 +7285,7 @@ fn bootstrap_image_cache_identity(image_identity: &str) -> String { let image_identity = compute_bytes_sha256_hex(image_identity.as_bytes()); format!( "{BOOTSTRAP_IMAGE_CACHE_LAYOUT_VERSION}:openshell-{}:guest-{}:{image_identity}", - openshell_core::VERSION, + openshell_driver_sdk::VERSION, sandbox_guest_runtime_identity() ) } @@ -7297,7 +7305,7 @@ fn prepared_image_cache_identity(image_identity: &str, config: &VmDriverConfig) ); format!( "{PREPARED_IMAGE_CACHE_LAYOUT_VERSION}:openshell-{}:{identity}:{image_identity}", - openshell_core::VERSION + openshell_driver_sdk::VERSION ) } @@ -7330,7 +7338,7 @@ fn sanitize_image_identity(image_identity: &str) -> String { fn unique_image_cache_suffix() -> String { let counter = IMAGE_CACHE_BUILD_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("{}-{counter}", openshell_core::time::now_ms()) + format!("{}-{counter}", openshell_driver_sdk::time::now_ms()) } async fn write_sandbox_image_metadata( @@ -7959,7 +7967,7 @@ fn overlay_staging_dir(overlay_disk: &Path) -> PathBuf { parent.join(format!( ".openshell-overlay-staging-{}-{}", std::process::id(), - openshell_core::time::now_ms() + openshell_driver_sdk::time::now_ms() )) } @@ -8083,7 +8091,10 @@ fn supervisor_otlp_environment(config: &VmDriverConfig) -> Vec<(&'static str, St environment.extend(openshell_otel::current_trace_context_environment()); } if let Some(name) = &config.supervisor_gateway_name { - environment.push((openshell_core::sandbox_env::GATEWAY_NAME, name.clone())); + environment.push(( + openshell_driver_sdk::sandbox_env::GATEWAY_NAME, + name.clone(), + )); } environment } @@ -8209,8 +8220,10 @@ fn error_condition(reason: &str, message: &str) -> SandboxCondition { fn platform_event(source: &str, event_type: &str, reason: &str, message: String) -> PlatformEvent { let mut event = PlatformEvent { - event_time: openshell_core::time::timestamp_from_millis(openshell_core::time::now_ms()) - .ok(), + event_time: openshell_driver_sdk::time::timestamp_from_millis( + openshell_driver_sdk::time::now_ms(), + ) + .ok(), source: source.to_string(), r#type: event_type.to_string(), reason: reason.to_string(), @@ -8310,11 +8323,11 @@ fn pulling_layer_detail(metadata: &HashMap) -> Option { mod tests { use super::*; use crate::gpu::allocate_vsock_cid; - use openshell_core::progress::{ + use openshell_driver_sdk::progress::{ PROGRESS_ACTIVE_DETAIL_KEY, PROGRESS_ACTIVE_STEP_KEY, PROGRESS_COMPLETE_LABEL_KEY, PROGRESS_COMPLETE_STEP_KEY, }; - use openshell_core::proto::compute::v1::{ + use openshell_driver_sdk::proto::compute::v1::{ DriverSandboxSpec as SandboxSpec, DriverSandboxTemplate as SandboxTemplate, GpuResourceRequirements, ResourceRequirements, }; @@ -8775,7 +8788,7 @@ mod tests { } type TestDriverClient = - openshell_core::proto::compute::v1::compute_driver_client::ComputeDriverClient< + openshell_driver_sdk::proto::compute::v1::compute_driver_client::ComputeDriverClient< tonic::transport::Channel, >; @@ -8817,7 +8830,7 @@ mod tests { } async fn traced_driver_client(driver: VmDriver) -> TracedDriverClient { - use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; + use openshell_driver_sdk::proto::compute::v1::compute_driver_server::ComputeDriverServer; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); @@ -8854,8 +8867,8 @@ mod tests { client .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { - gateway: Some(openshell_core::extension_protocol::gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + gateway: Some(openshell_driver_sdk::extension_protocol::gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, )), })) .await @@ -8890,8 +8903,8 @@ mod tests { client .get_capabilities(request_with_traceparent(GetCapabilitiesRequest { - gateway: Some(openshell_core::extension_protocol::gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + gateway: Some(openshell_driver_sdk::extension_protocol::gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, )), })) .await @@ -9947,7 +9960,7 @@ mod tests { #[test] fn validate_vm_sandbox_accepts_template_resources_as_noop() { - use openshell_core::proto::compute::v1::DriverResourceRequirements; + use openshell_driver_sdk::proto::compute::v1::DriverResourceRequirements; let sandbox = Sandbox { id: "sandbox-123".to_string(), @@ -10837,7 +10850,7 @@ mod tests { .await .expect("persisted sandbox request"); let persisted_authentication = - serde_json::from_slice::( + serde_json::from_slice::( &persisted .spec .expect("persisted sandbox spec") @@ -11235,22 +11248,25 @@ mod tests { } } - fn test_launch_authentication(label: &str) -> (Vec, openshell_core::SandboxSessionId) { - use openshell_core::jwt::{ + fn test_launch_authentication( + label: &str, + ) -> (Vec, openshell_driver_sdk::SandboxSessionId) { + use openshell_driver_sdk::jwt::{ SandboxLaunchAuthentication, SecretJwt, SessionVerificationKey, SupervisorAuthBundle, }; - let session_id = openshell_core::SandboxSessionId::new(); + let session_id = openshell_driver_sdk::SandboxSessionId::new(); let authentication = SandboxLaunchAuthentication { supervisor: SupervisorAuthBundle { session_id, - runtime_generation: openshell_core::sandbox_generation::SandboxGenerationId::parse( - "generation-1", - ) - .expect("runtime generation"), - session_rotation: openshell_core::jwt::SessionRotation::new(1) + runtime_generation: + openshell_driver_sdk::sandbox_generation::SandboxGenerationId::parse( + "generation-1", + ) + .expect("runtime generation"), + session_rotation: openshell_driver_sdk::jwt::SessionRotation::new(1) .expect("session rotation"), - auth_epoch: openshell_core::jwt::CredentialEpoch::new(1).expect("auth epoch"), + auth_epoch: openshell_driver_sdk::jwt::CredentialEpoch::new(1).expect("auth epoch"), gateway_token: SecretJwt::parse(format!("gateway-{label}")).expect("gateway token"), gateway_expires_at: 1, sandbox_token: SecretJwt::parse(format!("sandbox-{label}")).expect("sandbox token"), @@ -11373,18 +11389,18 @@ mod tests { }; assert_eq!(driver.capabilities().default_image, "openshell/sandbox:dev"); - let gateway = openshell_core::extension_protocol::gateway_metadata( - openshell_core::extension_protocol::ExtensionFamily::Compute, + let gateway = openshell_driver_sdk::extension_protocol::gateway_metadata( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, ); - let negotiated = openshell_core::extension_protocol::negotiate( - openshell_core::extension_protocol::ExtensionFamily::Compute, + let negotiated = openshell_driver_sdk::extension_protocol::negotiate( + openshell_driver_sdk::extension_protocol::ExtensionFamily::Compute, "vm", &gateway, driver.capabilities().extension, ) .unwrap(); assert!(negotiated.required_capabilities.iter().any(|capability| { - capability == openshell_core::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION + capability == openshell_driver_sdk::extension_protocol::COMPUTE_LAUNCH_AUTHENTICATION })); } @@ -11903,10 +11919,9 @@ mod tests { assert!(!env.iter().any(|entry| entry.starts_with("LD_PRELOAD="))); assert!(!env.iter().any(|entry| entry.starts_with("BAD;"))); - assert!( - !env.iter() - .any(|entry| { entry.starts_with(openshell_core::sandbox_env::USER_ENVIRONMENT) }) - ); + assert!(!env.iter().any(|entry| { + entry.starts_with(openshell_driver_sdk::sandbox_env::USER_ENVIRONMENT) + })); let child_env = merged_environment(&sandbox); assert_eq!( child_env.get("LD_PRELOAD"), @@ -11930,7 +11945,7 @@ mod tests { spec: Some(SandboxSpec { sandbox_token: "secret.jwt.value".to_string(), environment: HashMap::from([( - openshell_core::sandbox_env::SANDBOX_TOKEN.to_string(), + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN.to_string(), "user-provided-token".to_string(), )]), ..Default::default() @@ -11942,11 +11957,11 @@ mod tests { assert!(!env.iter().any(|v| v.starts_with(&format!( "{}=", - openshell_core::sandbox_env::SANDBOX_TOKEN + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN )))); assert!(!env.iter().any(|v| v.starts_with(&format!( "{}=", - openshell_core::sandbox_env::SANDBOX_TOKEN_FILE + openshell_driver_sdk::sandbox_env::SANDBOX_TOKEN_FILE )))); } @@ -11961,7 +11976,7 @@ mod tests { name: "sandbox-123".to_string(), spec: Some(SandboxSpec { environment: HashMap::from([( - openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME.to_string(), + openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME.to_string(), "evil.attacker.example.com".to_string(), )]), ..Default::default() @@ -11974,7 +11989,7 @@ mod tests { assert!( !env.iter().any(|v| v.starts_with(&format!( "{}=", - openshell_core::sandbox_env::GATEWAY_TLS_SERVER_NAME + openshell_driver_sdk::sandbox_env::GATEWAY_TLS_SERVER_NAME ))), "GATEWAY_TLS_SERVER_NAME must be stripped from the guest environment" ); @@ -11985,7 +12000,7 @@ mod tests { let _guard = ENV_LOCK.lock().unwrap(); temp_env::with_vars( [( - openshell_core::sandbox_env::TELEMETRY_ENABLED, + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED, Some("false"), )], || { @@ -11998,7 +12013,7 @@ mod tests { name: "sandbox-123".to_string(), spec: Some(SandboxSpec { environment: HashMap::from([( - openshell_core::sandbox_env::TELEMETRY_ENABLED.to_string(), + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED.to_string(), "true".to_string(), )]), ..Default::default() @@ -12012,7 +12027,7 @@ mod tests { .filter(|entry| { entry.starts_with(&format!( "{}=", - openshell_core::sandbox_env::TELEMETRY_ENABLED + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED )) }) .collect::>(); @@ -12020,7 +12035,10 @@ mod tests { assert_eq!(telemetry_entries.len(), 1); assert_eq!( telemetry_entries[0], - &format!("{}=false", openshell_core::sandbox_env::TELEMETRY_ENABLED) + &format!( + "{}=false", + openshell_driver_sdk::sandbox_env::TELEMETRY_ENABLED + ) ); }, ); @@ -12713,7 +12731,7 @@ mod tests { #[test] fn supervisor_otlp_environment_carries_only_configured_signals() { - use openshell_core::sandbox_env::{ + use openshell_driver_sdk::sandbox_env::{ GATEWAY_NAME, OTLP_AGENT_ENDPOINT, OTLP_AGENT_PROTOCOL, OTLP_ENDPOINT, OTLP_OCSF_ENDPOINT, OTLP_OCSF_PROTOCOL, OTLP_PROTOCOL, OtlpExport, OtlpProtocol, }; @@ -12805,7 +12823,7 @@ mod tests { image_account, format!( "sandbox-prepared-rootfs-ext4-umoci-v4:openshell-{}:image-account:{image_hash}", - openshell_core::VERSION + openshell_driver_sdk::VERSION ) ); @@ -12847,7 +12865,7 @@ mod tests { let identity = bootstrap_image_cache_identity("sha256:bootstrap-image"); assert!(identity.starts_with(&format!( "sandbox-bootstrap-rootfs-ext4-v6:openshell-{}:guest-", - openshell_core::VERSION + openshell_driver_sdk::VERSION ))); assert!(identity.ends_with(&compute_bytes_sha256_hex(b"sha256:bootstrap-image"))); assert!(identity.contains(&sandbox_guest_runtime_identity())); diff --git a/crates/openshell-driver-vm/src/embedded_runtime.rs b/crates/openshell-driver-vm/src/embedded_runtime.rs index 4b75bd4ba8..1278328300 100644 --- a/crates/openshell-driver-vm/src/embedded_runtime.rs +++ b/crates/openshell-driver-vm/src/embedded_runtime.rs @@ -129,14 +129,14 @@ fn runtime_cache_key() -> String { } fn runtime_cache_dir() -> Result { - let base = - openshell_core::paths::xdg_data_dir().map_err(|e| format!("resolve XDG data dir: {e}"))?; + let base = openshell_driver_sdk::paths::xdg_data_dir() + .map_err(|e| format!("resolve XDG data dir: {e}"))?; Ok(base.join("openshell").join("vm-runtime").join(VERSION)) } fn runtime_cache_base() -> Result { - let base = - openshell_core::paths::xdg_data_dir().map_err(|e| format!("resolve XDG data dir: {e}"))?; + let base = openshell_driver_sdk::paths::xdg_data_dir() + .map_err(|e| format!("resolve XDG data dir: {e}"))?; Ok(base.join("openshell").join("vm-runtime")) } diff --git a/crates/openshell-driver-vm/src/isolation/mod.rs b/crates/openshell-driver-vm/src/isolation/mod.rs index f52456b50e..397b43527e 100644 --- a/crates/openshell-driver-vm/src/isolation/mod.rs +++ b/crates/openshell-driver-vm/src/isolation/mod.rs @@ -46,9 +46,9 @@ impl VmOuterFenceEvidence<'_> { pub struct VmBoundarySpec { pub boundary_id: String, pub generation: String, - pub session_id: openshell_core::SandboxSessionId, - pub session_rotation: openshell_core::jwt::SessionRotation, - pub auth_epoch: openshell_core::jwt::CredentialEpoch, + pub session_id: openshell_driver_sdk::SandboxSessionId, + pub session_rotation: openshell_driver_sdk::jwt::SessionRotation, + pub auth_epoch: openshell_driver_sdk::jwt::CredentialEpoch, pub gateway_id: String, pub verification_keys: Vec, pub image_identity: String, @@ -178,14 +178,14 @@ mod tests { } fn assert_provisioning_claims(gpu_requested: bool) { - let session_id = openshell_core::SandboxSessionId::new(); + let session_id = openshell_driver_sdk::SandboxSessionId::new(); let material = generate_sandbox_tls_material(session_id).unwrap(); let provisioned = VmBoundarySpec { boundary_id: "sandbox-1".to_string(), generation: "generation-1".to_string(), session_id, - session_rotation: openshell_core::jwt::SessionRotation::new(1).unwrap(), - auth_epoch: openshell_core::jwt::CredentialEpoch::new(1).unwrap(), + session_rotation: openshell_driver_sdk::jwt::SessionRotation::new(1).unwrap(), + auth_epoch: openshell_driver_sdk::jwt::CredentialEpoch::new(1).unwrap(), gateway_id: "gateway-1".to_string(), verification_keys: vec![GatewayVerificationKey { key_id: "key-1".to_string(), diff --git a/crates/openshell-driver-vm/src/lifecycle.rs b/crates/openshell-driver-vm/src/lifecycle.rs index f92082d99e..ff67482861 100644 --- a/crates/openshell-driver-vm/src/lifecycle.rs +++ b/crates/openshell-driver-vm/src/lifecycle.rs @@ -5,8 +5,8 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; -use openshell_core::proto::compute::v1::DriverSandbox as Sandbox; -use openshell_core::settings::parse_bool_like; +use openshell_driver_sdk::proto::compute::v1::DriverSandbox as Sandbox; +use openshell_driver_sdk::settings::parse_bool_like; use crate::runtime::VmBackend; @@ -782,7 +782,7 @@ mod tests { use std::path::PathBuf; use std::sync::Mutex; - use openshell_core::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; + use openshell_driver_sdk::proto::compute::v1::{DriverSandboxSpec, DriverSandboxTemplate}; use super::*; diff --git a/crates/openshell-driver-vm/src/main.rs b/crates/openshell-driver-vm/src/main.rs index 90b4c1cbc4..ecc185a1b4 100644 --- a/crates/openshell-driver-vm/src/main.rs +++ b/crates/openshell-driver-vm/src/main.rs @@ -4,9 +4,9 @@ use clap::Parser; use futures::Stream; use miette::{IntoDiagnostic, Result}; -use openshell_core::VERSION; -use openshell_core::proto::compute::v1::compute_driver_server::ComputeDriverServer; -use openshell_core::sandbox_env::SupervisorOtlpArgs; +use openshell_driver_sdk::VERSION; +use openshell_driver_sdk::proto::compute::v1::compute_driver_server::ComputeDriverServer; +use openshell_driver_sdk::sandbox_env::SupervisorOtlpArgs; #[cfg(target_os = "macos")] use openshell_driver_vm::{VM_RUNTIME_DIR_ENV, configured_runtime_dir}; use openshell_driver_vm::{ @@ -32,7 +32,7 @@ struct Args { env = "OPENSHELL_DRIVER_ADMISSION_CONFIG_JSON", default_value = "{}" )] - admission_config_json: openshell_core::resource_admission::DriverAdmissionConfig, + admission_config_json: openshell_driver_sdk::resource_admission::DriverAdmissionConfig, #[arg(long, hide = true, default_value_t = false)] internal_run_vm: bool, @@ -347,7 +347,7 @@ async fn main() -> Result<()> { additional_ca_digest: args.additional_ca_digest.clone(), additional_ca_certificate_count: args.additional_ca_certificate_count, additional_ca_snapshot: None, - upstream_proxy: openshell_core::UpstreamProxyConfig { + upstream_proxy: openshell_driver_sdk::UpstreamProxyConfig { https_proxy: args.upstream_proxy.clone(), no_proxy: args.upstream_no_proxy.clone(), proxy_auth_file: args.upstream_proxy_auth_file.clone(), diff --git a/crates/openshell-driver-vm/src/otel_tracing.rs b/crates/openshell-driver-vm/src/otel_tracing.rs index a931ff502e..95f45f297b 100644 --- a/crates/openshell-driver-vm/src/otel_tracing.rs +++ b/crates/openshell-driver-vm/src/otel_tracing.rs @@ -11,7 +11,7 @@ mod tests { async fn driver_spans_reach_otlp_collector_with_resource_identity() { openshell_otel_test_support::assert_compute_driver_tracing( super::TRACING, - openshell_core::VERSION, + openshell_driver_sdk::VERSION, "vm.provision", ) .await; diff --git a/crates/openshell-driver-vm/src/registry_cache_tests.rs b/crates/openshell-driver-vm/src/registry_cache_tests.rs index 85a5f2084c..56cf1f0c84 100644 --- a/crates/openshell-driver-vm/src/registry_cache_tests.rs +++ b/crates/openshell-driver-vm/src/registry_cache_tests.rs @@ -28,7 +28,7 @@ impl Registry { let server = tokio::spawn(async move { loop { let (mut stream, _) = listener.accept().await.unwrap(); - openshell_core::net::set_tcp_nodelay_best_effort(&stream); + openshell_driver_sdk::net::set_tcp_nodelay_best_effort(&stream); let mut request = Vec::new(); while !request.ends_with(b"\r\n\r\n") { request.push(stream.read_u8().await.unwrap()); @@ -219,13 +219,13 @@ async fn rejects_unbound_manifest(bootstrap: bool) { let legacy_identity = if bootstrap { format!( "sandbox-bootstrap-rootfs-ext4-v5:openshell-{}:guest-{}:{trusted_digest}", - openshell_core::VERSION, + openshell_driver_sdk::VERSION, sandbox_guest_runtime_identity(), ) } else { format!( "sandbox-prepared-rootfs-ext4-umoci-v3:openshell-{}:image-account:{trusted_digest}", - openshell_core::VERSION, + openshell_driver_sdk::VERSION, ) }; assert_ne!(legacy_identity, identity); diff --git a/crates/openshell-driver-vm/src/rootfs.rs b/crates/openshell-driver-vm/src/rootfs.rs index a292e81994..27714642d1 100644 --- a/crates/openshell-driver-vm/src/rootfs.rs +++ b/crates/openshell-driver-vm/src/rootfs.rs @@ -24,8 +24,9 @@ const ROOTFS_VARIANT_MARKER: &str = ".openshell-rootfs-variant"; /// without trusting the file name. pub const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b]; const SANDBOX_GUEST_INIT_PATH: &str = "/srv/openshell-vm-sandbox-init.sh"; -const SANDBOX_SUPERVISOR_PATH: &str = openshell_core::driver_utils::SUPERVISOR_CONTAINER_BINARY; -const SANDBOX_UMOCI_PATH: &str = openshell_core::container_paths::VM_UMOCI_PATH; +const SANDBOX_SUPERVISOR_PATH: &str = + openshell_driver_sdk::driver_utils::SUPERVISOR_CONTAINER_BINARY; +const SANDBOX_UMOCI_PATH: &str = openshell_driver_sdk::container_paths::VM_UMOCI_PATH; const SANDBOX_VM_INIT_PATH: &str = "/opt/openshell/bin/openshell-vm-init"; const ROOTFS_IMAGE_MIN_SIZE_BYTES: u64 = 512 * 1024 * 1024; const ROOTFS_IMAGE_MIN_HEADROOM_BYTES: u64 = 256 * 1024 * 1024; @@ -672,7 +673,7 @@ fn round_up_to_mib(bytes: u64) -> u64 { } fn format_ext4_image_from_dir(source: &Path, image_path: &Path) -> Result<(), String> { - let path = openshell_core::e2fsprogs::resolve(&["mke2fs", "mkfs.ext4"])?; + let path = openshell_driver_sdk::e2fsprogs::resolve(&["mke2fs", "mkfs.ext4"])?; let output = Command::new(&path) .arg("-q") .arg("-F") @@ -1028,7 +1029,9 @@ fn run_debugfs(image_path: &Path, command: &str) -> Result<(), String> { fn e2fs_command(tool: &str) -> Result { // Preflight and image operations must execute the same selected file with // the same inherited environment, including when PATH is restricted. - Ok(Command::new(openshell_core::e2fsprogs::resolve(&[tool])?)) + Ok(Command::new(openshell_driver_sdk::e2fsprogs::resolve(&[ + tool, + ])?)) } fn temporary_injection_path(image_path: &Path) -> PathBuf { diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index a0c3c94a29..d3df620463 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -11,13 +11,10 @@ license.workspace = true repository.workspace = true [dependencies] -openshell-core = { path = "../openshell-core", default-features = false } -serde = { workspace = true } -serde_json = { workspace = true } -sha2 = { workspace = true } -tokio = { workspace = true } +openshell-driver-sdk = { path = "../openshell-driver-sdk" } [dev-dependencies] +serde_json = { workspace = true } tokio = { workspace = true } [lints] diff --git a/crates/openshell-isolation-interface/README.md b/crates/openshell-isolation-interface/README.md index 97aa927395..cff669db01 100644 --- a/crates/openshell-isolation-interface/README.md +++ b/crates/openshell-isolation-interface/README.md @@ -1,5 +1,7 @@ # Isolation backend shared types +The definitions live in `openshell-driver-sdk`; this crate re-exports them. + This crate contains shared data and validation for the Isolation Backend RPC contracts. It forbids unsafe code, including in its tests. diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index 446f6cc244..19f0068a2a 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -10,29 +10,10 @@ //! client in `openshell-sandbox-backend`; backends implement the generated //! services without implementing a parallel Rust backend trait. //! +//! The definitions live in `openshell-driver-sdk`; this crate re-exports them. +//! //! [`contract`] retains validated workload identities, confirmation evidence, //! process specifications, and local byte-stream types shared by client and //! server. Confirmation and generation checks remain mandatory before launch. -/// The agent workload to run inside the boundary. -/// -/// Carried by [`contract::SandboxContext`] so a backend's `start_agent` takes no -/// spec; the bound boundary already carries what runs inside it. -#[derive(Debug, Clone)] -pub struct AgentSpec { - /// Entrypoint program. - pub program: String, - /// Entrypoint arguments. - pub args: Vec, - /// Working directory for the entrypoint, if any. - pub workdir: Option, - /// Wall-clock timeout for the entrypoint in seconds (0 = no timeout). - pub timeout_secs: u64, - /// Whether the entrypoint runs interactively (inherits the parent pgrp). - pub interactive: bool, - /// Whether the supervisor serves the OTLP relay for this sandbox. The - /// boundary points workload SDKs at the relay only while it is active. - pub otlp_relay_active: bool, -} - -pub mod contract; +pub use openshell_driver_sdk::isolation_interface::*; diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index a84bca7868..3627ce0d19 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -805,17 +805,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { // Sandbox UID/GID constants // --------------------------------------------------------------------------- -/// Minimum accepted UID/GID for sandbox workload identity. -/// -/// Linux reserves only identity `0` for root. Non-root system identities are -/// valid workload identities when selected explicitly by the operator. -pub const MIN_SANDBOX_UID: u32 = 1; - -/// Maximum accepted UID/GID for sandbox workload identity. -/// -/// `u32::MAX` represents an invalid or unchanged identity in Linux APIs and -/// POSIX ACLs, so the largest usable workload identity is one less. -pub const MAX_SANDBOX_UID: u32 = u32::MAX - 1; +pub use openshell_core::policy::{MAX_SANDBOX_UID, MIN_SANDBOX_UID}; /// Minimum UID for the Kubernetes network proxy identity. /// diff --git a/crates/openshell-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index 8e8ff7cb05..a3140a3f67 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -15,6 +15,7 @@ test-support = [] [dependencies] openshell-crypto = { workspace = true } +openshell-driver-sdk = { path = "../openshell-driver-sdk" } openshell-core = { path = "../openshell-core", default-features = false, features = ["jwt"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } async-trait = "0.1" diff --git a/crates/openshell-sandbox-backend/src/lib.rs b/crates/openshell-sandbox-backend/src/lib.rs index 3df3f90ef7..6530f296df 100644 --- a/crates/openshell-sandbox-backend/src/lib.rs +++ b/crates/openshell-sandbox-backend/src/lib.rs @@ -31,26 +31,10 @@ pub use runtime::{ }; pub use runtime::{BoundaryTransport, BoundaryTransportConnector, validate_runtime_coordinates}; -/// Stable isolation backend name implemented by `openshell-sandbox`. -pub const BACKEND_NAME: &str = openshell_core::isolation_registration::BUILTIN_BACKEND_NAME; - -/// Resource claim set by compute drivers when the workload requests GPU access. -pub const GPU_RESOURCE_CLAIM: &str = "openshell.gpu"; - -/// Resource claim set when the runtime may retain supplementary groups in -/// addition to the image-derived workload identity. -pub const ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM: &str = - "openshell.identity.allow_extra_supplementary_groups"; - -/// Memory-backed parent used for supervisor CA material. -pub const SUPERVISOR_CA_RUNTIME_ROOT: &str = "/run/openshell-supervisor-ca"; - -/// Workload-visible directory for the supervisor's public HTTPS interception -/// certificate and combined trust bundle. -/// -/// Keeping the material below the mount root lets runtimes that cannot assign -/// tmpfs ownership create this child as the unprivileged sandbox identity. -pub const SUPERVISOR_CA_RUNTIME_DIR: &str = "/run/openshell-supervisor-ca/material"; +pub use openshell_driver_sdk::isolation_interface::{ + ALLOW_EXTRA_SUPPLEMENTARY_GROUPS_RESOURCE_CLAIM, BACKEND_NAME, GPU_RESOURCE_CLAIM, + SUPERVISOR_CA_RUNTIME_DIR, SUPERVISOR_CA_RUNTIME_ROOT, +}; /// Generated gRPC transport envelope for the `OpenShell` Sandbox Protocol. pub mod proto { diff --git a/deny.toml b/deny.toml index b813b44dd4..bbece0ac8f 100644 --- a/deny.toml +++ b/deny.toml @@ -159,10 +159,10 @@ wrappers = [ "openshell-driver-kubernetes", "openshell-driver-kubernetes-secrets", "openshell-driver-podman", + "openshell-driver-sdk", "openshell-driver-vault", "openshell-driver-vm", "openshell-gateway-interceptors", - "openshell-isolation-interface", "openshell-server", "openshell-supervisor-network", "openshell-supervisor-process", diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index fa08aa3aba..d49f55413b 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1864,6 +1864,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -1903,6 +1904,34 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "libc", + "miette", + "nix", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index 54445e64e4..38edfab708 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -2127,11 +2127,11 @@ dependencies = [ "base64 0.22.1", "glob", "ipnet", - "jsonwebtoken", "libc", "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2152,7 +2152,6 @@ dependencies = [ "tracing", "url", "uuid", - "zeroize", ] [[package]] @@ -2172,6 +2171,37 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "jsonwebtoken", + "libc", + "miette", + "nix", + "openshell-crypto", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix 1.1.5", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", + "zeroize", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" @@ -2188,11 +2218,7 @@ dependencies = [ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ - "openshell-core", - "serde", - "serde_json", - "sha2 0.10.9", - "tokio", + "openshell-driver-sdk", ] [[package]] @@ -2217,6 +2243,7 @@ dependencies = [ "libc", "openshell-core", "openshell-crypto", + "openshell-driver-sdk", "openshell-isolation-interface", "prost", "prost-types", diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index f453c06919..e72d51398e 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -2018,6 +2018,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2057,6 +2058,34 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "libc", + "miette", + "nix", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index ea04cd150d..f6e017eb5b 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2057,6 +2057,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2097,6 +2098,34 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "libc", + "miette", + "nix", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/tests/suites/drivers/podman/Cargo.lock b/tests/suites/drivers/podman/Cargo.lock index 6633a3e703..475a1b0640 100644 --- a/tests/suites/drivers/podman/Cargo.lock +++ b/tests/suites/drivers/podman/Cargo.lock @@ -2056,6 +2056,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2096,6 +2097,34 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "libc", + "miette", + "nix", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/tests/suites/features/Cargo.lock b/tests/suites/features/Cargo.lock index bb1a1427ee..7b46a84f92 100644 --- a/tests/suites/features/Cargo.lock +++ b/tests/suites/features/Cargo.lock @@ -2093,6 +2093,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2133,6 +2134,34 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "libc", + "miette", + "nix", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/tests/support/Cargo.lock b/tests/support/Cargo.lock index f8b591438f..752c7e3b4a 100644 --- a/tests/support/Cargo.lock +++ b/tests/support/Cargo.lock @@ -2065,6 +2065,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2105,6 +2106,34 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "libc", + "miette", + "nix", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index db2e79b6ca..f56eba2e22 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -2047,6 +2047,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2087,6 +2088,34 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "base64 0.22.1", + "libc", + "miette", + "nix", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", + "uuid", +] + [[package]] name = "openshell-extension-core" version = "0.0.0"