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 ':'")]