Skip to content

Commit 9ea4e48

Browse files
committed
unified-storage: Implement three-store pipeline for ostree backend
The goal of unified storage is to make the same on-disk layer data simultaneously visible to containers-storage (for \`podman run\`), composefs (for the boot overlay), and ostree (for deployment tracking), using reflinks so layers are stored once regardless of how many stores reference them. This wires the full pipeline into the ostree backend. When a system has unified storage enabled — either at install time via the [install.storage] config key, or post-install via \`bootc image set-unified\` — upgrades and switches route through pull_via_composefs: Stage 1: pull image into bootc-owned containers-storage Stage 2: zero-copy reflink import into the composefs OCI repo Stage 3: synthesize an ostree commit from the composefs tree Whether unified storage is active is tracked by composefs/bootc.json (BootcRepoMeta). This replaces the previous heuristic that checked per-image presence in containers-storage, which broke when switching to a new image reference. The install config gains a storage.unified key with three values: disabled (default), enabled (fail if reflinks unavailable), and enabled-with-copy (copy fallback). This lets an image opt into unified storage without requiring a CLI flag to be threaded through every installer. bootc image list cross-references composefs tags against containers-storage by config digest to report images as unified (in all three stores) or partial (cstorage only, composefs import pending). bootc internals fsck images checks consistency and --repair restores cstorage from composefs when needed. The cstorage GC is extended to protect images that have composefs tags, since the composefs splitstreams reference the cstorage layer data — pruning one without the other would corrupt the repo. Assisted-by: OpenCode (claude-sonnet-4-6@default) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 0f285a0 commit 9ea4e48

48 files changed

Lines changed: 3553 additions & 462 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ jobs:
158158
# These tests may mutate the system live so we can't run in parallel
159159
sudo bootc-integration-tests system-reinstall localhost/bootc --test-threads=1
160160
161+
# Unified storage case
162+
sudo podman build -t localhost/bootc-unified-storage -f ci/Containerfile.install-unified-storage
163+
sudo podman run --privileged --pid=host localhost/bootc-unified-storage bootc install to-existing-root --stateroot=unified-storage --acknowledge-destructive --skip-fetch-check
164+
# Verify unified storage was activated; composefs/bootc.json is written relative to
165+
# the target physical root (/target bind-mounted to host /), so the file appears at /composefs/bootc.json
166+
sudo test -f /composefs/bootc.json
167+
161168
# And the fsverity case
162169
sudo podman run --privileged --pid=host localhost/bootc-fsverity bootc install to-existing-root --stateroot=other \
163170
--acknowledge-destructive --skip-fetch-check

Justfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,27 @@ test-tmt-baseconfig baseconfig *ARGS:
290290
--seal-state={{seal_state}} \
291291
{{base_img}} readonly {{ARGS}}
292292

293+
# Run unified-storage baseconfig test (works with ostree variant, no composefs required)
294+
[group('testing')]
295+
test-tmt-baseconfig-unified-storage *ARGS:
296+
just baseconfigs=unified-storage build
297+
just baseconfigs=unified-storage _build-upgrade-image
298+
cargo xtask run-tmt \
299+
--env=BOOTC_baseconfigs=unified-storage \
300+
--upgrade-image={{upgrade_img}} \
301+
--bootloader={{bootloader}} \
302+
--filesystem={{filesystem}} \
303+
--boot-type={{boot_type}} \
304+
--seal-state={{seal_state}} \
305+
{{base_img}} readonly {{ARGS}}
306+
293307
# Run readonly tests for all standard baseconfigs
294308
[group('testing')]
295309
test-baseconfigs *ARGS:
296310
just test-tmt-baseconfig etc-transient {{ARGS}}
297311
just test-tmt-baseconfig root-transient {{ARGS}}
298312
just test-tmt-baseconfig var-volatile {{ARGS}}
313+
just test-tmt-baseconfig-unified-storage {{ARGS}}
299314

300315
# Run tmt tests on Fedora CoreOS
301316
[group('testing')]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Enable unified storage (composefs+ostree) at install time via image-embedded config
2+
FROM localhost/bootc-install
3+
RUN <<EORUN
4+
set -xeuo pipefail
5+
mkdir -p /usr/lib/bootc/install
6+
printf '[install.storage]\nunified = "enabled-with-copies"\n' > /usr/lib/bootc/install/00-storage.toml
7+
bootc container lint
8+
EORUN

contrib/packaging/inject-baseconfig

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,8 @@ if [ -z "${BASECONFIGS}" ]; then
1111
exit 0
1212
fi
1313

14-
# setup-root-conf.toml is composefs-specific; ostree uses prepare-root.conf
15-
# which has a different (INI) format and different option names.
16-
case "${VARIANT}" in
17-
composefs*)
18-
TARGET="/usr/lib/composefs/setup-root-conf.toml"
19-
;;
20-
*)
21-
echo "inject-baseconfig: baseconfigs not supported for variant '${VARIANT}'" >&2
22-
exit 1
23-
;;
24-
esac
25-
26-
mkdir -p "$(dirname "${TARGET}")"
27-
28-
# Split on commas and process each token
14+
# Split and process tokens; unified-storage is handled before the variant check
15+
# because it is backend-independent (works with both ostree and composefs).
2916
IFS=',' read -ra TOKENS <<< "${BASECONFIGS}"
3017
for raw_token in "${TOKENS[@]}"; do
3118
# Trim leading/trailing spaces
@@ -35,27 +22,50 @@ for raw_token in "${TOKENS[@]}"; do
3522
[ -z "${token}" ] && continue
3623

3724
case "${token}" in
38-
etc-transient)
39-
printf '[etc]\ntransient = true\n' >> "${TARGET}"
40-
;;
41-
root-transient)
42-
printf '[root]\ntransient = true\n' >> "${TARGET}"
43-
;;
44-
var-volatile)
45-
# Mount /var as a fresh tmpfs on every boot via systemd.volatile=state.
46-
# bootc-root-setup detects this karg in the initramfs and automatically
47-
# skips the /var state bind-mount, leaving /var as an empty directory
48-
# from the composefs image. systemd-fstab-generator then mounts a fresh
49-
# tmpfs there at local-fs.target. Using a plain tmpfs avoids the
50-
# overlayfs-on-overlayfs restriction that breaks tools like podman which
51-
# use overlayfs under /var/lib/containers.
52-
mkdir -p /usr/lib/bootc/kargs.d
53-
printf 'kargs = ["systemd.volatile=state"]\n' \
54-
> /usr/lib/bootc/kargs.d/50-var-volatile.toml
25+
unified-storage)
26+
# Write the bootc install config to enable unified storage at install time.
27+
# This is backend-independent and works with both ostree and composefs variants.
28+
mkdir -p /usr/lib/bootc/install
29+
printf '[install.storage]\nunified = "enabled-with-copy"\n' \
30+
> /usr/lib/bootc/install/00-storage.toml
5531
;;
5632
*)
57-
echo "Unknown baseconfig: ${token}" >&2
58-
exit 1
33+
# All other tokens require the composefs variant (they write to composefs-specific paths).
34+
# Validate variant here, not at the top, so unified-storage can run on any variant.
35+
case "${VARIANT}" in
36+
composefs*)
37+
TARGET="/usr/lib/composefs/setup-root-conf.toml"
38+
mkdir -p "$(dirname "${TARGET}")"
39+
;;
40+
*)
41+
echo "inject-baseconfig: baseconfig '${token}' not supported for variant '${VARIANT}'" >&2
42+
exit 1
43+
;;
44+
esac
45+
case "${token}" in
46+
etc-transient)
47+
printf '[etc]\ntransient = true\n' >> "${TARGET}"
48+
;;
49+
root-transient)
50+
printf '[root]\ntransient = true\n' >> "${TARGET}"
51+
;;
52+
var-volatile)
53+
# Mount /var as a fresh tmpfs on every boot via systemd.volatile=state.
54+
# bootc-root-setup detects this karg in the initramfs and automatically
55+
# skips the /var state bind-mount, leaving /var as an empty directory
56+
# from the composefs image. systemd-fstab-generator then mounts a fresh
57+
# tmpfs there at local-fs.target. Using a plain tmpfs avoids the
58+
# overlayfs-on-overlayfs restriction that breaks tools like podman which
59+
# use overlayfs under /var/lib/containers.
60+
mkdir -p /usr/lib/bootc/kargs.d
61+
printf 'kargs = ["systemd.volatile=state"]\n' \
62+
> /usr/lib/bootc/kargs.d/50-var-volatile.toml
63+
;;
64+
*)
65+
echo "Unknown baseconfig: ${token}" >&2
66+
exit 1
67+
;;
68+
esac
5969
;;
6070
esac
6171
done

crates/lib/src/bootc_composefs/export.rs

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,44 +7,27 @@ use composefs_ctl::composefs;
77
use composefs_ctl::composefs_oci;
88
use composefs_oci::open_config;
99
use ocidir::{OciDir, oci_spec::image::Platform};
10+
use ostree_ext::container::ImageReference;
1011
use ostree_ext::container::Transport;
1112
use ostree_ext::container::skopeo;
1213
use tar::EntryType;
1314

1415
use crate::image::get_imgrefs_for_copy;
1516
use crate::{
16-
bootc_composefs::status::{get_composefs_status, get_imginfo},
17-
store::{BootedComposefs, Storage},
17+
bootc_composefs::status::{ImgConfigManifest, get_composefs_status, get_imginfo},
18+
store::{BootedComposefs, ComposefsRepository, Storage},
1819
};
1920

20-
/// Exports a composefs repository to a container image in containers-storage:
21-
pub async fn export_repo_to_image(
22-
storage: &Storage,
23-
booted_cfs: &BootedComposefs,
24-
source: Option<&str>,
25-
target: Option<&str>,
21+
/// Streams a composefs OCI image out to a destination image reference.
22+
///
23+
/// Given a composefs repository handle and image metadata (manifest + config),
24+
/// reconstructs the container image by reading layer data from the composefs
25+
/// splitstreams and copies the assembled OCI image to `dest_imgref` via skopeo.
26+
pub(crate) async fn export_composefs_to_dest(
27+
composefs_repo: &ComposefsRepository,
28+
imginfo: &ImgConfigManifest,
29+
dest_imgref: &ImageReference,
2630
) -> Result<()> {
27-
let host = get_composefs_status(storage, booted_cfs).await?;
28-
29-
let (source, dest_imgref) = get_imgrefs_for_copy(&host, source, target).await?;
30-
31-
let mut depl_verity = None;
32-
33-
for depl in host.list_deployments() {
34-
let img = &depl.image.as_ref().unwrap().image;
35-
36-
// Not checking transport here as we'll be pulling from the repo anyway
37-
// So, image name is all we need
38-
if img.image == source.name {
39-
depl_verity = Some(depl.require_composefs()?.verity.clone());
40-
break;
41-
}
42-
}
43-
44-
let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?;
45-
46-
let imginfo = get_imginfo(storage, &depl_verity)?;
47-
4831
let config_digest = imginfo.manifest.config().digest().clone();
4932

5033
let var_tmp =
@@ -54,7 +37,7 @@ pub async fn export_repo_to_image(
5437
let oci_dir = OciDir::ensure(tmpdir.try_clone()?).context("Opening OCI")?;
5538

5639
// Use composefs_oci::open_config to get the config and layer map
57-
let open = open_config(&*booted_cfs.repo, &config_digest, None).context("Opening config")?;
40+
let open = open_config(composefs_repo, &config_digest, None).context("Opening config")?;
5841
let config = open.config;
5942
let layer_map = open.layer_refs;
6043

@@ -77,7 +60,7 @@ pub async fn export_repo_to_image(
7760
.get(old_diff_id.as_str())
7861
.ok_or_else(|| anyhow::anyhow!("Layer {old_diff_id} not found in config"))?;
7962

80-
let mut layer_stream = booted_cfs.repo.open_stream("", Some(layer_verity), None)?;
63+
let mut layer_stream = composefs_repo.open_stream("", Some(layer_verity), None)?;
8164

8265
let mut layer_writer = oci_dir.create_layer(None)?;
8366
layer_writer.follow_symlinks(false);
@@ -113,7 +96,7 @@ pub async fn export_repo_to_image(
11396
match layer_stream.read_exact(size as usize, ((size as usize) + 511) & !511)? {
11497
SplitStreamData::External(obj_id) => match header.entry_type() {
11598
EntryType::Regular | EntryType::Continuous => {
116-
let file = File::from(booted_cfs.repo.open_object(&obj_id)?);
99+
let file = File::from(composefs_repo.open_object(&obj_id)?);
117100

118101
layer_writer
119102
.append(&header, file)
@@ -196,7 +179,7 @@ pub async fn export_repo_to_image(
196179

197180
skopeo::copy(
198181
&tempoci,
199-
&dest_imgref,
182+
dest_imgref,
200183
None,
201184
Some((
202185
std::sync::Arc::new(tmpdir.try_clone()?.into()),
@@ -208,3 +191,37 @@ pub async fn export_repo_to_image(
208191

209192
Ok(())
210193
}
194+
195+
/// Exports a composefs repository to a container image in containers-storage:
196+
pub async fn export_repo_to_image(
197+
storage: &Storage,
198+
booted_cfs: &BootedComposefs,
199+
source: Option<&str>,
200+
target: Option<&str>,
201+
) -> Result<()> {
202+
let host = get_composefs_status(storage, booted_cfs).await?;
203+
204+
let (source, dest_imgref) = get_imgrefs_for_copy(&host, source, target).await?;
205+
206+
let mut depl_verity = None;
207+
208+
for depl in host.list_deployments() {
209+
let img = &depl.image.as_ref().unwrap().image;
210+
211+
// Not checking transport here as we'll be pulling from the repo anyway
212+
// So, image name is all we need
213+
if img.image == source.name {
214+
depl_verity = Some(depl.require_composefs()?.verity.clone());
215+
break;
216+
}
217+
}
218+
219+
let depl_verity = depl_verity.ok_or_else(|| anyhow::anyhow!("Image {source} not found"))?;
220+
221+
let imginfo = get_imginfo(storage, &depl_verity)?;
222+
223+
println!("Copying local image {source} to {dest_imgref} ...");
224+
export_composefs_to_dest(&booted_cfs.repo, &imginfo, &dest_imgref).await?;
225+
println!("Pushed: {dest_imgref}");
226+
Ok(())
227+
}

crates/lib/src/bootc_composefs/repo.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pub(crate) async fn initialize_composefs_repository(
4141
root_setup: &RootSetup,
4242
allow_missing_fsverity: bool,
4343
use_unified: bool,
44+
local_fetch: LocalFetchOpt,
4445
) -> Result<PullResult<Sha512HashValue>> {
4546
const COMPOSEFS_REPO_INIT_JOURNAL_ID: &str = "5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9";
4647

@@ -99,7 +100,7 @@ pub(crate) async fn initialize_composefs_repository(
99100
let imgstore = CStorage::create(rootfs_dir, &run, sepolicy.as_ref())?;
100101
let storage_path = root_setup.physical_root_path.join(CStorage::subpath());
101102

102-
let r = pull_composefs_unified(&imgstore, storage_path.as_str(), &repo, &imgref).await?;
103+
let r = pull_composefs_unified(&imgstore, storage_path.as_str(), &repo, &imgref, local_fetch).await?;
103104

104105
// SELinux-label the containers-storage now that all pulls are done.
105106
imgstore
@@ -179,16 +180,18 @@ async fn pull_composefs_unified(
179180
storage_path: &str,
180181
repo: &Arc<crate::store::ComposefsRepository>,
181182
imgref: &containers_image_proxy::ImageReference,
183+
local_fetch: LocalFetchOpt,
182184
) -> Result<PullResult<Sha512HashValue>> {
183185
let image = &imgref.name;
184186

185187
// Stage 1: get the image into bootc-owned containers-storage.
186188
if imgref.transport == containers_image_proxy::Transport::ContainerStorage {
187-
// The image is in the default containers-storage (/var/lib/containers/storage).
188-
// Copy it into bootc-owned storage.
189+
// The image is in a containers-storage instance — either the default
190+
// /var/lib/containers/storage or an additional image store advertised
191+
// via STORAGE_OPTS (e.g. the bcvk virtiofs mount).
189192
tracing::info!("Unified pull: copying {image} from host containers-storage");
190193
imgstore
191-
.pull_from_host_storage(image)
194+
.pull_from_containers_storage(image)
192195
.await
193196
.context("Copying image from host containers-storage into bootc storage")?;
194197
} else {
@@ -210,11 +213,11 @@ async fn pull_composefs_unified(
210213
let storage = std::path::Path::new(storage_path);
211214
let pull_opts = PullOptions {
212215
// The image is already in bootc-owned containers-storage at this point
213-
// (placed there by Stage 1 of the unified pull). Use ZeroCopy so we
214-
// actually import via reflink/hardlink and fail loudly if that isn't
215-
// possible — a plain copy fallback here would mean Stage 1 and Stage 2
216-
// are on different filesystems or the storage root is wrong.
217-
local_fetch: LocalFetchOpt::ZeroCopy,
216+
// (placed there by Stage 1 of the unified pull). CopyMode controls
217+
// whether a fallback to byte copies is acceptable:
218+
// ZeroCopy → fail if reflinks unavailable (storage.unified = "enabled")
219+
// IfPossible → byte-copy fallback ok (storage.unified = "enabled-with-copy")
220+
local_fetch,
218221
storage_root: Some(storage),
219222
..Default::default()
220223
};
@@ -240,6 +243,7 @@ pub(crate) async fn pull_composefs_repo(
240243
spec_imgref: &crate::spec::ImageReference,
241244
allow_missing_fsverity: bool,
242245
use_unified: bool,
246+
local_fetch: LocalFetchOpt,
243247
) -> Result<PullRepoResult> {
244248
const COMPOSEFS_PULL_JOURNAL_ID: &str = "4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8";
245249

@@ -290,7 +294,7 @@ pub(crate) async fn pull_composefs_repo(
290294
let imgstore = CStorage::create(&rootfs_dir, &run, sepolicy.as_ref())?;
291295
let storage_path = format!("/sysroot/{}", CStorage::subpath());
292296

293-
pull_composefs_unified(&imgstore, &storage_path, &repo, &imgref).await?
297+
pull_composefs_unified(&imgstore, &storage_path, &repo, &imgref, local_fetch).await?
294298
} else {
295299
pull_composefs_direct(&repo, &imgref).await?
296300
};

crates/lib/src/bootc_composefs/status.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,12 @@ async fn composefs_deployment_status_from(
970970

971971
host.status.usr_overlay = get_composefs_usr_overlay_status().ok().flatten();
972972

973+
// Populate storage status from bootc repo metadata stored on the physical root.
974+
host.status.storage = crate::store::BootcRepoMeta::read(&storage.physical_root)
975+
.ok()
976+
.flatten()
977+
.map(|meta| crate::spec::StorageStatus { unified: meta.unified });
978+
973979
set_soft_reboot_capability(storage, &mut host, sorted_bls_config, cmdline)?;
974980

975981
Ok(host)

crates/lib/src/bootc_composefs/switch.rs

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,11 @@ pub(crate) async fn switch_composefs(
5151
let repo = &*booted_cfs.repo;
5252
let (image, img_config) = is_image_pulled(repo, &target_imgref).await?;
5353

54-
// Use unified storage if explicitly requested, or auto-detect: either the
55-
// target image is already in bootc-owned containers-storage, OR the booted
56-
// image is — which means the user has opted into unified storage and all
57-
// subsequent operations (including switch to a new image) should use it.
58-
let use_unified = if opts.unified_storage_exp {
59-
true
60-
} else {
61-
let booted_imgref = host.spec.image.as_ref();
62-
let booted_unified = if let Some(booted) = booted_imgref {
63-
crate::deploy::image_exists_in_unified_storage(storage, booted).await?
64-
} else {
65-
false
66-
};
67-
let target_unified =
68-
crate::deploy::image_exists_in_unified_storage(storage, &target_imgref).await?;
69-
booted_unified || target_unified
70-
};
54+
// Use unified storage if explicitly requested via flag, or if the
55+
// composefs/bootc.json marker says unified storage is enabled on this system.
56+
let use_unified = opts.unified_storage_exp
57+
|| crate::deploy::unified_storage_enabled(storage)
58+
.context("Checking unified storage flag")?;
7159

7260
let do_upgrade_opts = DoUpgradeOpts {
7361
soft_reboot: opts.soft_reboot,

0 commit comments

Comments
 (0)