Skip to content

Commit 77beaa2

Browse files
committed
integration-tests: Add containers-storage import tests
Add comprehensive integration tests for the containers-storage import functionality: cstor module: - test_cstor_idempotent_import: Verify second import uses cache - test_cstor_import_with_reference: Test import with stream reference - test_cstor_vs_skopeo_equivalence: Compare cstor vs skopeo import paths cli module updates: - Add test utilities for building test container images - Add helper functions for running cstor imports Also adds a test-cleanup binary for cleaning up test resources, and updates the Justfile to explicitly specify the test binary name since the package now has multiple binaries. Tests use `quay.io/centos/centos:stream10` as base image for compatibility with CI environments. Assisted-by: OpenCode (Opus 4.5)
1 parent 5ca2fb3 commit 77beaa2

8 files changed

Lines changed: 837 additions & 9 deletions

File tree

Justfile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ COMPOSEFS_TEST_IMAGE := "localhost/composefs-rs-test:latest"
3535

3636
# Run integration tests (builds cfsctl first); pass extra args to the harness
3737
test-integration *ARGS: build
38-
CFSCTL_PATH=$(pwd)/target/debug/cfsctl cargo run -p integration-tests -- {{ ARGS }}
38+
CFSCTL_PATH=$(pwd)/target/debug/cfsctl cargo run -p integration-tests --bin cfsctl-integration-tests -- {{ ARGS }}
3939

4040
# Run only the fast unprivileged integration tests (no root, no VM)
4141
integration-unprivileged: build
42-
CFSCTL_PATH=$(pwd)/target/debug/cfsctl cargo run -p integration-tests -- --skip privileged_
42+
CFSCTL_PATH=$(pwd)/target/debug/cfsctl cargo run -p integration-tests --bin cfsctl-integration-tests -- --skip privileged_
4343

4444
# Build the test container image for VM-based integration tests
4545
integration-container-build:
@@ -49,7 +49,7 @@ integration-container-build:
4949
integration-container: build integration-container-build
5050
COMPOSEFS_TEST_IMAGE={{COMPOSEFS_TEST_IMAGE}} \
5151
CFSCTL_PATH=$(pwd)/target/debug/cfsctl \
52-
cargo run -p integration-tests
52+
cargo run -p integration-tests --bin cfsctl-integration-tests
5353

5454
# Run all tests with all features enabled
5555
test-all:
@@ -59,6 +59,10 @@ test-all:
5959
build-cstorage:
6060
cargo build --workspace --features containers-storage
6161

62+
# Run integration tests (requires podman and skopeo)
63+
integration-test: build-release
64+
cargo run --release -p integration-tests --bin integration-tests
65+
6266
# Clean build artifacts
6367
clean:
6468
cargo clean

crates/integration-tests/Cargo.toml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[package]
22
name = "integration-tests"
33
publish = false
4+
description = "Integration tests for composefs-rs (not published)"
45

56
edition.workspace = true
67
license.workspace = true
@@ -12,18 +13,26 @@ version.workspace = true
1213
name = "cfsctl-integration-tests"
1314
path = "src/main.rs"
1415

16+
[[bin]]
17+
name = "test-cleanup"
18+
path = "src/cleanup.rs"
19+
1520
[dependencies]
1621
anyhow = "1"
1722
cap-std-ext = "4.0"
1823
composefs = { workspace = true }
24+
composefs-oci = { path = "../composefs-oci", features = ["containers-storage"] }
25+
hex = "0.4"
1926
libtest-mimic = "0.8"
2027
linkme = "0.3"
2128
ocidir = "0.6"
2229
paste = "1"
23-
rustix = { version = "1.0.0", default-features = false, features = ["process"] }
24-
serde_json = "1.0"
30+
rustix = { version = "1", features = ["fs", "process"] }
31+
serde = { version = "1", features = ["derive"] }
32+
serde_json = "1"
2533
tar = "0.4"
2634
tempfile = "3"
35+
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
2736
xshell = "0.2"
2837

2938
[lints]
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+
}

crates/integration-tests/src/lib.rs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@
77
// linkme requires unsafe for distributed slices
88
#![allow(unsafe_code)]
99

10+
use std::process::{Command, ExitStatus, Stdio};
11+
use std::sync::Arc;
12+
13+
use anyhow::{Context, Result};
14+
use composefs::fsverity::Sha256HashValue;
15+
use composefs::repository::Repository;
16+
use tempfile::TempDir;
17+
1018
/// A test function that returns a Result.
1119
pub type TestFn = fn() -> anyhow::Result<()>;
1220

@@ -50,3 +58,202 @@ macro_rules! integration_test {
5058
}
5159
};
5260
}
61+
62+
// ============================================================================
63+
// Utilities for containers-storage tests
64+
// ============================================================================
65+
66+
/// Test label for cleanup
67+
pub const INTEGRATION_TEST_LABEL: &str = "composefs-rs.integration-test=1";
68+
69+
/// Get the path to cfsctl binary
70+
pub fn get_cfsctl_path() -> Result<String> {
71+
// Check environment first
72+
if let Ok(path) = std::env::var("CFSCTL_PATH") {
73+
return Ok(path);
74+
}
75+
// Look in common locations
76+
for path in [
77+
"./target/release/cfsctl",
78+
"./target/debug/cfsctl",
79+
"/usr/bin/cfsctl",
80+
] {
81+
if std::path::Path::new(path).exists() {
82+
return Ok(path.to_string());
83+
}
84+
}
85+
anyhow::bail!("cfsctl not found; set CFSCTL_PATH or build with `cargo build --release`")
86+
}
87+
88+
/// Get the primary test image
89+
pub fn get_primary_image() -> String {
90+
std::env::var("COMPOSEFS_RS_PRIMARY_IMAGE")
91+
.unwrap_or_else(|_| "quay.io/centos-bootc/centos-bootc:stream10".to_string())
92+
}
93+
94+
/// Get all test images
95+
pub fn get_all_images() -> Vec<String> {
96+
std::env::var("COMPOSEFS_RS_ALL_IMAGES")
97+
.unwrap_or_else(|_| get_primary_image())
98+
.split_whitespace()
99+
.map(String::from)
100+
.collect()
101+
}
102+
103+
/// Captured command output
104+
#[derive(Debug)]
105+
pub struct CapturedOutput {
106+
/// Exit status
107+
pub status: ExitStatus,
108+
/// Captured stdout
109+
pub stdout: String,
110+
/// Captured stderr
111+
pub stderr: String,
112+
}
113+
114+
impl CapturedOutput {
115+
/// Assert the command succeeded
116+
pub fn assert_success(&self) -> Result<()> {
117+
if !self.status.success() {
118+
anyhow::bail!(
119+
"Command failed with status {}\nstdout: {}\nstderr: {}",
120+
self.status,
121+
self.stdout,
122+
self.stderr
123+
);
124+
}
125+
Ok(())
126+
}
127+
}
128+
129+
/// Run a command and capture output
130+
pub fn run_command(cmd: &str, args: &[&str]) -> Result<CapturedOutput> {
131+
let output = Command::new(cmd)
132+
.args(args)
133+
.stdout(Stdio::piped())
134+
.stderr(Stdio::piped())
135+
.output()
136+
.with_context(|| format!("Failed to execute: {} {:?}", cmd, args))?;
137+
138+
Ok(CapturedOutput {
139+
status: output.status,
140+
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
141+
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
142+
})
143+
}
144+
145+
/// Run cfsctl with arguments
146+
pub fn run_cfsctl(args: &[&str]) -> Result<CapturedOutput> {
147+
let cfsctl = get_cfsctl_path()?;
148+
run_command(&cfsctl, args)
149+
}
150+
151+
/// Create a test repository in a temporary directory.
152+
///
153+
/// The TempDir is returned alongside the repo to keep it alive.
154+
pub fn create_test_repository(tempdir: &TempDir) -> Result<Arc<Repository<Sha256HashValue>>> {
155+
let fd = rustix::fs::open(
156+
tempdir.path(),
157+
rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::PATH,
158+
0.into(),
159+
)?;
160+
161+
let mut repo = Repository::open_path(&fd, ".")?;
162+
repo.set_insecure(true);
163+
Ok(Arc::new(repo))
164+
}
165+
166+
/// Check if rootless podman works in this environment.
167+
///
168+
/// In nested container environments (e.g., devcontainers), rootless podman
169+
/// may fail due to user namespace restrictions. This function detects that
170+
/// and returns whether we need to use sudo.
171+
fn needs_sudo_for_podman() -> bool {
172+
// Try a simple rootless podman command
173+
let output = Command::new("podman")
174+
.args(["info", "--format", "{{.Host.RemoteSocket.Exists}}"])
175+
.stdout(Stdio::null())
176+
.stderr(Stdio::piped())
177+
.output();
178+
179+
match output {
180+
Ok(o) if o.status.success() => false,
181+
_ => {
182+
// Rootless failed, check if sudo podman works
183+
let sudo_output = Command::new("sudo")
184+
.args([
185+
"podman",
186+
"info",
187+
"--format",
188+
"{{.Host.RemoteSocket.Exists}}",
189+
])
190+
.stdout(Stdio::null())
191+
.stderr(Stdio::null())
192+
.output();
193+
matches!(sudo_output, Ok(o) if o.status.success())
194+
}
195+
}
196+
}
197+
198+
/// Get the podman command, using sudo if needed for this environment.
199+
fn podman_command() -> Command {
200+
if needs_sudo_for_podman() {
201+
let mut cmd = Command::new("sudo");
202+
cmd.arg("podman");
203+
cmd
204+
} else {
205+
Command::new("podman")
206+
}
207+
}
208+
209+
/// Build a minimal test image using podman and return its ID
210+
pub fn build_test_image() -> Result<String> {
211+
let temp_dir = TempDir::new()?;
212+
let containerfile = temp_dir.path().join("Containerfile");
213+
214+
// Create a simple Containerfile with various file sizes to test
215+
// both inline and external storage paths.
216+
// Use Fedora instead of busybox because busybox has UID 65534 which
217+
// breaks in nested container environments due to user namespace issues.
218+
std::fs::write(
219+
&containerfile,
220+
r#"FROM quay.io/centos/centos:stream10
221+
# Small file (should be inlined)
222+
RUN echo "small content" > /small.txt
223+
# Larger file (should be external)
224+
RUN dd if=/dev/zero of=/large.bin bs=1024 count=100 2>/dev/null
225+
# Directory with files
226+
RUN mkdir -p /testdir && echo "file1" > /testdir/a.txt && echo "file2" > /testdir/b.txt
227+
# Symlink
228+
RUN ln -s /small.txt /link.txt
229+
"#,
230+
)?;
231+
232+
let iid_file = temp_dir.path().join("image.iid");
233+
234+
let output = podman_command()
235+
.args([
236+
"build",
237+
"--pull=newer",
238+
&format!("--iidfile={}", iid_file.display()),
239+
"-f",
240+
&containerfile.to_string_lossy(),
241+
&temp_dir.path().to_string_lossy(),
242+
])
243+
.output()?;
244+
245+
if !output.status.success() {
246+
anyhow::bail!(
247+
"podman build failed: {}",
248+
String::from_utf8_lossy(&output.stderr)
249+
);
250+
}
251+
252+
let image_id = std::fs::read_to_string(&iid_file)?.trim().to_string();
253+
Ok(image_id)
254+
}
255+
256+
/// Remove a test image
257+
pub fn cleanup_test_image(image_id: &str) {
258+
let _ = podman_command().args(["rmi", "-f", image_id]).output();
259+
}

crates/integration-tests/src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
//! This binary uses [`libtest_mimic`] as a custom test harness (no `#[test]`).
44
//! Tests are registered via the [`integration_test!`] macro in submodules
55
//! and collected from the [`INTEGRATION_TESTS`] distributed slice at startup.
6+
//!
7+
//! IMPORTANT: This binary may be re-executed via `podman unshare` to act as a
8+
//! userns helper for rootless containers-storage access. The init_if_helper()
9+
//! call at the start of main() handles this.
610
711
// linkme requires unsafe for distributed slices
812
#![allow(unsafe_code)]
@@ -71,6 +75,11 @@ pub(crate) fn create_test_rootfs(parent: &Path) -> Result<PathBuf> {
7175
}
7276

7377
fn main() {
78+
// CRITICAL: Handle userns helper re-execution.
79+
// When running rootless, this binary may be re-executed via `podman unshare`
80+
// to act as a helper process for containers-storage access.
81+
composefs_oci::cstor::init_if_helper();
82+
7483
let args = Arguments::from_args();
7584

7685
let tests: Vec<Trial> = INTEGRATION_TESTS

0 commit comments

Comments
 (0)