mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-26 21:43:09 +00:00
Merge pull request #1 from marcoallegretti/feat/pin-offline-locked
Feat/pin offline locked
This commit is contained in:
commit
9ed29b7b37
15 changed files with 421 additions and 27 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
|
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 karapace_store::StoreLayout;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ pub fn run(
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
manifest: &Path,
|
manifest: &Path,
|
||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
|
options: BuildOptions,
|
||||||
json: bool,
|
json: bool,
|
||||||
) -> Result<u8, String> {
|
) -> Result<u8, String> {
|
||||||
let layout = StoreLayout::new(store_path);
|
let layout = StoreLayout::new(store_path);
|
||||||
|
|
@ -18,7 +19,7 @@ pub fn run(
|
||||||
} else {
|
} else {
|
||||||
Some(spinner("building environment..."))
|
Some(spinner("building environment..."))
|
||||||
};
|
};
|
||||||
let result = match engine.build(manifest) {
|
let result = match engine.build_with_options(manifest, options) {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
if let Some(ref pb) = pb {
|
if let Some(ref pb) = pb {
|
||||||
spin_ok(pb, "environment built");
|
spin_ok(pb, "environment built");
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ pub mod list;
|
||||||
pub mod man_pages;
|
pub mod man_pages;
|
||||||
pub mod migrate;
|
pub mod migrate;
|
||||||
pub mod new;
|
pub mod new;
|
||||||
|
pub mod pin;
|
||||||
pub mod pull;
|
pub mod pull;
|
||||||
pub mod push;
|
pub mod push;
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
|
|
|
||||||
83
crates/karapace-cli/src/commands/pin.rs
Normal file
83
crates/karapace-cli/src/commands/pin.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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<u8, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
|
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 karapace_store::StoreLayout;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ pub fn run(
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
manifest: &Path,
|
manifest: &Path,
|
||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
|
options: BuildOptions,
|
||||||
json: bool,
|
json: bool,
|
||||||
) -> Result<u8, String> {
|
) -> Result<u8, String> {
|
||||||
let layout = StoreLayout::new(store_path);
|
let layout = StoreLayout::new(store_path);
|
||||||
|
|
@ -18,7 +19,7 @@ pub fn run(
|
||||||
} else {
|
} else {
|
||||||
Some(spinner("rebuilding environment..."))
|
Some(spinner("rebuilding environment..."))
|
||||||
};
|
};
|
||||||
let result = match engine.rebuild(manifest) {
|
let result = match engine.rebuild_with_options(manifest, options) {
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
if let Some(ref pb) = pb {
|
if let Some(ref pb) = pb {
|
||||||
spin_ok(pb, "environment rebuilt");
|
spin_ok(pb, "environment rebuilt");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ mod commands;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use clap_complete::Shell;
|
use clap_complete::Shell;
|
||||||
use commands::{EXIT_FAILURE, EXIT_MANIFEST_ERROR, EXIT_STORE_ERROR};
|
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::path::PathBuf;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
|
@ -51,6 +51,15 @@ enum Commands {
|
||||||
/// Human-readable name for the environment.
|
/// Human-readable name for the environment.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
/// 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.
|
/// Destroy and rebuild an environment from manifest.
|
||||||
Rebuild {
|
Rebuild {
|
||||||
|
|
@ -60,6 +69,28 @@ enum Commands {
|
||||||
/// Human-readable name for the environment.
|
/// Human-readable name for the environment.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
/// 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 a built environment (use -- to pass a command instead of interactive shell).
|
||||||
Enter {
|
Enter {
|
||||||
|
|
@ -211,6 +242,10 @@ fn main() -> ExitCode {
|
||||||
| Commands::Enter { .. }
|
| Commands::Enter { .. }
|
||||||
| Commands::Exec { .. }
|
| Commands::Exec { .. }
|
||||||
| Commands::Rebuild { .. }
|
| Commands::Rebuild { .. }
|
||||||
|
| Commands::Pin {
|
||||||
|
write_lock: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
| Commands::Tui
|
| Commands::Tui
|
||||||
);
|
);
|
||||||
if needs_runtime && std::env::var("KARAPACE_SKIP_PREREQS").as_deref() != Ok("1") {
|
if needs_runtime && std::env::var("KARAPACE_SKIP_PREREQS").as_deref() != Ok("1") {
|
||||||
|
|
@ -227,20 +262,47 @@ fn main() -> ExitCode {
|
||||||
template,
|
template,
|
||||||
force,
|
force,
|
||||||
} => commands::new::run(&name, template.as_deref(), force, json_output),
|
} => 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,
|
&engine,
|
||||||
&store_path,
|
&store_path,
|
||||||
&manifest,
|
&manifest,
|
||||||
name.as_deref(),
|
name.as_deref(),
|
||||||
|
BuildOptions {
|
||||||
|
locked,
|
||||||
|
offline,
|
||||||
|
require_pinned_image,
|
||||||
|
},
|
||||||
json_output,
|
json_output,
|
||||||
),
|
),
|
||||||
Commands::Rebuild { manifest, name } => commands::rebuild::run(
|
Commands::Rebuild {
|
||||||
|
manifest,
|
||||||
|
name,
|
||||||
|
locked,
|
||||||
|
offline,
|
||||||
|
require_pinned_image,
|
||||||
|
} => commands::rebuild::run(
|
||||||
&engine,
|
&engine,
|
||||||
&store_path,
|
&store_path,
|
||||||
&manifest,
|
&manifest,
|
||||||
name.as_deref(),
|
name.as_deref(),
|
||||||
|
BuildOptions {
|
||||||
|
locked,
|
||||||
|
offline,
|
||||||
|
require_pinned_image,
|
||||||
|
},
|
||||||
json_output,
|
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 { env_id, command } => {
|
||||||
commands::enter::run(&engine, &store_path, &env_id, &command)
|
commands::enter::run(&engine, &store_path, &env_id, &command)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,25 @@ fn karapace_bin() -> Command {
|
||||||
cmd
|
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 {
|
fn temp_store() -> tempfile::TempDir {
|
||||||
tempfile::tempdir().unwrap()
|
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
|
// A5: CLI Validation — list with JSON output
|
||||||
#[test]
|
#[test]
|
||||||
fn cli_list_json_output_stable() {
|
fn cli_list_json_output_stable() {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,13 @@ pub struct BuildResult {
|
||||||
pub lock_file: LockFile,
|
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 {
|
impl Engine {
|
||||||
/// Create a new engine rooted at the given store directory.
|
/// Create a new engine rooted at the given store directory.
|
||||||
///
|
///
|
||||||
|
|
@ -148,14 +155,53 @@ impl Engine {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub fn build(&self, manifest_path: &Path) -> Result<BuildResult, CoreError> {
|
pub fn build(&self, manifest_path: &Path) -> Result<BuildResult, CoreError> {
|
||||||
|
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<BuildResult, CoreError> {
|
||||||
info!("building environment from {}", manifest_path.display());
|
info!("building environment from {}", manifest_path.display());
|
||||||
self.layout.initialize()?;
|
self.layout.initialize()?;
|
||||||
|
|
||||||
let manifest = parse_manifest_file(manifest_path)?;
|
let manifest = parse_manifest_file(manifest_path)?;
|
||||||
let normalized = manifest.normalize()?;
|
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);
|
let policy = SecurityPolicy::from_manifest(&normalized);
|
||||||
policy.validate_mounts(&normalized)?;
|
policy.validate_mounts(&normalized)?;
|
||||||
policy.validate_devices(&normalized)?;
|
policy.validate_devices(&normalized)?;
|
||||||
|
|
@ -182,6 +228,7 @@ impl Engine {
|
||||||
.to_string(),
|
.to_string(),
|
||||||
store_root: store_str.clone(),
|
store_root: store_str.clone(),
|
||||||
manifest: normalized.clone(),
|
manifest: normalized.clone(),
|
||||||
|
offline: options.offline,
|
||||||
};
|
};
|
||||||
let resolution = backend.resolve(&preliminary_spec)?;
|
let resolution = backend.resolve(&preliminary_spec)?;
|
||||||
debug!(
|
debug!(
|
||||||
|
|
@ -196,6 +243,17 @@ impl Engine {
|
||||||
let lock = LockFile::from_resolved(&normalized, &resolution);
|
let lock = LockFile::from_resolved(&normalized, &resolution);
|
||||||
let identity = lock.compute_identity();
|
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!(
|
info!(
|
||||||
"canonical env_id: {} ({})",
|
"canonical env_id: {} ({})",
|
||||||
identity.env_id, identity.short_id
|
identity.env_id, identity.short_id
|
||||||
|
|
@ -223,6 +281,7 @@ impl Engine {
|
||||||
overlay_path: env_dir.to_string_lossy().to_string(),
|
overlay_path: env_dir.to_string_lossy().to_string(),
|
||||||
store_root: store_str,
|
store_root: store_str,
|
||||||
manifest: normalized.clone(),
|
manifest: normalized.clone(),
|
||||||
|
offline: options.offline,
|
||||||
};
|
};
|
||||||
if let Err(e) = backend.build(&spec) {
|
if let Err(e) = backend.build(&spec) {
|
||||||
let _ = std::fs::remove_dir_all(&env_dir);
|
let _ = std::fs::remove_dir_all(&env_dir);
|
||||||
|
|
@ -285,11 +344,9 @@ impl Engine {
|
||||||
}
|
}
|
||||||
self.meta_store.put(&meta)?;
|
self.meta_store.put(&meta)?;
|
||||||
|
|
||||||
let lock_path = manifest_path
|
if !options.locked {
|
||||||
.parent()
|
lock.write_to_file(&lock_path)?;
|
||||||
.unwrap_or(Path::new("."))
|
}
|
||||||
.join("karapace.lock");
|
|
||||||
lock.write_to_file(&lock_path)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -322,6 +379,7 @@ impl Engine {
|
||||||
overlay_path: env_path_str,
|
overlay_path: env_path_str,
|
||||||
store_root: self.store_root_str.clone(),
|
store_root: self.store_root_str.clone(),
|
||||||
manifest,
|
manifest,
|
||||||
|
offline: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -545,6 +603,14 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rebuild(&self, manifest_path: &Path) -> Result<BuildResult, CoreError> {
|
pub fn rebuild(&self, manifest_path: &Path) -> Result<BuildResult, CoreError> {
|
||||||
|
self.rebuild_with_options(manifest_path, BuildOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rebuild_with_options(
|
||||||
|
&self,
|
||||||
|
manifest_path: &Path,
|
||||||
|
options: BuildOptions,
|
||||||
|
) -> Result<BuildResult, CoreError> {
|
||||||
// Collect the old env_id(s) to clean up AFTER a successful build.
|
// 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.
|
// This ensures we don't lose the old environment if the new build fails.
|
||||||
let lock_path = manifest_path
|
let lock_path = manifest_path
|
||||||
|
|
@ -568,7 +634,7 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build first — if this fails, old environment is preserved.
|
// 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.
|
// Only destroy the old environment(s) after the new build succeeds.
|
||||||
for old_id in &old_env_ids {
|
for old_id in &old_env_ids {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pub mod lifecycle;
|
||||||
|
|
||||||
pub use concurrency::{install_signal_handler, shutdown_requested, StoreLock};
|
pub use concurrency::{install_signal_handler, shutdown_requested, StoreLock};
|
||||||
pub use drift::{commit_overlay, diff_overlay, export_overlay, DriftReport};
|
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;
|
pub use lifecycle::validate_transition;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ pub struct RuntimeSpec {
|
||||||
pub overlay_path: String,
|
pub overlay_path: String,
|
||||||
pub store_root: String,
|
pub store_root: String,
|
||||||
pub manifest: NormalizedManifest,
|
pub manifest: NormalizedManifest,
|
||||||
|
#[serde(default)]
|
||||||
|
pub offline: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ pub struct ResolvedImage {
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_pinned_image_url(name: &str) -> Result<String, RuntimeError> {
|
||||||
|
let resolved = resolve_image(name)?;
|
||||||
|
download_url(&resolved.source)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn resolve_image(name: &str) -> Result<ResolvedImage, RuntimeError> {
|
pub fn resolve_image(name: &str) -> Result<ResolvedImage, RuntimeError> {
|
||||||
let name = name.trim().to_lowercase();
|
let name = name.trim().to_lowercase();
|
||||||
|
|
@ -248,6 +253,7 @@ impl ImageCache {
|
||||||
&self,
|
&self,
|
||||||
resolved: &ResolvedImage,
|
resolved: &ResolvedImage,
|
||||||
progress: &dyn Fn(&str),
|
progress: &dyn Fn(&str),
|
||||||
|
offline: bool,
|
||||||
) -> Result<PathBuf, RuntimeError> {
|
) -> Result<PathBuf, RuntimeError> {
|
||||||
let rootfs = self.rootfs_path(&resolved.cache_key);
|
let rootfs = self.rootfs_path(&resolved.cache_key);
|
||||||
if self.is_cached(&resolved.cache_key) {
|
if self.is_cached(&resolved.cache_key) {
|
||||||
|
|
@ -255,6 +261,13 @@ impl ImageCache {
|
||||||
return Ok(rootfs);
|
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)?;
|
std::fs::create_dir_all(&rootfs)?;
|
||||||
|
|
||||||
progress(&format!(
|
progress(&format!(
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,7 @@ image = "rolling"
|
||||||
overlay_path: dir.join("overlay").to_string_lossy().to_string(),
|
overlay_path: dir.join("overlay").to_string_lossy().to_string(),
|
||||||
store_root: dir.to_string_lossy().to_string(),
|
store_root: dir.to_string_lossy().to_string(),
|
||||||
manifest,
|
manifest,
|
||||||
|
offline: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,6 +218,7 @@ backend = "mock"
|
||||||
overlay_path: dir.path().join("overlay").to_string_lossy().to_string(),
|
overlay_path: dir.path().join("overlay").to_string_lossy().to_string(),
|
||||||
store_root: dir.path().to_string_lossy().to_string(),
|
store_root: dir.path().to_string_lossy().to_string(),
|
||||||
manifest,
|
manifest,
|
||||||
|
offline: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let backend = MockBackend::new();
|
let backend = MockBackend::new();
|
||||||
|
|
|
||||||
|
|
@ -62,11 +62,17 @@ impl RuntimeBackend for NamespaceBackend {
|
||||||
// Download/cache the base image
|
// Download/cache the base image
|
||||||
let resolved = resolve_image(&spec.manifest.base_image)?;
|
let resolved = resolve_image(&spec.manifest.base_image)?;
|
||||||
let image_cache = ImageCache::new(&self.store_root);
|
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
|
// Compute content digest of the base image
|
||||||
let base_image_digest = compute_image_digest(&rootfs)?;
|
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
|
// If there are packages to resolve, set up a temporary overlay
|
||||||
// and install+query to get exact versions
|
// and install+query to get exact versions
|
||||||
let resolved_packages = if spec.manifest.system_packages.is_empty() {
|
let resolved_packages = if spec.manifest.system_packages.is_empty() {
|
||||||
|
|
@ -145,11 +151,11 @@ impl RuntimeBackend for NamespaceBackend {
|
||||||
// Resolve and download the base image
|
// Resolve and download the base image
|
||||||
let resolved = resolve_image(&spec.manifest.base_image)?;
|
let resolved = resolve_image(&spec.manifest.base_image)?;
|
||||||
let image_cache = ImageCache::new(&self.store_root);
|
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
|
// Set up overlay filesystem
|
||||||
let mut sandbox = SandboxConfig::new(rootfs.clone(), &spec.env_id, &env_dir);
|
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)?;
|
mount_overlay(&sandbox)?;
|
||||||
|
|
||||||
|
|
@ -158,6 +164,11 @@ impl RuntimeBackend for NamespaceBackend {
|
||||||
|
|
||||||
// Install system packages if any
|
// Install system packages if any
|
||||||
if !spec.manifest.system_packages.is_empty() {
|
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)
|
let pkg_mgr = detect_package_manager(&sandbox.overlay_merged)
|
||||||
.or_else(|| detect_package_manager(&rootfs))
|
.or_else(|| detect_package_manager(&rootfs))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
|
|
@ -216,7 +227,7 @@ impl RuntimeBackend for NamespaceBackend {
|
||||||
|
|
||||||
// Create sandbox config
|
// Create sandbox config
|
||||||
let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir);
|
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())]);
|
sandbox.hostname = format!("karapace-{}", &spec.env_id[..12.min(spec.env_id.len())]);
|
||||||
|
|
||||||
// Compute host integration (Wayland, PipeWire, GPU, etc.)
|
// Compute host integration (Wayland, PipeWire, GPU, etc.)
|
||||||
|
|
|
||||||
|
|
@ -173,9 +173,15 @@ impl RuntimeBackend for OciBackend {
|
||||||
|
|
||||||
let resolved = resolve_image(&spec.manifest.base_image)?;
|
let resolved = resolve_image(&spec.manifest.base_image)?;
|
||||||
let image_cache = ImageCache::new(&self.store_root);
|
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)?;
|
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() {
|
let resolved_packages = if spec.manifest.system_packages.is_empty() {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -250,15 +256,20 @@ impl RuntimeBackend for OciBackend {
|
||||||
|
|
||||||
let resolved = resolve_image(&spec.manifest.base_image)?;
|
let resolved = resolve_image(&spec.manifest.base_image)?;
|
||||||
let image_cache = ImageCache::new(&self.store_root);
|
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);
|
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)?;
|
mount_overlay(&sandbox)?;
|
||||||
setup_container_rootfs(&sandbox)?;
|
setup_container_rootfs(&sandbox)?;
|
||||||
|
|
||||||
if !spec.manifest.system_packages.is_empty() {
|
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)
|
let pkg_mgr = detect_package_manager(&sandbox.overlay_merged)
|
||||||
.or_else(|| detect_package_manager(&rootfs))
|
.or_else(|| detect_package_manager(&rootfs))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
|
|
@ -319,7 +330,7 @@ impl RuntimeBackend for OciBackend {
|
||||||
let rootfs = image_cache.rootfs_path(&resolved.cache_key);
|
let rootfs = image_cache.rootfs_path(&resolved.cache_key);
|
||||||
|
|
||||||
let mut sandbox = SandboxConfig::new(rootfs, &spec.env_id, &env_dir);
|
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);
|
let host = compute_host_integration(&spec.manifest);
|
||||||
sandbox.bind_mounts.extend(host.bind_mounts);
|
sandbox.bind_mounts.extend(host.bind_mounts);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::{error::Error as StdError, fmt};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
|
|
@ -14,6 +15,8 @@ pub enum ManifestError {
|
||||||
UnsupportedVersion(u32),
|
UnsupportedVersion(u32),
|
||||||
#[error("base.image must not be empty")]
|
#[error("base.image must not be empty")]
|
||||||
EmptyBaseImage,
|
EmptyBaseImage,
|
||||||
|
#[error("base.image is not pinned: '{0}' (expected http(s)://...)")]
|
||||||
|
UnpinnedBaseImage(String),
|
||||||
#[error("mount label must not be empty")]
|
#[error("mount label must not be empty")]
|
||||||
EmptyMountLabel,
|
EmptyMountLabel,
|
||||||
#[error("invalid mount declaration for '{label}': '{spec}', expected '<host>:<container>'")]
|
#[error("invalid mount declaration for '{label}': '{spec}', expected '<host>:<container>'")]
|
||||||
|
|
@ -106,12 +109,40 @@ fn default_backend() -> String {
|
||||||
"namespace".to_owned()
|
"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<ManifestV1, ManifestError> {
|
pub fn parse_manifest_str(input: &str) -> Result<ManifestV1, ManifestError> {
|
||||||
Ok(toml::from_str(input)?)
|
Ok(toml::from_str(input)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_manifest_file(path: impl AsRef<Path>) -> Result<ManifestV1, ManifestError> {
|
pub fn parse_manifest_file(path: impl AsRef<Path>) -> Result<ManifestV1, ManifestError> {
|
||||||
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)
|
parse_manifest_str(&content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,16 @@ If `--template` is not provided, the command uses interactive prompts (requires
|
||||||
Build an environment from a manifest.
|
Build an environment from a manifest.
|
||||||
|
|
||||||
```
|
```
|
||||||
karapace build [manifest] [--name <name>]
|
karapace build [manifest] [--name <name>] [--locked] [--offline] [--require-pinned-image]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Argument | Default | Description |
|
| Argument | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `manifest` | `karapace.toml` | Path to manifest file |
|
| `manifest` | `karapace.toml` | Path to manifest file |
|
||||||
| `--name` | — | Assign a human-readable name |
|
| `--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).
|
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.
|
Destroy the existing environment and build a new one from the manifest.
|
||||||
|
|
||||||
```
|
```
|
||||||
karapace rebuild [manifest] [--name <name>]
|
karapace rebuild [manifest] [--name <name>] [--locked] [--offline] [--require-pinned-image]
|
||||||
```
|
```
|
||||||
|
|
||||||
Same arguments as `build`. The old environment is destroyed only after the new one builds successfully.
|
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`
|
||||||
|
|
||||||
Enter an environment interactively, or run a command.
|
Enter an environment interactively, or run a command.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue