Skip to content

Commit 6c16d20

Browse files
committed
integration-tests: Add crate with cstor/skopeo equivalence test
Add a libtest-mimic based integration test suite that: - Runs as a userns helper (calls init_if_helper at startup) - Tests cfsctl CLI functionality - Compares containers-storage vs skopeo import paths The equivalence test builds a minimal podman image, imports via both paths, and verifies both produce identical splitstream digests. Assisted-by: OpenCode (Claude claude-opus-4-5-20250514)
1 parent 8728f3e commit 6c16d20

6 files changed

Lines changed: 432 additions & 2 deletions

File tree

Justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ test-all:
3939
build-cstorage:
4040
cargo build --workspace --features containers-storage
4141

42+
# Run integration tests (requires podman and skopeo)
43+
integration-test: build-release
44+
cargo run --release -p integration-tests --bin integration-tests
45+
4246
# Clean build artifacts
4347
clean:
4448
cargo clean

crates/composefs/src/repository.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,8 +1406,6 @@ impl<ObjectID: FsVerityHashValue> Repository<ObjectID> {
14061406

14071407
#[cfg(test)]
14081408
mod tests {
1409-
use std::vec;
1410-
14111409
use super::*;
14121410
use crate::fsverity::Sha512HashValue;
14131411
use crate::test::tempdir;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "integration-tests"
3+
version = "0.0.0"
4+
edition.workspace = true
5+
license.workspace = true
6+
publish = false
7+
description = "Integration tests for composefs-rs (not published)"
8+
9+
# The main integration test binary
10+
[[bin]]
11+
name = "integration-tests"
12+
path = "src/main.rs"
13+
14+
[[bin]]
15+
name = "test-cleanup"
16+
path = "src/cleanup.rs"
17+
18+
[dependencies]
19+
anyhow = "1"
20+
composefs = { workspace = true }
21+
composefs-oci = { path = "../composefs-oci", features = ["containers-storage"] }
22+
cstorage = { path = "../cstorage", features = ["userns-helper"] }
23+
hex = "0.4"
24+
libtest-mimic = "0.8"
25+
rustix = { version = "1", features = ["fs"] }
26+
serde = { version = "1", features = ["derive"] }
27+
serde_json = "1"
28+
tempfile = "3"
29+
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
30+
xshell = "0.2"
31+
32+
# This crate doesn't follow the same linting rules
33+
[lints.rust]
34+
missing_docs = "allow"
35+
missing_debug_implementations = "allow"
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//! Cleanup utility for integration test resources
2+
//!
3+
//! This binary cleans up any leftover resources from integration tests.
4+
5+
use std::process::Command;
6+
7+
use integration_tests::INTEGRATION_TEST_LABEL;
8+
9+
fn main() {
10+
println!("Cleaning up integration test resources...");
11+
12+
// Clean up podman containers with our label
13+
let output = Command::new("podman")
14+
.args([
15+
"ps",
16+
"-a",
17+
"--filter",
18+
&format!("label={}", INTEGRATION_TEST_LABEL),
19+
"-q",
20+
])
21+
.output();
22+
23+
if let Ok(output) = output {
24+
let container_ids = String::from_utf8_lossy(&output.stdout);
25+
for id in container_ids.lines() {
26+
if !id.is_empty() {
27+
println!("Removing container: {}", id);
28+
let _ = Command::new("podman").args(["rm", "-f", id]).output();
29+
}
30+
}
31+
}
32+
33+
// Clean up podman images with our label
34+
let output = Command::new("podman")
35+
.args([
36+
"images",
37+
"--filter",
38+
&format!("label={}", INTEGRATION_TEST_LABEL),
39+
"-q",
40+
])
41+
.output();
42+
43+
if let Ok(output) = output {
44+
let image_ids = String::from_utf8_lossy(&output.stdout);
45+
for id in image_ids.lines() {
46+
if !id.is_empty() {
47+
println!("Removing image: {}", id);
48+
let _ = Command::new("podman").args(["rmi", "-f", id]).output();
49+
}
50+
}
51+
}
52+
53+
println!("Cleanup complete.");
54+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//! Integration test utilities for composefs-rs
2+
//!
3+
//! This library provides utilities for running integration tests.
4+
//! The main test runner is in main.rs.
5+
6+
use std::process::{Command, ExitStatus, Stdio};
7+
use std::sync::Arc;
8+
9+
use anyhow::{Context, Result};
10+
use composefs::fsverity::Sha256HashValue;
11+
use composefs::repository::Repository;
12+
use tempfile::TempDir;
13+
14+
/// Test label for cleanup
15+
pub const INTEGRATION_TEST_LABEL: &str = "composefs-rs.integration-test=1";
16+
17+
/// Get the path to cfsctl binary
18+
pub fn get_cfsctl_path() -> Result<String> {
19+
// Check environment first
20+
if let Ok(path) = std::env::var("CFSCTL_PATH") {
21+
return Ok(path);
22+
}
23+
// Look in common locations
24+
for path in [
25+
"./target/release/cfsctl",
26+
"./target/debug/cfsctl",
27+
"/usr/bin/cfsctl",
28+
] {
29+
if std::path::Path::new(path).exists() {
30+
return Ok(path.to_string());
31+
}
32+
}
33+
anyhow::bail!("cfsctl not found; set CFSCTL_PATH or build with `cargo build --release`")
34+
}
35+
36+
/// Get the primary test image
37+
pub fn get_primary_image() -> String {
38+
std::env::var("COMPOSEFS_RS_PRIMARY_IMAGE")
39+
.unwrap_or_else(|_| "quay.io/centos-bootc/centos-bootc:stream10".to_string())
40+
}
41+
42+
/// Get all test images
43+
pub fn get_all_images() -> Vec<String> {
44+
std::env::var("COMPOSEFS_RS_ALL_IMAGES")
45+
.unwrap_or_else(|_| get_primary_image())
46+
.split_whitespace()
47+
.map(String::from)
48+
.collect()
49+
}
50+
51+
/// Captured command output
52+
#[derive(Debug)]
53+
pub struct CapturedOutput {
54+
/// Exit status
55+
pub status: ExitStatus,
56+
/// Captured stdout
57+
pub stdout: String,
58+
/// Captured stderr
59+
pub stderr: String,
60+
}
61+
62+
impl CapturedOutput {
63+
/// Assert the command succeeded
64+
pub fn assert_success(&self) -> Result<()> {
65+
if !self.status.success() {
66+
anyhow::bail!(
67+
"Command failed with status {}\nstdout: {}\nstderr: {}",
68+
self.status,
69+
self.stdout,
70+
self.stderr
71+
);
72+
}
73+
Ok(())
74+
}
75+
}
76+
77+
/// Run a command and capture output
78+
pub fn run_command(cmd: &str, args: &[&str]) -> Result<CapturedOutput> {
79+
let output = Command::new(cmd)
80+
.args(args)
81+
.stdout(Stdio::piped())
82+
.stderr(Stdio::piped())
83+
.output()
84+
.with_context(|| format!("Failed to execute: {} {:?}", cmd, args))?;
85+
86+
Ok(CapturedOutput {
87+
status: output.status,
88+
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
89+
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
90+
})
91+
}
92+
93+
/// Run cfsctl with arguments
94+
pub fn run_cfsctl(args: &[&str]) -> Result<CapturedOutput> {
95+
let cfsctl = get_cfsctl_path()?;
96+
run_command(&cfsctl, args)
97+
}
98+
99+
/// Create a test repository in a temporary directory.
100+
///
101+
/// The TempDir is returned alongside the repo to keep it alive.
102+
pub fn create_test_repository(tempdir: &TempDir) -> Result<Arc<Repository<Sha256HashValue>>> {
103+
let fd = rustix::fs::open(
104+
tempdir.path(),
105+
rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::PATH,
106+
0.into(),
107+
)?;
108+
109+
let mut repo = Repository::open_path(&fd, ".")?;
110+
repo.set_insecure(true);
111+
Ok(Arc::new(repo))
112+
}
113+
114+
/// Build a minimal test image using podman and return its ID
115+
pub fn build_test_image() -> Result<String> {
116+
let temp_dir = TempDir::new()?;
117+
let containerfile = temp_dir.path().join("Containerfile");
118+
119+
// Create a simple Containerfile with various file sizes to test
120+
// both inline and external storage paths
121+
std::fs::write(
122+
&containerfile,
123+
r#"FROM busybox:latest
124+
# Small file (should be inlined)
125+
RUN echo "small content" > /small.txt
126+
# Larger file (should be external)
127+
RUN dd if=/dev/zero of=/large.bin bs=1024 count=100 2>/dev/null
128+
# Directory with files
129+
RUN mkdir -p /testdir && echo "file1" > /testdir/a.txt && echo "file2" > /testdir/b.txt
130+
# Symlink
131+
RUN ln -s /small.txt /link.txt
132+
"#,
133+
)?;
134+
135+
let iid_file = temp_dir.path().join("image.iid");
136+
137+
let output = Command::new("podman")
138+
.args([
139+
"build",
140+
"--pull=newer",
141+
&format!("--iidfile={}", iid_file.display()),
142+
"-f",
143+
&containerfile.to_string_lossy(),
144+
&temp_dir.path().to_string_lossy(),
145+
])
146+
.output()?;
147+
148+
if !output.status.success() {
149+
anyhow::bail!(
150+
"podman build failed: {}",
151+
String::from_utf8_lossy(&output.stderr)
152+
);
153+
}
154+
155+
let image_id = std::fs::read_to_string(&iid_file)?.trim().to_string();
156+
Ok(image_id)
157+
}
158+
159+
/// Remove a test image
160+
pub fn cleanup_test_image(image_id: &str) {
161+
let _ = Command::new("podman")
162+
.args(["rmi", "-f", image_id])
163+
.output();
164+
}

0 commit comments

Comments
 (0)