mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-26 21:43:09 +00:00
feat: karapace-cli — 23 commands, thin dispatcher, progress indicators
- 23 commands, each in its own module under commands/ - Thin main.rs dispatcher with clap subcommand routing - Progress spinners (indicatif) and colored state output (console) - Environment resolution by env_id, short_id, name, or prefix - Structured JSON output (--json) on all query commands - --verbose/-v for debug, --trace for trace-level logging - KARAPACE_LOG env var for fine-grained log control - Exit codes: 0 success, 1 failure, 2 manifest error, 3 store error - Prerequisite check before runtime operations - Shell completions (bash/zsh/fish/elvish/powershell) and man page generation
This commit is contained in:
parent
4a90300807
commit
1416b0fc99
28 changed files with 6403 additions and 0 deletions
37
crates/karapace-cli/Cargo.toml
Normal file
37
crates/karapace-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
[package]
|
||||||
|
name = "karapace-cli"
|
||||||
|
description = "CLI interface for Karapace deterministic environment engine"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "karapace"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap.workspace = true
|
||||||
|
clap_complete.workspace = true
|
||||||
|
clap_mangen.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tempfile.workspace = true
|
||||||
|
indicatif.workspace = true
|
||||||
|
console.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
|
karapace-schema = { path = "../karapace-schema" }
|
||||||
|
karapace-core = { path = "../karapace-core" }
|
||||||
|
karapace-store = { path = "../karapace-store" }
|
||||||
|
karapace-runtime = { path = "../karapace-runtime" }
|
||||||
|
karapace-tui = { path = "../karapace-tui" }
|
||||||
|
karapace-remote = { path = "../karapace-remote" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
4614
crates/karapace-cli/karapace-cli.cdx.json
Normal file
4614
crates/karapace-cli/karapace-cli.cdx.json
Normal file
File diff suppressed because it is too large
Load diff
14
crates/karapace-cli/src/commands/archive.rs
Normal file
14
crates/karapace-cli/src/commands/archive.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use super::{resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
engine.archive(&resolved).map_err(|e| e.to_string())?;
|
||||||
|
println!("archived environment {env_id}");
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
57
crates/karapace-cli/src/commands/build.rs
Normal file
57
crates/karapace-cli/src/commands/build.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: &Engine,
|
||||||
|
store_path: &Path,
|
||||||
|
manifest: &Path,
|
||||||
|
name: Option<&str>,
|
||||||
|
json: bool,
|
||||||
|
) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let pb = if json {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(spinner("building environment..."))
|
||||||
|
};
|
||||||
|
let result = match engine.build(manifest) {
|
||||||
|
Ok(r) => {
|
||||||
|
if let Some(ref pb) = pb {
|
||||||
|
spin_ok(pb, "environment built");
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(ref pb) = pb {
|
||||||
|
spin_fail(pb, "build failed");
|
||||||
|
}
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(n) = name {
|
||||||
|
engine
|
||||||
|
.set_name(&result.identity.env_id, Some(n.to_owned()))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"env_id": result.identity.env_id,
|
||||||
|
"short_id": result.identity.short_id,
|
||||||
|
"name": name,
|
||||||
|
"status": "built"
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
if let Some(n) = name {
|
||||||
|
println!("built environment '{}' ({})", n, result.identity.short_id);
|
||||||
|
} else {
|
||||||
|
println!("built environment {}", result.identity.short_id);
|
||||||
|
}
|
||||||
|
println!("env_id: {}", result.identity.env_id);
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
22
crates/karapace-cli/src/commands/commit.rs
Normal file
22
crates/karapace-cli/src/commands/commit.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, env_id: &str, json: bool) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
let tar_hash = engine.commit(&resolved).map_err(|e| e.to_string())?;
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"env_id": resolved,
|
||||||
|
"snapshot_hash": tar_hash,
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
println!("committed snapshot {tar_hash} for {env_id}");
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
9
crates/karapace-cli/src/commands/completions.rs
Normal file
9
crates/karapace-cli/src/commands/completions.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
use super::EXIT_SUCCESS;
|
||||||
|
use clap::CommandFactory;
|
||||||
|
use clap_complete::Shell;
|
||||||
|
|
||||||
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
|
pub fn run<C: CommandFactory>(shell: Shell) -> Result<u8, String> {
|
||||||
|
clap_complete::generate(shell, &mut C::command(), "karapace", &mut std::io::stdout());
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
14
crates/karapace-cli/src/commands/destroy.rs
Normal file
14
crates/karapace-cli/src/commands/destroy.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use super::{resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
engine.destroy(&resolved).map_err(|e| e.to_string())?;
|
||||||
|
println!("destroyed environment {env_id}");
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
26
crates/karapace-cli/src/commands/diff.rs
Normal file
26
crates/karapace-cli/src/commands/diff.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, env_id: &str, json: bool) -> Result<u8, String> {
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
let report =
|
||||||
|
karapace_core::diff_overlay(engine.store_layout(), &resolved).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
println!("{}", json_pretty(&report)?);
|
||||||
|
} else if report.has_drift {
|
||||||
|
println!("drift detected in environment {env_id}:");
|
||||||
|
for f in &report.added {
|
||||||
|
println!(" + {f}");
|
||||||
|
}
|
||||||
|
for f in &report.modified {
|
||||||
|
println!(" ~ {f}");
|
||||||
|
}
|
||||||
|
for f in &report.removed {
|
||||||
|
println!(" - {f}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("no drift detected in environment {env_id}");
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
255
crates/karapace-cli/src/commands/doctor.rs
Normal file
255
crates/karapace-cli/src/commands/doctor.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
use super::{EXIT_FAILURE, EXIT_SUCCESS};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(store_path: &Path, json_output: bool) -> Result<u8, String> {
|
||||||
|
let mut checks: Vec<Check> = Vec::new();
|
||||||
|
let mut all_pass = true;
|
||||||
|
|
||||||
|
check_prereqs(&mut checks, &mut all_pass);
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
if store_path.join("store").exists() {
|
||||||
|
checks.push(Check::pass("store_exists", "Store directory exists"));
|
||||||
|
check_store(&layout, &mut checks, &mut all_pass);
|
||||||
|
check_disk_space(store_path, &mut checks);
|
||||||
|
} else {
|
||||||
|
checks.push(Check::info(
|
||||||
|
"store_exists",
|
||||||
|
"Store not initialized (will be created on first build)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
print_results(&checks, all_pass, json_output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_prereqs(checks: &mut Vec<Check>, all_pass: &mut bool) {
|
||||||
|
let missing = karapace_runtime::check_namespace_prereqs();
|
||||||
|
if missing.is_empty() {
|
||||||
|
checks.push(Check::pass(
|
||||||
|
"runtime_prereqs",
|
||||||
|
"Runtime prerequisites satisfied",
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
*all_pass = false;
|
||||||
|
checks.push(Check::fail(
|
||||||
|
"runtime_prereqs",
|
||||||
|
&format!(
|
||||||
|
"Missing prerequisites: {}",
|
||||||
|
karapace_runtime::format_missing(&missing)
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_store(layout: &StoreLayout, checks: &mut Vec<Check>, all_pass: &mut bool) {
|
||||||
|
// Version
|
||||||
|
match layout.initialize() {
|
||||||
|
Ok(()) => checks.push(Check::pass("store_version", "Store format version valid")),
|
||||||
|
Err(e) => {
|
||||||
|
*all_pass = false;
|
||||||
|
checks.push(Check::fail(
|
||||||
|
"store_version",
|
||||||
|
&format!("Store version check failed: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrity
|
||||||
|
match karapace_store::verify_store_integrity(layout) {
|
||||||
|
Ok(report) if report.failed.is_empty() => {
|
||||||
|
checks.push(Check::pass(
|
||||||
|
"store_integrity",
|
||||||
|
&format!("Store integrity OK ({} objects checked)", report.checked),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(report) => {
|
||||||
|
*all_pass = false;
|
||||||
|
checks.push(Check::fail(
|
||||||
|
"store_integrity",
|
||||||
|
&format!(
|
||||||
|
"{} of {} objects corrupted",
|
||||||
|
report.failed.len(),
|
||||||
|
report.checked
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
*all_pass = false;
|
||||||
|
checks.push(Check::fail(
|
||||||
|
"store_integrity",
|
||||||
|
&format!("Integrity check failed: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAL
|
||||||
|
let wal = karapace_store::WriteAheadLog::new(layout);
|
||||||
|
match wal.list_incomplete() {
|
||||||
|
Ok(entries) if entries.is_empty() => {
|
||||||
|
checks.push(Check::pass(
|
||||||
|
"wal_clean",
|
||||||
|
"WAL is clean (no incomplete entries)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(entries) => {
|
||||||
|
checks.push(Check::warn(
|
||||||
|
"wal_clean",
|
||||||
|
&format!(
|
||||||
|
"WAL has {} incomplete entries (will recover on next start)",
|
||||||
|
entries.len()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => checks.push(Check::warn("wal_clean", &format!("Cannot read WAL: {e}"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock
|
||||||
|
match karapace_core::StoreLock::try_acquire(&layout.lock_file()) {
|
||||||
|
Ok(Some(_)) => checks.push(Check::pass("store_lock", "Store lock is free")),
|
||||||
|
Ok(None) => checks.push(Check::warn(
|
||||||
|
"store_lock",
|
||||||
|
"Store lock is held by another process",
|
||||||
|
)),
|
||||||
|
Err(e) => {
|
||||||
|
*all_pass = false;
|
||||||
|
checks.push(Check::fail(
|
||||||
|
"store_lock",
|
||||||
|
&format!("Cannot check store lock: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environments
|
||||||
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
||||||
|
match meta_store.list() {
|
||||||
|
Ok(envs) => {
|
||||||
|
let running = envs
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.state == karapace_store::EnvState::Running)
|
||||||
|
.count();
|
||||||
|
checks.push(Check::info(
|
||||||
|
"environments",
|
||||||
|
&format!("{} environments ({running} running)", envs.len()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => checks.push(Check::warn(
|
||||||
|
"environments",
|
||||||
|
&format!("Cannot list environments: {e}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_results(checks: &[Check], all_pass: bool, json_output: bool) -> Result<u8, String> {
|
||||||
|
if json_output {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"healthy": all_pass,
|
||||||
|
"checks": checks.iter().map(|c| serde_json::json!({
|
||||||
|
"name": c.name,
|
||||||
|
"status": c.status,
|
||||||
|
"message": c.message,
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&json).map_err(|e| e.to_string())?
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Karapace Doctor\n");
|
||||||
|
for check in checks {
|
||||||
|
let icon = match check.status.as_str() {
|
||||||
|
"pass" => "✓",
|
||||||
|
"fail" => "✗",
|
||||||
|
"warn" => "⚠",
|
||||||
|
_ => "ℹ",
|
||||||
|
};
|
||||||
|
println!(" {icon} {}", check.message);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
if all_pass {
|
||||||
|
println!("All checks passed.");
|
||||||
|
} else {
|
||||||
|
println!("Some checks failed. See above for details.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(if all_pass { EXIT_SUCCESS } else { EXIT_FAILURE })
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Check {
|
||||||
|
name: String,
|
||||||
|
status: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Check {
|
||||||
|
fn pass(name: &str, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_owned(),
|
||||||
|
status: "pass".to_owned(),
|
||||||
|
message: message.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(name: &str, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_owned(),
|
||||||
|
status: "fail".to_owned(),
|
||||||
|
message: message.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn(name: &str, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_owned(),
|
||||||
|
status: "warn".to_owned(),
|
||||||
|
message: message.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(name: &str, message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_owned(),
|
||||||
|
status: "info".to_owned(),
|
||||||
|
message: message.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_disk_space(store_path: &Path, checks: &mut Vec<Check>) {
|
||||||
|
let Ok(c_path) = std::ffi::CString::new(store_path.to_string_lossy().as_bytes()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// SAFETY: zeroed statvfs is a valid initial state for the struct.
|
||||||
|
#[allow(unsafe_code, clippy::undocumented_unsafe_blocks)]
|
||||||
|
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||||
|
// SAFETY: statvfs with a valid, NUL-terminated path and a properly
|
||||||
|
// zeroed output struct is well-defined. The struct is stack-allocated
|
||||||
|
// and only read after the call succeeds (ret == 0).
|
||||||
|
#[allow(unsafe_code, clippy::undocumented_unsafe_blocks)]
|
||||||
|
let ret = unsafe { libc::statvfs(c_path.as_ptr(), &raw mut stat) };
|
||||||
|
if ret != 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let avail_bytes = stat.f_bavail * stat.f_frsize;
|
||||||
|
let avail_mb = avail_bytes / (1024 * 1024);
|
||||||
|
|
||||||
|
if avail_mb < 100 {
|
||||||
|
checks.push(Check::fail(
|
||||||
|
"disk_space",
|
||||||
|
&format!("Low disk space: {avail_mb} MB available"),
|
||||||
|
));
|
||||||
|
} else if avail_mb < 1024 {
|
||||||
|
checks.push(Check::warn(
|
||||||
|
"disk_space",
|
||||||
|
&format!("Disk space: {avail_mb} MB available (consider freeing space)"),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
let free_gb = avail_mb / 1024;
|
||||||
|
checks.push(Check::pass(
|
||||||
|
"disk_space",
|
||||||
|
&format!("Disk space: {free_gb} GB available"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
crates/karapace-cli/src/commands/enter.rs
Normal file
22
crates/karapace-cli/src/commands/enter.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use super::{resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: &Engine,
|
||||||
|
store_path: &Path,
|
||||||
|
env_id: &str,
|
||||||
|
command: &[String],
|
||||||
|
) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
if command.is_empty() {
|
||||||
|
engine.enter(&resolved).map_err(|e| e.to_string())?;
|
||||||
|
} else {
|
||||||
|
engine.exec(&resolved, command).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
19
crates/karapace-cli/src/commands/exec.rs
Normal file
19
crates/karapace-cli/src/commands/exec.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use super::{resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: &Engine,
|
||||||
|
store_path: &Path,
|
||||||
|
env_id: &str,
|
||||||
|
command: &[String],
|
||||||
|
_json: bool,
|
||||||
|
) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
engine.exec(&resolved, command).map_err(|e| e.to_string())?;
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
14
crates/karapace-cli/src/commands/freeze.rs
Normal file
14
crates/karapace-cli/src/commands/freeze.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use super::{resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
engine.freeze(&resolved).map_err(|e| e.to_string())?;
|
||||||
|
println!("frozen environment {env_id}");
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
33
crates/karapace-cli/src/commands/gc.rs
Normal file
33
crates/karapace-cli/src/commands/gc.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
use super::{json_pretty, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, dry_run: bool, json: bool) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let report = engine.gc(&lock, dry_run).map_err(|e| e.to_string())?;
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"orphaned_envs": report.orphaned_envs,
|
||||||
|
"orphaned_layers": report.orphaned_layers,
|
||||||
|
"orphaned_objects": report.orphaned_objects,
|
||||||
|
"removed_envs": report.removed_envs,
|
||||||
|
"removed_layers": report.removed_layers,
|
||||||
|
"removed_objects": report.removed_objects,
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
let prefix = if dry_run { "would remove" } else { "removed" };
|
||||||
|
println!(
|
||||||
|
"gc: {prefix} {} envs, {} layers, {} objects",
|
||||||
|
report.removed_envs, report.removed_layers, report.removed_objects
|
||||||
|
);
|
||||||
|
if dry_run && !report.orphaned_envs.is_empty() {
|
||||||
|
println!("orphaned envs: {:?}", report.orphaned_envs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
21
crates/karapace-cli/src/commands/inspect.rs
Normal file
21
crates/karapace-cli/src/commands/inspect.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use super::{colorize_state, json_pretty, resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, env_id: &str, json: bool) -> Result<u8, String> {
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
let meta = engine.inspect(&resolved).map_err(|e| e.to_string())?;
|
||||||
|
if json {
|
||||||
|
println!("{}", json_pretty(&meta)?);
|
||||||
|
} else {
|
||||||
|
println!("env_id: {}", meta.env_id);
|
||||||
|
println!("short_id: {}", meta.short_id);
|
||||||
|
println!("name: {}", meta.name.as_deref().unwrap_or("(none)"));
|
||||||
|
println!("state: {}", colorize_state(&meta.state.to_string()));
|
||||||
|
println!("base_layer: {}", meta.base_layer);
|
||||||
|
println!("deps: {}", meta.dependency_layers.len());
|
||||||
|
println!("ref_count: {}", meta.ref_count);
|
||||||
|
println!("created_at: {}", meta.created_at);
|
||||||
|
println!("updated_at: {}", meta.updated_at);
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
22
crates/karapace-cli/src/commands/list.rs
Normal file
22
crates/karapace-cli/src/commands/list.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use super::{colorize_state, json_pretty, EXIT_SUCCESS};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, json: bool) -> Result<u8, String> {
|
||||||
|
let envs = engine.list().map_err(|e| e.to_string())?;
|
||||||
|
if json {
|
||||||
|
println!("{}", json_pretty(&envs)?);
|
||||||
|
} else if envs.is_empty() {
|
||||||
|
println!("no environments found");
|
||||||
|
} else {
|
||||||
|
println!("{:<14} {:<16} {:<10} ENV_ID", "SHORT_ID", "NAME", "STATE");
|
||||||
|
for env in &envs {
|
||||||
|
let name_display = env.name.as_deref().unwrap_or("");
|
||||||
|
let state_str = colorize_state(&env.state.to_string());
|
||||||
|
println!(
|
||||||
|
"{:<14} {:<16} {:<10} {}",
|
||||||
|
env.short_id, name_display, state_str, env.env_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
26
crates/karapace-cli/src/commands/man_pages.rs
Normal file
26
crates/karapace-cli/src/commands/man_pages.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use super::EXIT_SUCCESS;
|
||||||
|
use clap::CommandFactory;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run<C: CommandFactory>(dir: &Path) -> Result<u8, String> {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| format!("failed to create dir: {e}"))?;
|
||||||
|
let cmd = C::command();
|
||||||
|
let man = clap_mangen::Man::new(cmd.clone());
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
man.render(&mut buf)
|
||||||
|
.map_err(|e| format!("man page render failed: {e}"))?;
|
||||||
|
let path = dir.join("karapace.1");
|
||||||
|
std::fs::write(&path, &buf).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
|
||||||
|
for sub in cmd.get_subcommands() {
|
||||||
|
let sub_name = format!("karapace-{}", sub.get_name());
|
||||||
|
let man = clap_mangen::Man::new(sub.clone());
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
man.render(&mut buf)
|
||||||
|
.map_err(|e| format!("man page render failed: {e}"))?;
|
||||||
|
let path = dir.join(format!("{sub_name}.1"));
|
||||||
|
std::fs::write(&path, &buf)
|
||||||
|
.map_err(|e| format!("failed to write {}: {e}", path.display()))?;
|
||||||
|
}
|
||||||
|
println!("man pages written to {}", dir.display());
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
100
crates/karapace-cli/src/commands/migrate.rs
Normal file
100
crates/karapace-cli/src/commands/migrate.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
use super::{EXIT_FAILURE, EXIT_SUCCESS};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(store_path: &Path, json_output: bool) -> Result<u8, String> {
|
||||||
|
let store_dir = store_path.join("store");
|
||||||
|
if !store_dir.exists() {
|
||||||
|
msg(
|
||||||
|
json_output,
|
||||||
|
r#"{"status": "no_store", "message": "No store found."}"#,
|
||||||
|
&format!(
|
||||||
|
"No store found at {}. Nothing to migrate.",
|
||||||
|
store_path.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Ok(EXIT_SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
let version_path = store_dir.join("version");
|
||||||
|
if !version_path.exists() {
|
||||||
|
msg(json_output,
|
||||||
|
r#"{"status": "error", "message": "Store exists but has no version file."}"#,
|
||||||
|
&format!("Store at {} has no version file. May be corrupted or very old.\nRecommended: back up and rebuild.", store_path.display()));
|
||||||
|
return Ok(EXIT_FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&version_path)
|
||||||
|
.map_err(|e| format!("failed to read version file: {e}"))?;
|
||||||
|
let ver: serde_json::Value =
|
||||||
|
serde_json::from_str(&content).map_err(|e| format!("invalid version file: {e}"))?;
|
||||||
|
let found = ver
|
||||||
|
.get("format_version")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let current = u64::from(karapace_store::STORE_FORMAT_VERSION);
|
||||||
|
|
||||||
|
if found == current {
|
||||||
|
msg(
|
||||||
|
json_output,
|
||||||
|
&format!(r#"{{"status": "current", "format_version": {current}}}"#),
|
||||||
|
&format!("Store format version: {current} (current)\nNo migration needed."),
|
||||||
|
);
|
||||||
|
return Ok(EXIT_SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if found > current {
|
||||||
|
msg(json_output,
|
||||||
|
&format!(r#"{{"status": "newer", "found": {found}, "supported": {current}}}"#),
|
||||||
|
&format!("Store format version: {found}\nSupported: {current}\n\nCreated by a newer Karapace. Please upgrade."));
|
||||||
|
return Ok(EXIT_FAILURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt automatic migration
|
||||||
|
match karapace_store::migrate_store(store_path) {
|
||||||
|
Ok(Some(result)) => {
|
||||||
|
msg(
|
||||||
|
json_output,
|
||||||
|
&format!(
|
||||||
|
r#"{{"status": "migrated", "from": {}, "to": {}, "environments": {}, "backup": "{}"}}"#,
|
||||||
|
result.from_version,
|
||||||
|
result.to_version,
|
||||||
|
result.environments_migrated,
|
||||||
|
result.backup_path.display()
|
||||||
|
),
|
||||||
|
&format!(
|
||||||
|
"Migrated store from v{} to v{}.\n{} environments updated.\nBackup: {}",
|
||||||
|
result.from_version,
|
||||||
|
result.to_version,
|
||||||
|
result.environments_migrated,
|
||||||
|
result.backup_path.display()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// Should not reach here (we already checked found != current above)
|
||||||
|
msg(
|
||||||
|
json_output,
|
||||||
|
&format!(r#"{{"status": "current", "format_version": {current}}}"#),
|
||||||
|
&format!("Store format version: {current} (current)\nNo migration needed."),
|
||||||
|
);
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
msg(
|
||||||
|
json_output,
|
||||||
|
&format!(r#"{{"status": "error", "message": "{e}"}}"#),
|
||||||
|
&format!("Migration failed: {e}"),
|
||||||
|
);
|
||||||
|
Ok(EXIT_FAILURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn msg(json_output: bool, json: &str, human: &str) {
|
||||||
|
if json_output {
|
||||||
|
println!("{json}");
|
||||||
|
} else {
|
||||||
|
println!("{human}");
|
||||||
|
}
|
||||||
|
}
|
||||||
209
crates/karapace-cli/src/commands/mod.rs
Normal file
209
crates/karapace-cli/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
pub mod archive;
|
||||||
|
pub mod build;
|
||||||
|
pub mod commit;
|
||||||
|
pub mod completions;
|
||||||
|
pub mod destroy;
|
||||||
|
pub mod diff;
|
||||||
|
pub mod doctor;
|
||||||
|
pub mod enter;
|
||||||
|
pub mod exec;
|
||||||
|
pub mod freeze;
|
||||||
|
pub mod gc;
|
||||||
|
pub mod inspect;
|
||||||
|
pub mod list;
|
||||||
|
pub mod man_pages;
|
||||||
|
pub mod migrate;
|
||||||
|
pub mod pull;
|
||||||
|
pub mod push;
|
||||||
|
pub mod rebuild;
|
||||||
|
pub mod rename;
|
||||||
|
pub mod restore;
|
||||||
|
pub mod snapshots;
|
||||||
|
pub mod stop;
|
||||||
|
pub mod verify_store;
|
||||||
|
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub const EXIT_SUCCESS: u8 = 0;
|
||||||
|
pub const EXIT_FAILURE: u8 = 1;
|
||||||
|
pub const EXIT_MANIFEST_ERROR: u8 = 2;
|
||||||
|
pub const EXIT_STORE_ERROR: u8 = 3;
|
||||||
|
|
||||||
|
pub fn json_pretty(value: &impl serde::Serialize) -> Result<String, String> {
|
||||||
|
serde_json::to_string_pretty(value).map_err(|e| format!("JSON serialization failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spinner(msg: &str) -> ProgressBar {
|
||||||
|
let pb = ProgressBar::new_spinner();
|
||||||
|
pb.set_style(
|
||||||
|
ProgressStyle::with_template("{spinner:.cyan} {msg}")
|
||||||
|
.expect("valid template")
|
||||||
|
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
|
||||||
|
);
|
||||||
|
pb.set_message(msg.to_owned());
|
||||||
|
pb.enable_steady_tick(Duration::from_millis(80));
|
||||||
|
pb
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spin_ok(pb: &ProgressBar, msg: &str) {
|
||||||
|
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
|
||||||
|
pb.finish_with_message(format!("✓ {msg}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spin_fail(pb: &ProgressBar, msg: &str) {
|
||||||
|
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
|
||||||
|
pb.finish_with_message(format!("✗ {msg}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn colorize_state(state: &str) -> String {
|
||||||
|
use console::Style;
|
||||||
|
match state {
|
||||||
|
"built" => Style::new().green().apply_to(state).to_string(),
|
||||||
|
"running" => Style::new().cyan().bold().apply_to(state).to_string(),
|
||||||
|
"defined" => Style::new().yellow().apply_to(state).to_string(),
|
||||||
|
"frozen" => Style::new().blue().apply_to(state).to_string(),
|
||||||
|
"archived" => Style::new().dim().apply_to(state).to_string(),
|
||||||
|
other => other.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_env_id(engine: &Engine, input: &str) -> Result<String, String> {
|
||||||
|
if input.len() == 64 {
|
||||||
|
return Ok(input.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let envs = engine.list().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for e in &envs {
|
||||||
|
if *e.env_id == *input || *e.short_id == *input || e.name.as_deref() == Some(input) {
|
||||||
|
return Ok(e.env_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches: Vec<_> = envs
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.env_id.starts_with(input) || e.short_id.starts_with(input))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match matches.len() {
|
||||||
|
0 => Err(format!("no environment matching '{input}'")),
|
||||||
|
1 => Ok(matches[0].env_id.to_string()),
|
||||||
|
n => Err(format!(
|
||||||
|
"ambiguous env_id prefix '{input}': matches {n} environments"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_remote_backend(
|
||||||
|
remote_url: Option<&str>,
|
||||||
|
) -> Result<karapace_remote::http::HttpBackend, String> {
|
||||||
|
let config = if let Some(url) = remote_url {
|
||||||
|
karapace_remote::RemoteConfig::new(url)
|
||||||
|
} else {
|
||||||
|
karapace_remote::RemoteConfig::load_default()
|
||||||
|
.map_err(|e| format!("no --remote and no config: {e}"))?
|
||||||
|
};
|
||||||
|
Ok(karapace_remote::http::HttpBackend::new(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_pretty_serializes_string() {
|
||||||
|
let val = serde_json::json!({"key": "value"});
|
||||||
|
let result = json_pretty(&val).unwrap();
|
||||||
|
assert!(result.contains("\"key\""));
|
||||||
|
assert!(result.contains("\"value\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_pretty_serializes_array() {
|
||||||
|
let val = vec![1, 2, 3];
|
||||||
|
let result = json_pretty(&val).unwrap();
|
||||||
|
assert!(result.contains('1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_state_built() {
|
||||||
|
let result = colorize_state("built");
|
||||||
|
assert!(result.contains("built"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_state_running() {
|
||||||
|
let result = colorize_state("running");
|
||||||
|
assert!(result.contains("running"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_state_defined() {
|
||||||
|
let result = colorize_state("defined");
|
||||||
|
assert!(result.contains("defined"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_state_frozen() {
|
||||||
|
let result = colorize_state("frozen");
|
||||||
|
assert!(result.contains("frozen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_state_archived() {
|
||||||
|
let result = colorize_state("archived");
|
||||||
|
assert!(result.contains("archived"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_state_unknown() {
|
||||||
|
assert_eq!(colorize_state("unknown"), "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_env_id_64_char_passthrough() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(dir.path());
|
||||||
|
let id = "a".repeat(64);
|
||||||
|
assert_eq!(resolve_env_id(&engine, &id).unwrap(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_env_id_not_found() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(dir.path());
|
||||||
|
karapace_store::StoreLayout::new(dir.path())
|
||||||
|
.initialize()
|
||||||
|
.unwrap();
|
||||||
|
let result = resolve_env_id(&engine, "nonexistent");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("no environment matching"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_codes_are_distinct() {
|
||||||
|
assert_ne!(EXIT_SUCCESS, EXIT_FAILURE);
|
||||||
|
assert_ne!(EXIT_FAILURE, EXIT_MANIFEST_ERROR);
|
||||||
|
assert_ne!(EXIT_MANIFEST_ERROR, EXIT_STORE_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn make_remote_backend_with_url() {
|
||||||
|
let backend = make_remote_backend(Some("http://localhost:8080"));
|
||||||
|
assert!(backend.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spinner_creates_progress_bar() {
|
||||||
|
let pb = spinner("testing...");
|
||||||
|
spin_ok(&pb, "done");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spinner_fail_creates_progress_bar() {
|
||||||
|
let pb = spinner("testing...");
|
||||||
|
spin_fail(&pb, "failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
44
crates/karapace-cli/src/commands/pull.rs
Normal file
44
crates/karapace-cli/src/commands/pull.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use super::{json_pretty, make_remote_backend, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: &Engine,
|
||||||
|
reference: &str,
|
||||||
|
remote_url: Option<&str>,
|
||||||
|
json: bool,
|
||||||
|
) -> Result<u8, String> {
|
||||||
|
let backend = make_remote_backend(remote_url)?;
|
||||||
|
|
||||||
|
// Resolve reference: try as registry ref first, fall back to raw env_id
|
||||||
|
let env_id = match Engine::resolve_remote_ref(&backend, reference) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => reference.to_owned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pb = spinner("pulling environment…");
|
||||||
|
let result = engine.pull(&env_id, &backend).map_err(|e| {
|
||||||
|
spin_fail(&pb, "pull failed");
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
spin_ok(&pb, "pull complete");
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"env_id": env_id,
|
||||||
|
"objects_pulled": result.objects_pulled,
|
||||||
|
"layers_pulled": result.layers_pulled,
|
||||||
|
"objects_skipped": result.objects_skipped,
|
||||||
|
"layers_skipped": result.layers_skipped,
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"pulled {} ({} objects, {} layers; {} skipped)",
|
||||||
|
&env_id[..12.min(env_id.len())],
|
||||||
|
result.objects_pulled,
|
||||||
|
result.layers_pulled,
|
||||||
|
result.objects_skipped + result.layers_skipped,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
46
crates/karapace-cli/src/commands/push.rs
Normal file
46
crates/karapace-cli/src/commands/push.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
use super::{
|
||||||
|
json_pretty, make_remote_backend, resolve_env_id, spin_fail, spin_ok, spinner, EXIT_SUCCESS,
|
||||||
|
};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: &Engine,
|
||||||
|
env_id: &str,
|
||||||
|
tag: Option<&str>,
|
||||||
|
remote_url: Option<&str>,
|
||||||
|
json: bool,
|
||||||
|
) -> Result<u8, String> {
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
let backend = make_remote_backend(remote_url)?;
|
||||||
|
|
||||||
|
let pb = spinner("pushing environment…");
|
||||||
|
let result = engine.push(&resolved, &backend, tag).map_err(|e| {
|
||||||
|
spin_fail(&pb, "push failed");
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
spin_ok(&pb, "push complete");
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"env_id": resolved,
|
||||||
|
"tag": tag,
|
||||||
|
"objects_pushed": result.objects_pushed,
|
||||||
|
"layers_pushed": result.layers_pushed,
|
||||||
|
"objects_skipped": result.objects_skipped,
|
||||||
|
"layers_skipped": result.layers_skipped,
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"pushed {} ({} objects, {} layers; {} skipped)",
|
||||||
|
&resolved[..12],
|
||||||
|
result.objects_pushed,
|
||||||
|
result.layers_pushed,
|
||||||
|
result.objects_skipped + result.layers_skipped,
|
||||||
|
);
|
||||||
|
if let Some(t) = tag {
|
||||||
|
println!("tagged as '{t}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
57
crates/karapace-cli/src/commands/rebuild.rs
Normal file
57
crates/karapace-cli/src/commands/rebuild.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use super::{json_pretty, spin_fail, spin_ok, spinner, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: &Engine,
|
||||||
|
store_path: &Path,
|
||||||
|
manifest: &Path,
|
||||||
|
name: Option<&str>,
|
||||||
|
json: bool,
|
||||||
|
) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let pb = if json {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(spinner("rebuilding environment..."))
|
||||||
|
};
|
||||||
|
let result = match engine.rebuild(manifest) {
|
||||||
|
Ok(r) => {
|
||||||
|
if let Some(ref pb) = pb {
|
||||||
|
spin_ok(pb, "environment rebuilt");
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if let Some(ref pb) = pb {
|
||||||
|
spin_fail(pb, "rebuild failed");
|
||||||
|
}
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(n) = name {
|
||||||
|
engine
|
||||||
|
.set_name(&result.identity.env_id, Some(n.to_owned()))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"env_id": result.identity.env_id,
|
||||||
|
"short_id": result.identity.short_id,
|
||||||
|
"name": name,
|
||||||
|
"status": "rebuilt"
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
if let Some(n) = name {
|
||||||
|
println!("rebuilt environment '{}' ({})", n, result.identity.short_id);
|
||||||
|
} else {
|
||||||
|
println!("rebuilt environment {}", result.identity.short_id);
|
||||||
|
}
|
||||||
|
println!("env_id: {}", result.identity.env_id);
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
16
crates/karapace-cli/src/commands/rename.rs
Normal file
16
crates/karapace-cli/src/commands/rename.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
use super::{resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, env_id: &str, new_name: &str) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
engine
|
||||||
|
.rename(&resolved, new_name)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
println!("renamed {} → '{}'", &resolved[..12], new_name);
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
31
crates/karapace-cli/src/commands/restore.rs
Normal file
31
crates/karapace-cli/src/commands/restore.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(
|
||||||
|
engine: &Engine,
|
||||||
|
store_path: &Path,
|
||||||
|
env_id: &str,
|
||||||
|
snapshot_hash: &str,
|
||||||
|
json: bool,
|
||||||
|
) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
engine
|
||||||
|
.restore(&resolved, snapshot_hash)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"env_id": resolved,
|
||||||
|
"restored_snapshot": snapshot_hash,
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
println!("restored {env_id} from snapshot {snapshot_hash}");
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
39
crates/karapace-cli/src/commands/snapshots.rs
Normal file
39
crates/karapace-cli/src/commands/snapshots.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
use super::{json_pretty, resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, env_id: &str, json: bool) -> Result<u8, String> {
|
||||||
|
let _layout = StoreLayout::new(store_path);
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
let snapshots = engine
|
||||||
|
.list_snapshots(&resolved)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let entries: Vec<_> = snapshots
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
serde_json::json!({
|
||||||
|
"hash": s.hash,
|
||||||
|
"tar_hash": s.tar_hash,
|
||||||
|
"parent": s.parent,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"env_id": resolved,
|
||||||
|
"snapshots": entries,
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else if snapshots.is_empty() {
|
||||||
|
println!("no snapshots for {env_id}");
|
||||||
|
} else {
|
||||||
|
println!("snapshots for {env_id}:");
|
||||||
|
for s in &snapshots {
|
||||||
|
println!(" {} (tar: {})", &s.hash[..12], &s.tar_hash[..12]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
14
crates/karapace-cli/src/commands/stop.rs
Normal file
14
crates/karapace-cli/src/commands/stop.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use super::{resolve_env_id, EXIT_SUCCESS};
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, store_path: &Path, env_id: &str) -> Result<u8, String> {
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
let _lock = StoreLock::acquire(&layout.lock_file()).map_err(|e| format!("store lock: {e}"))?;
|
||||||
|
|
||||||
|
let resolved = resolve_env_id(engine, env_id)?;
|
||||||
|
engine.stop(&resolved).map_err(|e| e.to_string())?;
|
||||||
|
println!("stopped environment {env_id}");
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
}
|
||||||
30
crates/karapace-cli/src/commands/verify_store.rs
Normal file
30
crates/karapace-cli/src/commands/verify_store.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use super::{json_pretty, EXIT_STORE_ERROR, EXIT_SUCCESS};
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use karapace_store::verify_store_integrity;
|
||||||
|
|
||||||
|
pub fn run(engine: &Engine, json: bool) -> Result<u8, String> {
|
||||||
|
let report = verify_store_integrity(engine.store_layout()).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"checked": report.checked,
|
||||||
|
"passed": report.passed,
|
||||||
|
"failed": report.failed.len(),
|
||||||
|
});
|
||||||
|
println!("{}", json_pretty(&payload)?);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"store integrity: {}/{} objects passed",
|
||||||
|
report.passed, report.checked
|
||||||
|
);
|
||||||
|
for f in &report.failed {
|
||||||
|
println!(" FAIL {}: {}", f.hash, f.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.failed.is_empty() {
|
||||||
|
Ok(EXIT_SUCCESS)
|
||||||
|
} else {
|
||||||
|
Ok(EXIT_STORE_ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
302
crates/karapace-cli/src/main.rs
Normal file
302
crates/karapace-cli/src/main.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
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 std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "karapace",
|
||||||
|
version,
|
||||||
|
about = "Deterministic environment engine for immutable systems"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Path to the Karapace store directory.
|
||||||
|
#[arg(long, default_value = "~/.local/share/karapace")]
|
||||||
|
store: String,
|
||||||
|
|
||||||
|
/// Output results as structured JSON.
|
||||||
|
#[arg(long, default_value_t = false, global = true)]
|
||||||
|
json: bool,
|
||||||
|
|
||||||
|
/// Enable verbose (debug) logging output.
|
||||||
|
#[arg(short, long, default_value_t = false, global = true)]
|
||||||
|
verbose: bool,
|
||||||
|
|
||||||
|
/// Enable trace-level logging (more detailed than --verbose).
|
||||||
|
#[arg(long, default_value_t = false, global = true)]
|
||||||
|
trace: bool,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Build an environment from a manifest.
|
||||||
|
Build {
|
||||||
|
/// Path to manifest TOML file.
|
||||||
|
#[arg(default_value = "karapace.toml")]
|
||||||
|
manifest: PathBuf,
|
||||||
|
/// Human-readable name for the environment.
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
/// Destroy and rebuild an environment from manifest.
|
||||||
|
Rebuild {
|
||||||
|
/// Path to manifest TOML file.
|
||||||
|
#[arg(default_value = "karapace.toml")]
|
||||||
|
manifest: PathBuf,
|
||||||
|
/// Human-readable name for the environment.
|
||||||
|
#[arg(long)]
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
/// Enter a built environment (use -- to pass a command instead of interactive shell).
|
||||||
|
Enter {
|
||||||
|
/// Environment ID (full or short).
|
||||||
|
env_id: String,
|
||||||
|
/// Command to run inside the environment (after --).
|
||||||
|
#[arg(last = true)]
|
||||||
|
command: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Execute a command inside a built environment (non-interactive).
|
||||||
|
Exec {
|
||||||
|
/// Environment ID (full or short).
|
||||||
|
env_id: String,
|
||||||
|
/// Command and arguments to run.
|
||||||
|
#[arg(required = true, last = true)]
|
||||||
|
command: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Destroy an environment and its overlay.
|
||||||
|
Destroy {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// Stop a running environment.
|
||||||
|
Stop {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// Freeze an environment (prevent further writes).
|
||||||
|
Freeze {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// Archive an environment (preserve but prevent entry).
|
||||||
|
Archive {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// List all known environments.
|
||||||
|
List,
|
||||||
|
/// Inspect environment metadata.
|
||||||
|
Inspect {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// Show drift in the writable overlay of an environment.
|
||||||
|
Diff {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// List snapshots for an environment.
|
||||||
|
Snapshots {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// Commit overlay drift into the content store as a snapshot.
|
||||||
|
Commit {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
},
|
||||||
|
/// Restore an environment's overlay from a snapshot.
|
||||||
|
Restore {
|
||||||
|
/// Environment ID.
|
||||||
|
env_id: String,
|
||||||
|
/// Snapshot layer hash to restore from.
|
||||||
|
snapshot: String,
|
||||||
|
},
|
||||||
|
/// Run garbage collection on the store.
|
||||||
|
Gc {
|
||||||
|
/// Only report what would be removed.
|
||||||
|
#[arg(long, default_value_t = false)]
|
||||||
|
dry_run: bool,
|
||||||
|
},
|
||||||
|
/// Verify store integrity.
|
||||||
|
VerifyStore,
|
||||||
|
/// Push an environment to a remote store.
|
||||||
|
Push {
|
||||||
|
/// Environment ID, short ID, or name.
|
||||||
|
env_id: String,
|
||||||
|
/// Registry tag (e.g. "my-env@latest"). If omitted, pushed without a tag.
|
||||||
|
#[arg(long)]
|
||||||
|
tag: Option<String>,
|
||||||
|
/// Remote store URL (overrides config file).
|
||||||
|
#[arg(long)]
|
||||||
|
remote: Option<String>,
|
||||||
|
},
|
||||||
|
/// Pull an environment from a remote store.
|
||||||
|
Pull {
|
||||||
|
/// Registry reference (e.g. "my-env@latest") or raw env_id.
|
||||||
|
reference: String,
|
||||||
|
/// Remote store URL (overrides config file).
|
||||||
|
#[arg(long)]
|
||||||
|
remote: Option<String>,
|
||||||
|
},
|
||||||
|
/// Rename an environment.
|
||||||
|
Rename {
|
||||||
|
/// Environment ID or current name.
|
||||||
|
env_id: String,
|
||||||
|
/// New name for the environment.
|
||||||
|
new_name: String,
|
||||||
|
},
|
||||||
|
/// Generate shell completions for bash, zsh, fish, elvish, or powershell.
|
||||||
|
Completions {
|
||||||
|
/// Shell to generate completions for.
|
||||||
|
shell: Shell,
|
||||||
|
},
|
||||||
|
/// Generate man pages in the specified directory.
|
||||||
|
ManPages {
|
||||||
|
/// Output directory for man pages.
|
||||||
|
#[arg(default_value = "man")]
|
||||||
|
dir: PathBuf,
|
||||||
|
},
|
||||||
|
/// Run diagnostic checks on the system and store.
|
||||||
|
Doctor,
|
||||||
|
/// Check store version and show migration guidance.
|
||||||
|
Migrate,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let default_level = if cli.trace {
|
||||||
|
"trace"
|
||||||
|
} else if cli.verbose {
|
||||||
|
"debug"
|
||||||
|
} else {
|
||||||
|
"warn"
|
||||||
|
};
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_env("KARAPACE_LOG")
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level)),
|
||||||
|
)
|
||||||
|
.with_target(false)
|
||||||
|
.without_time()
|
||||||
|
.init();
|
||||||
|
|
||||||
|
install_signal_handler();
|
||||||
|
|
||||||
|
let store_path = expand_tilde(&cli.store);
|
||||||
|
let engine = Engine::new(&store_path);
|
||||||
|
let json_output = cli.json;
|
||||||
|
|
||||||
|
let needs_runtime = matches!(
|
||||||
|
cli.command,
|
||||||
|
Commands::Build { .. }
|
||||||
|
| Commands::Enter { .. }
|
||||||
|
| Commands::Exec { .. }
|
||||||
|
| Commands::Rebuild { .. }
|
||||||
|
);
|
||||||
|
if needs_runtime {
|
||||||
|
let missing = karapace_runtime::check_namespace_prereqs();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
eprintln!("error: {}", karapace_runtime::format_missing(&missing));
|
||||||
|
return ExitCode::from(EXIT_FAILURE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = match cli.command {
|
||||||
|
Commands::Build { manifest, name } => commands::build::run(
|
||||||
|
&engine,
|
||||||
|
&store_path,
|
||||||
|
&manifest,
|
||||||
|
name.as_deref(),
|
||||||
|
json_output,
|
||||||
|
),
|
||||||
|
Commands::Rebuild { manifest, name } => commands::rebuild::run(
|
||||||
|
&engine,
|
||||||
|
&store_path,
|
||||||
|
&manifest,
|
||||||
|
name.as_deref(),
|
||||||
|
json_output,
|
||||||
|
),
|
||||||
|
Commands::Enter { env_id, command } => {
|
||||||
|
commands::enter::run(&engine, &store_path, &env_id, &command)
|
||||||
|
}
|
||||||
|
Commands::Exec { env_id, command } => {
|
||||||
|
commands::exec::run(&engine, &store_path, &env_id, &command, json_output)
|
||||||
|
}
|
||||||
|
Commands::Destroy { env_id } => commands::destroy::run(&engine, &store_path, &env_id),
|
||||||
|
Commands::Stop { env_id } => commands::stop::run(&engine, &store_path, &env_id),
|
||||||
|
Commands::Freeze { env_id } => commands::freeze::run(&engine, &store_path, &env_id),
|
||||||
|
Commands::Archive { env_id } => commands::archive::run(&engine, &store_path, &env_id),
|
||||||
|
Commands::List => commands::list::run(&engine, json_output),
|
||||||
|
Commands::Inspect { env_id } => commands::inspect::run(&engine, &env_id, json_output),
|
||||||
|
Commands::Diff { env_id } => commands::diff::run(&engine, &env_id, json_output),
|
||||||
|
Commands::Snapshots { env_id } => {
|
||||||
|
commands::snapshots::run(&engine, &store_path, &env_id, json_output)
|
||||||
|
}
|
||||||
|
Commands::Commit { env_id } => {
|
||||||
|
commands::commit::run(&engine, &store_path, &env_id, json_output)
|
||||||
|
}
|
||||||
|
Commands::Restore { env_id, snapshot } => {
|
||||||
|
commands::restore::run(&engine, &store_path, &env_id, &snapshot, json_output)
|
||||||
|
}
|
||||||
|
Commands::Gc { dry_run } => commands::gc::run(&engine, &store_path, dry_run, json_output),
|
||||||
|
Commands::VerifyStore => commands::verify_store::run(&engine, json_output),
|
||||||
|
Commands::Push {
|
||||||
|
env_id,
|
||||||
|
tag,
|
||||||
|
remote,
|
||||||
|
} => commands::push::run(
|
||||||
|
&engine,
|
||||||
|
&env_id,
|
||||||
|
tag.as_deref(),
|
||||||
|
remote.as_deref(),
|
||||||
|
json_output,
|
||||||
|
),
|
||||||
|
Commands::Pull { reference, remote } => {
|
||||||
|
commands::pull::run(&engine, &reference, remote.as_deref(), json_output)
|
||||||
|
}
|
||||||
|
Commands::Rename { env_id, new_name } => {
|
||||||
|
commands::rename::run(&engine, &store_path, &env_id, &new_name)
|
||||||
|
}
|
||||||
|
Commands::Completions { shell } => commands::completions::run::<Cli>(shell),
|
||||||
|
Commands::ManPages { dir } => commands::man_pages::run::<Cli>(&dir),
|
||||||
|
Commands::Doctor => commands::doctor::run(&store_path, json_output),
|
||||||
|
Commands::Migrate => commands::migrate::run(&store_path, json_output),
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(code) => ExitCode::from(code),
|
||||||
|
Err(msg) => {
|
||||||
|
eprintln!("error: {msg}");
|
||||||
|
let code = if msg.starts_with("manifest error:")
|
||||||
|
|| msg.starts_with("failed to parse manifest")
|
||||||
|
|| msg.starts_with("failed to read manifest")
|
||||||
|
{
|
||||||
|
EXIT_MANIFEST_ERROR
|
||||||
|
} else if msg.starts_with("store error:") || msg.starts_with("store lock:") {
|
||||||
|
EXIT_STORE_ERROR
|
||||||
|
} else {
|
||||||
|
EXIT_FAILURE
|
||||||
|
};
|
||||||
|
ExitCode::from(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand_tilde(path: &str) -> PathBuf {
|
||||||
|
if let Some(stripped) = path.strip_prefix("~/") {
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
return PathBuf::from(home).join(stripped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from(path)
|
||||||
|
}
|
||||||
310
crates/karapace-cli/tests/cli_integration.rs
Normal file
310
crates/karapace-cli/tests/cli_integration.rs
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
//! CLI subprocess integration tests.
|
||||||
|
//!
|
||||||
|
//! These tests invoke the `karapace` binary as a subprocess and verify
|
||||||
|
//! exit codes, stdout content, and JSON output stability.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn karapace_bin() -> Command {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_karapace"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_store() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_test_manifest(dir: &std::path::Path) -> std::path::PathBuf {
|
||||||
|
let path = dir.join("karapace.toml");
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[system]
|
||||||
|
packages = ["git"]
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "mock"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — version flag
|
||||||
|
#[test]
|
||||||
|
fn cli_version_exits_zero() {
|
||||||
|
let output = karapace_bin().arg("--version").output().unwrap();
|
||||||
|
assert!(output.status.success(), "karapace --version must exit 0");
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("karapace"),
|
||||||
|
"version output must contain 'karapace': {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — help flag
|
||||||
|
#[test]
|
||||||
|
fn cli_help_exits_zero() {
|
||||||
|
let output = karapace_bin().arg("--help").output().unwrap();
|
||||||
|
assert!(output.status.success(), "karapace --help must exit 0");
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
assert!(stdout.contains("build"), "help must list 'build' command");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("destroy"),
|
||||||
|
"help must list 'destroy' command"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — build with mock backend
|
||||||
|
#[test]
|
||||||
|
fn cli_build_succeeds_with_mock() {
|
||||||
|
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(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"build must exit 0. stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — list with JSON output
|
||||||
|
#[test]
|
||||||
|
fn cli_list_json_output_stable() {
|
||||||
|
let store = temp_store();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = write_test_manifest(project.path());
|
||||||
|
|
||||||
|
// Build first
|
||||||
|
let build_out = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"build",
|
||||||
|
&manifest.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(build_out.status.success());
|
||||||
|
|
||||||
|
// List with --json
|
||||||
|
let output = karapace_bin()
|
||||||
|
.args(["--store", &store.path().to_string_lossy(), "--json", "list"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"list --json must exit 0. stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
// Must be valid JSON
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&stdout)
|
||||||
|
.unwrap_or_else(|e| panic!("list --json must produce valid JSON: {e}\nstdout: {stdout}"));
|
||||||
|
assert!(parsed.is_array(), "list output must be a JSON array");
|
||||||
|
let arr = parsed.as_array().unwrap();
|
||||||
|
assert_eq!(arr.len(), 1, "should have exactly 1 environment");
|
||||||
|
// Verify expected fields exist
|
||||||
|
assert!(arr[0]["env_id"].is_string());
|
||||||
|
assert!(arr[0]["state"].is_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — inspect with JSON output
|
||||||
|
#[test]
|
||||||
|
fn cli_inspect_json_output_stable() {
|
||||||
|
let store = temp_store();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = write_test_manifest(project.path());
|
||||||
|
|
||||||
|
// Build first
|
||||||
|
let build_out = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"--json",
|
||||||
|
"build",
|
||||||
|
&manifest.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(build_out.status.success());
|
||||||
|
|
||||||
|
// Parse build JSON to get env_id
|
||||||
|
let build_stdout = String::from_utf8_lossy(&build_out.stdout);
|
||||||
|
let build_json: serde_json::Value = serde_json::from_str(&build_stdout)
|
||||||
|
.unwrap_or_else(|e| panic!("build --json must produce valid JSON: {e}\n{build_stdout}"));
|
||||||
|
let env_id = build_json["env_id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Inspect
|
||||||
|
let output = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"--json",
|
||||||
|
"inspect",
|
||||||
|
env_id,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.status.success());
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let inspect_json: serde_json::Value = serde_json::from_str(&stdout)
|
||||||
|
.unwrap_or_else(|e| panic!("inspect --json must produce valid JSON: {e}\n{stdout}"));
|
||||||
|
assert_eq!(inspect_json["env_id"].as_str().unwrap(), env_id);
|
||||||
|
assert_eq!(inspect_json["state"].as_str().unwrap(), "Built");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — destroy succeeds
|
||||||
|
#[test]
|
||||||
|
fn cli_destroy_succeeds() {
|
||||||
|
let store = temp_store();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = write_test_manifest(project.path());
|
||||||
|
|
||||||
|
// Build
|
||||||
|
let build_out = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"--json",
|
||||||
|
"build",
|
||||||
|
&manifest.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(build_out.status.success());
|
||||||
|
let build_json: serde_json::Value =
|
||||||
|
serde_json::from_str(&String::from_utf8_lossy(&build_out.stdout)).unwrap();
|
||||||
|
let env_id = build_json["env_id"].as_str().unwrap();
|
||||||
|
|
||||||
|
// Destroy
|
||||||
|
let output = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"destroy",
|
||||||
|
env_id,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"destroy must exit 0. stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — build with nonexistent manifest fails
|
||||||
|
#[test]
|
||||||
|
fn cli_build_nonexistent_manifest_fails() {
|
||||||
|
let store = temp_store();
|
||||||
|
|
||||||
|
let output = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"build",
|
||||||
|
"/tmp/nonexistent_karapace_manifest_12345.toml",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!output.status.success(),
|
||||||
|
"build with missing manifest must fail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — gc on empty store succeeds
|
||||||
|
#[test]
|
||||||
|
fn cli_gc_empty_store_succeeds() {
|
||||||
|
let store = temp_store();
|
||||||
|
|
||||||
|
// Initialize store first via a build
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = write_test_manifest(project.path());
|
||||||
|
let _ = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"build",
|
||||||
|
&manifest.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"--json",
|
||||||
|
"gc",
|
||||||
|
"--dry-run",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"gc --dry-run must exit 0. stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A5: CLI Validation — verify-store on clean store
|
||||||
|
#[test]
|
||||||
|
fn cli_verify_store_clean() {
|
||||||
|
let store = temp_store();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = write_test_manifest(project.path());
|
||||||
|
|
||||||
|
// Build to populate store
|
||||||
|
let _ = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"build",
|
||||||
|
&manifest.to_string_lossy(),
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let output = karapace_bin()
|
||||||
|
.args([
|
||||||
|
"--store",
|
||||||
|
&store.path().to_string_lossy(),
|
||||||
|
"--json",
|
||||||
|
"verify-store",
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"verify-store must exit 0. stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let json: serde_json::Value = serde_json::from_str(&stdout)
|
||||||
|
.unwrap_or_else(|e| panic!("verify-store --json must produce valid JSON: {e}\n{stdout}"));
|
||||||
|
assert_eq!(json["failed"].as_u64().unwrap(), 0);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue