mirror of
https://github.com/marcoallegretti/karapace.git
synced 2026-03-26 21:43:09 +00:00
feat: karapace-core — engine orchestration, lifecycle state machine, drift control
- Engine: init → resolve → lock → build → enter/exec → freeze → archive → destroy - Cached store_root_str avoiding repeated to_string_lossy() allocations - WAL-protected build, enter, exec, destroy, commit, restore, GC operations - Overlay drift detection: diff/commit/export via upper_dir scanning - Deterministic snapshot commit with composite identity hashing - Atomic restore via staging directory swap - StoreLock: compile-time enforcement via type parameter on gc() - Signal handler: SIGINT/SIGTERM graceful shutdown with AtomicBool - Push/pull delegation to karapace-remote backend - Crash recovery: stale .running marker cleanup on Engine::new() - Integration tests, E2E tests, crash injection tests, ENOSPC simulation - Criterion benchmarks: build, rebuild, commit, restore, GC, verify
This commit is contained in:
parent
8493831222
commit
f535020600
13 changed files with 8329 additions and 0 deletions
37
crates/karapace-core/Cargo.toml
Normal file
37
crates/karapace-core/Cargo.toml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
[package]
|
||||||
|
name = "karapace-core"
|
||||||
|
description = "Build engine, lifecycle state machine, drift control, and concurrency for Karapace"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
blake3.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
ctrlc.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
fs2.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
|
karapace-schema = { path = "../karapace-schema" }
|
||||||
|
karapace-store = { path = "../karapace-store" }
|
||||||
|
karapace-runtime = { path = "../karapace-runtime" }
|
||||||
|
karapace-remote = { path = "../karapace-remote" }
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
criterion.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "stress_test"
|
||||||
|
path = "src/bin/stress_test.rs"
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "engine_benchmarks"
|
||||||
|
harness = false
|
||||||
202
crates/karapace-core/benches/engine_benchmarks.rs
Normal file
202
crates/karapace-core/benches/engine_benchmarks.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
use criterion::{criterion_group, criterion_main, Criterion};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn create_test_manifest(dir: &Path) -> std::path::PathBuf {
|
||||||
|
let manifest_path = dir.join("karapace.toml");
|
||||||
|
fs::write(
|
||||||
|
&manifest_path,
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["git", "clang"]
|
||||||
|
[runtime]
|
||||||
|
backend = "mock"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
manifest_path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_build(c: &mut Criterion) {
|
||||||
|
c.bench_function("engine_build_mock_2pkg", |b| {
|
||||||
|
b.iter_with_setup(
|
||||||
|
|| {
|
||||||
|
let store_dir = tempfile::tempdir().unwrap();
|
||||||
|
let project_dir = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = create_test_manifest(project_dir.path());
|
||||||
|
let engine = karapace_core::Engine::new(store_dir.path());
|
||||||
|
(store_dir, project_dir, manifest, engine)
|
||||||
|
},
|
||||||
|
|(_sd, _pd, manifest, engine)| {
|
||||||
|
engine.build(&manifest).unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_rebuild_unchanged(c: &mut Criterion) {
|
||||||
|
c.bench_function("engine_rebuild_unchanged", |b| {
|
||||||
|
b.iter_with_setup(
|
||||||
|
|| {
|
||||||
|
let store_dir = tempfile::tempdir().unwrap();
|
||||||
|
let project_dir = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = create_test_manifest(project_dir.path());
|
||||||
|
let engine = karapace_core::Engine::new(store_dir.path());
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
(store_dir, project_dir, manifest, engine, result)
|
||||||
|
},
|
||||||
|
|(_sd, _pd, manifest, engine, _result)| {
|
||||||
|
engine.build(&manifest).unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_commit(c: &mut Criterion) {
|
||||||
|
c.bench_function("engine_commit_100files", |b| {
|
||||||
|
b.iter_with_setup(
|
||||||
|
|| {
|
||||||
|
let store_dir = tempfile::tempdir().unwrap();
|
||||||
|
let project_dir = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = create_test_manifest(project_dir.path());
|
||||||
|
let engine = karapace_core::Engine::new(store_dir.path());
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.to_string();
|
||||||
|
|
||||||
|
// Create 100 files in the upper directory to simulate drift
|
||||||
|
let upper = store_dir.path().join("env").join(&env_id).join("upper");
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
for i in 0..100 {
|
||||||
|
fs::write(
|
||||||
|
upper.join(format!("file_{i:03}.txt")),
|
||||||
|
format!("content {i}"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
(store_dir, project_dir, engine, env_id)
|
||||||
|
},
|
||||||
|
|(_sd, _pd, engine, env_id)| {
|
||||||
|
engine.commit(&env_id).unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_restore(c: &mut Criterion) {
|
||||||
|
c.bench_function("engine_restore_snapshot", |b| {
|
||||||
|
b.iter_with_setup(
|
||||||
|
|| {
|
||||||
|
let store_dir = tempfile::tempdir().unwrap();
|
||||||
|
let project_dir = tempfile::tempdir().unwrap();
|
||||||
|
let manifest = create_test_manifest(project_dir.path());
|
||||||
|
let engine = karapace_core::Engine::new(store_dir.path());
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.to_string();
|
||||||
|
|
||||||
|
// Create files and commit a snapshot
|
||||||
|
let upper = store_dir.path().join("env").join(&env_id).join("upper");
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
for i in 0..50 {
|
||||||
|
fs::write(
|
||||||
|
upper.join(format!("file_{i:03}.txt")),
|
||||||
|
format!("content {i}"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let snapshot_hash = engine.commit(&env_id).unwrap();
|
||||||
|
|
||||||
|
(store_dir, project_dir, engine, env_id, snapshot_hash)
|
||||||
|
},
|
||||||
|
|(_sd, _pd, engine, env_id, snapshot_hash)| {
|
||||||
|
engine.restore(&env_id, &snapshot_hash).unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_gc(c: &mut Criterion) {
|
||||||
|
c.bench_function("gc_50envs", |b| {
|
||||||
|
b.iter_with_setup(
|
||||||
|
|| {
|
||||||
|
let store_dir = tempfile::tempdir().unwrap();
|
||||||
|
let layout = karapace_store::StoreLayout::new(store_dir.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
||||||
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
||||||
|
|
||||||
|
// Create 50 environments: 25 live (ref_count=1), 25 dead (ref_count=0)
|
||||||
|
for i in 0..50 {
|
||||||
|
let obj_hash = obj_store.put(format!("obj-{i}").as_bytes()).unwrap();
|
||||||
|
let meta = karapace_store::EnvMetadata {
|
||||||
|
env_id: format!("env_{i:04}").into(),
|
||||||
|
short_id: format!("env_{i:04}").into(),
|
||||||
|
name: None,
|
||||||
|
state: karapace_store::EnvState::Built,
|
||||||
|
manifest_hash: obj_hash.into(),
|
||||||
|
base_layer: "".into(),
|
||||||
|
dependency_layers: vec![],
|
||||||
|
policy_layer: None,
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
|
ref_count: u32::from(i < 25),
|
||||||
|
checksum: None,
|
||||||
|
};
|
||||||
|
meta_store.put(&meta).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 200 orphan objects
|
||||||
|
for i in 0..200 {
|
||||||
|
obj_store
|
||||||
|
.put(format!("orphan-object-{i}").as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
(store_dir, layout)
|
||||||
|
},
|
||||||
|
|(_sd, layout)| {
|
||||||
|
let gc = karapace_store::GarbageCollector::new(layout);
|
||||||
|
gc.collect(false).unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bench_verify_store(c: &mut Criterion) {
|
||||||
|
c.bench_function("verify_store_200objects", |b| {
|
||||||
|
b.iter_with_setup(
|
||||||
|
|| {
|
||||||
|
let store_dir = tempfile::tempdir().unwrap();
|
||||||
|
let layout = karapace_store::StoreLayout::new(store_dir.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
||||||
|
|
||||||
|
// Create 200 objects
|
||||||
|
for i in 0..200 {
|
||||||
|
obj_store
|
||||||
|
.put(format!("verify-object-{i}").as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
(store_dir, layout)
|
||||||
|
},
|
||||||
|
|(_sd, layout)| {
|
||||||
|
karapace_store::verify_store_integrity(&layout).unwrap();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
criterion_group!(
|
||||||
|
benches,
|
||||||
|
bench_build,
|
||||||
|
bench_rebuild_unchanged,
|
||||||
|
bench_commit,
|
||||||
|
bench_restore,
|
||||||
|
bench_gc,
|
||||||
|
bench_verify_store,
|
||||||
|
);
|
||||||
|
criterion_main!(benches);
|
||||||
2521
crates/karapace-core/karapace-core.cdx.json
Normal file
2521
crates/karapace-core/karapace-core.cdx.json
Normal file
File diff suppressed because it is too large
Load diff
238
crates/karapace-core/src/bin/stress_test.rs
Normal file
238
crates/karapace-core/src/bin/stress_test.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
//! Long-running stress test for the Karapace engine.
|
||||||
|
//!
|
||||||
|
//! Runs hundreds of build/commit/destroy/gc cycles with the mock backend,
|
||||||
|
//! checking for resource leaks (orphaned files, stale WAL entries, metadata
|
||||||
|
//! corruption) after every cycle.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --bin stress_test -- [--cycles N]
|
||||||
|
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use karapace_store::{verify_store_integrity, GarbageCollector, StoreLayout};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
fn write_manifest(dir: &Path) -> std::path::PathBuf {
|
||||||
|
let manifest_path = dir.join("karapace.toml");
|
||||||
|
fs::write(
|
||||||
|
&manifest_path,
|
||||||
|
r#"manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["git", "clang"]
|
||||||
|
[runtime]
|
||||||
|
backend = "mock"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("write manifest");
|
||||||
|
manifest_path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_files_in(dir: &Path) -> usize {
|
||||||
|
if !dir.exists() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
fs::read_dir(dir)
|
||||||
|
.map(|rd| {
|
||||||
|
rd.filter_map(Result::ok)
|
||||||
|
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Timings {
|
||||||
|
build: Duration,
|
||||||
|
commit: Duration,
|
||||||
|
destroy: Duration,
|
||||||
|
gc: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_cycle(
|
||||||
|
engine: &Engine,
|
||||||
|
manifest_path: &Path,
|
||||||
|
layout: &StoreLayout,
|
||||||
|
cycle: usize,
|
||||||
|
timings: &mut Timings,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let build_result = engine
|
||||||
|
.build(manifest_path)
|
||||||
|
.map_err(|e| format!("cycle {cycle}: BUILD FAILED: {e}"))?;
|
||||||
|
timings.build += t0.elapsed();
|
||||||
|
let env_id = build_result.identity.env_id.to_string();
|
||||||
|
|
||||||
|
let t0 = Instant::now();
|
||||||
|
if let Err(e) = engine.commit(&env_id) {
|
||||||
|
eprintln!(" cycle {cycle}: COMMIT FAILED: {e}");
|
||||||
|
}
|
||||||
|
timings.commit += t0.elapsed();
|
||||||
|
|
||||||
|
let t0 = Instant::now();
|
||||||
|
engine
|
||||||
|
.destroy(&env_id)
|
||||||
|
.map_err(|e| format!("cycle {cycle}: DESTROY FAILED: {e}"))?;
|
||||||
|
timings.destroy += t0.elapsed();
|
||||||
|
|
||||||
|
if cycle.is_multiple_of(10) {
|
||||||
|
let gc = GarbageCollector::new(layout.clone());
|
||||||
|
let t0 = Instant::now();
|
||||||
|
match gc.collect(false) {
|
||||||
|
Ok(report) => {
|
||||||
|
timings.gc += t0.elapsed();
|
||||||
|
if cycle.is_multiple_of(100) {
|
||||||
|
println!(
|
||||||
|
" cycle {cycle}: GC collected {} objects, {} layers",
|
||||||
|
report.removed_objects, report.removed_layers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("cycle {cycle}: GC FAILED: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_health(layout: &StoreLayout, wal_dir: &Path, cycle: usize) -> u64 {
|
||||||
|
let mut failures = 0u64;
|
||||||
|
match verify_store_integrity(layout) {
|
||||||
|
Ok(report) => {
|
||||||
|
if !report.failed.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
" cycle {cycle}: INTEGRITY FAILURE: {} objects failed",
|
||||||
|
report.failed.len()
|
||||||
|
);
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(" cycle {cycle}: INTEGRITY CHECK ERROR: {e}");
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let wal_files = count_files_in(wal_dir);
|
||||||
|
if wal_files > 0 {
|
||||||
|
eprintln!(" cycle {cycle}: WAL LEAK: {wal_files} stale entries");
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
let meta_count = count_files_in(&layout.metadata_dir());
|
||||||
|
if meta_count > 0 {
|
||||||
|
eprintln!(" cycle {cycle}: METADATA LEAK: {meta_count} entries after full destroy+gc");
|
||||||
|
}
|
||||||
|
failures
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_report(
|
||||||
|
cycles: usize,
|
||||||
|
failures: u64,
|
||||||
|
timings: &Timings,
|
||||||
|
layout: &StoreLayout,
|
||||||
|
wal_dir: &Path,
|
||||||
|
) {
|
||||||
|
let final_integrity = verify_store_integrity(layout);
|
||||||
|
let wal_files = count_files_in(wal_dir);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("============================================");
|
||||||
|
println!("Results: {cycles} cycles, {failures} failures");
|
||||||
|
println!(
|
||||||
|
" build: {:.3}s total, {:.3}ms avg",
|
||||||
|
timings.build.as_secs_f64(),
|
||||||
|
timings.build.as_secs_f64() * 1000.0 / cycles as f64
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" commit: {:.3}s total, {:.3}ms avg",
|
||||||
|
timings.commit.as_secs_f64(),
|
||||||
|
timings.commit.as_secs_f64() * 1000.0 / cycles as f64
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" destroy: {:.3}s total, {:.3}ms avg",
|
||||||
|
timings.destroy.as_secs_f64(),
|
||||||
|
timings.destroy.as_secs_f64() * 1000.0 / cycles as f64
|
||||||
|
);
|
||||||
|
println!(" gc: {:.3}s total", timings.gc.as_secs_f64());
|
||||||
|
println!(" WAL entries remaining: {wal_files}");
|
||||||
|
println!(
|
||||||
|
" metadata remaining: {}",
|
||||||
|
count_files_in(&layout.metadata_dir())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" objects remaining: {}",
|
||||||
|
count_files_in(&layout.objects_dir())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" layers remaining: {}",
|
||||||
|
count_files_in(&layout.layers_dir())
|
||||||
|
);
|
||||||
|
match final_integrity {
|
||||||
|
Ok(report) => println!(
|
||||||
|
" integrity: {} checked, {} passed, {} failed",
|
||||||
|
report.checked,
|
||||||
|
report.passed,
|
||||||
|
report.failed.len()
|
||||||
|
),
|
||||||
|
Err(e) => println!(" integrity: ERROR: {e}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if failures > 0 || wal_files > 0 {
|
||||||
|
eprintln!("\nSTRESS TEST FAILED");
|
||||||
|
std::process::exit(1);
|
||||||
|
} else {
|
||||||
|
println!("\nSTRESS TEST PASSED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let cycles: usize = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--cycles")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(500);
|
||||||
|
|
||||||
|
println!("Karapace stress test: {cycles} cycles");
|
||||||
|
println!("============================================");
|
||||||
|
|
||||||
|
let store_dir = tempfile::tempdir().expect("create temp dir");
|
||||||
|
let project_dir = tempfile::tempdir().expect("create project dir");
|
||||||
|
let manifest_path = write_manifest(project_dir.path());
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(store_dir.path());
|
||||||
|
layout.initialize().expect("initialize store");
|
||||||
|
let engine = Engine::new(store_dir.path());
|
||||||
|
let wal_dir = store_dir.path().join("store").join("wal");
|
||||||
|
|
||||||
|
let mut timings = Timings {
|
||||||
|
build: Duration::ZERO,
|
||||||
|
commit: Duration::ZERO,
|
||||||
|
destroy: Duration::ZERO,
|
||||||
|
gc: Duration::ZERO,
|
||||||
|
};
|
||||||
|
let mut failures = 0u64;
|
||||||
|
|
||||||
|
for cycle in 1..=cycles {
|
||||||
|
if let Err(msg) = run_cycle(&engine, &manifest_path, &layout, cycle, &mut timings) {
|
||||||
|
eprintln!(" {msg}");
|
||||||
|
failures += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if cycle.is_multiple_of(50) {
|
||||||
|
failures += check_health(&layout, &wal_dir, cycle);
|
||||||
|
}
|
||||||
|
if cycle.is_multiple_of(100) {
|
||||||
|
let elapsed = timings.build + timings.commit + timings.destroy + timings.gc;
|
||||||
|
println!(
|
||||||
|
" cycle {cycle}/{cycles}: {:.1}s elapsed, {failures} failures",
|
||||||
|
elapsed.as_secs_f64()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let gc = GarbageCollector::new(layout.clone());
|
||||||
|
let _ = gc.collect(false);
|
||||||
|
|
||||||
|
print_report(cycles, failures, &timings, &layout, &wal_dir);
|
||||||
|
}
|
||||||
106
crates/karapace-core/src/concurrency.rs
Normal file
106
crates/karapace-core/src/concurrency.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use crate::CoreError;
|
||||||
|
use fs2::FileExt;
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
pub struct StoreLock {
|
||||||
|
lock_file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoreLock {
|
||||||
|
pub fn acquire(lock_path: &Path) -> Result<Self, CoreError> {
|
||||||
|
if let Some(parent) = lock_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(lock_path)?;
|
||||||
|
|
||||||
|
file.lock_exclusive()
|
||||||
|
.map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::WouldBlock, e)))?;
|
||||||
|
|
||||||
|
Ok(Self { lock_file: file })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_acquire(lock_path: &Path) -> Result<Option<Self>, CoreError> {
|
||||||
|
if let Some(parent) = lock_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(lock_path)?;
|
||||||
|
|
||||||
|
match file.try_lock_exclusive() {
|
||||||
|
Ok(()) => Ok(Some(Self { lock_file: file })),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StoreLock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.lock_file.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub fn install_signal_handler() {
|
||||||
|
let _ = ctrlc::set_handler(move || {
|
||||||
|
if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
|
||||||
|
eprintln!("\nshutdown requested, finishing current operation...");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shutdown_requested() -> bool {
|
||||||
|
SHUTDOWN_REQUESTED.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_acquire_and_release() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let lock_path = dir.path().join("test.lock");
|
||||||
|
|
||||||
|
{
|
||||||
|
let _lock = StoreLock::acquire(&lock_path).unwrap();
|
||||||
|
assert!(lock_path.exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_acquire_returns_none_when_held() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let lock_path = dir.path().join("test.lock");
|
||||||
|
|
||||||
|
let _lock = StoreLock::acquire(&lock_path).unwrap();
|
||||||
|
let result = StoreLock::try_acquire(&lock_path).unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lock_released_on_drop() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let lock_path = dir.path().join("test.lock");
|
||||||
|
|
||||||
|
{
|
||||||
|
let _lock = StoreLock::acquire(&lock_path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let lock2 = StoreLock::try_acquire(&lock_path).unwrap();
|
||||||
|
assert!(lock2.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
277
crates/karapace-core/src/drift.rs
Normal file
277
crates/karapace-core/src/drift.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
use crate::CoreError;
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const WHITEOUT_PREFIX: &str = ".wh.";
|
||||||
|
|
||||||
|
/// Report of filesystem drift detected in an environment's overlay upper layer.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct DriftReport {
|
||||||
|
pub env_id: String,
|
||||||
|
pub added: Vec<String>,
|
||||||
|
pub modified: Vec<String>,
|
||||||
|
pub removed: Vec<String>,
|
||||||
|
pub has_drift: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the overlay upper directory for added, modified, and removed files.
|
||||||
|
pub fn diff_overlay(layout: &StoreLayout, env_id: &str) -> Result<DriftReport, CoreError> {
|
||||||
|
let upper_dir = layout.upper_dir(env_id);
|
||||||
|
let lower_dir = layout.env_path(env_id).join("lower");
|
||||||
|
|
||||||
|
let mut added = Vec::new();
|
||||||
|
let mut modified = Vec::new();
|
||||||
|
let mut removed = Vec::new();
|
||||||
|
|
||||||
|
if upper_dir.exists() {
|
||||||
|
collect_drift(
|
||||||
|
&upper_dir,
|
||||||
|
&upper_dir,
|
||||||
|
&lower_dir,
|
||||||
|
&mut added,
|
||||||
|
&mut modified,
|
||||||
|
&mut removed,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
added.sort();
|
||||||
|
modified.sort();
|
||||||
|
removed.sort();
|
||||||
|
|
||||||
|
let has_drift = !added.is_empty() || !modified.is_empty() || !removed.is_empty();
|
||||||
|
|
||||||
|
Ok(DriftReport {
|
||||||
|
env_id: env_id.to_owned(),
|
||||||
|
added,
|
||||||
|
modified,
|
||||||
|
removed,
|
||||||
|
has_drift,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_drift(
|
||||||
|
upper_base: &Path,
|
||||||
|
current: &Path,
|
||||||
|
lower_base: &Path,
|
||||||
|
added: &mut Vec<String>,
|
||||||
|
modified: &mut Vec<String>,
|
||||||
|
removed: &mut Vec<String>,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
if !current.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(current)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let name_str = file_name.to_string_lossy();
|
||||||
|
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(upper_base)
|
||||||
|
.unwrap_or(&path)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Overlayfs whiteout files indicate deletion of the corresponding
|
||||||
|
// file in the lower layer.
|
||||||
|
if name_str.starts_with(WHITEOUT_PREFIX) {
|
||||||
|
let deleted_name = name_str.strip_prefix(WHITEOUT_PREFIX).unwrap_or(&name_str);
|
||||||
|
let deleted_rel = if let Some(parent) = path.parent() {
|
||||||
|
let parent_rel = parent
|
||||||
|
.strip_prefix(upper_base)
|
||||||
|
.unwrap_or(parent)
|
||||||
|
.to_string_lossy();
|
||||||
|
if parent_rel.is_empty() {
|
||||||
|
deleted_name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{parent_rel}/{deleted_name}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deleted_name.to_string()
|
||||||
|
};
|
||||||
|
removed.push(deleted_rel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_drift(upper_base, &path, lower_base, added, modified, removed)?;
|
||||||
|
} else {
|
||||||
|
// If the same relative path exists in the lower layer,
|
||||||
|
// this is a modification; otherwise it's a new file.
|
||||||
|
let lower_path = lower_base.join(&rel);
|
||||||
|
if lower_path.exists() {
|
||||||
|
modified.push(rel);
|
||||||
|
} else {
|
||||||
|
added.push(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_overlay(layout: &StoreLayout, env_id: &str, dest: &Path) -> Result<usize, CoreError> {
|
||||||
|
let upper_dir = layout.upper_dir(env_id);
|
||||||
|
if !upper_dir.exists() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(dest)?;
|
||||||
|
let mut count = 0;
|
||||||
|
copy_recursive(&upper_dir, dest, &mut count)?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_recursive(src: &Path, dst: &Path, count: &mut usize) -> Result<(), CoreError> {
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let src_path = entry.path();
|
||||||
|
let dst_path = dst.join(entry.file_name());
|
||||||
|
|
||||||
|
if src_path.is_dir() {
|
||||||
|
fs::create_dir_all(&dst_path)?;
|
||||||
|
copy_recursive(&src_path, &dst_path, count)?;
|
||||||
|
} else {
|
||||||
|
fs::copy(&src_path, &dst_path)?;
|
||||||
|
*count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commit_overlay(
|
||||||
|
layout: &StoreLayout,
|
||||||
|
env_id: &str,
|
||||||
|
obj_store: &karapace_store::ObjectStore,
|
||||||
|
) -> Result<Vec<String>, CoreError> {
|
||||||
|
let upper_dir = layout.upper_dir(env_id);
|
||||||
|
if !upper_dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut committed = Vec::new();
|
||||||
|
commit_files(&upper_dir, obj_store, &mut committed)?;
|
||||||
|
Ok(committed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_files(
|
||||||
|
current: &Path,
|
||||||
|
obj_store: &karapace_store::ObjectStore,
|
||||||
|
committed: &mut Vec<String>,
|
||||||
|
) -> Result<(), CoreError> {
|
||||||
|
if !current.is_dir() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(current)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.is_dir() {
|
||||||
|
commit_files(&path, obj_store, committed)?;
|
||||||
|
} else {
|
||||||
|
let data = fs::read(&path)?;
|
||||||
|
let hash = obj_store.put(&data)?;
|
||||||
|
committed.push(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn setup() -> (tempfile::TempDir, StoreLayout) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let layout = StoreLayout::new(dir.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
(dir, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_overlay_reports_no_drift() {
|
||||||
|
let (_dir, layout) = setup();
|
||||||
|
let report = diff_overlay(&layout, "test-env").unwrap();
|
||||||
|
assert!(!report.has_drift);
|
||||||
|
assert!(report.added.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn files_in_overlay_detected_as_drift() {
|
||||||
|
let (_dir, layout) = setup();
|
||||||
|
let upper = layout.upper_dir("test-env");
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
fs::write(upper.join("new_file.txt"), "content").unwrap();
|
||||||
|
|
||||||
|
let report = diff_overlay(&layout, "test-env").unwrap();
|
||||||
|
assert!(report.has_drift);
|
||||||
|
assert_eq!(report.added.len(), 1);
|
||||||
|
assert!(report.added.contains(&"new_file.txt".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whiteout_files_detected_as_removed() {
|
||||||
|
let (_dir, layout) = setup();
|
||||||
|
let upper = layout.upper_dir("test-env");
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
// Overlayfs whiteout: .wh.deleted_file marks "deleted_file" as removed
|
||||||
|
fs::write(upper.join(".wh.deleted_file"), "").unwrap();
|
||||||
|
|
||||||
|
let report = diff_overlay(&layout, "test-env").unwrap();
|
||||||
|
assert!(report.has_drift);
|
||||||
|
assert_eq!(report.removed.len(), 1);
|
||||||
|
assert!(report.removed.contains(&"deleted_file".to_owned()));
|
||||||
|
assert!(report.added.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_files_classified_correctly() {
|
||||||
|
let (_dir, layout) = setup();
|
||||||
|
// Create a "lower" layer with an existing file
|
||||||
|
let env_dir = layout.env_path("test-env");
|
||||||
|
let lower = env_dir.join("lower");
|
||||||
|
fs::create_dir_all(&lower).unwrap();
|
||||||
|
fs::write(lower.join("existing.txt"), "original").unwrap();
|
||||||
|
|
||||||
|
// Same file in upper = modification
|
||||||
|
let upper = layout.upper_dir("test-env");
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
fs::write(upper.join("existing.txt"), "changed").unwrap();
|
||||||
|
// New file in upper only = added
|
||||||
|
fs::write(upper.join("brand_new.txt"), "new").unwrap();
|
||||||
|
|
||||||
|
let report = diff_overlay(&layout, "test-env").unwrap();
|
||||||
|
assert!(report.has_drift);
|
||||||
|
assert_eq!(report.modified.len(), 1);
|
||||||
|
assert!(report.modified.contains(&"existing.txt".to_owned()));
|
||||||
|
assert_eq!(report.added.len(), 1);
|
||||||
|
assert!(report.added.contains(&"brand_new.txt".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_copies_overlay_files() {
|
||||||
|
let (_dir, layout) = setup();
|
||||||
|
let upper = layout.upper_dir("test-env");
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
fs::write(upper.join("file.txt"), "data").unwrap();
|
||||||
|
|
||||||
|
let export_dir = tempfile::tempdir().unwrap();
|
||||||
|
let count = export_overlay(&layout, "test-env", export_dir.path()).unwrap();
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert!(export_dir.path().join("file.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_stores_overlay_as_objects() {
|
||||||
|
let (_dir, layout) = setup();
|
||||||
|
let upper = layout.upper_dir("test-env");
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
fs::write(upper.join("file.txt"), "data").unwrap();
|
||||||
|
|
||||||
|
let obj_store = karapace_store::ObjectStore::new(layout.clone());
|
||||||
|
let committed = commit_overlay(&layout, "test-env", &obj_store).unwrap();
|
||||||
|
assert_eq!(committed.len(), 1);
|
||||||
|
assert!(obj_store.exists(&committed[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
1049
crates/karapace-core/src/engine.rs
Normal file
1049
crates/karapace-core/src/engine.rs
Normal file
File diff suppressed because it is too large
Load diff
40
crates/karapace-core/src/lib.rs
Normal file
40
crates/karapace-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
//! Core orchestration engine for Karapace environment lifecycle.
|
||||||
|
//!
|
||||||
|
//! This crate ties together schema parsing, store operations, and runtime backends
|
||||||
|
//! into the `Engine` — the central API for building, entering, stopping, destroying,
|
||||||
|
//! and inspecting deterministic container environments. It also provides overlay
|
||||||
|
//! drift detection, concurrent store locking, and state-machine lifecycle validation.
|
||||||
|
|
||||||
|
pub mod concurrency;
|
||||||
|
pub mod drift;
|
||||||
|
pub mod engine;
|
||||||
|
pub mod lifecycle;
|
||||||
|
|
||||||
|
pub use concurrency::{install_signal_handler, shutdown_requested, StoreLock};
|
||||||
|
pub use drift::{commit_overlay, diff_overlay, export_overlay, DriftReport};
|
||||||
|
pub use engine::{BuildResult, Engine};
|
||||||
|
pub use lifecycle::validate_transition;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum CoreError {
|
||||||
|
#[error("manifest error: {0}")]
|
||||||
|
Manifest(#[from] karapace_schema::ManifestError),
|
||||||
|
#[error("lock error: {0}")]
|
||||||
|
Lock(#[from] karapace_schema::LockError),
|
||||||
|
#[error("store error: {0}")]
|
||||||
|
Store(#[from] karapace_store::StoreError),
|
||||||
|
#[error("runtime error: {0}")]
|
||||||
|
Runtime(#[from] karapace_runtime::RuntimeError),
|
||||||
|
#[error("invalid state transition: {from} -> {to}")]
|
||||||
|
InvalidTransition { from: String, to: String },
|
||||||
|
#[error("environment not found: {0}")]
|
||||||
|
EnvNotFound(String),
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("serialization error: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
#[error("remote error: {0}")]
|
||||||
|
Remote(#[from] karapace_remote::RemoteError),
|
||||||
|
}
|
||||||
57
crates/karapace-core/src/lifecycle.rs
Normal file
57
crates/karapace-core/src/lifecycle.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
use crate::CoreError;
|
||||||
|
use karapace_store::EnvState;
|
||||||
|
|
||||||
|
pub fn validate_transition(from: EnvState, to: EnvState) -> Result<(), CoreError> {
|
||||||
|
let valid = matches!(
|
||||||
|
(from, to),
|
||||||
|
(
|
||||||
|
EnvState::Defined
|
||||||
|
| EnvState::Built
|
||||||
|
| EnvState::Running
|
||||||
|
| EnvState::Frozen
|
||||||
|
| EnvState::Archived,
|
||||||
|
EnvState::Built
|
||||||
|
) | (
|
||||||
|
EnvState::Built,
|
||||||
|
EnvState::Running | EnvState::Frozen | EnvState::Archived
|
||||||
|
) | (EnvState::Running, EnvState::Frozen)
|
||||||
|
| (EnvState::Frozen, EnvState::Archived)
|
||||||
|
);
|
||||||
|
|
||||||
|
if valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CoreError::InvalidTransition {
|
||||||
|
from: from.to_string(),
|
||||||
|
to: to.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_transitions() {
|
||||||
|
assert!(validate_transition(EnvState::Defined, EnvState::Built).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Built, EnvState::Built).is_ok()); // idempotent rebuild
|
||||||
|
assert!(validate_transition(EnvState::Built, EnvState::Running).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Built, EnvState::Frozen).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Running, EnvState::Built).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Running, EnvState::Frozen).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Frozen, EnvState::Built).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Built, EnvState::Archived).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Frozen, EnvState::Archived).is_ok());
|
||||||
|
assert!(validate_transition(EnvState::Archived, EnvState::Built).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_transitions() {
|
||||||
|
assert!(validate_transition(EnvState::Defined, EnvState::Running).is_err());
|
||||||
|
assert!(validate_transition(EnvState::Defined, EnvState::Frozen).is_err());
|
||||||
|
assert!(validate_transition(EnvState::Archived, EnvState::Running).is_err());
|
||||||
|
assert!(validate_transition(EnvState::Running, EnvState::Defined).is_err());
|
||||||
|
assert!(validate_transition(EnvState::Frozen, EnvState::Running).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
391
crates/karapace-core/tests/crash.rs
Normal file
391
crates/karapace-core/tests/crash.rs
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
#![allow(unsafe_code, clippy::undocumented_unsafe_blocks)]
|
||||||
|
//! Real crash recovery tests using fork + SIGKILL.
|
||||||
|
//!
|
||||||
|
//! These tests fork a child process that runs Karapace operations in a tight
|
||||||
|
//! loop, kill it mid-flight with SIGKILL, then verify the store is recoverable
|
||||||
|
//! and consistent in the parent.
|
||||||
|
//!
|
||||||
|
//! This validates that:
|
||||||
|
//! - WAL recovery cleans up incomplete operations
|
||||||
|
//! - No partially created environment directories survive
|
||||||
|
//! - No corrupted metadata remains
|
||||||
|
//! - Store integrity check passes after recovery
|
||||||
|
//! - Lock state is released (flock auto-released on process death)
|
||||||
|
|
||||||
|
use karapace_core::{Engine, StoreLock};
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
|
||||||
|
let path = dir.join("karapace.toml");
|
||||||
|
fs::write(&path, content).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mock_manifest(packages: &[&str]) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = [{}]
|
||||||
|
[runtime]
|
||||||
|
backend = "mock"
|
||||||
|
"#,
|
||||||
|
packages
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("\"{p}\""))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the store is in a consistent state after crash recovery.
|
||||||
|
fn verify_store_healthy(store_path: &Path) {
|
||||||
|
// Creating a new Engine triggers WAL recovery
|
||||||
|
let engine = Engine::new(store_path);
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
|
||||||
|
// WAL must be empty after recovery
|
||||||
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
||||||
|
let incomplete = wal.list_incomplete().unwrap();
|
||||||
|
assert!(
|
||||||
|
incomplete.is_empty(),
|
||||||
|
"WAL must be clean after recovery, found {} incomplete entries",
|
||||||
|
incomplete.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store integrity check must pass
|
||||||
|
let report = karapace_store::verify_store_integrity(&layout).unwrap();
|
||||||
|
assert!(
|
||||||
|
report.failed.is_empty(),
|
||||||
|
"store integrity check found {} failures: {:?}",
|
||||||
|
report.failed.len(),
|
||||||
|
report.failed
|
||||||
|
);
|
||||||
|
|
||||||
|
// All listed environments must be inspectable
|
||||||
|
let envs = engine.list().unwrap();
|
||||||
|
for env in &envs {
|
||||||
|
let meta = engine.inspect(&env.env_id).unwrap();
|
||||||
|
// No environment should be stuck in Running state after crash recovery
|
||||||
|
// (WAL ResetState rollback should have fixed it)
|
||||||
|
assert_ne!(
|
||||||
|
meta.state,
|
||||||
|
karapace_store::EnvState::Running,
|
||||||
|
"env {} stuck in Running after crash recovery",
|
||||||
|
env.env_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock must be acquirable (proves the dead child released it)
|
||||||
|
let lock = StoreLock::try_acquire(&layout.lock_file()).unwrap();
|
||||||
|
assert!(
|
||||||
|
lock.is_some(),
|
||||||
|
"store lock must be acquirable after child death"
|
||||||
|
);
|
||||||
|
|
||||||
|
// No orphaned env directories (dirs in env/ without matching metadata)
|
||||||
|
let env_base = layout.env_dir();
|
||||||
|
if env_base.exists() {
|
||||||
|
if let Ok(entries) = fs::read_dir(&env_base) {
|
||||||
|
let meta_store = karapace_store::MetadataStore::new(layout.clone());
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let dir_name = entry.file_name();
|
||||||
|
let dir_name_str = dir_name.to_string_lossy();
|
||||||
|
// Skip dotfiles
|
||||||
|
if dir_name_str.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Every env dir should have matching metadata (or be cleaned up by WAL)
|
||||||
|
// We don't assert this is always true because the build might have
|
||||||
|
// completed successfully before the kill. But if metadata exists,
|
||||||
|
// it should be readable.
|
||||||
|
if meta_store.get(&dir_name_str).is_ok() {
|
||||||
|
// Metadata exists and is valid — good
|
||||||
|
} else {
|
||||||
|
// Orphaned env dir — WAL should have cleaned it, but if the
|
||||||
|
// build completed and was killed before metadata write,
|
||||||
|
// this is acceptable as long as it doesn't cause errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No stale .running markers
|
||||||
|
if env_base.exists() {
|
||||||
|
if let Ok(entries) = fs::read_dir(&env_base) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let running = entry.path().join(".running");
|
||||||
|
assert!(
|
||||||
|
!running.exists(),
|
||||||
|
"stale .running marker at {}",
|
||||||
|
running.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fork a child that runs `child_fn` in a loop, kill it after `delay`,
|
||||||
|
/// then verify store health in the parent.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// Uses `libc::fork()` which is inherently unsafe. The child must not
|
||||||
|
/// return — it either loops forever or exits.
|
||||||
|
unsafe fn crash_test(store_path: &Path, delay: std::time::Duration, child_fn: fn(&Path)) {
|
||||||
|
let pid = libc::fork();
|
||||||
|
assert!(pid >= 0, "fork() failed");
|
||||||
|
|
||||||
|
if pid == 0 {
|
||||||
|
// === CHILD PROCESS ===
|
||||||
|
// Run the operation in a tight loop until killed
|
||||||
|
child_fn(store_path);
|
||||||
|
// If child_fn returns, exit immediately
|
||||||
|
libc::_exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === PARENT PROCESS ===
|
||||||
|
std::thread::sleep(delay);
|
||||||
|
|
||||||
|
// Send SIGKILL — no chance for cleanup
|
||||||
|
let ret = libc::kill(pid, libc::SIGKILL);
|
||||||
|
assert_eq!(ret, 0, "kill() failed");
|
||||||
|
|
||||||
|
// Wait for child to die
|
||||||
|
let mut status: i32 = 0;
|
||||||
|
let waited = libc::waitpid(pid, &raw mut status, 0);
|
||||||
|
assert_eq!(waited, pid, "waitpid() failed");
|
||||||
|
|
||||||
|
// Now verify the store
|
||||||
|
verify_store_healthy(store_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child function: build environments in a tight loop
|
||||||
|
fn child_build_loop(store_path: &Path) {
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store_path);
|
||||||
|
|
||||||
|
for i in 0u64.. {
|
||||||
|
let pkgs: Vec<String> = (0..=(i % 4)).map(|j| format!("pkg{j}")).collect();
|
||||||
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
||||||
|
let manifest = write_manifest(project.path(), &mock_manifest(&pkg_refs));
|
||||||
|
let _ = engine.build(&manifest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child function: build + destroy in a tight loop
|
||||||
|
fn child_build_destroy_loop(store_path: &Path) {
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store_path);
|
||||||
|
|
||||||
|
for i in 0u64.. {
|
||||||
|
let pkgs: Vec<String> = (0..=(i % 2)).map(|j| format!("pkg{j}")).collect();
|
||||||
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
||||||
|
let manifest = write_manifest(project.path(), &mock_manifest(&pkg_refs));
|
||||||
|
if let Ok(r) = engine.build(&manifest) {
|
||||||
|
let _ = engine.destroy(&r.identity.env_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child function: build + commit in a tight loop
|
||||||
|
fn child_build_commit_loop(store_path: &Path) {
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store_path);
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
||||||
|
if let Ok(r) = engine.build(&manifest) {
|
||||||
|
let env_id = r.identity.env_id.to_string();
|
||||||
|
let upper = store_path.join("env").join(&env_id).join("upper");
|
||||||
|
let _ = fs::create_dir_all(&upper);
|
||||||
|
|
||||||
|
for i in 0u64.. {
|
||||||
|
let _ = fs::write(upper.join(format!("file_{i}.txt")), format!("data {i}"));
|
||||||
|
let _ = engine.commit(&env_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child function: build + commit + restore in a tight loop
|
||||||
|
fn child_commit_restore_loop(store_path: &Path) {
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store_path);
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
||||||
|
if let Ok(r) = engine.build(&manifest) {
|
||||||
|
let env_id = r.identity.env_id.to_string();
|
||||||
|
let upper = store_path.join("env").join(&env_id).join("upper");
|
||||||
|
let _ = fs::create_dir_all(&upper);
|
||||||
|
|
||||||
|
// Create initial snapshot
|
||||||
|
let _ = fs::write(upper.join("base.txt"), "base content");
|
||||||
|
if let Ok(snap_hash) = engine.commit(&env_id) {
|
||||||
|
for i in 0u64.. {
|
||||||
|
let _ = fs::write(upper.join(format!("file_{i}.txt")), format!("data {i}"));
|
||||||
|
let _ = engine.commit(&env_id);
|
||||||
|
let _ = engine.restore(&env_id, &snap_hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child function: build + GC in a tight loop
|
||||||
|
fn child_gc_loop(store_path: &Path) {
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store_path);
|
||||||
|
|
||||||
|
// Build several environments
|
||||||
|
let mut env_ids = Vec::new();
|
||||||
|
for i in 0..5 {
|
||||||
|
let pkgs: Vec<String> = (0..=i).map(|j| format!("pkg{j}")).collect();
|
||||||
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
||||||
|
let manifest = write_manifest(project.path(), &mock_manifest(&pkg_refs));
|
||||||
|
if let Ok(r) = engine.build(&manifest) {
|
||||||
|
env_ids.push(r.identity.env_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(store_path);
|
||||||
|
for i in 0u64.. {
|
||||||
|
// Destroy one environment per cycle
|
||||||
|
let idx = (i as usize) % env_ids.len();
|
||||||
|
let _ = engine.destroy(&env_ids[idx]);
|
||||||
|
|
||||||
|
// Run GC
|
||||||
|
if let Ok(Some(lock)) = StoreLock::try_acquire(&layout.lock_file()) {
|
||||||
|
let _ = engine.gc(&lock, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild
|
||||||
|
let pkgs: Vec<String> = (0..=idx).map(|j| format!("pkg{j}")).collect();
|
||||||
|
let pkg_refs: Vec<&str> = pkgs.iter().map(String::as_str).collect();
|
||||||
|
let manifest = write_manifest(project.path(), &mock_manifest(&pkg_refs));
|
||||||
|
if let Ok(r) = engine.build(&manifest) {
|
||||||
|
env_ids[idx] = r.identity.env_id.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child function: build + enter in a tight loop (tests ResetState WAL)
|
||||||
|
fn child_enter_loop(store_path: &Path) {
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store_path);
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &mock_manifest(&["git"]));
|
||||||
|
if let Ok(r) = engine.build(&manifest) {
|
||||||
|
let env_id = r.identity.env_id.to_string();
|
||||||
|
for _ in 0u64.. {
|
||||||
|
let _ = engine.enter(&env_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Crash tests ---
|
||||||
|
// Each test runs with multiple delay values to increase the chance of hitting
|
||||||
|
// different points in the operation lifecycle.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_during_build() {
|
||||||
|
for delay_ms in [1, 5, 10, 20, 50] {
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
// Pre-initialize the store
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
crash_test(
|
||||||
|
store.path(),
|
||||||
|
std::time::Duration::from_millis(delay_ms),
|
||||||
|
child_build_loop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_during_build_destroy() {
|
||||||
|
for delay_ms in [1, 5, 10, 20, 50] {
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
crash_test(
|
||||||
|
store.path(),
|
||||||
|
std::time::Duration::from_millis(delay_ms),
|
||||||
|
child_build_destroy_loop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_during_commit() {
|
||||||
|
for delay_ms in [1, 5, 10, 20, 50] {
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
crash_test(
|
||||||
|
store.path(),
|
||||||
|
std::time::Duration::from_millis(delay_ms),
|
||||||
|
child_build_commit_loop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_during_restore() {
|
||||||
|
for delay_ms in [1, 5, 10, 20, 50] {
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
crash_test(
|
||||||
|
store.path(),
|
||||||
|
std::time::Duration::from_millis(delay_ms),
|
||||||
|
child_commit_restore_loop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_during_gc() {
|
||||||
|
for delay_ms in [5, 10, 20, 50, 100] {
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
crash_test(
|
||||||
|
store.path(),
|
||||||
|
std::time::Duration::from_millis(delay_ms),
|
||||||
|
child_gc_loop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crash_during_enter() {
|
||||||
|
for delay_ms in [1, 5, 10, 20, 50] {
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
crash_test(
|
||||||
|
store.path(),
|
||||||
|
std::time::Duration::from_millis(delay_ms),
|
||||||
|
child_enter_loop,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
630
crates/karapace-core/tests/e2e.rs
Normal file
630
crates/karapace-core/tests/e2e.rs
Normal file
|
|
@ -0,0 +1,630 @@
|
||||||
|
//! End-to-end tests that exercise the real namespace backend.
|
||||||
|
//!
|
||||||
|
//! These tests are `#[ignore]` by default because they require:
|
||||||
|
//! - Linux with user namespace support
|
||||||
|
//! - `fuse-overlayfs` installed
|
||||||
|
//! - `curl` installed
|
||||||
|
//! - Network access (to download base images)
|
||||||
|
//!
|
||||||
|
//! Run with: `cargo test --test e2e -- --ignored`
|
||||||
|
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use karapace_store::{EnvState, StoreLayout};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn namespace_manifest(packages: &[&str]) -> String {
|
||||||
|
let pkgs = if packages.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let list: Vec<String> = packages.iter().map(|p| format!("\"{p}\"")).collect();
|
||||||
|
format!("\n[system]\npackages = [{}]\n", list.join(", "))
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
{pkgs}
|
||||||
|
[runtime]
|
||||||
|
backend = "namespace"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
|
||||||
|
let path = dir.join("karapace.toml");
|
||||||
|
fs::write(&path, content).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prereqs_available() -> bool {
|
||||||
|
let ns = karapace_runtime::check_namespace_prereqs();
|
||||||
|
if !ns.is_empty() {
|
||||||
|
let msg = karapace_runtime::format_missing(&ns);
|
||||||
|
assert!(
|
||||||
|
std::env::var("CI").is_err(),
|
||||||
|
"CI FATAL: E2E prerequisites missing — tests cannot silently skip in CI.\n{msg}"
|
||||||
|
);
|
||||||
|
eprintln!("skipping E2E: missing prerequisites: {msg}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a minimal environment with the namespace backend (no packages).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_build_minimal_namespace() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
|
||||||
|
assert!(!result.identity.env_id.is_empty());
|
||||||
|
assert!(!result.identity.short_id.is_empty());
|
||||||
|
|
||||||
|
let meta = engine.inspect(&result.identity.env_id).unwrap();
|
||||||
|
assert_eq!(meta.state, EnvState::Built);
|
||||||
|
|
||||||
|
// Verify the environment directory was created
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
assert!(layout.env_path(&result.identity.env_id).exists());
|
||||||
|
|
||||||
|
// Lock file was written
|
||||||
|
assert!(project.path().join("karapace.lock").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exec a command inside a built environment and verify stdout.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_exec_in_namespace() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
|
||||||
|
// Exec `echo hello` inside the container
|
||||||
|
let cmd = vec!["echo".to_owned(), "hello".to_owned()];
|
||||||
|
// exec() writes to stdout/stderr directly; just verify it doesn't error
|
||||||
|
engine.exec(&result.identity.env_id, &cmd).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destroy cleans up all overlay directories.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_destroy_cleans_up() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.clone();
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
assert!(layout.env_path(&env_id).exists());
|
||||||
|
|
||||||
|
engine.destroy(&env_id).unwrap();
|
||||||
|
|
||||||
|
// Environment directory should be gone
|
||||||
|
assert!(!layout.env_path(&env_id).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild produces the same env_id for the same manifest.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_rebuild_determinism() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let r1 = engine.build(&manifest).unwrap();
|
||||||
|
let r2 = engine.rebuild(&manifest).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
r1.identity.env_id, r2.identity.env_id,
|
||||||
|
"rebuild must produce the same env_id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot and restore round-trip with real namespace backend.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_snapshot_and_restore() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.clone();
|
||||||
|
|
||||||
|
// Write a file to the upper dir (simulating user modifications)
|
||||||
|
let upper = StoreLayout::new(store.path()).upper_dir(&env_id);
|
||||||
|
if upper.exists() {
|
||||||
|
// Clear build artifacts first
|
||||||
|
let _ = fs::remove_dir_all(&upper);
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
fs::write(upper.join("user_data.txt"), "snapshot baseline").unwrap();
|
||||||
|
|
||||||
|
// Commit a snapshot
|
||||||
|
let snapshot_hash = engine.commit(&env_id).unwrap();
|
||||||
|
assert!(!snapshot_hash.is_empty());
|
||||||
|
|
||||||
|
// Verify snapshot is listed
|
||||||
|
let snapshots = engine.list_snapshots(&env_id).unwrap();
|
||||||
|
assert_eq!(snapshots.len(), 1);
|
||||||
|
|
||||||
|
// Mutate upper dir after snapshot
|
||||||
|
fs::write(upper.join("user_data.txt"), "MODIFIED").unwrap();
|
||||||
|
fs::write(upper.join("extra.txt"), "should disappear").unwrap();
|
||||||
|
|
||||||
|
// Restore from snapshot
|
||||||
|
engine.restore(&env_id, &snapshot_hash).unwrap();
|
||||||
|
|
||||||
|
// Verify restore worked
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(upper.join("user_data.txt")).unwrap(),
|
||||||
|
"snapshot baseline"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!upper.join("extra.txt").exists(),
|
||||||
|
"extra file must be gone after restore"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overlay correctness: files written in upper are visible, base is read-only.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_overlay_file_visibility() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.clone();
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
let upper = layout.upper_dir(&env_id);
|
||||||
|
fs::create_dir_all(&upper).unwrap();
|
||||||
|
|
||||||
|
// Write a file in upper — should be visible via exec
|
||||||
|
fs::write(upper.join("test_marker.txt"), "visible").unwrap();
|
||||||
|
|
||||||
|
// exec `cat /test_marker.txt` should succeed (file visible through overlay)
|
||||||
|
let cmd = vec!["cat".to_owned(), "/test_marker.txt".to_owned()];
|
||||||
|
let result = engine.exec(&env_id, &cmd);
|
||||||
|
// If overlay is correctly mounted, the file is visible
|
||||||
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"files in upper dir must be visible through overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter/exit cycle: repeated enter should not leak state.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_enter_exit_cycle() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.clone();
|
||||||
|
|
||||||
|
// Run exec 20 times — should not accumulate state or leak
|
||||||
|
for i in 0..20 {
|
||||||
|
let cmd = vec!["echo".to_owned(), format!("cycle-{i}")];
|
||||||
|
engine.exec(&env_id, &cmd).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment should still be in Built state
|
||||||
|
let meta = engine.inspect(&env_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
meta.state,
|
||||||
|
EnvState::Built,
|
||||||
|
"env must be Built after enter/exit cycles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IG-M1: Real Runtime Backend Validation ---
|
||||||
|
|
||||||
|
/// Verify no fuse-overlayfs mounts leak after build + exec + destroy cycle.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_mount_leak_detection() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounts_before = fs::read_to_string("/proc/mounts").unwrap_or_default();
|
||||||
|
let fuse_before = mounts_before
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.contains("fuse-overlayfs"))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.clone();
|
||||||
|
|
||||||
|
// Exec inside
|
||||||
|
engine
|
||||||
|
.exec(&env_id, &["echo".to_owned(), "leak-test".to_owned()])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Destroy
|
||||||
|
engine.destroy(&env_id).unwrap();
|
||||||
|
|
||||||
|
let mounts_after = fs::read_to_string("/proc/mounts").unwrap_or_default();
|
||||||
|
let fuse_after = mounts_after
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.contains("fuse-overlayfs"))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
fuse_before, fuse_after,
|
||||||
|
"fuse-overlayfs mount count must not change after build+exec+destroy: before={fuse_before}, after={fuse_after}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repeated build/destroy cycles must not accumulate state or stale mounts.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_build_destroy_20_cycles() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
|
||||||
|
for i in 0..20 {
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.clone();
|
||||||
|
engine.destroy(&env_id).unwrap();
|
||||||
|
assert!(
|
||||||
|
!layout.env_path(&env_id).exists(),
|
||||||
|
"env dir must be gone after destroy in cycle {i}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final integrity check
|
||||||
|
let report = karapace_store::verify_store_integrity(&layout).unwrap();
|
||||||
|
assert!(
|
||||||
|
report.failed.is_empty(),
|
||||||
|
"store integrity must pass after 20 build/destroy cycles: {:?}",
|
||||||
|
report.failed
|
||||||
|
);
|
||||||
|
|
||||||
|
// No stale overlays
|
||||||
|
let mounts = fs::read_to_string("/proc/mounts").unwrap_or_default();
|
||||||
|
let store_path_str = store.path().to_string_lossy();
|
||||||
|
let stale: Vec<&str> = mounts
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.contains("fuse-overlayfs") && l.contains(store_path_str.as_ref()))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
stale.is_empty(),
|
||||||
|
"no stale overlayfs mounts after 20 cycles: {stale:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If an OCI runtime (crun/runc) is available, build and destroy with it.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_oci_build_if_available() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if crun or runc exists
|
||||||
|
let has_oci = std::process::Command::new("which")
|
||||||
|
.arg("crun")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| std::process::Command::new("which")
|
||||||
|
.arg("runc")
|
||||||
|
.output()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_oci {
|
||||||
|
assert!(
|
||||||
|
std::env::var("CI").is_err(),
|
||||||
|
"CI FATAL: OCI test requires crun or runc — install in CI or remove test from CI job"
|
||||||
|
);
|
||||||
|
eprintln!("skipping OCI test: no crun or runc found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest_content = r#"manifest_version = 1
|
||||||
|
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
backend = "oci"
|
||||||
|
"#;
|
||||||
|
let manifest = write_manifest(project.path(), manifest_content);
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = result.identity.env_id.clone();
|
||||||
|
|
||||||
|
let meta = engine.inspect(&env_id).unwrap();
|
||||||
|
assert_eq!(meta.state, EnvState::Built);
|
||||||
|
|
||||||
|
engine.destroy(&env_id).unwrap();
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
assert!(
|
||||||
|
!layout.env_path(&env_id).exists(),
|
||||||
|
"OCI env dir must be cleaned up after destroy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Concurrent exec calls on the same environment must all succeed.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_namespace_concurrent_exec() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = std::sync::Arc::new(Engine::new(store.path()));
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&[]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
let env_id = std::sync::Arc::new(result.identity.env_id.clone());
|
||||||
|
|
||||||
|
let handles: Vec<_> = (0..4)
|
||||||
|
.map(|i| {
|
||||||
|
let eng = std::sync::Arc::clone(&engine);
|
||||||
|
let eid = std::sync::Arc::clone(&env_id);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let cmd = vec!["echo".to_owned(), format!("thread-{i}")];
|
||||||
|
eng.exec(&eid, &cmd).unwrap();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for h in handles {
|
||||||
|
h.join().expect("exec thread must not panic");
|
||||||
|
}
|
||||||
|
|
||||||
|
// No stale .running markers
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
let env_path = layout.env_path(&env_id);
|
||||||
|
if env_path.exists() {
|
||||||
|
let running_markers: Vec<_> = fs::read_dir(&env_path)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().ends_with(".running"))
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
running_markers.is_empty(),
|
||||||
|
"no stale .running markers after concurrent exec: {:?}",
|
||||||
|
running_markers
|
||||||
|
.iter()
|
||||||
|
.map(fs::DirEntry::file_name)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IG-M2: Real Package Resolution Validation ---
|
||||||
|
|
||||||
|
/// Verify resolved packages have real versions (not mock/unresolved).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_resolve_pins_exact_versions() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&["curl"]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
|
||||||
|
for pkg in &result.lock_file.resolved_packages {
|
||||||
|
assert!(
|
||||||
|
!pkg.version.is_empty(),
|
||||||
|
"package {} has empty version",
|
||||||
|
pkg.name
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
pkg.version, "0.0.0-mock",
|
||||||
|
"package {} has mock version — real resolver not running",
|
||||||
|
pkg.name
|
||||||
|
);
|
||||||
|
// Version should contain at least one digit
|
||||||
|
assert!(
|
||||||
|
pkg.version.chars().any(|c| c.is_ascii_digit()),
|
||||||
|
"package {} version '{}' contains no digits — suspect",
|
||||||
|
pkg.name,
|
||||||
|
pkg.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild same manifest must produce identical env_id and resolved versions.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_resolve_deterministic_across_rebuilds() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&["curl"]));
|
||||||
|
let r1 = engine.build(&manifest).unwrap();
|
||||||
|
let r2 = engine.rebuild(&manifest).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
r1.identity.env_id, r2.identity.env_id,
|
||||||
|
"same manifest must produce same env_id"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r1.lock_file.resolved_packages, r2.lock_file.resolved_packages,
|
||||||
|
"resolved packages must be identical across rebuilds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Building with a non-existent package must fail cleanly.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_resolve_nonexistent_package_fails() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(
|
||||||
|
project.path(),
|
||||||
|
&namespace_manifest(&["nonexistent-pkg-that-does-not-exist-xyz"]),
|
||||||
|
);
|
||||||
|
let result = engine.build(&manifest);
|
||||||
|
|
||||||
|
assert!(result.is_err(), "build with non-existent package must fail");
|
||||||
|
|
||||||
|
// No orphaned env directories
|
||||||
|
let layout = StoreLayout::new(store.path());
|
||||||
|
let env_dir = layout.env_dir();
|
||||||
|
if env_dir.exists() {
|
||||||
|
let entries: Vec<_> = fs::read_dir(&env_dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
entries.is_empty(),
|
||||||
|
"no orphaned env dirs after failed build: {:?}",
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(fs::DirEntry::file_name)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build with multiple packages — all must have non-empty resolved versions.
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_resolve_multiple_packages() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&["curl", "git"]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.lock_file.resolved_packages.len() >= 2,
|
||||||
|
"at least 2 resolved packages expected, got {}",
|
||||||
|
result.lock_file.resolved_packages.len()
|
||||||
|
);
|
||||||
|
for pkg in &result.lock_file.resolved_packages {
|
||||||
|
assert!(
|
||||||
|
!pkg.version.is_empty() && pkg.version != "unresolved",
|
||||||
|
"package {} has unresolved version",
|
||||||
|
pkg.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build with packages (requires network to download image + install).
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires Linux user namespaces, fuse-overlayfs, curl, and network"]
|
||||||
|
fn e2e_build_with_packages() {
|
||||||
|
if !prereqs_available() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = tempfile::tempdir().unwrap();
|
||||||
|
let project = tempfile::tempdir().unwrap();
|
||||||
|
let engine = Engine::new(store.path());
|
||||||
|
|
||||||
|
// Use a package whose zypper name matches its RPM name on openSUSE
|
||||||
|
let manifest = write_manifest(project.path(), &namespace_manifest(&["curl"]));
|
||||||
|
let result = engine.build(&manifest).unwrap();
|
||||||
|
|
||||||
|
assert!(!result.identity.env_id.is_empty());
|
||||||
|
|
||||||
|
// Lock file should have resolved packages with real versions
|
||||||
|
let lock = result.lock_file;
|
||||||
|
assert_eq!(lock.lock_version, 2);
|
||||||
|
assert!(!lock.resolved_packages.is_empty());
|
||||||
|
// At least one package should have a resolved version.
|
||||||
|
// Note: some package names may not match their RPM names exactly,
|
||||||
|
// causing fallback to "unresolved". This is a known limitation.
|
||||||
|
let resolved_count = lock
|
||||||
|
.resolved_packages
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.version != "unresolved")
|
||||||
|
.count();
|
||||||
|
assert!(
|
||||||
|
resolved_count > 0,
|
||||||
|
"at least one package should have a resolved version, got: {:?}",
|
||||||
|
lock.resolved_packages
|
||||||
|
);
|
||||||
|
}
|
||||||
444
crates/karapace-core/tests/enospc.rs
Normal file
444
crates/karapace-core/tests/enospc.rs
Normal file
|
|
@ -0,0 +1,444 @@
|
||||||
|
//! IG-M4: True disk-full (ENOSPC) simulation tests.
|
||||||
|
//!
|
||||||
|
//! These tests mount a tiny tmpfs to trigger real ENOSPC conditions.
|
||||||
|
//! They require root (or equivalent) to mount tmpfs, so they are ignored
|
||||||
|
//! by default and run in CI with: `sudo -E cargo test --test enospc -- --ignored`
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Mount a tmpfs of the given size (in KB) at `path`.
|
||||||
|
/// Returns true if successful. Requires root.
|
||||||
|
fn mount_tiny_tmpfs(path: &Path, size_kb: u64) -> bool {
|
||||||
|
std::fs::create_dir_all(path).unwrap();
|
||||||
|
let status = Command::new("mount")
|
||||||
|
.args(["-t", "tmpfs", "-o", &format!("size={size_kb}k"), "tmpfs"])
|
||||||
|
.arg(path)
|
||||||
|
.status();
|
||||||
|
matches!(status, Ok(s) if s.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unmount the tmpfs at `path`.
|
||||||
|
fn unmount(path: &Path) {
|
||||||
|
let _ = Command::new("umount").arg(path).status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII guard that unmounts on drop.
|
||||||
|
struct TmpfsGuard {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TmpfsGuard {
|
||||||
|
fn mount(path: &Path, size_kb: u64) -> Option<Self> {
|
||||||
|
if mount_tiny_tmpfs(path, size_kb) {
|
||||||
|
Some(Self {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TmpfsGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unmount(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_object_put_returns_io_error() {
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("tiny");
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 64)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
let layout = karapace_store::StoreLayout::new(&mount_point);
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
let obj_store = karapace_store::ObjectStore::new(layout);
|
||||||
|
|
||||||
|
// Write objects until we hit ENOSPC
|
||||||
|
let mut hit_error = false;
|
||||||
|
for i in 0..10_000 {
|
||||||
|
let data = format!("object-data-{i}-padding-to-fill-disk-quickly").repeat(10);
|
||||||
|
match obj_store.put(data.as_bytes()) {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
let msg = format!("{e}");
|
||||||
|
eprintln!("ENOSPC triggered at object {i}: {msg}");
|
||||||
|
hit_error = true;
|
||||||
|
// Must be an Io error, never a panic
|
||||||
|
assert!(
|
||||||
|
matches!(e, karapace_store::StoreError::Io(_)),
|
||||||
|
"expected StoreError::Io, got: {e}"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
hit_error,
|
||||||
|
"should have hit ENOSPC within 10000 objects on 64KB tmpfs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_build_fails_cleanly() {
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("tiny");
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 64)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(&mount_point);
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
let manifest = r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
[system]
|
||||||
|
packages = ["curl", "git", "vim", "wget", "htop"]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let manifest_path = mount_point.join("karapace.toml");
|
||||||
|
std::fs::write(&manifest_path, manifest).unwrap();
|
||||||
|
|
||||||
|
let engine = Engine::new(&mount_point);
|
||||||
|
let result = engine.build(&manifest_path);
|
||||||
|
|
||||||
|
// Build must fail (ENOSPC), not panic
|
||||||
|
assert!(result.is_err(), "build on 64KB tmpfs must fail");
|
||||||
|
|
||||||
|
// WAL must have no incomplete entries after error cleanup
|
||||||
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
||||||
|
let incomplete = wal.list_incomplete().unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
incomplete.is_empty(),
|
||||||
|
"WAL must be clean after failed build, found {} incomplete entries",
|
||||||
|
incomplete.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// No orphaned env directories
|
||||||
|
let env_dir = layout.env_dir();
|
||||||
|
if env_dir.exists() {
|
||||||
|
let entries: Vec<_> = std::fs::read_dir(&env_dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
entries.is_empty(),
|
||||||
|
"no orphaned env dirs after failed build, found: {:?}",
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(std::fs::DirEntry::file_name)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_wal_write_fails() {
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("tiny");
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 4)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
// Create minimal store structure
|
||||||
|
let store_dir = mount_point.join("store");
|
||||||
|
std::fs::create_dir_all(store_dir.join("wal")).unwrap();
|
||||||
|
|
||||||
|
// Fill the tmpfs with dummy data until nearly full
|
||||||
|
for i in 0..100 {
|
||||||
|
let path = mount_point.join(format!("filler_{i}"));
|
||||||
|
if std::fs::write(&path, [0u8; 512]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = karapace_store::StoreLayout::new(&mount_point);
|
||||||
|
let wal = karapace_store::WriteAheadLog::new(&layout);
|
||||||
|
|
||||||
|
// WAL begin should fail due to ENOSPC
|
||||||
|
let result = wal.begin(karapace_store::WalOpKind::Build, "test_env");
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"WAL begin on full disk must fail, not panic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_commit_fails_cleanly() {
|
||||||
|
use karapace_core::Engine;
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("medium");
|
||||||
|
// 256KB — enough for build, but commit with large upper should fail
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 256)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(&mount_point);
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
let manifest = r#"
|
||||||
|
manifest_version = 1
|
||||||
|
[base]
|
||||||
|
image = "rolling"
|
||||||
|
"#;
|
||||||
|
let manifest_path = mount_point.join("karapace.toml");
|
||||||
|
std::fs::write(&manifest_path, manifest).unwrap();
|
||||||
|
|
||||||
|
let engine = Engine::new(&mount_point);
|
||||||
|
|
||||||
|
// Build must succeed on 256KB — if it doesn't, the test setup is wrong
|
||||||
|
let build_result = engine.build(&manifest_path);
|
||||||
|
assert!(
|
||||||
|
build_result.is_ok(),
|
||||||
|
"build on 256KB tmpfs must succeed for commit test to be valid: {:?}",
|
||||||
|
build_result.err()
|
||||||
|
);
|
||||||
|
let env_id = build_result.unwrap().identity.env_id;
|
||||||
|
|
||||||
|
// Write enough data to the upper dir to fill the disk
|
||||||
|
let upper = layout.upper_dir(&env_id);
|
||||||
|
std::fs::create_dir_all(&upper).unwrap();
|
||||||
|
let mut filled = false;
|
||||||
|
for i in 0..500 {
|
||||||
|
let path = upper.join(format!("bigfile_{i}"));
|
||||||
|
if std::fs::write(&path, [0xAB; 1024]).is_err() {
|
||||||
|
filled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
filled,
|
||||||
|
"must fill disk before commit — 256KB tmpfs should be exhaustible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Commit MUST fail due to ENOSPC during layer packing
|
||||||
|
let commit_result = engine.commit(&env_id);
|
||||||
|
assert!(
|
||||||
|
commit_result.is_err(),
|
||||||
|
"commit on full disk MUST fail — test is invalid if it succeeds"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify env state is still Built (not corrupted)
|
||||||
|
let meta = karapace_store::MetadataStore::new(layout.clone())
|
||||||
|
.get(&env_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
meta.state,
|
||||||
|
karapace_store::EnvState::Built,
|
||||||
|
"env state must remain Built after failed commit"
|
||||||
|
);
|
||||||
|
|
||||||
|
// No partial commit artifacts
|
||||||
|
let layers_dir = layout.layers_dir();
|
||||||
|
if layers_dir.exists() {
|
||||||
|
let staging = layout.staging_dir();
|
||||||
|
if staging.exists() {
|
||||||
|
let staging_entries: Vec<_> = std::fs::read_dir(&staging)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
staging_entries.is_empty(),
|
||||||
|
"no partial staging artifacts after failed commit: {:?}",
|
||||||
|
staging_entries
|
||||||
|
.iter()
|
||||||
|
.map(std::fs::DirEntry::file_name)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_recovery_after_freeing_space() {
|
||||||
|
use karapace_store::{ObjectStore, StoreLayout};
|
||||||
|
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("recov");
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 128)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(&mount_point);
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
let obj_store = ObjectStore::new(layout);
|
||||||
|
|
||||||
|
// Fill with objects
|
||||||
|
let mut hashes = Vec::new();
|
||||||
|
for i in 0..500 {
|
||||||
|
let data = format!("fill-data-{i}").repeat(5);
|
||||||
|
match obj_store.put(data.as_bytes()) {
|
||||||
|
Ok(h) => hashes.push(h),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!hashes.is_empty(), "should have stored at least one object");
|
||||||
|
|
||||||
|
// Attempt one more write — MUST fail (disk full)
|
||||||
|
let big_data = [0xCD; 4096];
|
||||||
|
let err_result = obj_store.put(&big_data);
|
||||||
|
assert!(
|
||||||
|
err_result.is_err(),
|
||||||
|
"128KB tmpfs must be full after filling — test setup invalid if write succeeds"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete half the objects to free space
|
||||||
|
let objects_dir = mount_point.join("store").join("objects");
|
||||||
|
let half = hashes.len() / 2;
|
||||||
|
for h in &hashes[..half] {
|
||||||
|
let _ = std::fs::remove_file(objects_dir.join(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now writes should succeed again
|
||||||
|
let recovery_result = obj_store.put(b"recovery data after freeing space");
|
||||||
|
assert!(
|
||||||
|
recovery_result.is_ok(),
|
||||||
|
"write must succeed after freeing space: {:?}",
|
||||||
|
recovery_result.err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_layer_put_fails_cleanly() {
|
||||||
|
use karapace_store::{LayerKind, LayerManifest, LayerStore, StoreLayout};
|
||||||
|
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("tiny_layer");
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 8)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(&mount_point);
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
// Fill the tmpfs
|
||||||
|
for i in 0..200 {
|
||||||
|
let path = mount_point.join(format!("filler_{i}"));
|
||||||
|
if std::fs::write(&path, [0u8; 256]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let layer_store = LayerStore::new(layout.clone());
|
||||||
|
let manifest = LayerManifest {
|
||||||
|
hash: "test_layer_enospc".to_owned(),
|
||||||
|
kind: LayerKind::Base,
|
||||||
|
parent: None,
|
||||||
|
object_refs: vec!["obj1".to_owned(), "obj2".to_owned()],
|
||||||
|
read_only: true,
|
||||||
|
tar_hash: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = layer_store.put(&manifest);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"layer put on full disk MUST fail, not succeed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
result.as_ref().unwrap_err(),
|
||||||
|
karapace_store::StoreError::Io(_)
|
||||||
|
),
|
||||||
|
"expected StoreError::Io, got: {:?}",
|
||||||
|
result.unwrap_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_metadata_put_fails_cleanly() {
|
||||||
|
use karapace_store::{EnvMetadata, EnvState, MetadataStore, StoreLayout};
|
||||||
|
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("tiny_meta");
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 8)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
let layout = StoreLayout::new(&mount_point);
|
||||||
|
layout.initialize().unwrap();
|
||||||
|
|
||||||
|
// Fill the tmpfs
|
||||||
|
for i in 0..200 {
|
||||||
|
let path = mount_point.join(format!("filler_{i}"));
|
||||||
|
if std::fs::write(&path, [0u8; 256]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta_store = MetadataStore::new(layout);
|
||||||
|
let meta = EnvMetadata {
|
||||||
|
env_id: "enospc_test_env".into(),
|
||||||
|
short_id: "enospc_test".into(),
|
||||||
|
name: Some("enospc-test".to_owned()),
|
||||||
|
state: EnvState::Built,
|
||||||
|
base_layer: "fake_layer".into(),
|
||||||
|
dependency_layers: vec![],
|
||||||
|
policy_layer: None,
|
||||||
|
manifest_hash: "fake_hash".into(),
|
||||||
|
ref_count: 1,
|
||||||
|
created_at: "2025-01-01T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2025-01-01T00:00:00Z".to_owned(),
|
||||||
|
checksum: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = meta_store.put(&meta);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"metadata put on full disk MUST fail, not succeed"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
result.as_ref().unwrap_err(),
|
||||||
|
karapace_store::StoreError::Io(_)
|
||||||
|
),
|
||||||
|
"expected StoreError::Io, got: {:?}",
|
||||||
|
result.unwrap_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires root for tmpfs mount"]
|
||||||
|
fn enospc_version_file_write_fails() {
|
||||||
|
use karapace_store::StoreLayout;
|
||||||
|
|
||||||
|
let base = tempfile::tempdir().unwrap();
|
||||||
|
let mount_point = base.path().join("tiny_ver");
|
||||||
|
// Very small: just enough for dirs but not for version file after fill
|
||||||
|
let _guard = TmpfsGuard::mount(&mount_point, 4)
|
||||||
|
.expect("failed to mount tmpfs — are you running as root?");
|
||||||
|
|
||||||
|
// Manually create minimal dirs (initialize writes version file, we want it to fail)
|
||||||
|
let store_dir = mount_point.join("store");
|
||||||
|
std::fs::create_dir_all(store_dir.join("objects")).unwrap();
|
||||||
|
std::fs::create_dir_all(store_dir.join("layers")).unwrap();
|
||||||
|
std::fs::create_dir_all(store_dir.join("metadata")).unwrap();
|
||||||
|
std::fs::create_dir_all(store_dir.join("staging")).unwrap();
|
||||||
|
std::fs::create_dir_all(mount_point.join("env")).unwrap();
|
||||||
|
|
||||||
|
// Fill the tmpfs completely
|
||||||
|
for i in 0..200 {
|
||||||
|
let path = mount_point.join(format!("filler_{i}"));
|
||||||
|
if std::fs::write(&path, [0u8; 256]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now try to initialize (which writes the version file) — must fail
|
||||||
|
let layout = StoreLayout::new(&mount_point);
|
||||||
|
let result = layout.initialize();
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"StoreLayout::initialize on full disk MUST fail when writing version file"
|
||||||
|
);
|
||||||
|
}
|
||||||
2337
crates/karapace-core/tests/integration.rs
Normal file
2337
crates/karapace-core/tests/integration.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue