From f1c6e55e09ada5b61e800f799bec06b7a9129d96 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Mon, 23 Feb 2026 18:29:46 +0100 Subject: [PATCH] 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) }