Skip to main content

bootc_lib/bootc_composefs/
repo.rs

1//! Composefs repository lifecycle and OCI pull paths.
2//!
3//! This module owns how OCI images get into the composefs object store.
4//! There are two pull paths, selected by the `use_unified` flag:
5//!
6//! ## Direct pull (`use_unified = false`)
7//!
8//! `pull_composefs_direct` fetches from the source transport (registry, OCI
9//! dir, etc.) straight into the composefs repo via `composefs_oci::pull` with
10//! default options. No containers-storage involvement.
11//!
12//! ## Unified pull (`use_unified = true`)
13//!
14//! `pull_composefs_unified` is the two-stage path that populates all three
15//! stores (see [`crate::store`] for the architecture overview):
16//!
17//! **Stage 1** — Pull into bootc-owned containers-storage via
18//! `CStorage::pull_with_progress` (or `pull_from_host_storage` if the image
19//! already exists in the default podman store, saving a network round-trip).
20//!
21//! **Stage 2** — `composefs_oci::pull` with `LocalFetchOpt::ZeroCopy` and
22//! `storage_root` pointing at the containers-storage directory. composefs-ctl
23//! walks the overlay `diff/` directories and FICLONEs each file into the
24//! composefs object store keyed by its SHA-512 fsverity digest. On a
25//! reflink-capable filesystem this is near-instantaneous and consumes no
26//! additional disk space.
27//!
28//! The caller provides `storage_path` as an absolute filesystem path string
29//! (not a `Dir` fd) because composefs-ctl passes it to a child skopeo process.
30//! It is derived from the physical root fd via `/proc/self/fd/{fd}` readlink.
31//!
32//! ## Entry points
33//!
34//! - [`pull_composefs_repo`] — upgrade/switch on a composefs-booted system.
35//! - [`initialize_composefs_repository`] — `bootc install` with the composefs
36//!   backend.
37
38use fn_error_context::context;
39use std::sync::Arc;
40
41use anyhow::{Context, Result};
42
43use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
44use composefs::repository::RepositoryConfig;
45use composefs_boot::bootloader::{BootEntry as ComposefsBootEntry, get_boot_resources};
46use composefs_ctl::composefs;
47use composefs_ctl::composefs_boot;
48use composefs_ctl::composefs_oci;
49use composefs_oci::{
50    LocalFetchOpt, PullOptions, PullResult,
51    image::create_filesystem as create_composefs_filesystem, tag_image,
52};
53
54use ostree_ext::containers_image_proxy;
55
56use cap_std_ext::cap_std::{ambient_authority, fs::Dir};
57
58use crate::bootc_composefs::progress;
59use crate::composefs_consts::BOOTC_TAG_PREFIX;
60use crate::install::{RootSetup, State};
61use crate::lsm;
62use crate::podstorage::CStorage;
63use crate::progress_jsonl::ProgressWriter;
64
65/// Create a composefs OCI tag name for the given manifest digest.
66///
67/// Returns a tag like `localhost/bootc-sha256:abc...` which acts as a GC root
68/// in the composefs repository, keeping the manifest, config, and all layer
69/// splitstreams alive.
70pub(crate) fn bootc_tag_for_manifest(manifest_digest: &str) -> String {
71    format!("{BOOTC_TAG_PREFIX}{manifest_digest}")
72}
73
74pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result<crate::store::ComposefsRepository> {
75    crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs")
76        .context("Failed to open composefs repository")
77}
78
79pub(crate) async fn initialize_composefs_repository(
80    state: &State,
81    root_setup: &RootSetup,
82    allow_missing_fsverity: bool,
83    use_unified: bool,
84) -> Result<PullResult<Sha512HashValue>> {
85    const COMPOSEFS_REPO_INIT_JOURNAL_ID: &str = "5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9";
86
87    let rootfs_dir = &root_setup.physical_root;
88    let image_name = &state.source.imageref.name;
89    let transport = &state.source.imageref.transport;
90
91    tracing::info!(
92        message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID,
93        bootc.operation = "repository_init",
94        bootc.source_image = %image_name,
95        bootc.transport = %transport,
96        bootc.allow_missing_fsverity = allow_missing_fsverity,
97        bootc.unified_storage = use_unified,
98        "Initializing composefs repository for image {}:{}",
99        transport,
100        image_name
101    );
102
103    crate::store::ensure_composefs_dir(rootfs_dir)?;
104
105    let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512);
106    let config = if allow_missing_fsverity {
107        config.set_insecure()
108    } else {
109        config
110    };
111    let (repo, _created) =
112        crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config)
113            .context("Failed to initialize composefs repository")?;
114
115    let imgref: containers_image_proxy::ImageReference = state
116        .source
117        .imageref
118        .to_string()
119        .as_str()
120        .try_into()
121        .context("Parsing source image reference")?;
122
123    // Ensure the compatibility symlink ostree/bootc -> ../composefs/bootc
124    // exists.  This is needed for LBI and (when unified storage is enabled)
125    // for containers-storage under composefs/bootc/storage.  The existing
126    // /usr/lib/bootc/storage symlink and all runtime code using
127    // ostree/bootc/storage depend on this link.
128    crate::store::ensure_composefs_bootc_link(rootfs_dir)?;
129
130    let repo = Arc::new(repo);
131
132    let pull_result = if use_unified {
133        // Unified path: first into containers-storage on the target
134        // rootfs, then cstor zero-copy into composefs. This ensures the image
135        // is available for `podman run` from first boot.
136        let sepolicy = state.load_policy()?;
137        let run = Dir::open_ambient_dir("/run", ambient_authority())?;
138        let imgstore = CStorage::create(rootfs_dir, &run, sepolicy.as_ref())?;
139        let storage_path = root_setup.physical_root_path.join(CStorage::subpath());
140
141        // `bootc install` does not yet plumb `--quiet`/`--progress-fd` down to
142        // this path; use defaults for now (terminal progress still renders).
143        let r = pull_composefs_unified(
144            &imgstore,
145            storage_path.as_str(),
146            &repo,
147            &imgref,
148            false,
149            ProgressWriter::default(),
150        )
151        .await?;
152
153        // SELinux-label the containers-storage now that all pulls are done.
154        imgstore
155            .ensure_labeled()
156            .context("SELinux labeling of containers-storage")?;
157        r
158    } else {
159        // Direct path: pull directly into composefs via skopeo, without
160        // containers-storage as intermediary.
161        pull_composefs_direct(&repo, &imgref, false, ProgressWriter::default()).await?
162    };
163
164    // Tag the manifest as a bootc-owned GC root.
165    let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string());
166    tag_image(&*repo, &pull_result.manifest_digest, &tag)
167        .context("Tagging pulled image as bootc GC root")?;
168
169    tracing::info!(
170        message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID,
171        bootc.operation = "repository_init",
172        bootc.manifest_digest = %pull_result.manifest_digest,
173        bootc.manifest_verity = pull_result.manifest_verity.to_hex(),
174        bootc.config_digest = %pull_result.config_digest,
175        bootc.config_verity = pull_result.config_verity.to_hex(),
176        bootc.tag = tag,
177        "Pulled image into composefs repository",
178    );
179
180    Ok(pull_result)
181}
182
183/// Result of pulling a composefs repository, including the OCI manifest digest
184/// needed to reconstruct image metadata from the local composefs repo.
185pub(crate) struct PullRepoResult {
186    pub(crate) repo: crate::store::ComposefsRepository,
187    pub(crate) entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
188    pub(crate) id: Sha512HashValue,
189    /// The OCI manifest content digest (e.g. "sha256:abc...")
190    pub(crate) manifest_digest: String,
191}
192
193/// Pull an image directly into the composefs repository via skopeo.
194///
195/// This is the default path: the image is fetched directly from the source
196/// transport (registry, oci directory, etc.) into the composefs repo without
197/// going through containers-storage first.
198async fn pull_composefs_direct(
199    repo: &Arc<crate::store::ComposefsRepository>,
200    imgref: &containers_image_proxy::ImageReference,
201    quiet: bool,
202    prog: ProgressWriter,
203) -> Result<PullResult<Sha512HashValue>> {
204    let imgref_str = imgref.to_string();
205    tracing::info!("Direct pull: fetching {imgref_str} into composefs repository");
206
207    let mut config = crate::deploy::new_proxy_config();
208    ostree_ext::container::merge_default_container_proxy_opts(&mut config)?;
209
210    let (reporter, prog_task) = progress::spawn(quiet, prog);
211
212    let pull_result = composefs_oci::pull(
213        repo,
214        &imgref_str,
215        None,
216        PullOptions {
217            img_proxy_config: Some(config),
218            progress: Some(reporter),
219            ..Default::default()
220        },
221    )
222    .await;
223
224    // Awaiting the progress task after the pull future completes ensures the
225    // reporter's `Arc` (and hence the channel it feeds) has been dropped, so
226    // the background task drains its queue and exits rather than hanging.
227    prog_task
228        .await
229        .context("Composefs progress task panicked")?;
230
231    pull_result.context("Pulling image into composefs repository")
232}
233
234/// Pull an image via unified storage: first into bootc-owned containers-storage,
235/// then from there into the composefs repository via cstor (zero-copy
236/// reflink/hardlink).
237///
238/// The caller provides:
239/// - `imgstore`: the bootc-owned `CStorage` instance (may be on an arbitrary
240///   mount point during install, or under `/sysroot` during upgrade)
241/// - `storage_path`: the absolute filesystem path to that containers-storage
242///   directory, so cstor and skopeo can find it (e.g.
243///   `/mnt/sysroot/ostree/bootc/storage` during install, or
244///   `/sysroot/ostree/bootc/storage` during upgrade)
245///
246/// This ensures the image is available in containers-storage for `podman run`
247/// while also populating the composefs repo for booting.
248async fn pull_composefs_unified(
249    imgstore: &CStorage,
250    storage_path: &str,
251    repo: &Arc<crate::store::ComposefsRepository>,
252    imgref: &containers_image_proxy::ImageReference,
253    quiet: bool,
254    prog: ProgressWriter,
255) -> Result<PullResult<Sha512HashValue>> {
256    let image = &imgref.name;
257
258    // Stage 1: get the image into bootc-owned containers-storage.
259    if imgref.transport == containers_image_proxy::Transport::ContainerStorage {
260        // The image is in the default containers-storage (/var/lib/containers/storage).
261        // Copy it into bootc-owned storage.
262        tracing::info!("Unified pull: copying {image} from host containers-storage");
263        imgstore
264            .pull_from_host_storage(image)
265            .await
266            .context("Copying image from host containers-storage into bootc storage")?;
267    } else {
268        // For registry (docker://), oci:, docker-daemon:, etc. — pull
269        // via the native podman API with streaming progress display.
270        let pull_ref = imgref.to_string();
271        tracing::info!("Unified pull: fetching {pull_ref} into containers-storage");
272        imgstore
273            .pull_with_progress(&pull_ref)
274            .await
275            .context("Pulling image into bootc containers-storage")?;
276    }
277
278    // Stage 2: import full OCI structure (layers + config + manifest) from
279    // containers-storage into composefs via cstor (zero-copy reflink/hardlink).
280    let cstor_imgref_str = format!("containers-storage:{image}");
281    tracing::info!("Unified pull: importing from {cstor_imgref_str} (zero-copy)");
282
283    let storage = std::path::Path::new(storage_path);
284    let (reporter, prog_task) = progress::spawn(quiet, prog);
285    let pull_opts = PullOptions {
286        // The image is already in bootc-owned containers-storage at this point
287        // (placed there by Stage 1 of the unified pull). Use ZeroCopy so we
288        // actually import via reflink/hardlink and fail loudly if that isn't
289        // possible — a plain copy fallback here would mean Stage 1 and Stage 2
290        // are on different filesystems or the storage root is wrong.
291        local_fetch: LocalFetchOpt::ZeroCopy,
292        storage_root: Some(storage),
293        progress: Some(reporter),
294        ..Default::default()
295    };
296    let pull_result = composefs_oci::pull(repo, &cstor_imgref_str, None, pull_opts).await;
297
298    prog_task
299        .await
300        .context("Composefs progress task panicked")?;
301
302    let pull_result = pull_result.context("Importing from containers-storage into composefs")?;
303
304    Ok(pull_result)
305}
306
307/// Pulls an image into a composefs repository at /sysroot.
308///
309/// When `use_unified` is true, the image is first pulled into bootc-owned
310/// containers-storage (so it's available for `podman run`), then imported
311/// from there into the composefs repo via zero-copy reflinks.
312///
313/// When `use_unified` is false (the default), the image is pulled directly
314/// into the composefs repo via skopeo.
315///
316/// Checks for boot entries in the image and returns them.
317#[context("Pulling composefs repository")]
318pub(crate) async fn pull_composefs_repo(
319    spec_imgref: &crate::spec::ImageReference,
320    allow_missing_fsverity: bool,
321    use_unified: bool,
322    quiet: bool,
323    prog: ProgressWriter,
324) -> Result<PullRepoResult> {
325    const COMPOSEFS_PULL_JOURNAL_ID: &str = "4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8";
326
327    let imgref = spec_imgref.to_image_proxy_ref()?;
328
329    tracing::info!(
330        message_id = COMPOSEFS_PULL_JOURNAL_ID,
331        bootc.operation = "pull",
332        bootc.source_image = &spec_imgref.image,
333        bootc.transport = %imgref.transport,
334        bootc.allow_missing_fsverity = allow_missing_fsverity,
335        bootc.unified_storage = use_unified,
336        "Pulling composefs image {imgref}",
337    );
338
339    let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?;
340
341    let mut repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?;
342    if allow_missing_fsverity {
343        repo.set_insecure();
344    }
345
346    let repo = Arc::new(repo);
347
348    // Upgrade any old-format OCI images before pulling.  Old bootc
349    // (composefs-rs ≤ 2203e8f) did not add IMAGE_REF_KEY to config
350    // splitstreams, so the new GC's tag-based stream walk cannot reach
351    // their layer objects.  upgrade_repo() rewrites those config
352    // splitstreams in place before we add a new deployment, ensuring all
353    // existing deployments are GC-safe.  It is idempotent and fast when
354    // images are already in the current format.
355    let upgrade_result =
356        composefs_oci::upgrade_repo(&repo).context("Upgrading old-format OCI images")?;
357    if upgrade_result.upgraded > 0 {
358        tracing::info!(
359            "Upgraded {} old-format OCI image(s) to current format",
360            upgrade_result.upgraded
361        );
362    }
363
364    let pull_result = if use_unified {
365        // Create bootc-owned containers-storage on the rootfs.
366        // Load SELinux policy from the running system so newly pulled layers
367        // get the correct container_var_lib_t labels.
368        let root = Dir::open_ambient_dir("/", ambient_authority())?;
369        let sepolicy = lsm::new_sepolicy_at(&root)?;
370        let run = Dir::open_ambient_dir("/run", ambient_authority())?;
371        let imgstore = CStorage::create(&rootfs_dir, &run, sepolicy.as_ref())?;
372        let storage_path = format!("/sysroot/{}", CStorage::subpath());
373
374        pull_composefs_unified(&imgstore, &storage_path, &repo, &imgref, quiet, prog).await?
375    } else {
376        pull_composefs_direct(&repo, &imgref, quiet, prog).await?
377    };
378
379    // Tag the manifest as a bootc-owned GC root.
380    let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string());
381    tag_image(&*repo, &pull_result.manifest_digest, &tag)
382        .context("Tagging pulled image as bootc GC root")?;
383
384    tracing::info!(
385        message_id = COMPOSEFS_PULL_JOURNAL_ID,
386        bootc.operation = "pull",
387        bootc.manifest_digest = %pull_result.manifest_digest,
388        bootc.manifest_verity = pull_result.manifest_verity.to_hex(),
389        bootc.config_digest = %pull_result.config_digest,
390        bootc.config_verity = pull_result.config_verity.to_hex(),
391        bootc.tag = tag,
392        "Pulled image into composefs repository",
393    );
394
395    // Generate the bootable EROFS image (idempotent).
396    let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest)
397        .context("Generating bootable EROFS image")?;
398
399    // Get boot entries from the OCI filesystem (untransformed).
400    let fs = create_composefs_filesystem(&*repo, &pull_result.config_digest, None)
401        .context("Creating composefs filesystem for boot entry discovery")?;
402    let entries =
403        get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?;
404
405    // Unwrap the Arc to get the owned repo back.
406    let mut repo = Arc::try_unwrap(repo).map_err(|_| {
407        anyhow::anyhow!("BUG: Arc<Repository> still has other references after pull completed")
408    })?;
409    if allow_missing_fsverity {
410        repo.set_insecure();
411    }
412
413    Ok(PullRepoResult {
414        repo,
415        entries,
416        id,
417        manifest_digest: pull_result.manifest_digest.to_string(),
418    })
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn test_bootc_tag_for_manifest() {
427        let digest = "sha256:abc123def456";
428        let tag = bootc_tag_for_manifest(digest);
429        assert_eq!(tag, "localhost/bootc-sha256:abc123def456");
430        assert!(tag.starts_with(BOOTC_TAG_PREFIX));
431    }
432}