From cbf954beadc2db534986fee778c762ac8c218700 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 18:28:10 +0100 Subject: [PATCH 1/7] runtime: propagate offline mode Add RuntimeSpec.offline and thread it through OCI/namespace backends. Offline mode requires cached base images, forces sandbox network isolation, and fails fast when system package resolution/installation would require network access. --- crates/karapace-runtime/src/backend.rs | 2 ++ crates/karapace-runtime/src/image.rs | 13 +++++++++++++ crates/karapace-runtime/src/mock.rs | 2 ++ crates/karapace-runtime/src/namespace.rs | 19 +++++++++++++++---- crates/karapace-runtime/src/oci.rs | 19 +++++++++++++++---- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/karapace-runtime/src/backend.rs b/crates/karapace-runtime/src/backend.rs index 23cc528..d0114de 100644 --- a/crates/karapace-runtime/src/backend.rs +++ b/crates/karapace-runtime/src/backend.rs @@ -9,6 +9,8 @@ pub struct RuntimeSpec { pub overlay_path: String, pub store_root: String, pub manifest: NormalizedManifest, + #[serde(default)] + pub offline: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/karapace-runtime/src/image.rs b/crates/karapace-runtime/src/image.rs index d302c77..a0451f5 100644 --- a/crates/karapace-runtime/src/image.rs +++ b/crates/karapace-runtime/src/image.rs @@ -21,6 +21,11 @@ pub struct ResolvedImage { pub display_name: String, } +pub fn resolve_pinned_image_url(name: &str) -> Result { + let resolved = resolve_image(name)?; + download_url(&resolved.source) +} + #[allow(clippy::too_many_lines)] pub fn resolve_image(name: &str) -> Result { let name = name.trim().to_lowercase(); @@ -248,6 +253,7 @@ impl ImageCache { &self, resolved: &ResolvedImage, progress: &dyn Fn(&str), + offline: bool, ) -> Result { let rootfs = self.rootfs_path(&resolved.cache_key); if self.is_cached(&resolved.cache_key) { @@ -255,6 +261,13 @@ impl ImageCache { return Ok(rootfs); } + if offline { + return Err(RuntimeError::ExecFailed(format!( + "offline mode: base image '{}' is not cached", + resolved.display_name + ))); + } + std::fs::create_dir_all(&rootfs)?; progress(&format!( diff --git a/crates/karapace-runtime/src/mock.rs b/crates/karapace-runtime/src/mock.rs index a4ac979..7176b29 100644 --- a/crates/karapace-runtime/src/mock.rs +++ b/crates/karapace-runtime/src/mock.rs @@ -173,6 +173,7 @@ image = "rolling" overlay_path: dir.join("overlay").to_string_lossy().to_string(), store_root: dir.to_string_lossy().to_string(), manifest, + offline: false, } } @@ -217,6 +218,7 @@ backend = "mock" overlay_path: dir.path().join("overlay").to_string_lossy().to_string(), store_root: dir.path().to_string_lossy().to_string(), manifest, + offline: false, }; let backend = MockBackend::new(); diff --git a/crates/karapace-runtime/src/namespace.rs b/crates/karapace-runtime/src/namespace.rs index 267bd6c..c5443b6 100644 --- a/crates/karapace-runtime/src/namespace.rs +++ b/crates/karapace-runtime/src/namespace.rs @@ -62,11 +62,17 @@ impl RuntimeBackend for NamespaceBackend { // Download/cache the base image let resolved = resolve_image(&spec.manifest.base_image)?; let image_cache = ImageCache::new(&self.store_root); - let rootfs = image_cache.ensure_image(&resolved, &progress)?; + let rootfs = image_cache.ensure_image(&resolved, &progress, spec.offline)?; // Compute content digest of the base image let base_image_digest = compute_image_digest(&rootfs)?; + if spec.offline && !spec.manifest.system_packages.is_empty() { + return Err(RuntimeError::ExecFailed( + "offline mode: cannot resolve system packages".to_owned(), + )); + } + // If there are packages to resolve, set up a temporary overlay // and install+query to get exact versions let resolved_packages = if spec.manifest.system_packages.is_empty() { @@ -145,11 +151,11 @@ impl RuntimeBackend for NamespaceBackend { // Resolve and download the base image let resolved = resolve_image(&spec.manifest.base_image)?; let image_cache = ImageCache::new(&self.store_root); - let rootfs = image_cache.ensure_image(&resolved, &progress)?; + let rootfs = image_cache.ensure_image(&resolved, &progress, spec.offline)?; // Set up overlay filesystem let mut sandbox = SandboxConfig::new(rootfs.clone(), &spec.env_id, &env_dir); - sandbox.isolate_network = spec.manifest.network_isolation; + sandbox.isolate_network = spec.offline || spec.manifest.network_isolation; mount_overlay(&sandbox)?; @@ -158,6 +164,11 @@ impl RuntimeBackend for NamespaceBackend { // Install system packages if any if !spec.manifest.system_packages.is_empty() { + if spec.offline { + return Err(RuntimeError::ExecFailed( + "offline mode: cannot install system packages".to_owned(), + )); + } let pkg_mgr = detect_package_manager(&sandbox.overlay_merged) .or_else(|| detect_package_manager(&rootfs)) .ok_or_else(|| { @@ -216,7 +227,7 @@ impl RuntimeBackend for NamespaceBackend { // Create sandbox config let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir); - sandbox.isolate_network = spec.manifest.network_isolation; + sandbox.isolate_network = spec.offline || spec.manifest.network_isolation; sandbox.hostname = format!("karapace-{}", &spec.env_id[..12.min(spec.env_id.len())]); // Compute host integration (Wayland, PipeWire, GPU, etc.) diff --git a/crates/karapace-runtime/src/oci.rs b/crates/karapace-runtime/src/oci.rs index dc33550..833b081 100644 --- a/crates/karapace-runtime/src/oci.rs +++ b/crates/karapace-runtime/src/oci.rs @@ -173,9 +173,15 @@ impl RuntimeBackend for OciBackend { let resolved = resolve_image(&spec.manifest.base_image)?; let image_cache = ImageCache::new(&self.store_root); - let rootfs = image_cache.ensure_image(&resolved, &progress)?; + let rootfs = image_cache.ensure_image(&resolved, &progress, spec.offline)?; let base_image_digest = compute_image_digest(&rootfs)?; + if spec.offline && !spec.manifest.system_packages.is_empty() { + return Err(RuntimeError::ExecFailed( + "offline mode: cannot resolve system packages".to_owned(), + )); + } + let resolved_packages = if spec.manifest.system_packages.is_empty() { Vec::new() } else { @@ -250,15 +256,20 @@ impl RuntimeBackend for OciBackend { let resolved = resolve_image(&spec.manifest.base_image)?; let image_cache = ImageCache::new(&self.store_root); - let rootfs = image_cache.ensure_image(&resolved, &progress)?; + let rootfs = image_cache.ensure_image(&resolved, &progress, spec.offline)?; let mut sandbox = SandboxConfig::new(rootfs.clone(), &spec.env_id, &env_dir); - sandbox.isolate_network = spec.manifest.network_isolation; + sandbox.isolate_network = spec.offline || spec.manifest.network_isolation; mount_overlay(&sandbox)?; setup_container_rootfs(&sandbox)?; if !spec.manifest.system_packages.is_empty() { + if spec.offline { + return Err(RuntimeError::ExecFailed( + "offline mode: cannot install system packages".to_owned(), + )); + } let pkg_mgr = detect_package_manager(&sandbox.overlay_merged) .or_else(|| detect_package_manager(&rootfs)) .ok_or_else(|| { @@ -319,7 +330,7 @@ impl RuntimeBackend for OciBackend { let rootfs = image_cache.rootfs_path(&resolved.cache_key); let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir); - sandbox.isolate_network = spec.manifest.network_isolation; + sandbox.isolate_network = spec.offline || spec.manifest.network_isolation; let host = compute_host_integration(&spec.manifest); sandbox.bind_mounts.extend(host.bind_mounts); From 6e66c58e5e98af1c079eedfcf28e4d68b5dadd31 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 18:29:18 +0100 Subject: [PATCH 2/7] core: add build options Introduce BuildOptions to parameterize build and rebuild. Add build_with_options/rebuild_with_options to support locked, offline, and require-pinned-image modes. Locked mode verifies an existing lock file and fails on drift. Offline mode fails fast when system packages are requested. Also re-export BuildOptions from karapace-core. --- crates/karapace-core/src/engine.rs | 78 +++++++++++++++++++++++--- crates/karapace-core/src/lib.rs | 2 +- crates/karapace-schema/src/manifest.rs | 2 + 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/crates/karapace-core/src/engine.rs b/crates/karapace-core/src/engine.rs index 9f74f46..01c331e 100644 --- a/crates/karapace-core/src/engine.rs +++ b/crates/karapace-core/src/engine.rs @@ -36,6 +36,13 @@ pub struct BuildResult { pub lock_file: LockFile, } +#[derive(Debug, Clone, Copy, Default)] +pub struct BuildOptions { + pub locked: bool, + pub offline: bool, + pub require_pinned_image: bool, +} + impl Engine { /// Create a new engine rooted at the given store directory. /// @@ -148,14 +155,51 @@ impl Engine { }) } - #[allow(clippy::too_many_lines)] pub fn build(&self, manifest_path: &Path) -> Result { + self.build_with_options(manifest_path, BuildOptions::default()) + } + + #[allow(clippy::too_many_lines)] + pub fn build_with_options( + &self, + manifest_path: &Path, + options: BuildOptions, + ) -> Result { info!("building environment from {}", manifest_path.display()); self.layout.initialize()?; let manifest = parse_manifest_file(manifest_path)?; let normalized = manifest.normalize()?; + if options.offline && !normalized.system_packages.is_empty() { + return Err(CoreError::Runtime(karapace_runtime::RuntimeError::ExecFailed( + "offline mode: cannot resolve system packages".to_owned(), + ))); + } + + if options.require_pinned_image + && !(normalized.base_image.starts_with("http://") + || normalized.base_image.starts_with("https://")) + { + return Err(CoreError::Manifest( + karapace_schema::ManifestError::UnpinnedBaseImage(normalized.base_image.clone()), + )); + } + + let lock_path = manifest_path + .parent() + .unwrap_or(Path::new(".")) + .join("karapace.lock"); + + let locked = if options.locked { + let lock = LockFile::read_from_file(&lock_path)?; + let _ = lock.verify_integrity()?; + lock.verify_manifest_intent(&normalized)?; + Some(lock) + } else { + None + }; + let policy = SecurityPolicy::from_manifest(&normalized); policy.validate_mounts(&normalized)?; policy.validate_devices(&normalized)?; @@ -182,6 +226,7 @@ impl Engine { .to_string(), store_root: store_str.clone(), manifest: normalized.clone(), + offline: options.offline, }; let resolution = backend.resolve(&preliminary_spec)?; debug!( @@ -196,6 +241,17 @@ impl Engine { let lock = LockFile::from_resolved(&normalized, &resolution); let identity = lock.compute_identity(); + if let Some(existing) = locked { + if existing.env_id != identity.env_id.as_str() { + return Err(CoreError::Lock(karapace_schema::LockError::ManifestDrift( + format!( + "locked mode: lock env_id '{}' does not match resolved env_id '{}'", + existing.env_id, identity.env_id + ), + ))); + } + } + info!( "canonical env_id: {} ({})", identity.env_id, identity.short_id @@ -223,6 +279,7 @@ impl Engine { overlay_path: env_dir.to_string_lossy().to_string(), store_root: store_str, manifest: normalized.clone(), + offline: options.offline, }; if let Err(e) = backend.build(&spec) { let _ = std::fs::remove_dir_all(&env_dir); @@ -285,11 +342,9 @@ impl Engine { } self.meta_store.put(&meta)?; - let lock_path = manifest_path - .parent() - .unwrap_or(Path::new(".")) - .join("karapace.lock"); - lock.write_to_file(&lock_path)?; + if !options.locked { + lock.write_to_file(&lock_path)?; + } Ok(()) }; @@ -322,6 +377,7 @@ impl Engine { overlay_path: env_path_str, store_root: self.store_root_str.clone(), manifest, + offline: false, } } @@ -545,6 +601,14 @@ impl Engine { } pub fn rebuild(&self, manifest_path: &Path) -> Result { + self.rebuild_with_options(manifest_path, BuildOptions::default()) + } + + pub fn rebuild_with_options( + &self, + manifest_path: &Path, + options: BuildOptions, + ) -> Result { // Collect the old env_id(s) to clean up AFTER a successful build. // This ensures we don't lose the old environment if the new build fails. let lock_path = manifest_path @@ -568,7 +632,7 @@ impl Engine { } // Build first — if this fails, old environment is preserved. - let result = self.build(manifest_path)?; + let result = self.build_with_options(manifest_path, options)?; // Only destroy the old environment(s) after the new build succeeds. for old_id in &old_env_ids { diff --git a/crates/karapace-core/src/lib.rs b/crates/karapace-core/src/lib.rs index 047e209..1a6a372 100644 --- a/crates/karapace-core/src/lib.rs +++ b/crates/karapace-core/src/lib.rs @@ -12,7 +12,7 @@ pub mod lifecycle; pub use concurrency::{install_signal_handler, shutdown_requested, StoreLock}; pub use drift::{commit_overlay, diff_overlay, export_overlay, DriftReport}; -pub use engine::{BuildResult, Engine}; +pub use engine::{BuildOptions, BuildResult, Engine}; pub use lifecycle::validate_transition; use thiserror::Error; diff --git a/crates/karapace-schema/src/manifest.rs b/crates/karapace-schema/src/manifest.rs index 288bc89..bfc1ac8 100644 --- a/crates/karapace-schema/src/manifest.rs +++ b/crates/karapace-schema/src/manifest.rs @@ -14,6 +14,8 @@ pub enum ManifestError { UnsupportedVersion(u32), #[error("base.image must not be empty")] EmptyBaseImage, + #[error("base.image is not pinned: '{0}' (expected http(s)://...)")] + UnpinnedBaseImage(String), #[error("mount label must not be empty")] EmptyMountLabel, #[error("invalid mount declaration for '{label}': '{spec}', expected ':'")] From f1c6e55e09ada5b61e800f799bec06b7a9129d96 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 18:29:46 +0100 Subject: [PATCH 3/7] cli: add pin command Add a new 'pin' subcommand to rewrite base.image to an explicit URL. Extend build and rebuild with --locked, --offline, and --require-pinned-image, and wire flags into the core engine build options. --- crates/karapace-cli/src/commands/build.rs | 13 +++- crates/karapace-cli/src/commands/mod.rs | 1 + crates/karapace-cli/src/commands/pin.rs | 85 +++++++++++++++++++++ crates/karapace-cli/src/commands/rebuild.rs | 13 +++- crates/karapace-cli/src/main.rs | 68 ++++++++++++++++- 5 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 crates/karapace-cli/src/commands/pin.rs diff --git a/crates/karapace-cli/src/commands/build.rs b/crates/karapace-cli/src/commands/build.rs index 2f19bba..4c73f72 100644 --- a/crates/karapace-cli/src/commands/build.rs +++ b/crates/karapace-cli/src/commands/build.rs @@ -1,5 +1,5 @@ use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS}; -use karapace_core::{Engine, StoreLock}; +use karapace_core::{BuildOptions, Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -8,6 +8,9 @@ pub fn run( store_path: &Path, manifest: &Path, name: Option<&str>, + locked: bool, + offline: bool, + require_pinned_image: bool, json: bool, ) -> Result { let layout = StoreLayout::new(store_path); @@ -18,7 +21,13 @@ pub fn run( } else { Some(spinner("building environment...")) }; - let result = match engine.build(manifest) { + let options = BuildOptions { + locked, + offline, + require_pinned_image, + }; + + let result = match engine.build_with_options(manifest, options) { Ok(r) => { if let Some(ref pb) = pb { spin_ok(pb, "environment built"); diff --git a/crates/karapace-cli/src/commands/mod.rs b/crates/karapace-cli/src/commands/mod.rs index 239c20c..5eb5873 100644 --- a/crates/karapace-cli/src/commands/mod.rs +++ b/crates/karapace-cli/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod list; pub mod man_pages; pub mod migrate; pub mod new; +pub mod pin; pub mod pull; pub mod push; pub mod rebuild; diff --git a/crates/karapace-cli/src/commands/pin.rs b/crates/karapace-cli/src/commands/pin.rs new file mode 100644 index 0000000..9bd09d2 --- /dev/null +++ b/crates/karapace-cli/src/commands/pin.rs @@ -0,0 +1,85 @@ +use super::{json_pretty, EXIT_SUCCESS}; +use karapace_runtime::image::resolve_pinned_image_url; +use karapace_schema::manifest::{parse_manifest_file, ManifestV1}; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; + +fn write_atomic(dest: &Path, content: &str) -> Result<(), String> { + let dir = dest + .parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let mut tmp = NamedTempFile::new_in(&dir).map_err(|e| format!("write temp file: {e}"))?; + use std::io::Write; + tmp.write_all(content.as_bytes()) + .map_err(|e| format!("write temp file: {e}"))?; + tmp.as_file() + .sync_all() + .map_err(|e| format!("fsync temp file: {e}"))?; + tmp.persist(dest) + .map_err(|e| format!("persist manifest: {}", e.error))?; + Ok(()) +} + +fn is_pinned(image: &str) -> bool { + let s = image.trim(); + s.starts_with("http://") || s.starts_with("https://") +} + +pub fn run( + manifest_path: &Path, + check: bool, + write_lock: bool, + json: bool, + store_path: Option<&Path>, +) -> Result { + let manifest = parse_manifest_file(manifest_path) + .map_err(|e| format!("failed to parse manifest: {e}"))?; + + if check { + if is_pinned(&manifest.base.image) { + if json { + let payload = serde_json::json!({ + "status": "pinned", + "manifest": manifest_path, + }); + println!("{}", json_pretty(&payload)?); + } + return Ok(EXIT_SUCCESS); + } + return Err(format!( + "base.image is not pinned: '{}' (run 'karapace pin')", + manifest.base.image + )); + } + + let pinned = resolve_pinned_image_url(&manifest.base.image) + .map_err(|e| format!("failed to resolve pinned image URL: {e}"))?; + + let mut updated: ManifestV1 = manifest; + updated.base.image = pinned; + + let toml = + toml::to_string_pretty(&updated).map_err(|e| format!("TOML serialization failed: {e}"))?; + write_atomic(manifest_path, &toml)?; + + if write_lock { + let store = store_path.ok_or_else(|| "internal error: missing store path".to_owned())?; + let engine = karapace_core::Engine::new(store); + engine + .build(manifest_path) + .map_err(|e| e.to_string())?; + } + + if json { + let payload = serde_json::json!({ + "status": "pinned", + "manifest": manifest_path, + "base_image": updated.base.image, + }); + println!("{}", json_pretty(&payload)?); + } else { + println!("pinned base image in {}", manifest_path.display()); + } + + Ok(EXIT_SUCCESS) +} diff --git a/crates/karapace-cli/src/commands/rebuild.rs b/crates/karapace-cli/src/commands/rebuild.rs index 0f2ee92..60c948e 100644 --- a/crates/karapace-cli/src/commands/rebuild.rs +++ b/crates/karapace-cli/src/commands/rebuild.rs @@ -1,5 +1,5 @@ use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS}; -use karapace_core::{Engine, StoreLock}; +use karapace_core::{BuildOptions, Engine, StoreLock}; use karapace_store::StoreLayout; use std::path::Path; @@ -8,6 +8,9 @@ pub fn run( store_path: &Path, manifest: &Path, name: Option<&str>, + locked: bool, + offline: bool, + require_pinned_image: bool, json: bool, ) -> Result { let layout = StoreLayout::new(store_path); @@ -18,7 +21,13 @@ pub fn run( } else { Some(spinner("rebuilding environment...")) }; - let result = match engine.rebuild(manifest) { + let options = BuildOptions { + locked, + offline, + require_pinned_image, + }; + + let result = match engine.rebuild_with_options(manifest, options) { Ok(r) => { if let Some(ref pb) = pb { spin_ok(pb, "environment rebuilt"); diff --git a/crates/karapace-cli/src/main.rs b/crates/karapace-cli/src/main.rs index b1895d0..2f64d35 100644 --- a/crates/karapace-cli/src/main.rs +++ b/crates/karapace-cli/src/main.rs @@ -51,6 +51,15 @@ enum Commands { /// Human-readable name for the environment. #[arg(long)] name: Option, + /// Require an existing lock file and fail if resolved state would drift. + #[arg(long, default_value_t = false)] + locked: bool, + /// Forbid all network access (host downloads and container networking). + #[arg(long, default_value_t = false)] + offline: bool, + /// Require base.image to be a pinned http(s) URL. + #[arg(long, default_value_t = false)] + require_pinned_image: bool, }, /// Destroy and rebuild an environment from manifest. Rebuild { @@ -60,6 +69,28 @@ enum Commands { /// Human-readable name for the environment. #[arg(long)] name: Option, + /// Require an existing lock file and fail if resolved state would drift. + #[arg(long, default_value_t = false)] + locked: bool, + /// Forbid all network access (host downloads and container networking). + #[arg(long, default_value_t = false)] + offline: bool, + /// Require base.image to be a pinned http(s) URL. + #[arg(long, default_value_t = false)] + require_pinned_image: bool, + }, + + /// Rewrite a manifest to use an explicit pinned base image reference. + Pin { + /// Path to manifest TOML file. + #[arg(default_value = "karapace.toml")] + manifest: PathBuf, + /// Exit non-zero if the manifest is not already pinned. + #[arg(long, default_value_t = false)] + check: bool, + /// After pinning, write/update karapace.lock by running a build. + #[arg(long, default_value_t = false)] + write_lock: bool, }, /// Enter a built environment (use -- to pass a command instead of interactive shell). Enter { @@ -211,6 +242,10 @@ fn main() -> ExitCode { | Commands::Enter { .. } | Commands::Exec { .. } | Commands::Rebuild { .. } + | Commands::Pin { + write_lock: true, + .. + } | Commands::Tui ); if needs_runtime && std::env::var("KARAPACE_SKIP_PREREQS").as_deref() != Ok("1") { @@ -227,20 +262,49 @@ fn main() -> ExitCode { template, force, } => commands::new::run(&name, template.as_deref(), force, json_output), - Commands::Build { manifest, name } => commands::build::run( + Commands::Build { + manifest, + name, + locked, + offline, + require_pinned_image, + } => commands::build::run( &engine, &store_path, &manifest, name.as_deref(), + locked, + offline, + require_pinned_image, json_output, ), - Commands::Rebuild { manifest, name } => commands::rebuild::run( + Commands::Rebuild { + manifest, + name, + locked, + offline, + require_pinned_image, + } => commands::rebuild::run( &engine, &store_path, &manifest, name.as_deref(), + locked, + offline, + require_pinned_image, json_output, ), + Commands::Pin { + manifest, + check, + write_lock, + } => commands::pin::run( + &manifest, + check, + write_lock, + json_output, + Some(&store_path), + ), Commands::Enter { env_id, command } => { commands::enter::run(&engine, &store_path, &env_id, &command) } From d2bbe9b648c438379ba9a3c02873a67d5238398b Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 18:30:46 +0100 Subject: [PATCH 4/7] tests: cover pin and offline modes Add CLI integration coverage for: - 'pin --check' on pinned and unpinned base.image - 'build --offline' failing fast when system packages are requested --- crates/karapace-cli/tests/cli_integration.rs | 93 ++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/karapace-cli/tests/cli_integration.rs b/crates/karapace-cli/tests/cli_integration.rs index f782a06..abe5f80 100644 --- a/crates/karapace-cli/tests/cli_integration.rs +++ b/crates/karapace-cli/tests/cli_integration.rs @@ -12,6 +12,25 @@ fn karapace_bin() -> Command { cmd } +fn write_minimal_manifest(dir: &std::path::Path, base_image: &str) -> std::path::PathBuf { + let path = dir.join("karapace.toml"); + std::fs::write( + &path, + format!( + r#"manifest_version = 1 + +[base] +image = "{base_image}" + +[runtime] +backend = "mock" +"# + ), + ) + .unwrap(); + path +} + fn temp_store() -> tempfile::TempDir { tempfile::tempdir().unwrap() } @@ -85,6 +104,80 @@ fn cli_build_succeeds_with_mock() { ); } +#[test] +fn cli_pin_check_fails_when_unpinned() { + let store = temp_store(); + let project = tempfile::tempdir().unwrap(); + let manifest = write_minimal_manifest(project.path(), "rolling"); + + let output = karapace_bin() + .args([ + "--store", + &store.path().to_string_lossy(), + "pin", + &manifest.to_string_lossy(), + "--check", + ]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "pin --check must fail for unpinned base.image" + ); +} + +#[test] +fn cli_pin_check_succeeds_when_pinned_url() { + let store = temp_store(); + let project = tempfile::tempdir().unwrap(); + let manifest = write_minimal_manifest(project.path(), "https://example.invalid/rootfs.tar.xz"); + + let output = karapace_bin() + .args([ + "--store", + &store.path().to_string_lossy(), + "pin", + &manifest.to_string_lossy(), + "--check", + ]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "pin --check must exit 0 when already pinned" + ); +} + +#[test] +fn cli_build_offline_fails_fast_with_packages() { + let store = temp_store(); + let project = tempfile::tempdir().unwrap(); + let manifest = write_test_manifest(project.path()); + + let output = karapace_bin() + .args([ + "--store", + &store.path().to_string_lossy(), + "build", + &manifest.to_string_lossy(), + "--offline", + ]) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "build --offline with packages must fail fast" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("offline mode") && stderr.contains("system packages"), + "stderr must mention offline + system packages, got: {stderr}" + ); +} + // A5: CLI Validation — list with JSON output #[test] fn cli_list_json_output_stable() { From 52b42532ce3752af4180d5558a7c38ade877b62c Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 18:31:25 +0100 Subject: [PATCH 5/7] docs: document pin and build flags Update CLI reference for: - build/rebuild: --locked, --offline, --require-pinned-image - pin: --check and --write-lock --- docs/cli-reference.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 247b421..b46fe50 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -58,13 +58,16 @@ If `--template` is not provided, the command uses interactive prompts (requires Build an environment from a manifest. ``` -karapace build [manifest] [--name ] +karapace build [manifest] [--name ] [--locked] [--offline] [--require-pinned-image] ``` | Argument | Default | Description | |----------|---------|-------------| | `manifest` | `karapace.toml` | Path to manifest file | | `--name` | — | Assign a human-readable name | +| `--locked` | — | Require existing `karapace.lock` and fail on drift | +| `--offline` | — | Forbid network (host downloads and container networking) | +| `--require-pinned-image` | — | Fail if `base.image` is not an http(s) URL | Executes: parse → normalize → resolve → lock → build. Writes `karapace.lock` next to the manifest. Requires runtime prerequisites (user namespaces, fuse-overlayfs). @@ -73,11 +76,25 @@ Executes: parse → normalize → resolve → lock → build. Writes `karapace.l Destroy the existing environment and build a new one from the manifest. ``` -karapace rebuild [manifest] [--name ] +karapace rebuild [manifest] [--name ] [--locked] [--offline] [--require-pinned-image] ``` Same arguments as `build`. The old environment is destroyed only after the new one builds successfully. +### `pin` + +Rewrite a manifest to use an explicit pinned base image reference. + +``` +karapace pin [manifest] [--check] [--write-lock] +``` + +| Argument | Default | Description | +|----------|---------|-------------| +| `manifest` | `karapace.toml` | Path to manifest file | +| `--check` | — | Exit non-zero if `base.image` is not already pinned | +| `--write-lock` | — | After pinning, run a build to write/update `karapace.lock` | + ### `enter` Enter an environment interactively, or run a command. From eff53cf7afb478dab237b383f01ace37aa7d1b7c Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 18:50:23 +0100 Subject: [PATCH 6/7] cli: satisfy clippy and rustfmt Refactor build and rebuild command handlers to pass BuildOptions instead of multiple boolean flags, satisfying clippy's excessive-bools and too-many-arguments lints. Apply rustfmt output in CLI and core engine code. --- crates/karapace-cli/src/commands/build.rs | 10 +------- crates/karapace-cli/src/commands/pin.rs | 8 +++---- crates/karapace-cli/src/commands/rebuild.rs | 10 +------- crates/karapace-cli/src/main.rs | 26 ++++++++++----------- crates/karapace-core/src/engine.rs | 8 ++++--- 5 files changed, 22 insertions(+), 40 deletions(-) diff --git a/crates/karapace-cli/src/commands/build.rs b/crates/karapace-cli/src/commands/build.rs index 4c73f72..71f7f1f 100644 --- a/crates/karapace-cli/src/commands/build.rs +++ b/crates/karapace-cli/src/commands/build.rs @@ -8,9 +8,7 @@ pub fn run( store_path: &Path, manifest: &Path, name: Option<&str>, - locked: bool, - offline: bool, - require_pinned_image: bool, + options: BuildOptions, json: bool, ) -> Result { let layout = StoreLayout::new(store_path); @@ -21,12 +19,6 @@ pub fn run( } else { Some(spinner("building environment...")) }; - let options = BuildOptions { - locked, - offline, - require_pinned_image, - }; - let result = match engine.build_with_options(manifest, options) { Ok(r) => { if let Some(ref pb) = pb { diff --git a/crates/karapace-cli/src/commands/pin.rs b/crates/karapace-cli/src/commands/pin.rs index 9bd09d2..fd56df1 100644 --- a/crates/karapace-cli/src/commands/pin.rs +++ b/crates/karapace-cli/src/commands/pin.rs @@ -32,8 +32,8 @@ pub fn run( json: bool, store_path: Option<&Path>, ) -> Result { - let manifest = parse_manifest_file(manifest_path) - .map_err(|e| format!("failed to parse manifest: {e}"))?; + let manifest = + parse_manifest_file(manifest_path).map_err(|e| format!("failed to parse manifest: {e}"))?; if check { if is_pinned(&manifest.base.image) { @@ -65,9 +65,7 @@ pub fn run( if write_lock { let store = store_path.ok_or_else(|| "internal error: missing store path".to_owned())?; let engine = karapace_core::Engine::new(store); - engine - .build(manifest_path) - .map_err(|e| e.to_string())?; + engine.build(manifest_path).map_err(|e| e.to_string())?; } if json { diff --git a/crates/karapace-cli/src/commands/rebuild.rs b/crates/karapace-cli/src/commands/rebuild.rs index 60c948e..a1a6b95 100644 --- a/crates/karapace-cli/src/commands/rebuild.rs +++ b/crates/karapace-cli/src/commands/rebuild.rs @@ -8,9 +8,7 @@ pub fn run( store_path: &Path, manifest: &Path, name: Option<&str>, - locked: bool, - offline: bool, - require_pinned_image: bool, + options: BuildOptions, json: bool, ) -> Result { let layout = StoreLayout::new(store_path); @@ -21,12 +19,6 @@ pub fn run( } else { Some(spinner("rebuilding environment...")) }; - let options = BuildOptions { - locked, - offline, - require_pinned_image, - }; - let result = match engine.rebuild_with_options(manifest, options) { Ok(r) => { if let Some(ref pb) = pb { diff --git a/crates/karapace-cli/src/main.rs b/crates/karapace-cli/src/main.rs index 2f64d35..8138048 100644 --- a/crates/karapace-cli/src/main.rs +++ b/crates/karapace-cli/src/main.rs @@ -3,7 +3,7 @@ mod commands; use clap::{Parser, Subcommand}; use clap_complete::Shell; use commands::{EXIT_FAILURE, EXIT_MANIFEST_ERROR, EXIT_STORE_ERROR}; -use karapace_core::{install_signal_handler, Engine}; +use karapace_core::{install_signal_handler, BuildOptions, Engine}; use std::path::PathBuf; use std::process::ExitCode; @@ -273,9 +273,11 @@ fn main() -> ExitCode { &store_path, &manifest, name.as_deref(), - locked, - offline, - require_pinned_image, + BuildOptions { + locked, + offline, + require_pinned_image, + }, json_output, ), Commands::Rebuild { @@ -289,22 +291,18 @@ fn main() -> ExitCode { &store_path, &manifest, name.as_deref(), - locked, - offline, - require_pinned_image, + BuildOptions { + locked, + offline, + require_pinned_image, + }, json_output, ), Commands::Pin { manifest, check, write_lock, - } => commands::pin::run( - &manifest, - check, - write_lock, - json_output, - Some(&store_path), - ), + } => commands::pin::run(&manifest, check, write_lock, json_output, Some(&store_path)), Commands::Enter { env_id, command } => { commands::enter::run(&engine, &store_path, &env_id, &command) } diff --git a/crates/karapace-core/src/engine.rs b/crates/karapace-core/src/engine.rs index 01c331e..356883b 100644 --- a/crates/karapace-core/src/engine.rs +++ b/crates/karapace-core/src/engine.rs @@ -172,9 +172,11 @@ impl Engine { let normalized = manifest.normalize()?; if options.offline && !normalized.system_packages.is_empty() { - return Err(CoreError::Runtime(karapace_runtime::RuntimeError::ExecFailed( - "offline mode: cannot resolve system packages".to_owned(), - ))); + return Err(CoreError::Runtime( + karapace_runtime::RuntimeError::ExecFailed( + "offline mode: cannot resolve system packages".to_owned(), + ), + )); } if options.require_pinned_image From b73fd9eaec367af6ccd1b3a01cf7567e422a1571 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 19:07:11 +0100 Subject: [PATCH 7/7] schema: include manifest path in read errors Manifest read failures now include the attempted path, making missing ./karapace.toml errors actionable. --- crates/karapace-schema/src/manifest.rs | 33 ++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/karapace-schema/src/manifest.rs b/crates/karapace-schema/src/manifest.rs index bfc1ac8..96a9f08 100644 --- a/crates/karapace-schema/src/manifest.rs +++ b/crates/karapace-schema/src/manifest.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::{error::Error as StdError, fmt}; use thiserror::Error; #[derive(Debug, Error)] @@ -108,12 +109,40 @@ fn default_backend() -> String { "namespace".to_owned() } +#[derive(Debug)] +struct ManifestIoWithPath { + path: PathBuf, + source: std::io::Error, +} + +impl fmt::Display for ManifestIoWithPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.path.display(), self.source) + } +} + +impl StdError for ManifestIoWithPath { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.source) + } +} + pub fn parse_manifest_str(input: &str) -> Result { Ok(toml::from_str(input)?) } pub fn parse_manifest_file(path: impl AsRef) -> Result { - let content = fs::read_to_string(path)?; + let path = path.as_ref().to_path_buf(); + let content = fs::read_to_string(&path).map_err(|e| { + let kind = e.kind(); + ManifestError::Io(std::io::Error::new( + kind, + ManifestIoWithPath { + path: path.clone(), + source: e, + }, + )) + })?; parse_manifest_str(&content) }