Skip to content

Commit 1b4a733

Browse files
committed
oci: Add containers-storage integration with zero-copy import
Add a native containers-storage import path that bypasses skopeo's tar streaming by reading layer content directly from the overlay diff directories and using reflink/hardlink to avoid data copies. The import path is gated by a new LocalFetchOpt enum on PullOptions: - Disabled (default): fall through to skopeo like any other transport - IfPossible: use native import with reflink/hardlink/copy fallback - ZeroCopy: use native import but error if zero-copy is not possible This is exposed via `cfsctl oci pull --local-fetch disabled|auto|zerocopy`. The hardlink path enables fs-verity on source files in-place (permanent, irreversible) and requires CAP_DAC_READ_SEARCH for linkat(AT_EMPTY_PATH); try_hardlink_object now checks this upfront with a clear error message. Integration tests cover the full {ext4,xfs} x {sha256,sha512} x {auto,zerocopy} matrix using synthetic OCI images on loopback filesystems. Assisted-by: OpenCode (Claude Opus 4) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 20aa08c commit 1b4a733

24 files changed

Lines changed: 2525 additions & 52 deletions

File tree

.config/nextest.toml

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ failure-output = "immediate"
1414
success-output = "never"
1515
status-level = "pass"
1616

17+
# Tests that pull OCI images need more time (especially on cold CI runners)
18+
[[profile.default.overrides]]
19+
filter = 'test(/digest_stability|oci_pull/)'
20+
slow-timeout = { period = "300s", terminate-after = 2 }
21+
1722
# Profile for integration tests — run with limited parallelism due to QEMU/KVM resources
1823
[profile.integration]
1924
test-threads = 2
@@ -30,7 +35,12 @@ path = "junit.xml"
3035
store-success-output = true
3136
store-failure-output = true
3237

33-
# VM tests boot an ephemeral QEMU instance per test, limit parallelism
38+
# Privileged tests boot an ephemeral QEMU instance per test — limit
39+
# parallelism to avoid OOM kills on 16 GB CI runners.
40+
# Requiring all 2 threads effectively serialises them.
41+
#
42+
# NOTE: use /regex/ syntax, not ~substring — the ~ operator treats ^ $ as
43+
# literal characters, so ~^foo never matches anything.
3444
[[profile.integration.overrides]]
35-
filter = 'test(~^vm_)'
36-
threads-required = 4
45+
filter = 'test(/^privileged_/)'
46+
threads-required = 2

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ jobs:
5151
- uses: actions/checkout@v6
5252
- uses: bootc-dev/actions/bootc-ubuntu-setup@main
5353
- uses: dtolnay/rust-toolchain@stable
54+
- uses: taiki-e/install-action@nextest
5455
- uses: Swatinem/rust-cache@v2
5556
- run: just test-integration
5657

@@ -114,6 +115,7 @@ jobs:
114115
libvirt: true
115116

116117
- uses: dtolnay/rust-toolchain@stable
118+
- uses: taiki-e/install-action@nextest
117119

118120
- uses: Swatinem/rust-cache@v2
119121

Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
[workspace]
22
members = ["crates/*"]
3+
# Exclude integration-tests from default `cargo test` — those require a
4+
# built cfsctl binary and (for privileged tests) a VM. Run them via
5+
# `just test-integration` or `just test-integration-vm` instead.
6+
default-members = [
7+
"crates/cfsctl",
8+
"crates/composefs",
9+
"crates/composefs-boot",
10+
"crates/composefs-fuse",
11+
"crates/composefs-http",
12+
"crates/composefs-ioctls",
13+
"crates/composefs-oci",
14+
"crates/composefs-setup-root",
15+
"crates/cstorage",
16+
"crates/erofs-debug",
17+
"crates/splitfdstream",
18+
]
319
resolver = "2"
420

521
[workspace.package]

Justfile

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,37 @@ cfsctl_features := env("COMPOSEFS_CFSCTL_FEATURES", "pre-6.15")
5858
_test_image := if base_image =~ "debian" { "localhost/composefs-rs-test-debian:latest" } else if base_image =~ "stream9" { "localhost/composefs-rs-test-c9s:latest" } else { "localhost/composefs-rs-test:latest" }
5959

6060
# Run unprivileged integration tests against the cfsctl binary (no root, no VM)
61-
test-integration: build
62-
CFSCTL_PATH=$(pwd)/target/debug/cfsctl cargo run -p integration-tests --bin cfsctl-integration-tests -- --skip privileged_
61+
# Prefers nextest for parallelism control and better UX; falls back to direct harness.
62+
test-integration *ARGS: build
63+
#!/usr/bin/env bash
64+
set -euo pipefail
65+
export CFSCTL_PATH=$(pwd)/target/debug/cfsctl
66+
if command -v cargo-nextest &> /dev/null; then
67+
cargo nextest run -p integration-tests -E 'not test(/^privileged_/)' {{ ARGS }}
68+
else
69+
cargo test -p integration-tests --test cfsctl-integration-tests -- --skip privileged_ {{ ARGS }}
70+
fi
6371
6472
# Build the test container image for VM-based integration tests
6573
_integration-container-build:
6674
podman build --build-arg base_image={{base_image}} --build-arg cfsctl_features={{cfsctl_features}} -t {{_test_image}} .
6775

6876
# Run all integration tests including privileged VM tests (requires podman + libvirt)
69-
test-integration-vm: build _integration-container-build
70-
COMPOSEFS_TEST_IMAGE={{_test_image}} \
71-
CFSCTL_PATH=$(pwd)/target/debug/cfsctl \
72-
cargo run -p integration-tests --bin cfsctl-integration-tests
77+
# Uses nextest with the integration profile for parallelism control of VM tests.
78+
test-integration-vm *ARGS: build _integration-container-build
79+
#!/usr/bin/env bash
80+
set -euo pipefail
81+
export COMPOSEFS_TEST_IMAGE={{_test_image}}
82+
export CFSCTL_PATH=$(pwd)/target/debug/cfsctl
83+
if command -v cargo-nextest &> /dev/null; then
84+
cargo nextest run -P integration -p integration-tests {{ ARGS }}
85+
else
86+
cargo test -p integration-tests --test cfsctl-integration-tests -- {{ ARGS }}
87+
fi
88+
89+
# Install cargo-nextest if not already installed
90+
install-nextest:
91+
@which cargo-nextest > /dev/null 2>&1 || cargo install cargo-nextest --locked
7392

7493
# Run everything: checks + full integration tests including VM
7594
ci: check test-integration-vm

crates/cfsctl/Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ version.workspace = true
1414
path = "src/lib.rs"
1515

1616
[features]
17-
default = ['pre-6.15', 'oci']
17+
default = ['pre-6.15', 'oci', 'containers-storage']
1818
http = ['composefs-http']
1919
oci = ['composefs-oci']
20+
containers-storage = ['composefs-oci/containers-storage', 'cstorage']
2021
rhel9 = ['composefs/rhel9']
2122
'pre-6.15' = ['composefs/pre-6.15']
2223

@@ -29,8 +30,10 @@ composefs = { workspace = true }
2930
composefs-boot = { workspace = true }
3031
composefs-oci = { workspace = true, optional = true, features = ["boot"] }
3132
composefs-http = { workspace = true, optional = true }
33+
cstorage = { path = "../cstorage", version = "0.3.0", features = ["userns-helper"], optional = true }
3234
env_logger = { version = "0.11.0", default-features = false }
3335
hex = { version = "0.4.0", default-features = false }
36+
indicatif = { version = "0.17.0", default-features = false }
3437
rustix = { version = "1.0.0", default-features = false, features = ["fs", "process"] }
3538
serde = { version = "1.0", default-features = false, features = ["derive"] }
3639
serde_json = { version = "1.0", default-features = false, features = ["std"] }

crates/cfsctl/src/lib.rs

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,30 @@ impl std::fmt::Display for OciReference {
170170
}
171171
}
172172

173+
/// CLI representation of [`composefs_oci::LocalFetchOpt`].
174+
#[cfg(feature = "oci")]
175+
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
176+
enum LocalFetchCli {
177+
/// Do not use native containers-storage import; use skopeo.
178+
#[default]
179+
Disabled,
180+
/// Use native import with reflink/hardlink/copy fallback.
181+
Auto,
182+
/// Use native import; error if zero-copy is not possible.
183+
Zerocopy,
184+
}
185+
186+
#[cfg(feature = "oci")]
187+
impl From<LocalFetchCli> for composefs_oci::LocalFetchOpt {
188+
fn from(cli: LocalFetchCli) -> Self {
189+
match cli {
190+
LocalFetchCli::Disabled => Self::Disabled,
191+
LocalFetchCli::Auto => Self::IfPossible,
192+
LocalFetchCli::Zerocopy => Self::ZeroCopy,
193+
}
194+
}
195+
}
196+
173197
/// Common options for operations using OCI config manifest streams that may transform the image rootfs
174198
#[cfg(feature = "oci")]
175199
#[derive(Debug, Parser)]
@@ -226,6 +250,10 @@ enum OciCommand {
226250
/// Also generate a bootable EROFS image from the pulled OCI image
227251
#[arg(long)]
228252
bootable: bool,
253+
/// Controls whether containers-storage: references use the native
254+
/// import path with zero-copy reflink/hardlink support.
255+
#[arg(long, value_enum, default_value_t = LocalFetchCli::Disabled)]
256+
local_fetch: LocalFetchCli,
229257
},
230258
/// List all tagged OCI images in the repository
231259
#[clap(name = "images")]
@@ -925,23 +953,23 @@ where
925953
ref image,
926954
name,
927955
bootable,
956+
local_fetch,
928957
} => {
929958
// If no explicit name provided, use the image reference as the tag
930959
let tag_name = name.as_deref().unwrap_or(image);
931-
let (result, stats) =
932-
composefs_oci::pull_image(&repo, image, Some(tag_name), None).await?;
960+
961+
let opts = composefs_oci::PullOptions {
962+
local_fetch: local_fetch.into(),
963+
..Default::default()
964+
};
965+
966+
let result = composefs_oci::pull(&repo, image, Some(tag_name), opts).await?;
933967

934968
println!("manifest {}", result.manifest_digest);
935969
println!("config {}", result.config_digest);
936970
println!("verity {}", result.manifest_verity.to_hex());
937971
println!("tagged {tag_name}");
938-
println!(
939-
"objects {} copied, {} already present, {} bytes copied, {} bytes inlined",
940-
stats.objects_copied,
941-
stats.objects_already_present,
942-
stats.bytes_copied,
943-
stats.bytes_inlined,
944-
);
972+
println!("objects {}", result.stats);
945973

946974
if bootable {
947975
let image_verity =

crates/cfsctl/src/main.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,20 @@ use cfsctl::App;
99
use anyhow::Result;
1010
use clap::Parser;
1111

12-
#[tokio::main]
13-
async fn main() -> Result<()> {
12+
fn main() -> Result<()> {
13+
// If we were spawned as a userns helper process, handle that and exit.
14+
// This MUST be called before the tokio runtime is created.
15+
#[cfg(feature = "containers-storage")]
16+
cstorage::init_if_helper();
17+
18+
// Now we can create the tokio runtime for the main application
19+
tokio::runtime::Builder::new_multi_thread()
20+
.enable_all()
21+
.build()?
22+
.block_on(async_main())
23+
}
24+
25+
async fn async_main() -> Result<()> {
1426
env_logger::init();
1527

1628
let args = App::parse();

crates/composefs-oci/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,21 @@ rust-version.workspace = true
1111
version.workspace = true
1212

1313
[features]
14+
default = ["containers-storage"]
1415
test = ["tar", "rand", "composefs/test"]
1516
boot = ["composefs-boot"]
17+
containers-storage = ["dep:cstorage", "dep:base64", "cstorage/userns-helper"]
1618

1719
[dependencies]
1820
anyhow = { version = "1.0.87", default-features = false }
1921
fn-error-context = "0.2"
2022
async-compression = { version = "0.4.0", default-features = false, features = ["tokio", "zstd", "gzip"] }
23+
base64 = { version = "0.22", default-features = false, features = ["std"], optional = true }
2124
bytes = { version = "1", default-features = false }
2225
composefs = { workspace = true }
2326
composefs-boot = { workspace = true, optional = true }
2427
containers-image-proxy = { version = "0.9.2", default-features = false }
28+
cstorage = { path = "../cstorage", version = "0.3.0", optional = true }
2529
hex = { version = "0.4.0", default-features = false }
2630
indicatif = { version = "0.18.0", default-features = false, features = ["tokio"] }
2731
rustix = { version = "1.0.0", features = ["fs"] }
@@ -34,6 +38,7 @@ tar = { version = "0.4.38", default-features = false, optional = true }
3438
tar-core = "0.1.0"
3539
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }
3640
tokio-util = { version = "0.7", default-features = false, features = ["io"] }
41+
tracing = { version = "0.1", default-features = false }
3742

3843
[dev-dependencies]
3944
cap-std = { version = "4.0.0", default-features = false }

0 commit comments

Comments
 (0)