Skip to content

Commit e5596a4

Browse files
committed
tests: Add CLI integration tests for tag, untag, GC, and compute-id
Add four new integration tests exercising existing cfsctl functionality through the CLI: - test_oci_tag_and_untag: multi-tag and selective untag workflow - test_oci_gc_removes_untagged: verifies GC collects untagged images - test_layer_tar_roundtrip: imports a layer and verifies tar extraction - test_compute_image_id: deterministic fs-verity image ID computation Also fix create_oci_layout to include a runtime config (ConfigBuilder) which is required for the seal/compute-id operations. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 7785939 commit e5596a4

1 file changed

Lines changed: 292 additions & 1 deletion

File tree

  • crates/integration-tests/src/tests

crates/integration-tests/src/tests/cli.rs

Lines changed: 292 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ integration_test!(test_oci_images_json_empty_repo);
215215
fn create_oci_layout(parent: &std::path::Path) -> Result<std::path::PathBuf> {
216216
use cap_std_ext::cap_std;
217217
use ocidir::oci_spec::image::{
218-
ImageConfigurationBuilder, Platform, PlatformBuilder, RootFsBuilder,
218+
ConfigBuilder, ImageConfigurationBuilder, Platform, PlatformBuilder, RootFsBuilder,
219219
};
220220

221221
let oci_dir = parent.join("oci-image");
@@ -227,6 +227,9 @@ fn create_oci_layout(parent: &std::path::Path) -> Result<std::path::PathBuf> {
227227
// Create a new empty manifest
228228
let mut manifest = ocidir.new_empty_manifest()?.build()?;
229229

230+
// Create runtime config (required for seal operation)
231+
let runtime_config = ConfigBuilder::default().build()?;
232+
230233
// Create config with architecture and OS
231234
let rootfs = RootFsBuilder::default()
232235
.typ("layers")
@@ -236,6 +239,7 @@ fn create_oci_layout(parent: &std::path::Path) -> Result<std::path::PathBuf> {
236239
.architecture("amd64")
237240
.os("linux")
238241
.rootfs(rootfs)
242+
.config(runtime_config)
239243
.build()?;
240244

241245
// Create a simple layer with a usr/ directory and one file
@@ -1397,3 +1401,290 @@ fn test_init_reset_metadata_changes_algorithm() -> Result<()> {
13971401
Ok(())
13981402
}
13991403
integration_test!(test_init_reset_metadata_changes_algorithm);
1404+
1405+
/// Test tagging and untagging OCI images.
1406+
///
1407+
/// Verifies that:
1408+
/// - An image can be tagged with multiple names
1409+
/// - Tags appear in `oci images` output
1410+
/// - Tags can be removed with `oci untag`
1411+
/// - Untagging one name doesn't affect other tags
1412+
fn test_oci_tag_and_untag() -> Result<()> {
1413+
let sh = Shell::new()?;
1414+
let cfsctl = cfsctl()?;
1415+
let repo_dir = init_insecure_repo(&sh, &cfsctl)?;
1416+
let repo = repo_dir.path();
1417+
let fixture_dir = tempfile::tempdir()?;
1418+
let oci_layout = create_oci_layout(fixture_dir.path())?;
1419+
1420+
// Pull and tag with first name
1421+
let pull_output = cmd!(
1422+
sh,
1423+
"{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} myimage:v1"
1424+
)
1425+
.read()?;
1426+
1427+
// Extract manifest digest from pull output (e.g., "manifest sha256:abc...")
1428+
let manifest_digest = pull_output
1429+
.lines()
1430+
.find(|line| line.contains("manifest sha256:"))
1431+
.and_then(|line| line.split_whitespace().find(|s| s.starts_with("sha256:")))
1432+
.expect("expected manifest digest in pull output");
1433+
1434+
// Add a second tag using the manifest digest
1435+
cmd!(
1436+
sh,
1437+
"{cfsctl} --insecure --repo {repo} oci tag {manifest_digest} myimage:latest"
1438+
)
1439+
.read()?;
1440+
1441+
// Both tags should appear in list
1442+
let list_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images --json").read()?;
1443+
let images: serde_json::Value = serde_json::from_str(&list_output)?;
1444+
let names: Vec<&str> = images
1445+
.as_array()
1446+
.unwrap()
1447+
.iter()
1448+
.map(|img| img["name"].as_str().unwrap())
1449+
.collect();
1450+
assert!(names.contains(&"myimage:v1"), "expected myimage:v1 in list");
1451+
assert!(
1452+
names.contains(&"myimage:latest"),
1453+
"expected myimage:latest in list"
1454+
);
1455+
1456+
// Remove one tag
1457+
cmd!(sh, "{cfsctl} --insecure --repo {repo} oci untag myimage:v1").read()?;
1458+
1459+
// Only the remaining tag should appear
1460+
let list_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images --json").read()?;
1461+
let images: serde_json::Value = serde_json::from_str(&list_output)?;
1462+
let names: Vec<&str> = images
1463+
.as_array()
1464+
.unwrap()
1465+
.iter()
1466+
.map(|img| img["name"].as_str().unwrap())
1467+
.collect();
1468+
assert!(
1469+
!names.contains(&"myimage:v1"),
1470+
"myimage:v1 should be removed"
1471+
);
1472+
assert!(
1473+
names.contains(&"myimage:latest"),
1474+
"myimage:latest should still exist"
1475+
);
1476+
1477+
Ok(())
1478+
}
1479+
integration_test!(test_oci_tag_and_untag);
1480+
1481+
/// Test that GC removes untagged OCI images.
1482+
///
1483+
/// Verifies that:
1484+
/// - After untagging all references, GC collects the image
1485+
/// - Objects are actually removed from the repository
1486+
fn test_oci_gc_removes_untagged() -> Result<()> {
1487+
let sh = Shell::new()?;
1488+
let cfsctl = cfsctl()?;
1489+
let repo_dir = init_insecure_repo(&sh, &cfsctl)?;
1490+
let repo = repo_dir.path();
1491+
let fixture_dir = tempfile::tempdir()?;
1492+
let oci_layout = create_oci_layout(fixture_dir.path())?;
1493+
1494+
// Pull an image
1495+
cmd!(
1496+
sh,
1497+
"{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image"
1498+
)
1499+
.read()?;
1500+
1501+
// Verify it exists
1502+
let list_before = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images --json").read()?;
1503+
let images_before: Vec<serde_json::Value> = serde_json::from_str(&list_before)?;
1504+
assert_eq!(images_before.len(), 1, "expected 1 image before untag");
1505+
1506+
// Untag it
1507+
cmd!(sh, "{cfsctl} --insecure --repo {repo} oci untag test-image").read()?;
1508+
1509+
// Run GC
1510+
let gc_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} gc").read()?;
1511+
assert!(
1512+
gc_output.contains("removed"),
1513+
"expected GC to report removed objects: {gc_output}"
1514+
);
1515+
1516+
// Verify image is gone
1517+
let list_after = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci images --json").read()?;
1518+
let images_after: Vec<serde_json::Value> = serde_json::from_str(&list_after)?;
1519+
assert!(
1520+
images_after.is_empty(),
1521+
"expected no images after GC, got: {:?}",
1522+
images_after
1523+
);
1524+
1525+
// Verify objects were actually removed (streams dir should be mostly empty)
1526+
let streams_dir = repo.join("streams");
1527+
let stream_count = if streams_dir.exists() {
1528+
std::fs::read_dir(&streams_dir)?
1529+
.filter(|e| e.as_ref().map(|e| e.file_name() != "refs").unwrap_or(false))
1530+
.count()
1531+
} else {
1532+
0
1533+
};
1534+
assert_eq!(
1535+
stream_count, 0,
1536+
"expected no non-ref streams after GC, got {}",
1537+
stream_count
1538+
);
1539+
1540+
Ok(())
1541+
}
1542+
integration_test!(test_oci_gc_removes_untagged);
1543+
1544+
/// Test layer tar roundtrip: import a layer, extract as tar, verify integrity.
1545+
///
1546+
/// This verifies that the splitstream storage correctly preserves tar content
1547+
/// by comparing the original tar with the reconstructed one.
1548+
fn test_layer_tar_roundtrip() -> Result<()> {
1549+
use std::io::Read;
1550+
1551+
let sh = Shell::new()?;
1552+
let cfsctl = cfsctl()?;
1553+
let repo_dir = init_insecure_repo(&sh, &cfsctl)?;
1554+
let repo = repo_dir.path();
1555+
let fixture_dir = tempfile::tempdir()?;
1556+
let oci_layout = create_oci_layout(fixture_dir.path())?;
1557+
1558+
// Pull the image
1559+
cmd!(
1560+
sh,
1561+
"{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image"
1562+
)
1563+
.read()?;
1564+
1565+
// Get the layer diff_id
1566+
let config_output = cmd!(
1567+
sh,
1568+
"{cfsctl} --insecure --repo {repo} oci inspect test-image --config"
1569+
)
1570+
.read()?;
1571+
let config: serde_json::Value = serde_json::from_str(&config_output)?;
1572+
let layer_id = config["rootfs"]["diff_ids"][0]
1573+
.as_str()
1574+
.expect("expected layer diff_id");
1575+
1576+
// Extract the layer as tar
1577+
let tar_output = cmd!(sh, "{cfsctl} --insecure --repo {repo} oci layer {layer_id}").output()?;
1578+
assert!(tar_output.status.success(), "layer extraction failed");
1579+
1580+
// Parse the tar and collect file entries
1581+
let mut archive = tar::Archive::new(tar_output.stdout.as_slice());
1582+
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
1583+
1584+
for entry in archive.entries()? {
1585+
let mut entry = entry?;
1586+
let path = entry.path()?.to_string_lossy().to_string();
1587+
let mut content = Vec::new();
1588+
entry.read_to_end(&mut content)?;
1589+
entries.push((path, content));
1590+
}
1591+
1592+
// Verify we got the expected files (usr/ directory and hello.txt)
1593+
assert_eq!(
1594+
entries.len(),
1595+
2,
1596+
"expected 2 entries in layer (usr/ and hello.txt)"
1597+
);
1598+
1599+
// Find hello.txt and verify content
1600+
let hello_entry = entries
1601+
.iter()
1602+
.find(|(path, _)| path == "hello.txt")
1603+
.expect("expected hello.txt in layer");
1604+
assert_eq!(
1605+
hello_entry.1, b"hello from test layer\n",
1606+
"hello.txt content mismatch"
1607+
);
1608+
1609+
// Verify usr/ directory exists
1610+
assert!(
1611+
entries
1612+
.iter()
1613+
.any(|(path, _)| path == "usr" || path == "usr/"),
1614+
"expected usr/ directory in layer"
1615+
);
1616+
1617+
Ok(())
1618+
}
1619+
integration_test!(test_layer_tar_roundtrip);
1620+
1621+
/// Test computing the composefs image ID for an OCI image.
1622+
///
1623+
/// This verifies that we can compute the filesystem verity hash for an image,
1624+
/// which is the prerequisite for sealing and mounting.
1625+
fn test_compute_image_id() -> Result<()> {
1626+
let sh = Shell::new()?;
1627+
let cfsctl = cfsctl()?;
1628+
let repo_dir = init_insecure_repo(&sh, &cfsctl)?;
1629+
let repo = repo_dir.path();
1630+
let fixture_dir = tempfile::tempdir()?;
1631+
let oci_layout = create_oci_layout(fixture_dir.path())?;
1632+
1633+
// Pull an image
1634+
cmd!(
1635+
sh,
1636+
"{cfsctl} --insecure --repo {repo} oci pull oci:{oci_layout} test-image"
1637+
)
1638+
.read()?;
1639+
1640+
// Get the config digest from inspect output
1641+
let inspect_output = cmd!(
1642+
sh,
1643+
"{cfsctl} --insecure --repo {repo} oci inspect test-image"
1644+
)
1645+
.read()?;
1646+
let inspect: serde_json::Value = serde_json::from_str(&inspect_output)?;
1647+
let config_digest = inspect["manifest"]["config"]["digest"]
1648+
.as_str()
1649+
.expect("expected config digest");
1650+
// Digests must be prefixed with '@' to distinguish from named refs
1651+
let config_ref = format!("@{config_digest}");
1652+
1653+
// Compute the image ID
1654+
let compute_output = cmd!(
1655+
sh,
1656+
"{cfsctl} --insecure --repo {repo} oci compute-id {config_ref}"
1657+
)
1658+
.read()?;
1659+
1660+
// The output should be a valid hex digest
1661+
// composefs uses SHA-256 fs-verity which produces 64 hex chars
1662+
// (but the underlying digest could be longer in some configurations)
1663+
let image_id = compute_output.trim();
1664+
assert!(
1665+
image_id.len() >= 64,
1666+
"image ID should be at least 64 hex chars, got {} chars: {}",
1667+
image_id.len(),
1668+
image_id
1669+
);
1670+
assert!(
1671+
image_id.chars().all(|c| c.is_ascii_hexdigit()),
1672+
"image ID should be hex, got: {}",
1673+
image_id
1674+
);
1675+
1676+
// Computing the same image should produce the same ID (deterministic)
1677+
let compute_output2 = cmd!(
1678+
sh,
1679+
"{cfsctl} --insecure --repo {repo} oci compute-id {config_ref}"
1680+
)
1681+
.read()?;
1682+
assert_eq!(
1683+
image_id,
1684+
compute_output2.trim(),
1685+
"compute-id should be deterministic"
1686+
);
1687+
1688+
Ok(())
1689+
}
1690+
integration_test!(test_compute_image_id);

0 commit comments

Comments
 (0)