From 71e626c6c2328436531cdd3b08ecabde85dd0b41 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 9 Nov 2026 16:02:56 +0100 Subject: [PATCH 1/5] refactor(driver-sdk): add driver SDK crate owning the compute-driver proto and VERSION Move the generated compute-driver proto closure (options, extension, datamodel, sandbox, compute_driver) and the git-derived build VERSION into a new openshell-driver-sdk crate. openshell-core refers to the generated types by extern path and re-exports them, so every path under openshell_core::proto keeps resolving to the same single definition. Signed-off-by: Evan Lezar --- Cargo.lock | 14 ++ crates/openshell-core/Cargo.toml | 1 + crates/openshell-core/build.rs | 103 ++---------- crates/openshell-core/src/lib.rs | 15 +- crates/openshell-core/src/proto/mod.rs | 8 +- crates/openshell-driver-sdk/Cargo.toml | 25 +++ crates/openshell-driver-sdk/build.rs | 156 ++++++++++++++++++ .../build_version.rs | 0 crates/openshell-driver-sdk/src/lib.rs | 26 +++ crates/openshell-driver-sdk/src/proto.rs | 36 ++++ examples/governance-interceptor/Cargo.lock | 14 ++ examples/isolation-backend-v8/Cargo.lock | 14 ++ .../Cargo.lock | 14 ++ tests/suites/conformance/Cargo.lock | 14 ++ tests/suites/drivers/podman/Cargo.lock | 14 ++ tests/suites/features/Cargo.lock | 14 ++ tests/support/Cargo.lock | 14 ++ tests/testkit/Cargo.lock | 14 ++ 18 files changed, 388 insertions(+), 108 deletions(-) create mode 100644 crates/openshell-driver-sdk/Cargo.toml create mode 100644 crates/openshell-driver-sdk/build.rs rename crates/{openshell-core => openshell-driver-sdk}/build_version.rs (100%) create mode 100644 crates/openshell-driver-sdk/src/lib.rs create mode 100644 crates/openshell-driver-sdk/src/proto.rs diff --git a/Cargo.lock b/Cargo.lock index d178930a4a..e0813346a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4430,6 +4430,7 @@ dependencies = [ "miette", "nix 0.29.0", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-otel", "openshell-policy-schema", @@ -4699,6 +4700,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "openshell-driver-vault" version = "0.0.0" diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index aa83e10f86..e2ba1a77b7 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 } 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/lib.rs b/crates/openshell-core/src/lib.rs index 12d6cd26ff..e58f2311e5 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -86,20 +86,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/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-driver-sdk/Cargo.toml b/crates/openshell-driver-sdk/Cargo.toml new file mode 100644 index 0000000000..ac657a044f --- /dev/null +++ b/crates/openshell-driver-sdk/Cargo.toml @@ -0,0 +1,25 @@ +# 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] +prost = { workspace = true } +prost-types = { workspace = true } +tonic = { workspace = true, features = ["channel"] } +tonic-prost = { workspace = true } +serde = { workspace = true } + +[build-dependencies] +tonic-prost-build = { workspace = true } +protoc-bin-vendored = { workspace = true } + +[lints] +workspace = true 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-driver-sdk/src/lib.rs b/crates/openshell-driver-sdk/src/lib.rs new file mode 100644 index 0000000000..d7a77d274d --- /dev/null +++ b/crates/openshell-driver-sdk/src/lib.rs @@ -0,0 +1,26 @@ +// 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 proto; + +/// 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/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/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index fa08aa3aba..cb6fba658c 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,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[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..744efd7622 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -2132,6 +2132,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-driver-sdk", "openshell-extension-core", "openshell-policy-schema", "prost", @@ -2172,6 +2173,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index f453c06919..028dd530be 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,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[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..bb86f2fe38 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,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[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..413a453ffe 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,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[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..5b303f5704 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,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/tests/support/Cargo.lock b/tests/support/Cargo.lock index f8b591438f..207b08c619 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,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index db2e79b6ca..1b362ead9a 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,19 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "openshell-driver-sdk" +version = "0.0.0" +dependencies = [ + "prost", + "prost-types", + "protoc-bin-vendored", + "serde", + "tonic", + "tonic-prost", + "tonic-prost-build", +] + [[package]] name = "openshell-extension-core" version = "0.0.0" From 8ccf2149e7a9a547b12b02c12484c41f16802ea0 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 9 Nov 2026 16:12:38 +0100 Subject: [PATCH 2/5] refactor(driver-sdk): move shared core modules into the SDK Move sandbox_session, sandbox_generation, time, progress, image, external_driver_socket, jwt (session tokens), extension_protocol, network_trust and the compute driver trait into openshell-driver-sdk, and re-export each from openshell-core so existing paths keep working. Add openshell-container-runtime for desktop-only code and move the Podman destination-trust generation markers there. parse_exp_secs stays in core because only core uses it. Signed-off-by: Evan Lezar --- Cargo.lock | 25 +- crates/openshell-container-runtime/Cargo.toml | 17 + crates/openshell-container-runtime/src/lib.rs | 11 + .../src/network_trust.rs | 19 + crates/openshell-core/Cargo.toml | 6 +- crates/openshell-core/src/jwt.rs | 2047 +---------------- crates/openshell-core/src/lib.rs | 18 +- crates/openshell-driver-podman/Cargo.toml | 1 + .../openshell-driver-podman/src/container.rs | 6 +- crates/openshell-driver-podman/src/driver.rs | 6 +- crates/openshell-driver-sdk/Cargo.toml | 26 + .../src/compute.rs | 0 .../src/extension_protocol.rs | 2 +- .../src/external_driver_socket.rs | 0 .../src/image.rs | 0 crates/openshell-driver-sdk/src/jwt.rs | 2036 ++++++++++++++++ crates/openshell-driver-sdk/src/lib.rs | 12 + .../src/network_trust.rs | 14 - .../src/progress.rs | 0 .../src/sandbox_generation.rs | 0 .../src/sandbox_session.rs | 0 .../src/time.rs | 0 examples/governance-interceptor/Cargo.lock | 7 + examples/isolation-backend-v8/Cargo.lock | 12 +- .../Cargo.lock | 7 + tests/suites/conformance/Cargo.lock | 7 + tests/suites/drivers/podman/Cargo.lock | 7 + tests/suites/features/Cargo.lock | 7 + tests/support/Cargo.lock | 7 + tests/testkit/Cargo.lock | 7 + 30 files changed, 2223 insertions(+), 2084 deletions(-) create mode 100644 crates/openshell-container-runtime/Cargo.toml create mode 100644 crates/openshell-container-runtime/src/lib.rs create mode 100644 crates/openshell-container-runtime/src/network_trust.rs rename crates/{openshell-core => openshell-driver-sdk}/src/compute.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/extension_protocol.rs (99%) rename crates/{openshell-core => openshell-driver-sdk}/src/external_driver_socket.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/image.rs (100%) create mode 100644 crates/openshell-driver-sdk/src/jwt.rs rename crates/{openshell-core => openshell-driver-sdk}/src/network_trust.rs (95%) rename crates/{openshell-core => openshell-driver-sdk}/src/progress.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/sandbox_generation.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/sandbox_session.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/time.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index e0813346a1..42f977f2e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4414,6 +4414,13 @@ dependencies = [ "url", ] +[[package]] +name = "openshell-container-runtime" +version = "0.0.0" +dependencies = [ + "openshell-driver-sdk", +] + [[package]] name = "openshell-core" version = "0.0.0" @@ -4425,7 +4432,6 @@ dependencies = [ "glob", "h2", "ipnet", - "jsonwebtoken", "libc", "miette", "nix 0.29.0", @@ -4458,7 +4464,6 @@ dependencies = [ "tracing-subscriber", "url", "uuid", - "zeroize", ] [[package]] @@ -4671,6 +4676,7 @@ dependencies = [ "hyper-util", "miette", "nix 0.29.0", + "openshell-container-runtime", "openshell-core", "openshell-crypto", "openshell-isolation-interface", @@ -4704,13 +4710,28 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", + "jsonwebtoken", + "libc", + "openshell-crypto", + "openshell-otel", "prost", "prost-types", "protoc-bin-vendored", + "rcgen", + "rustix 1.1.4", "serde", + "serde_json", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", + "zeroize", ] [[package]] diff --git a/crates/openshell-container-runtime/Cargo.toml b/crates/openshell-container-runtime/Cargo.toml new file mode 100644 index 0000000000..ba238a1cdc --- /dev/null +++ b/crates/openshell-container-runtime/Cargo.toml @@ -0,0 +1,17 @@ +# 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] +openshell-driver-sdk = { path = "../openshell-driver-sdk" } + +[lints] +workspace = true diff --git a/crates/openshell-container-runtime/src/lib.rs b/crates/openshell-container-runtime/src/lib.rs new file mode 100644 index 0000000000..7e6f58001c --- /dev/null +++ b/crates/openshell-container-runtime/src/lib.rs @@ -0,0 +1,11 @@ +// 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 network_trust; 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-core/Cargo.toml b/crates/openshell-core/Cargo.toml index e2ba1a77b7..f1de4741ba 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -35,8 +35,6 @@ 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 } @@ -59,9 +57,9 @@ telemetry = ["dep:reqwest", "dep:chrono", "reqwest?/blocking"] ## 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"] ## Propagate the active W3C trace context on supervisor gateway calls. 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 e58f2311e5..58b4fcb1ab 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -12,7 +12,7 @@ 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 mod denial; @@ -26,15 +26,15 @@ pub mod endpoint_failure; pub mod endpoint_path; pub mod endpoint_status; pub mod error; -pub mod extension_protocol; +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 use openshell_driver_sdk::image; pub mod isolation_registration; pub mod jwt; pub mod local_api_socket; @@ -42,14 +42,14 @@ 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_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; @@ -58,15 +58,15 @@ pub mod replica_routing; pub mod resource_admission; pub mod rpc_error; pub mod sandbox_env; -pub mod sandbox_generation; -pub mod sandbox_session; +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; diff --git a/crates/openshell-driver-podman/Cargo.toml b/crates/openshell-driver-podman/Cargo.toml index 3fb3c51b5d..ea95527f1c 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -16,6 +16,7 @@ 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-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/container.rs b/crates/openshell-driver-podman/src/container.rs index 9943d7a0b6..d0a60bb17b 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -5,14 +5,14 @@ use crate::client::ImageInspect; use crate::config::PodmanComputeConfig; +use openshell_container_runtime::network_trust::{ + NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, +}; use openshell_core::ComputeDriverError; use openshell_core::driver_mounts::SelinuxLabel; use openshell_core::gpu::validate_cdi_device_names; #[cfg(test)] use openshell_core::gpu::{driver_gpu_requirements, validate_cdi_gpu_device_request}; -use openshell_core::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}; diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 6bea5b7f16..4988af01ca 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -10,6 +10,9 @@ use crate::watcher::{ self, LifecycleEventFences, WatchStream, driver_sandbox_from_inspect, driver_sandbox_from_list_entry, }; +use openshell_container_runtime::network_trust::{ + NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, +}; use openshell_core::config::CDI_GPU_DEVICE_ALL; use openshell_core::driver_utils::{ SANDBOX_RUNTIME_IMAGE_BINARY_PATH, extract_first_tar_entry, supervisor_image_should_refresh, @@ -19,9 +22,6 @@ use openshell_core::gpu::{ CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, effective_driver_gpu_count, validate_cdi_gpu_device_request, }; -use openshell_core::network_trust::{ - NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, -}; use openshell_core::proto::compute::v1::{ CpuResourceCapabilities, DriverSandbox, GetCapabilitiesResponse, GpuResourceCapabilities, GpuResourceRequirements, MemoryResourceCapabilities, ResourceCapabilities, diff --git a/crates/openshell-driver-sdk/Cargo.toml b/crates/openshell-driver-sdk/Cargo.toml index ac657a044f..bee64911f8 100644 --- a/crates/openshell-driver-sdk/Cargo.toml +++ b/crates/openshell-driver-sdk/Cargo.toml @@ -11,11 +11,37 @@ license.workspace = true repository.workspace = true [dependencies] +openshell-crypto = { workspace = true, optional = true } +openshell-otel = { path = "../openshell-otel", optional = true } +base64 = { 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 } +thiserror = { workspace = true } +uuid = { workspace = true } +tokio = { workspace = true } +tokio-stream = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true } +libc = "0.2" + +[dev-dependencies] +rcgen = { workspace = true } +base64 = { workspace = true } +tempfile = "3" +serde_json = { workspace = true } + +[features] +## 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"] [build-dependencies] tonic-prost-build = { workspace = true } 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-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-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-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 index d7a77d274d..3edac530f7 100644 --- a/crates/openshell-driver-sdk/src/lib.rs +++ b/crates/openshell-driver-sdk/src/lib.rs @@ -8,7 +8,19 @@ //! 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 extension_protocol; +#[cfg(unix)] +pub mod external_driver_socket; +pub mod image; +#[cfg(feature = "jwt")] +pub mod jwt; +pub mod network_trust; +pub mod progress; pub mod proto; +pub mod sandbox_generation; +pub mod sandbox_session; +pub mod time; /// Build version string derived from git metadata. /// 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-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-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-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/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index cb6fba658c..dbbaab9fbf 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1908,13 +1908,20 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "libc", "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", + "thiserror", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", ] [[package]] diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index 744efd7622..766e4dda4b 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -2127,7 +2127,6 @@ dependencies = [ "base64 0.22.1", "glob", "ipnet", - "jsonwebtoken", "libc", "miette", "nix", @@ -2153,7 +2152,6 @@ dependencies = [ "tracing", "url", "uuid", - "zeroize", ] [[package]] @@ -2177,13 +2175,23 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "jsonwebtoken", + "libc", + "openshell-crypto", "prost", "prost-types", "protoc-bin-vendored", + "rustix 1.1.5", "serde", + "thiserror", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", + "zeroize", ] [[package]] diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index 028dd530be..29e330786c 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -2062,13 +2062,20 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "libc", "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", + "thiserror", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", ] [[package]] diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index bb86f2fe38..a09bf25d1b 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2102,13 +2102,20 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "libc", "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", + "thiserror 2.0.20", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", ] [[package]] diff --git a/tests/suites/drivers/podman/Cargo.lock b/tests/suites/drivers/podman/Cargo.lock index 413a453ffe..43f7667df0 100644 --- a/tests/suites/drivers/podman/Cargo.lock +++ b/tests/suites/drivers/podman/Cargo.lock @@ -2101,13 +2101,20 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "libc", "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", + "thiserror 2.0.20", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", ] [[package]] diff --git a/tests/suites/features/Cargo.lock b/tests/suites/features/Cargo.lock index 5b303f5704..924de909be 100644 --- a/tests/suites/features/Cargo.lock +++ b/tests/suites/features/Cargo.lock @@ -2138,13 +2138,20 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "libc", "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", + "thiserror 2.0.20", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", ] [[package]] diff --git a/tests/support/Cargo.lock b/tests/support/Cargo.lock index 207b08c619..1482e54e53 100644 --- a/tests/support/Cargo.lock +++ b/tests/support/Cargo.lock @@ -2110,13 +2110,20 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "libc", "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", + "thiserror 2.0.20", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", ] [[package]] diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index 1b362ead9a..6a8172ff2c 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -2092,13 +2092,20 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "libc", "prost", "prost-types", "protoc-bin-vendored", + "rustix", "serde", + "thiserror 2.0.20", + "tokio", + "tokio-stream", "tonic", "tonic-prost", "tonic-prost-build", + "tracing", + "uuid", ] [[package]] From a210a67a7ba39ff8dfd2e23b52d6cfb4f0bc6961 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 9 Nov 2026 16:21:46 +0100 Subject: [PATCH 3/5] refactor(driver-sdk): split driver_utils, paths, and sandbox_env between SDK and desktop crate Move sandbox_env, container_paths, driver_mounts, e2fsprogs, and the shared half of driver_utils and paths into openshell-driver-sdk. Move the desktop-only half of driver_utils and local_api_socket into openshell-container-runtime, and point the Docker and Podman drivers at it. openshell-core re-exports the SDK modules so existing paths keep resolving. Signed-off-by: Evan Lezar --- Cargo.lock | 11 + crates/openshell-container-runtime/Cargo.toml | 4 + .../src/driver_utils.rs | 294 +++++++++++++++++ crates/openshell-container-runtime/src/lib.rs | 2 + .../src/local_api_socket.rs | 0 crates/openshell-core/Cargo.toml | 2 +- crates/openshell-core/src/lib.rs | 11 +- crates/openshell-core/src/paths.rs | 146 ++------- crates/openshell-driver-docker/Cargo.toml | 1 + crates/openshell-driver-docker/src/lib.rs | 47 ++- crates/openshell-driver-docker/src/tests.rs | 2 +- crates/openshell-driver-podman/src/config.rs | 8 +- .../openshell-driver-podman/src/container.rs | 7 +- crates/openshell-driver-podman/src/driver.rs | 13 +- .../src/socket_discovery.rs | 14 +- crates/openshell-driver-podman/src/watcher.rs | 6 +- crates/openshell-driver-sdk/Cargo.toml | 12 +- .../src/container_paths.rs | 0 .../src/driver_mounts.rs | 0 .../src/driver_utils.rs | 304 +----------------- .../src/e2fsprogs.rs | 0 crates/openshell-driver-sdk/src/lib.rs | 7 + crates/openshell-driver-sdk/src/paths.rs | 115 +++++++ .../src/sandbox_env.rs | 0 crates/openshell-driver-vm/Cargo.toml | 1 + examples/governance-interceptor/Cargo.lock | 6 + examples/isolation-backend-v8/Cargo.lock | 6 + .../Cargo.lock | 6 + tests/suites/conformance/Cargo.lock | 6 + tests/suites/drivers/podman/Cargo.lock | 6 + tests/suites/features/Cargo.lock | 6 + tests/support/Cargo.lock | 6 + tests/testkit/Cargo.lock | 6 + 33 files changed, 592 insertions(+), 463 deletions(-) create mode 100644 crates/openshell-container-runtime/src/driver_utils.rs rename crates/{openshell-core => openshell-container-runtime}/src/local_api_socket.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/container_paths.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/driver_mounts.rs (100%) rename crates/{openshell-core => openshell-driver-sdk}/src/driver_utils.rs (81%) rename crates/{openshell-core => openshell-driver-sdk}/src/e2fsprogs.rs (100%) create mode 100644 crates/openshell-driver-sdk/src/paths.rs rename crates/{openshell-core => openshell-driver-sdk}/src/sandbox_env.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 42f977f2e4..51faeea1f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4418,7 +4418,11 @@ dependencies = [ name = "openshell-container-runtime" version = "0.0.0" dependencies = [ + "miette", "openshell-driver-sdk", + "rustls", + "tar", + "tempfile", ] [[package]] @@ -4547,6 +4551,7 @@ dependencies = [ "http 1.4.0", "libc", "miette", + "openshell-container-runtime", "openshell-core", "openshell-isolation-interface", "openshell-otel", @@ -4711,8 +4716,11 @@ 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", "prost", @@ -4720,6 +4728,7 @@ dependencies = [ "protoc-bin-vendored", "rcgen", "rustix 1.1.4", + "rustls", "serde", "serde_json", "tempfile", @@ -4730,6 +4739,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", "zeroize", ] @@ -4775,6 +4785,7 @@ dependencies = [ "miette", "nix 0.29.0", "oci-client", + "openshell-container-runtime", "openshell-core", "openshell-crypto", "openshell-driver-podman", diff --git a/crates/openshell-container-runtime/Cargo.toml b/crates/openshell-container-runtime/Cargo.toml index ba238a1cdc..f9e4b368b2 100644 --- a/crates/openshell-container-runtime/Cargo.toml +++ b/crates/openshell-container-runtime/Cargo.toml @@ -11,6 +11,10 @@ license.workspace = true repository.workspace = true [dependencies] +miette = { workspace = true } +rustls = { workspace = true } +tar = "0.4" +tempfile = "3" openshell-driver-sdk = { path = "../openshell-driver-sdk" } [lints] 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/lib.rs b/crates/openshell-container-runtime/src/lib.rs index 7e6f58001c..b734320a15 100644 --- a/crates/openshell-container-runtime/src/lib.rs +++ b/crates/openshell-container-runtime/src/lib.rs @@ -8,4 +8,6 @@ //! 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 driver_utils; +pub mod local_api_socket; pub mod network_trust; 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-core/Cargo.toml b/crates/openshell-core/Cargo.toml index f1de4741ba..193c68f9d6 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -61,7 +61,7 @@ jwt = ["openshell-driver-sdk/jwt"] ## Conversions to `openshell-otel` types. 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/src/lib.rs b/crates/openshell-core/src/lib.rs index 58b4fcb1ab..3cdf9084c7 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -14,14 +14,14 @@ pub mod app_listener; pub mod auth; 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; @@ -37,7 +37,6 @@ pub mod host_pattern; pub use openshell_driver_sdk::image; pub mod isolation_registration; pub mod jwt; -pub mod local_api_socket; pub mod mcp; pub mod metadata; pub mod middleware; @@ -57,7 +56,7 @@ pub mod provider_credentials; pub mod replica_routing; pub mod resource_admission; pub mod rpc_error; -pub mod sandbox_env; +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; diff --git a/crates/openshell-core/src/paths.rs b/crates/openshell-core/src/paths.rs index 0470ab6764..c6e68024fb 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 @@ -172,32 +97,10 @@ 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 +119,24 @@ mod tests { ); } + #[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 openshell_config_dir_appends_openshell() { let dir = openshell_config_dir().unwrap(); @@ -234,29 +155,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-driver-docker/Cargo.toml b/crates/openshell-driver-docker/Cargo.toml index e4ba6f40c0..95e497f3ca 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -15,6 +15,7 @@ name = "openshell-driver-docker" path = "src/main.rs" [dependencies] +openshell-container-runtime = { path = "../openshell-container-runtime" } openshell-core = { path = "../openshell-core", default-features = false, features = ["driver-extraction", "otel", "clap"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index aa1d7de706..d202f094a7 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -23,15 +23,18 @@ use bollard::query_parameters::{ }; use bytes::Bytes; use futures::{Stream, StreamExt}; +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::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, - extract_first_tar_entry, supervisor_image_should_refresh, temp_extract_container_name, - validate_linux_elf_binary, + 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_core::gpu::{ CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, @@ -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; @@ -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 { @@ -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] @@ -4313,7 +4325,7 @@ fn sandbox_token_host_path_by_id( sandbox_id: &str, config: &DockerDriverRuntimeConfig, ) -> 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, @@ -5445,7 +5457,10 @@ async fn spawn_docker_control_process( )); } 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}", @@ -6793,7 +6808,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/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 70fc34f256..b954276551 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -2,9 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use openshell_container_runtime::driver_utils::LABEL_SANDBOX_NAMESPACE; use openshell_core::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, - LABEL_SANDBOX_NAMESPACE, }; use openshell_core::jwt::{ CredentialEpoch, SandboxLaunchAuthentication, SecretJwt, SessionVerificationKey, diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index a7a22ca686..ee68eb34b4 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -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 @@ -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()?; diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index d0a60bb17b..746d0462a2 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -40,8 +40,9 @@ fn is_selinux_enabled() -> bool { false } +pub use openshell_container_runtime::driver_utils::LABEL_SANDBOX_NAMESPACE; pub use openshell_core::driver_utils::{ - LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_NAMESPACE, LABEL_SANDBOX_WORKSPACE, + LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, }; /// Label applied to all managed containers. @@ -71,10 +72,10 @@ const SANDBOX_TOKEN_MOUNT_PATH: &str = openshell_core::driver_utils::SANDBOX_TOK const UPSTREAM_PROXY_AUTH_MOUNT_PATH: &str = openshell_core::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 PROXY_CA_MOUNT_PATH: &str = openshell_container_runtime::driver_utils::PROXY_CA_MOUNT_PATH; const ADDITIONAL_CA_MOUNT_PATH: &str = openshell_core::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; diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index 4988af01ca..d538f4274d 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -10,14 +10,14 @@ use crate::watcher::{ self, LifecycleEventFences, WatchStream, driver_sandbox_from_inspect, driver_sandbox_from_list_entry, }; +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_container_runtime::network_trust::{ NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, }; use openshell_core::config::CDI_GPU_DEVICE_ALL; -use openshell_core::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, @@ -2567,8 +2567,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))] 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..aa9c6020ce 100644 --- a/crates/openshell-driver-podman/src/watcher.rs +++ b/crates/openshell-driver-podman/src/watcher.rs @@ -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_container_runtime::driver_utils::{ + CONDITION_WORKSPACE_VALIDATION_FAILED, WORKSPACE_VALIDATION_FAILED_MESSAGE, +}; use openshell_core::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 index bee64911f8..d88ce9152f 100644 --- a/crates/openshell-driver-sdk/Cargo.toml +++ b/crates/openshell-driver-sdk/Cargo.toml @@ -13,7 +13,8 @@ repository.workspace = true [dependencies] openshell-crypto = { workspace = true, optional = true } openshell-otel = { path = "../openshell-otel", optional = true } -base64 = { workspace = true, optional = true } +base64 = { workspace = true } +clap = { workspace = true, optional = true } jsonwebtoken = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } prost = { workspace = true } @@ -21,27 +22,34 @@ 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 } +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" -serde_json = { workspace = true } [features] ## 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 } 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-driver-sdk/src/lib.rs b/crates/openshell-driver-sdk/src/lib.rs index 3edac530f7..c188408a54 100644 --- a/crates/openshell-driver-sdk/src/lib.rs +++ b/crates/openshell-driver-sdk/src/lib.rs @@ -9,6 +9,11 @@ //! It depends only on workspace crates and crates.io. pub mod compute; +pub mod container_paths; +pub mod driver_mounts; +pub mod driver_utils; +#[cfg(unix)] +pub mod e2fsprogs; pub mod extension_protocol; #[cfg(unix)] pub mod external_driver_socket; @@ -16,8 +21,10 @@ pub mod image; #[cfg(feature = "jwt")] pub mod jwt; pub mod network_trust; +pub mod paths; pub mod progress; pub mod proto; +pub mod sandbox_env; pub mod sandbox_generation; pub mod sandbox_session; pub mod time; diff --git a/crates/openshell-driver-sdk/src/paths.rs b/crates/openshell-driver-sdk/src/paths.rs new file mode 100644 index 0000000000..7583218a9e --- /dev/null +++ b/crates/openshell-driver-sdk/src/paths.rs @@ -0,0 +1,115 @@ +// 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(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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/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-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index 57921d4b9a..c1d09e8767 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -24,6 +24,7 @@ name = "openshell-vm-init" path = "src/bin/openshell-vm-init.rs" [dependencies] +openshell-container-runtime = { path = "../openshell-container-runtime" } openshell-core = { path = "../openshell-core", default-features = false, features = ["clap"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-sandbox-backend = { path = "../openshell-sandbox-backend" } diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index dbbaab9fbf..32e6d538e3 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1908,12 +1908,17 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "libc", + "miette", + "nix", "prost", "prost-types", "protoc-bin-vendored", "rustix", + "rustls", "serde", + "serde_json", "thiserror", "tokio", "tokio-stream", @@ -1921,6 +1926,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", ] diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index 766e4dda4b..158bb90f32 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -2175,14 +2175,19 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "jsonwebtoken", "libc", + "miette", + "nix", "openshell-crypto", "prost", "prost-types", "protoc-bin-vendored", "rustix 1.1.5", + "rustls", "serde", + "serde_json", "thiserror", "tokio", "tokio-stream", @@ -2190,6 +2195,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", "zeroize", ] diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index 29e330786c..fe74c96216 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -2062,12 +2062,17 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "libc", + "miette", + "nix", "prost", "prost-types", "protoc-bin-vendored", "rustix", + "rustls", "serde", + "serde_json", "thiserror", "tokio", "tokio-stream", @@ -2075,6 +2080,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", ] diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index a09bf25d1b..7e7339e820 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2102,12 +2102,17 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "libc", + "miette", + "nix", "prost", "prost-types", "protoc-bin-vendored", "rustix", + "rustls", "serde", + "serde_json", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -2115,6 +2120,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", ] diff --git a/tests/suites/drivers/podman/Cargo.lock b/tests/suites/drivers/podman/Cargo.lock index 43f7667df0..a8f03e075d 100644 --- a/tests/suites/drivers/podman/Cargo.lock +++ b/tests/suites/drivers/podman/Cargo.lock @@ -2101,12 +2101,17 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "libc", + "miette", + "nix", "prost", "prost-types", "protoc-bin-vendored", "rustix", + "rustls", "serde", + "serde_json", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -2114,6 +2119,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", ] diff --git a/tests/suites/features/Cargo.lock b/tests/suites/features/Cargo.lock index 924de909be..6460f90ae9 100644 --- a/tests/suites/features/Cargo.lock +++ b/tests/suites/features/Cargo.lock @@ -2138,12 +2138,17 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "libc", + "miette", + "nix", "prost", "prost-types", "protoc-bin-vendored", "rustix", + "rustls", "serde", + "serde_json", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -2151,6 +2156,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", ] diff --git a/tests/support/Cargo.lock b/tests/support/Cargo.lock index 1482e54e53..1a38a5b420 100644 --- a/tests/support/Cargo.lock +++ b/tests/support/Cargo.lock @@ -2110,12 +2110,17 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "libc", + "miette", + "nix", "prost", "prost-types", "protoc-bin-vendored", "rustix", + "rustls", "serde", + "serde_json", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -2123,6 +2128,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", ] diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index 6a8172ff2c..216f12055e 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -2092,12 +2092,17 @@ dependencies = [ name = "openshell-driver-sdk" version = "0.0.0" dependencies = [ + "base64 0.22.1", "libc", + "miette", + "nix", "prost", "prost-types", "protoc-bin-vendored", "rustix", + "rustls", "serde", + "serde_json", "thiserror 2.0.20", "tokio", "tokio-stream", @@ -2105,6 +2110,7 @@ dependencies = [ "tonic-prost", "tonic-prost-build", "tracing", + "url", "uuid", ] From 37f88fa502d5bfb44108abe111d3bb7816e33efb Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 9 Nov 2026 16:39:59 +0100 Subject: [PATCH 4/5] refactor(driver-sdk): split gpu, resource_admission, proto_struct, and config Move the items shared with core into openshell-driver-sdk and the desktop-only items (CDI GPU selection, resource-admission provenance labels, the string-list serde helper, and the Docker/Podman config defaults) into openshell-container-runtime. Core-only items stay in openshell-core, which re-exports the SDK items. Point the Docker, Podman, and VM drivers at the new paths. Signed-off-by: Evan Lezar --- Cargo.lock | 3 + crates/openshell-container-runtime/Cargo.toml | 5 + .../openshell-container-runtime/src/config.rs | 40 + crates/openshell-container-runtime/src/gpu.rs | 720 +++++++++++++++++ crates/openshell-container-runtime/src/lib.rs | 4 + .../src/proto_struct.rs | 80 ++ .../src/resource_admission.rs | 20 + crates/openshell-core/src/config.rs | 584 +------------- crates/openshell-core/src/gpu.rs | 762 +----------------- crates/openshell-core/src/lib.rs | 4 +- crates/openshell-driver-docker/src/lib.rs | 28 +- crates/openshell-driver-docker/src/tests.rs | 7 +- crates/openshell-driver-podman/src/config.rs | 8 +- .../openshell-driver-podman/src/container.rs | 19 +- crates/openshell-driver-podman/src/driver.rs | 20 +- crates/openshell-driver-podman/src/main.rs | 2 +- crates/openshell-driver-sdk/src/config.rs | 551 +++++++++++++ crates/openshell-driver-sdk/src/gpu.rs | 59 ++ crates/openshell-driver-sdk/src/lib.rs | 4 + .../src/proto_struct.rs | 70 -- .../src/resource_admission.rs | 21 +- crates/openshell-driver-vm/src/driver.rs | 10 +- 22 files changed, 1549 insertions(+), 1472 deletions(-) create mode 100644 crates/openshell-container-runtime/src/config.rs create mode 100644 crates/openshell-container-runtime/src/gpu.rs create mode 100644 crates/openshell-container-runtime/src/proto_struct.rs create mode 100644 crates/openshell-container-runtime/src/resource_admission.rs create mode 100644 crates/openshell-driver-sdk/src/config.rs create mode 100644 crates/openshell-driver-sdk/src/gpu.rs rename crates/{openshell-core => openshell-driver-sdk}/src/proto_struct.rs (71%) rename crates/{openshell-core => openshell-driver-sdk}/src/resource_admission.rs (95%) diff --git a/Cargo.lock b/Cargo.lock index 51faeea1f4..47accb29bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4421,8 +4421,11 @@ dependencies = [ "miette", "openshell-driver-sdk", "rustls", + "serde", + "serde_json", "tar", "tempfile", + "tonic", ] [[package]] diff --git a/crates/openshell-container-runtime/Cargo.toml b/crates/openshell-container-runtime/Cargo.toml index f9e4b368b2..363e4957bc 100644 --- a/crates/openshell-container-runtime/Cargo.toml +++ b/crates/openshell-container-runtime/Cargo.toml @@ -12,10 +12,15 @@ 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/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/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 index b734320a15..d72c31b4c0 100644 --- a/crates/openshell-container-runtime/src/lib.rs +++ b/crates/openshell-container-runtime/src/lib.rs @@ -8,6 +8,10 @@ //! 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-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/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/lib.rs b/crates/openshell-core/src/lib.rs index 3cdf9084c7..771e22128d 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -51,10 +51,10 @@ pub mod policy_identity; 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 use openshell_driver_sdk::sandbox_env; pub use openshell_driver_sdk::sandbox_generation; diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index d202f094a7..98c5ec061d 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -29,6 +29,11 @@ use openshell_container_runtime::driver_utils::{ extract_first_tar_entry, supervisor_image_should_refresh, temp_extract_container_name, validate_linux_elf_binary, }; +use openshell_container_runtime::gpu::{ + CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, validate_cdi_device_names, + validate_cdi_gpu_device_request, +}; +use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; use openshell_core::config::DEFAULT_STOP_TIMEOUT_SECS; use openshell_core::driver_mounts; use openshell_core::driver_utils::{ @@ -36,10 +41,7 @@ use openshell_core::driver_utils::{ LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED, }; -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_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail, @@ -57,9 +59,7 @@ 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::proto_struct::struct_to_json_value; use openshell_core::{ AppArmorProfile, Error, ImagePullPolicy, NetworkSupervisorTrustBundle, Result as CoreResult, UpstreamProxyConfig, @@ -251,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, @@ -329,7 +329,7 @@ impl Default for DockerComputeConfig { 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(), + sandbox_pids_limit: openshell_container_runtime::config::default_sandbox_pids_limit(), enable_bind_mounts: false, upstream_proxy: UpstreamProxyConfig::default(), proxy_ca_bundle: None, @@ -1289,10 +1289,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 { @@ -1305,7 +1305,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(); @@ -1767,7 +1767,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()) })?, @@ -6221,7 +6221,7 @@ fn build_container_create_body_for_image( .map_err(|error| Status::internal(error.to_string()))?, ); labels.insert( - openshell_core::resource_admission::CONFIG_USED_LABEL.into(), + openshell_container_runtime::resource_admission::CONFIG_USED_LABEL.into(), template .driver_config .as_ref() diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index b954276551..a8579d7088 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -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, @@ -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) ); } diff --git a/crates/openshell-driver-podman/src/config.rs b/crates/openshell-driver-podman/src/config.rs index ee68eb34b4..38ce2a2d58 100644 --- a/crates/openshell-driver-podman/src/config.rs +++ b/crates/openshell-driver-podman/src/config.rs @@ -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, @@ -557,7 +557,7 @@ impl Default for PodmanComputeConfig { 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, @@ -709,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); } @@ -720,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 746d0462a2..c8b70223a1 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -5,16 +5,18 @@ use crate::client::ImageInspect; use crate::config::PodmanComputeConfig; +use openshell_container_runtime::gpu::validate_cdi_device_names; +#[cfg(test)] +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_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; use openshell_core::ComputeDriverError; use openshell_core::driver_mounts::SelinuxLabel; -use openshell_core::gpu::validate_cdi_device_names; #[cfg(test)] -use openshell_core::gpu::{driver_gpu_requirements, validate_cdi_gpu_device_request}; +use openshell_core::gpu::driver_gpu_requirements; 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 serde::Serialize; use serde_json::Value; @@ -849,7 +851,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()) @@ -1383,7 +1385,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 @@ -1533,7 +1535,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| { @@ -1598,7 +1600,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()))?, ); @@ -2403,7 +2405,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}")); diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index d538f4274d..6f6319b941 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -10,18 +10,18 @@ use crate::watcher::{ self, LifecycleEventFences, WatchStream, driver_sandbox_from_inspect, driver_sandbox_from_list_entry, }; +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_container_runtime::gpu::{ + CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, validate_cdi_gpu_device_request, +}; use openshell_container_runtime::network_trust::{ NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, }; -use openshell_core::config::CDI_GPU_DEVICE_ALL; -use openshell_core::gpu::{ - CdiGpuDefaultSelector, CdiGpuInventory, CdiGpuSelectionError, driver_gpu_requirements, - effective_driver_gpu_count, validate_cdi_gpu_device_request, -}; +use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::proto::compute::v1::{ CpuResourceCapabilities, DriverSandbox, GetCapabilitiesResponse, GpuResourceCapabilities, GpuResourceRequirements, MemoryResourceCapabilities, ResourceCapabilities, @@ -901,10 +901,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 +919,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(); @@ -4053,8 +4053,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": [ diff --git a/crates/openshell-driver-podman/src/main.rs b/crates/openshell-driver-podman/src/main.rs index cc0ebe1a20..0626eaedcf 100644 --- a/crates/openshell-driver-podman/src/main.rs +++ b/crates/openshell-driver-podman/src/main.rs @@ -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-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-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-driver-sdk/src/lib.rs b/crates/openshell-driver-sdk/src/lib.rs index c188408a54..e467b61049 100644 --- a/crates/openshell-driver-sdk/src/lib.rs +++ b/crates/openshell-driver-sdk/src/lib.rs @@ -9,6 +9,7 @@ //! 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; @@ -17,6 +18,7 @@ pub mod e2fsprogs; pub mod extension_protocol; #[cfg(unix)] pub mod external_driver_socket; +pub mod gpu; pub mod image; #[cfg(feature = "jwt")] pub mod jwt; @@ -24,6 +26,8 @@ pub mod network_trust; pub mod paths; 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; 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-driver-vm/src/driver.rs b/crates/openshell-driver-vm/src/driver.rs index f7946c34c2..10497fc3f1 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -44,12 +44,12 @@ use oci_client::manifest::{ }; use oci_client::secrets::RegistryAuth; use oci_client::{Reference, RegistryOperation}; +use openshell_container_runtime::gpu::validate_specific_gpu_device_request; +use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; 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::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; use openshell_core::progress::{ PROGRESS_STEP_PULLING_IMAGE, PROGRESS_STEP_REQUESTING_SANDBOX, PROGRESS_STEP_STARTING_SANDBOX, format_bytes, mark_progress_active, mark_progress_complete, mark_progress_detail, @@ -69,9 +69,7 @@ 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::proto_struct::struct_to_json_value; use openshell_core::{ NetworkSupervisorTrustBundle, network_trust::MAX_NETWORK_SUPERVISOR_TRUST_BUNDLE_BYTES, }; From 21c05ff4f9b97454966db31f52586c9d7ac0a822 Mon Sep 17 00:00:00 2001 From: Evan Lezar Date: Mon, 9 Nov 2026 16:50:22 +0100 Subject: [PATCH 5/5] refactor(driver-sdk): move isolation contract types and drop core from the desktop drivers Move the Isolation Backend contract types, the policy conversion they depend on, the backend registration types, and the shared constants into openshell-driver-sdk, with openshell-isolation-interface, openshell-sandbox-backend, and openshell-policy re-exporting them. Move the remaining shared helpers (net, settings, telemetry env value) into the SDK. The Docker, Podman, and VM drivers now import from the SDK and openshell-container-runtime and no longer name openshell-core. Move the sha2 allowance in deny.toml and CRYPTO.md from openshell-isolation-interface to openshell-driver-sdk, where that code now lives, and document the new crates. Signed-off-by: Evan Lezar --- AGENTS.md | 2 + CONTRIBUTING.md | 2 + Cargo.lock | 14 +- crates/openshell-container-runtime/README.md | 19 ++ crates/openshell-core/Cargo.toml | 5 +- crates/openshell-core/README.md | 7 + crates/openshell-core/src/lib.rs | 6 +- crates/openshell-core/src/net.rs | 62 +---- crates/openshell-core/src/paths.rs | 14 - crates/openshell-core/src/settings.rs | 61 +--- crates/openshell-core/src/telemetry.rs | 133 +++------ crates/openshell-crypto/CRYPTO.md | 2 +- crates/openshell-driver-docker/Cargo.toml | 2 +- .../openshell-driver-docker/src/isolation.rs | 12 +- crates/openshell-driver-docker/src/lib.rs | 262 ++++++++++-------- crates/openshell-driver-docker/src/main.rs | 18 +- .../src/otel_tracing.rs | 2 +- crates/openshell-driver-docker/src/tests.rs | 95 ++++--- crates/openshell-driver-podman/Cargo.toml | 2 +- crates/openshell-driver-podman/src/client.rs | 6 +- crates/openshell-driver-podman/src/config.rs | 30 +- .../openshell-driver-podman/src/container.rs | 220 ++++++++------- crates/openshell-driver-podman/src/driver.rs | 140 +++++----- crates/openshell-driver-podman/src/grpc.rs | 30 +- .../openshell-driver-podman/src/isolation.rs | 29 +- crates/openshell-driver-podman/src/main.rs | 26 +- .../src/otel_tracing.rs | 2 +- crates/openshell-driver-podman/src/watcher.rs | 6 +- crates/openshell-driver-sdk/Cargo.toml | 6 + crates/openshell-driver-sdk/README.md | 32 +++ .../src/error.rs | 0 .../src/isolation_interface}/contract.rs | 6 +- .../src/isolation_interface/mod.rs | 54 ++++ .../src/isolation_registration.rs | 0 crates/openshell-driver-sdk/src/lib.rs | 12 + crates/openshell-driver-sdk/src/net.rs | 60 ++++ crates/openshell-driver-sdk/src/paths.rs | 14 + .../src/policy.rs | 16 +- crates/openshell-driver-sdk/src/settings.rs | 66 +++++ crates/openshell-driver-sdk/src/telemetry.rs | 61 ++++ crates/openshell-driver-vm/Cargo.toml | 8 +- crates/openshell-driver-vm/src/driver.rs | 256 +++++++++-------- .../src/embedded_runtime.rs | 8 +- .../openshell-driver-vm/src/isolation/mod.rs | 12 +- crates/openshell-driver-vm/src/lifecycle.rs | 6 +- crates/openshell-driver-vm/src/main.rs | 10 +- .../openshell-driver-vm/src/otel_tracing.rs | 2 +- .../src/registry_cache_tests.rs | 6 +- crates/openshell-driver-vm/src/rootfs.rs | 11 +- .../openshell-isolation-interface/Cargo.toml | 7 +- .../openshell-isolation-interface/README.md | 2 + .../openshell-isolation-interface/src/lib.rs | 25 +- crates/openshell-policy/src/lib.rs | 12 +- crates/openshell-sandbox-backend/Cargo.toml | 1 + crates/openshell-sandbox-backend/src/lib.rs | 24 +- deny.toml | 2 +- examples/governance-interceptor/Cargo.lock | 2 + examples/isolation-backend-v8/Cargo.lock | 9 +- .../Cargo.lock | 2 + tests/suites/conformance/Cargo.lock | 2 + tests/suites/drivers/podman/Cargo.lock | 2 + tests/suites/features/Cargo.lock | 2 + tests/support/Cargo.lock | 2 + tests/testkit/Cargo.lock | 2 + 64 files changed, 1092 insertions(+), 859 deletions(-) create mode 100644 crates/openshell-container-runtime/README.md create mode 100644 crates/openshell-driver-sdk/README.md rename crates/{openshell-core => openshell-driver-sdk}/src/error.rs (100%) rename crates/{openshell-isolation-interface/src => openshell-driver-sdk/src/isolation_interface}/contract.rs (99%) create mode 100644 crates/openshell-driver-sdk/src/isolation_interface/mod.rs rename crates/{openshell-core => openshell-driver-sdk}/src/isolation_registration.rs (100%) create mode 100644 crates/openshell-driver-sdk/src/net.rs rename crates/{openshell-core => openshell-driver-sdk}/src/policy.rs (92%) create mode 100644 crates/openshell-driver-sdk/src/settings.rs create mode 100644 crates/openshell-driver-sdk/src/telemetry.rs 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 47accb29bc..15d585c06a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4457,7 +4457,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "tar", "tempfile", "thiserror 2.0.20", "tokio", @@ -4555,7 +4554,7 @@ dependencies = [ "libc", "miette", "openshell-container-runtime", - "openshell-core", + "openshell-driver-sdk", "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", @@ -4685,8 +4684,8 @@ dependencies = [ "miette", "nix 0.29.0", "openshell-container-runtime", - "openshell-core", "openshell-crypto", + "openshell-driver-sdk", "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", @@ -4726,6 +4725,7 @@ dependencies = [ "nix 0.29.0", "openshell-crypto", "openshell-otel", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -4734,6 +4734,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 2.0.20", "tokio", @@ -4789,9 +4790,9 @@ dependencies = [ "nix 0.29.0", "oci-client", "openshell-container-runtime", - "openshell-core", "openshell-crypto", "openshell-driver-podman", + "openshell-driver-sdk", "openshell-isolation-interface", "openshell-otel", "openshell-otel-test-support", @@ -4893,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", ] @@ -5105,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/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-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 193c68f9d6..1ff78b9837 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -37,8 +37,6 @@ rustls = { workspace = true } base64 = { workspace = 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 } @@ -52,8 +50,7 @@ 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. 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/src/lib.rs b/crates/openshell-core/src/lib.rs index 771e22128d..90804521b6 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -25,7 +25,7 @@ pub use openshell_driver_sdk::e2fsprogs; pub mod endpoint_failure; pub mod endpoint_path; pub mod endpoint_status; -pub mod error; +pub use openshell_driver_sdk::error; pub use openshell_driver_sdk::extension_protocol; #[cfg(unix)] pub use openshell_driver_sdk::external_driver_socket; @@ -35,7 +35,7 @@ pub mod gpu; pub mod grpc_client; pub mod host_pattern; pub use openshell_driver_sdk::image; -pub mod isolation_registration; +pub use openshell_driver_sdk::isolation_registration; pub mod jwt; pub mod mcp; pub mod metadata; @@ -45,7 +45,7 @@ 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 use openshell_driver_sdk::progress; 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 c6e68024fb..4edab3f468 100644 --- a/crates/openshell-core/src/paths.rs +++ b/crates/openshell-core/src/paths.rs @@ -92,11 +92,6 @@ 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('/')) -} - #[cfg(test)] mod tests { use super::*; @@ -119,15 +114,6 @@ mod tests { ); } - #[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 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 95e497f3ca..0fec49ba63 100644 --- a/crates/openshell-driver-docker/Cargo.toml +++ b/crates/openshell-driver-docker/Cargo.toml @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] openshell-container-runtime = { path = "../openshell-container-runtime" } -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-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 98c5ec061d..9e8f8da3aa 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -34,19 +34,19 @@ use openshell_container_runtime::gpu::{ validate_cdi_gpu_device_request, }; use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; -use openshell_core::config::DEFAULT_STOP_TIMEOUT_SECS; -use openshell_core::driver_mounts; -use openshell_core::driver_utils::{ +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_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; -use openshell_core::progress::{ +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, @@ -59,8 +59,8 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesEvent, WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent, compute_driver_server::ComputeDriver, watch_sandboxes_event, }; -use openshell_core::proto_struct::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, }; @@ -144,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, @@ -186,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. @@ -213,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. @@ -313,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, @@ -328,7 +328,7 @@ 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(), + 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(), @@ -347,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, @@ -360,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, @@ -709,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(), @@ -791,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)] @@ -972,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 { @@ -1001,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( @@ -1077,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, @@ -1102,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, + [], + ), + ), } } @@ -1124,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, )?; @@ -2480,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()))?; @@ -2503,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 { @@ -2817,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(), @@ -3152,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 { @@ -3160,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, @@ -3317,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", )) @@ -3338,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, @@ -3712,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(), @@ -3741,8 +3750,10 @@ fn docker_pull_progress_event(image: &str, info: &CreateImageInfo) -> Option 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() @@ -4398,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() @@ -4409,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 @@ -4417,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, @@ -4460,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() @@ -4480,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(()); @@ -4655,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}" @@ -4672,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| { @@ -4788,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(), @@ -4897,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", @@ -5355,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 @@ -5365,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) @@ -5410,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); @@ -5453,7 +5475,7 @@ 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() { @@ -5464,7 +5486,7 @@ async fn spawn_docker_control_process( .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?; @@ -5975,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); } @@ -6007,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(()) } @@ -6398,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(()) } 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 a8579d7088..1a2293f537 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -3,19 +3,19 @@ use super::*; use openshell_container_runtime::driver_utils::LABEL_SANDBOX_NAMESPACE; -use openshell_core::driver_utils::{ +use openshell_driver_sdk::driver_utils::{ LABEL_MANAGED_BY, LABEL_MANAGED_BY_VALUE, LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, }; -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"), }), @@ -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, )), }), ) @@ -1513,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()); @@ -1536,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, }; @@ -1615,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 )))); } @@ -1665,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); @@ -1884,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"); @@ -4138,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}", @@ -4147,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" @@ -4159,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", @@ -4478,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"); @@ -4501,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 ea95527f1c..dcae6dd947 100644 --- a/crates/openshell-driver-podman/Cargo.toml +++ b/crates/openshell-driver-podman/Cargo.toml @@ -15,7 +15,7 @@ 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" } 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 38ce2a2d58..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, @@ -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 @@ -331,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 @@ -343,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), @@ -374,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", ) @@ -541,19 +541,19 @@ 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, diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index c8b70223a1..590c5b8a9a 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -12,12 +12,12 @@ use openshell_container_runtime::network_trust::{ NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, }; use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; -use openshell_core::ComputeDriverError; -use openshell_core::driver_mounts::SelinuxLabel; +use openshell_driver_sdk::ComputeDriverError; +use openshell_driver_sdk::driver_mounts::SelinuxLabel; #[cfg(test)] -use openshell_core::gpu::driver_gpu_requirements; -use openshell_core::proto::compute::v1::{DriverSandbox, DriverSandboxTemplate}; -use openshell_core::{NetworkSupervisorTrustBundle, driver_mounts, proto_struct}; +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}; @@ -43,7 +43,7 @@ fn is_selinux_enabled() -> bool { } pub use openshell_container_runtime::driver_utils::LABEL_SANDBOX_NAMESPACE; -pub use openshell_core::driver_utils::{ +pub use openshell_driver_sdk::driver_utils::{ LABEL_SANDBOX_ID, LABEL_SANDBOX_NAME, LABEL_SANDBOX_WORKSPACE, }; @@ -69,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_container_runtime::driver_utils::PROXY_CA_MOUNT_PATH; -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; const PROVIDER_SPIFFE_WORKLOAD_API_SOCKET_MOUNT_DIR: &str = 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)] @@ -98,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!( @@ -688,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(), ); } @@ -701,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). @@ -712,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 @@ -757,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(), ); } @@ -828,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(), ); } @@ -1463,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 @@ -1654,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 @@ -1719,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()]; @@ -1738,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; @@ -1785,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 { @@ -1831,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 { @@ -1934,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(())); @@ -2083,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()); @@ -2132,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") ); @@ -2140,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") ); @@ -2148,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()); @@ -2275,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") ); @@ -2382,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, }; @@ -2436,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()); } @@ -2477,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("") ); } @@ -2495,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" ); @@ -2533,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 { @@ -2558,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 { @@ -2575,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)), @@ -2610,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)), @@ -2652,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 { @@ -2674,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 { @@ -2704,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 { @@ -2727,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 { @@ -2746,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 { @@ -2766,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 { @@ -2786,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 { @@ -2824,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"), ); @@ -2879,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(); @@ -2924,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, }; @@ -3011,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()), @@ -3042,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" @@ -3053,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()), @@ -3066,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 + ) ); } @@ -3207,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 @@ -3259,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(); @@ -3423,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!( @@ -3440,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 { @@ -3509,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") @@ -3523,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 { @@ -3557,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 { @@ -3592,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 { @@ -3673,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 { @@ -3698,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 { @@ -3738,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 { @@ -3777,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 { @@ -3806,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 { @@ -3845,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 { @@ -3884,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"); @@ -4018,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 { @@ -4033,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") ); @@ -4171,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 6f6319b941..5435577544 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -21,12 +21,12 @@ use openshell_container_runtime::gpu::{ use openshell_container_runtime::network_trust::{ NETWORK_SUPERVISOR_TRUST_GENERATION_KEY, NETWORK_SUPERVISOR_TRUST_GENERATION_NONE, }; -use openshell_core::gpu::{driver_gpu_requirements, effective_driver_gpu_count}; -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, ) @@ -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 { @@ -2673,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; @@ -2687,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, @@ -2831,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}")], @@ -2852,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) } @@ -3538,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(), @@ -3557,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() @@ -3630,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, @@ -3699,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(), @@ -3718,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(), @@ -3738,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(), @@ -3759,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, @@ -3785,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, @@ -3808,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(), @@ -3898,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() }, @@ -3919,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") } @@ -4072,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; @@ -4097,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 0626eaedcf..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 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/watcher.rs b/crates/openshell-driver-podman/src/watcher.rs index aa9c6020ce..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, }; @@ -29,7 +29,7 @@ const CONDITION_STARTING: &str = "ContainerStarting"; use openshell_container_runtime::driver_utils::{ CONDITION_WORKSPACE_VALIDATION_FAILED, WORKSPACE_VALIDATION_FAILED_MESSAGE, }; -use openshell_core::driver_utils::{ +use openshell_driver_sdk::driver_utils::{ CONDITION_EXITED, CONDITION_RUNTIME_RESTART, CONDITION_STOPPED, SUPERVISOR_EXIT_WORKSPACE_VALIDATION_FAILED, }; diff --git a/crates/openshell-driver-sdk/Cargo.toml b/crates/openshell-driver-sdk/Cargo.toml index d88ce9152f..03277eea37 100644 --- a/crates/openshell-driver-sdk/Cargo.toml +++ b/crates/openshell-driver-sdk/Cargo.toml @@ -11,6 +11,7 @@ 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 } @@ -28,6 +29,7 @@ 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 } @@ -44,6 +46,10 @@ 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. 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-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-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/lib.rs b/crates/openshell-driver-sdk/src/lib.rs index e467b61049..b0bfe59d84 100644 --- a/crates/openshell-driver-sdk/src/lib.rs +++ b/crates/openshell-driver-sdk/src/lib.rs @@ -15,15 +15,20 @@ 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; @@ -31,8 +36,15 @@ 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 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-driver-sdk/src/paths.rs b/crates/openshell-driver-sdk/src/paths.rs index 7583218a9e..ab98a31037 100644 --- a/crates/openshell-driver-sdk/src/paths.rs +++ b/crates/openshell-driver-sdk/src/paths.rs @@ -86,10 +86,24 @@ pub fn set_file_owner_only(path: &Path) -> Result<()> { 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() { 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-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-driver-vm/Cargo.toml b/crates/openshell-driver-vm/Cargo.toml index c1d09e8767..4f01454123 100644 --- a/crates/openshell-driver-vm/Cargo.toml +++ b/crates/openshell-driver-vm/Cargo.toml @@ -25,7 +25,7 @@ path = "src/bin/openshell-vm-init.rs" [dependencies] openshell-container-runtime = { path = "../openshell-container-runtime" } -openshell-core = { path = "../openshell-core", default-features = false, features = ["clap"] } +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 } @@ -81,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", @@ -102,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 10497fc3f1..ed5a10e1a2 100644 --- a/crates/openshell-driver-vm/src/driver.rs +++ b/crates/openshell-driver-vm/src/driver.rs @@ -46,15 +46,15 @@ use oci_client::secrets::RegistryAuth; use oci_client::{Reference, RegistryOperation}; use openshell_container_runtime::gpu::validate_specific_gpu_device_request; use openshell_container_runtime::proto_struct::deserialize_optional_non_empty_string_list; -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}; -use openshell_core::progress::{ +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,8 +69,8 @@ use openshell_core::proto::compute::v1::{ WatchSandboxesPlatformEvent, WatchSandboxesRequest, WatchSandboxesSandboxEvent, WorkloadIdentityRequest, compute_driver_server::ComputeDriver, watch_sandboxes_event, }; -use openshell_core::proto_struct::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::{ @@ -202,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"; @@ -236,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"; @@ -344,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, @@ -493,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(), @@ -552,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, )?; @@ -1369,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 @@ -1414,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); @@ -1443,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| { @@ -1533,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, @@ -1580,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, )?; @@ -2427,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()))?; @@ -5294,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", )) @@ -5313,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, @@ -5517,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 \ @@ -5526,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") })?; @@ -6738,20 +6748,20 @@ fn build_guest_environment(sandbox: &Sandbox, config: &VmDriverConfig) -> Vec>(); pairs.sort_by(|left, right| left.0.cmp(&right.0)); @@ -7275,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() ) } @@ -7295,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 ) } @@ -7328,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( @@ -7957,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() )) } @@ -8081,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 } @@ -8207,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(), @@ -8308,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, }; @@ -8773,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, >; @@ -8815,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(); @@ -8852,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 @@ -8888,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 @@ -9945,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(), @@ -10835,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") @@ -11233,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"), @@ -11371,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 })); } @@ -11901,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"), @@ -11928,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() @@ -11940,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 )))); } @@ -11959,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() @@ -11972,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" ); @@ -11983,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"), )], || { @@ -11996,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() @@ -12010,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::>(); @@ -12018,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 + ) ); }, ); @@ -12711,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, }; @@ -12803,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 ) ); @@ -12845,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 32e6d538e3..d49f55413b 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -1912,6 +1912,7 @@ dependencies = [ "libc", "miette", "nix", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -1919,6 +1920,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror", "tokio", "tokio-stream", diff --git a/examples/isolation-backend-v8/Cargo.lock b/examples/isolation-backend-v8/Cargo.lock index 158bb90f32..38edfab708 100644 --- a/examples/isolation-backend-v8/Cargo.lock +++ b/examples/isolation-backend-v8/Cargo.lock @@ -2181,6 +2181,7 @@ dependencies = [ "miette", "nix", "openshell-crypto", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -2188,6 +2189,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror", "tokio", "tokio-stream", @@ -2216,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]] @@ -2245,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 fe74c96216..e72d51398e 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -2066,6 +2066,7 @@ dependencies = [ "libc", "miette", "nix", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -2073,6 +2074,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror", "tokio", "tokio-stream", diff --git a/tests/suites/conformance/Cargo.lock b/tests/suites/conformance/Cargo.lock index 7e7339e820..f6e017eb5b 100644 --- a/tests/suites/conformance/Cargo.lock +++ b/tests/suites/conformance/Cargo.lock @@ -2106,6 +2106,7 @@ dependencies = [ "libc", "miette", "nix", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -2113,6 +2114,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tokio-stream", diff --git a/tests/suites/drivers/podman/Cargo.lock b/tests/suites/drivers/podman/Cargo.lock index a8f03e075d..475a1b0640 100644 --- a/tests/suites/drivers/podman/Cargo.lock +++ b/tests/suites/drivers/podman/Cargo.lock @@ -2105,6 +2105,7 @@ dependencies = [ "libc", "miette", "nix", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -2112,6 +2113,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tokio-stream", diff --git a/tests/suites/features/Cargo.lock b/tests/suites/features/Cargo.lock index 6460f90ae9..7b46a84f92 100644 --- a/tests/suites/features/Cargo.lock +++ b/tests/suites/features/Cargo.lock @@ -2142,6 +2142,7 @@ dependencies = [ "libc", "miette", "nix", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -2149,6 +2150,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tokio-stream", diff --git a/tests/support/Cargo.lock b/tests/support/Cargo.lock index 1a38a5b420..752c7e3b4a 100644 --- a/tests/support/Cargo.lock +++ b/tests/support/Cargo.lock @@ -2114,6 +2114,7 @@ dependencies = [ "libc", "miette", "nix", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -2121,6 +2122,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tokio-stream", diff --git a/tests/testkit/Cargo.lock b/tests/testkit/Cargo.lock index 216f12055e..f56eba2e22 100644 --- a/tests/testkit/Cargo.lock +++ b/tests/testkit/Cargo.lock @@ -2096,6 +2096,7 @@ dependencies = [ "libc", "miette", "nix", + "openshell-policy-schema", "prost", "prost-types", "protoc-bin-vendored", @@ -2103,6 +2104,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tokio-stream",