From 19bced53068d3491e105116b8ffa9b5c46553990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Fri, 27 Jun 2025 14:50:53 -0500 Subject: [PATCH] feat(build): add embedded-sandbox-shell support for OCI builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds an optional "embedded-sandbox-shell" feature that embeds the sandbox shell binary directly into the build at compile time. This simplifies deployment by removing the runtime dependency on the path specified in SNIX_BUILD_SANDBOX_SHELL. The implementation: - Adds embedded-sandbox-shell feature flag in Cargo.toml - When feature is enabled: embeds binary contents at compile time - When feature is disabled: bakes the path at compile time - Extracts embedded binary to temp directory at runtime (when feature enabled) - Makes sandbox_shell configurable via URL query parameter for OCIBuildService - Updates OCIBuildService to accept sandbox shell path as constructor parameter This ensures the sandbox shell is always available without runtime environment dependencies, whether as a baked path or embedded binary. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Change-Id: I46e3856b7d5d8a65abd4938e713ecaece61f24cc --- snix/Cargo.lock | 2 + snix/Cargo.nix | 11 ++- snix/build/Cargo.toml | 3 + snix/build/build.rs | 23 +++++ snix/build/src/buildservice/from_addr.rs | 13 ++- .../src/buildservice/{oci.rs => oci/mod.rs} | 54 +++++++++++- .../src/buildservice/oci/sandbox_shell.rs | 87 +++++++++++++++++++ snix/build/src/oci/spec.rs | 4 +- snix/default.nix | 6 +- 9 files changed, 187 insertions(+), 16 deletions(-) rename snix/build/src/buildservice/{oci.rs => oci/mod.rs} (80%) create mode 100644 snix/build/src/buildservice/oci/sandbox_shell.rs diff --git a/snix/Cargo.lock b/snix/Cargo.lock index ad09cbbdb5..64a8ab2028 100644 --- a/snix/Cargo.lock +++ b/snix/Cargo.lock @@ -4164,7 +4164,9 @@ dependencies = [ "prost", "prost-build", "rstest", + "serde", "serde_json", + "serde_qs", "snix-castore", "snix-tracing", "tempfile", diff --git a/snix/Cargo.nix b/snix/Cargo.nix index 537f1e0842..3966fd4cf5 100644 --- a/snix/Cargo.nix +++ b/snix/Cargo.nix @@ -13553,10 +13553,19 @@ rec { name = "prost"; packageId = "prost"; } + { + name = "serde"; + packageId = "serde"; + features = [ "derive" ]; + } { name = "serde_json"; packageId = "serde_json"; } + { + name = "serde_qs"; + packageId = "serde_qs"; + } { name = "snix-castore"; packageId = "snix-castore"; @@ -13627,7 +13636,7 @@ rec { features = { "tonic-reflection" = [ "dep:tonic-reflection" "snix-castore/tonic-reflection" ]; }; - resolvedDefaultFeatures = [ "default" "tonic-reflection" ]; + resolvedDefaultFeatures = [ "default" "embedded-sandbox-shell" "tonic-reflection" ]; }; "snix-castore" = rec { crateName = "snix-castore"; diff --git a/snix/build/Cargo.toml b/snix/build/Cargo.toml index dd601ac0bc..d3ddf23e0f 100644 --- a/snix/build/Cargo.toml +++ b/snix/build/Cargo.toml @@ -26,7 +26,9 @@ data-encoding = "2.5.0" futures = "0.3.30" oci-spec = "0.7.0" nix = { version = "0.29.0", features = ["user"] } +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.111" +serde_qs.workspace = true snix-tracing = { path = "../tracing" } uuid = { version = "1.7.0", features = ["v4"] } @@ -37,6 +39,7 @@ tonic-build.workspace = true [features] default = [] tonic-reflection = ["dep:tonic-reflection", "snix-castore/tonic-reflection"] +embedded-sandbox-shell = [] [dev-dependencies] rstest.workspace = true diff --git a/snix/build/build.rs b/snix/build/build.rs index dcb00fa0b9..e4b8760b35 100644 --- a/snix/build/build.rs +++ b/snix/build/build.rs @@ -1,6 +1,29 @@ use std::io::Result; fn main() -> Result<()> { + // SNIX_BUILD_SANDBOX_SHELL is required at compile time for Linux builds + #[cfg(target_os = "linux")] + { + if let Ok(shell_path) = std::env::var("SNIX_BUILD_SANDBOX_SHELL") { + // Tell cargo to rerun if the sandbox shell binary changes + println!("cargo:rerun-if-changed={}", shell_path); + + // When embedded-sandbox-shell feature is enabled, verify the file exists + #[cfg(feature = "embedded-sandbox-shell")] + { + if !std::path::Path::new(&shell_path).exists() { + panic!( + "SNIX_BUILD_SANDBOX_SHELL points to non-existent file: {}", + shell_path + ); + } + } + } else { + panic!( + "SNIX_BUILD_SANDBOX_SHELL environment variable must be set at compile time for Linux builds" + ); + } + } #[allow(unused_mut)] let mut builder = tonic_build::configure(); diff --git a/snix/build/src/buildservice/from_addr.rs b/snix/build/src/buildservice/from_addr.rs index 4f64b2d92c..30c4118566 100644 --- a/snix/build/src/buildservice/from_addr.rs +++ b/snix/build/src/buildservice/from_addr.rs @@ -3,7 +3,7 @@ use snix_castore::{blobservice::BlobService, directoryservice::DirectoryService} use url::Url; #[cfg(target_os = "linux")] -use super::oci::OCIBuildService; +use super::oci::{OCIBuildService, OCIBuildServiceConfig}; /// Constructs a new instance of a [BuildService] from an URI. /// @@ -32,17 +32,14 @@ where "dummy" => Box::::default(), #[cfg(target_os = "linux")] "oci" => { - // oci wants a path in which it creates bundles. - if url.path().is_empty() { - Err(std::io::Error::other("oci needs a bundle dir as path"))? - } - - // TODO: make sandbox shell and rootless_uid_gid + let config = OCIBuildServiceConfig::try_from(url) + .map_err(|e| std::io::Error::other(format!("invalid oci config: {}", e)))?; Box::new(OCIBuildService::new( - url.path().into(), + config.bundle_root, blob_service, directory_service, + config.sandbox_shell, )) } scheme => { diff --git a/snix/build/src/buildservice/oci.rs b/snix/build/src/buildservice/oci/mod.rs similarity index 80% rename from snix/build/src/buildservice/oci.rs rename to snix/build/src/buildservice/oci/mod.rs index fcd648c2a8..5211be052b 100644 --- a/snix/build/src/buildservice/oci.rs +++ b/snix/build/src/buildservice/oci/mod.rs @@ -1,5 +1,8 @@ +mod sandbox_shell; + use anyhow::Context; use bstr::BStr; +use serde::Deserialize; use snix_castore::{ blobservice::BlobService, directoryservice::DirectoryService, @@ -10,6 +13,7 @@ use snix_castore::{ use tokio::process::{Child, Command}; use tonic::async_trait; use tracing::{Span, debug, instrument, warn}; +use url::Url; use uuid::Uuid; use crate::buildservice::{BuildOutput, BuildRequest, BuildResult}; @@ -18,9 +22,44 @@ use std::{ffi::OsStr, path::PathBuf, process::Stdio}; use super::BuildService; -const SANDBOX_SHELL: &str = env!("SNIX_BUILD_SANDBOX_SHELL"); const MAX_CONCURRENT_BUILDS: usize = 2; // TODO: make configurable +/// Configuration for OCIBuildService +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OCIBuildServiceConfig { + /// Root path in which all bundles are created + pub bundle_root: PathBuf, + + /// Path to the sandbox shell to use. + /// This needs to be a statically linked binary, or you must ensure all + /// dependencies are part of the build (which they usually are not). + #[serde(default = "sandbox_shell::default_sandbox_shell")] + pub sandbox_shell: PathBuf, + // TODO: make rootless_uid_gid configurable +} + +impl TryFrom for OCIBuildServiceConfig { + type Error = Box; + + fn try_from(mut url: Url) -> Result { + // oci wants a path in which it creates bundles + if url.path().is_empty() { + return Err("oci needs a bundle dir as path".into()); + } + + // Add the bundle_root from the URL path to the query parameters + let path = url.path().to_string(); + url.query_pairs_mut().append_pair("bundle_root", &path); + + // Parse the query string into the config struct using serde_qs + let config: OCIBuildServiceConfig = serde_qs::from_str(url.query().unwrap_or_default()) + .map_err(|e| format!("failed to parse OCI service parameters: {}", e))?; + + Ok(config) + } +} + pub struct OCIBuildService { /// Root path in which all bundles are created in bundle_root: PathBuf, @@ -30,13 +69,21 @@ pub struct OCIBuildService { /// Handle to a [DirectoryService], used by filesystems spawned during builds. directory_service: DS, + /// Path to the sandbox shell to use + sandbox_shell: PathBuf, + // semaphore to track number of concurrently running builds. // this is necessary, as otherwise we very quickly run out of open file handles. concurrent_builds: tokio::sync::Semaphore, } impl OCIBuildService { - pub fn new(bundle_root: PathBuf, blob_service: BS, directory_service: DS) -> Self { + pub fn new( + bundle_root: PathBuf, + blob_service: BS, + directory_service: DS, + sandbox_shell: PathBuf, + ) -> Self { // We map root inside the container to the uid/gid this is running at, // and allocate one for uid 1000 into the container from the range we // got in /etc/sub{u,g}id. @@ -45,6 +92,7 @@ impl OCIBuildService { bundle_root, blob_service, directory_service, + sandbox_shell, concurrent_builds: tokio::sync::Semaphore::new(MAX_CONCURRENT_BUILDS), } } @@ -66,7 +114,7 @@ where let span = Span::current(); span.record("bundle_name", bundle_name.to_string()); - let mut runtime_spec = make_spec(&request, true, SANDBOX_SHELL) + let mut runtime_spec = make_spec(&request, true, &self.sandbox_shell) .context("failed to create spec") .map_err(std::io::Error::other)?; diff --git a/snix/build/src/buildservice/oci/sandbox_shell.rs b/snix/build/src/buildservice/oci/sandbox_shell.rs new file mode 100644 index 0000000000..5cba27e103 --- /dev/null +++ b/snix/build/src/buildservice/oci/sandbox_shell.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; + +/// Compile-time path to sandbox shell (when embedded-sandbox-shell feature is disabled) +#[cfg(not(feature = "embedded-sandbox-shell"))] +const SNIX_BUILD_SANDBOX_SHELL: &str = env!("SNIX_BUILD_SANDBOX_SHELL"); + +/// Extract the embedded sandbox shell binary to a temporary location and return its path +fn get_embedded_sandbox_shell_path() -> Result { + #[cfg(feature = "embedded-sandbox-shell")] + { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::sync::{Mutex, OnceLock}; + + static EXTRACTED_SANDBOX_SHELL_PATH: OnceLock> = OnceLock::new(); + static INIT_MUTEX: Mutex<()> = Mutex::new(()); + + // The embedded sandbox shell binary (included at compile time) + static EMBEDDED_SANDBOX_SHELL_BINARY: &[u8] = + include_bytes!(env!("SNIX_BUILD_SANDBOX_SHELL")); + + let result = EXTRACTED_SANDBOX_SHELL_PATH.get_or_init(|| { + let _guard = INIT_MUTEX.lock().expect("mutex lock failed"); + + let temp_dir = std::env::temp_dir(); + let sandbox_shell_path = + temp_dir.join(format!("snix-sandbox-shell-{}", std::process::id())); + + // Write the binary + if let Err(e) = fs::write(&sandbox_shell_path, EMBEDDED_SANDBOX_SHELL_BINARY) { + return Err(e.to_string()); + } + + // Make it executable + match fs::metadata(&sandbox_shell_path) { + Ok(metadata) => { + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + if let Err(e) = fs::set_permissions(&sandbox_shell_path, perms) { + return Err(e.to_string()); + } + } + Err(e) => return Err(e.to_string()), + } + + tracing::debug!( + sandbox_shell.path = ?sandbox_shell_path, + "extracted embedded sandbox shell binary" + ); + + Ok(sandbox_shell_path) + }); + + match result { + Ok(path) => Ok(path.clone()), + Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e.clone())), + } + } + + #[cfg(not(feature = "embedded-sandbox-shell"))] + { + unreachable!( + "get_embedded_sandbox_shell_path called without embedded-sandbox-shell feature" + ) + } +} + +pub(crate) fn default_sandbox_shell() -> PathBuf { + if cfg!(feature = "embedded-sandbox-shell") { + // Extract and use the embedded binary + match get_embedded_sandbox_shell_path() { + Ok(path) => path, + Err(e) => { + panic!( + "Failed to extract embedded sandbox shell: {}\n\ + \n\ + The embedded sandbox shell could not be extracted to a temporary location.\n\ + This might be due to insufficient permissions or disk space in the temp directory.", + e + ); + } + } + } else { + // Use the compile-time path + PathBuf::from(SNIX_BUILD_SANDBOX_SHELL) + } +} diff --git a/snix/build/src/oci/spec.rs b/snix/build/src/oci/spec.rs index 557cf38cc9..92d722884b 100644 --- a/snix/build/src/oci/spec.rs +++ b/snix/build/src/oci/spec.rs @@ -43,7 +43,7 @@ pub enum SpecError { pub(crate) fn make_spec( request: &BuildRequest, rootless: bool, - sandbox_shell: &str, + sandbox_shell: &Path, ) -> Result { let allow_network = request .constraints @@ -64,7 +64,7 @@ pub(crate) fn make_spec( .constraints .contains(&BuildConstraints::ProvideBinSh) { - ro_host_mounts.push((Path::new(sandbox_shell), Path::new("/bin/sh"))) + ro_host_mounts.push((sandbox_shell, Path::new("/bin/sh"))) } oci_spec::runtime::SpecBuilder::default() diff --git a/snix/default.nix b/snix/default.nix index d4a6bfb534..2741487bb2 100644 --- a/snix/default.nix +++ b/snix/default.nix @@ -67,7 +67,8 @@ in inherit cargoDeps src; name = "snix-rust-docs"; PROTO_ROOT = protos; - SNIX_BUILD_SANDBOX_SHELL = "/homeless-shelter"; + # This path is resolved at build time in the Nix build environment + SNIX_BUILD_SANDBOX_SHELL = if pkgs.stdenv.isLinux then pkgs.busybox-sandbox-shell + "/bin/busybox" else "/bin/sh"; nativeBuildInputs = with pkgs; [ cargo @@ -93,7 +94,8 @@ in inherit cargoDeps src; name = "snix-clippy"; PROTO_ROOT = protos; - SNIX_BUILD_SANDBOX_SHELL = "/homeless-shelter"; + # This path is resolved at build time in the Nix build environment + SNIX_BUILD_SANDBOX_SHELL = if pkgs.stdenv.isLinux then pkgs.busybox-sandbox-shell + "/bin/busybox" else "/bin/sh"; buildInputs = [ pkgs.fuse