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::composefs_consts::BOOTC_TAG_PREFIX;
59use crate::install::{RootSetup, State};
60use crate::lsm;
61use crate::podstorage::CStorage;
62
63/// Create a composefs OCI tag name for the given manifest digest.
64///
65/// Returns a tag like `localhost/bootc-sha256:abc...` which acts as a GC root
66/// in the composefs repository, keeping the manifest, config, and all layer
67/// splitstreams alive.
68pub(crate) fn bootc_tag_for_manifest(manifest_digest: &str) -> String {
69    format!("{BOOTC_TAG_PREFIX}{manifest_digest}")
70}
71
72pub(crate) fn open_composefs_repo(rootfs_dir: &Dir) -> Result<crate::store::ComposefsRepository> {
73    crate::store::ComposefsRepository::open_path(rootfs_dir, "composefs")
74        .context("Failed to open composefs repository")
75}
76
77pub(crate) async fn initialize_composefs_repository(
78    state: &State,
79    root_setup: &RootSetup,
80    allow_missing_fsverity: bool,
81    use_unified: bool,
82) -> Result<PullResult<Sha512HashValue>> {
83    const COMPOSEFS_REPO_INIT_JOURNAL_ID: &str = "5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9";
84
85    let rootfs_dir = &root_setup.physical_root;
86    let image_name = &state.source.imageref.name;
87    let transport = &state.source.imageref.transport;
88
89    tracing::info!(
90        message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID,
91        bootc.operation = "repository_init",
92        bootc.source_image = %image_name,
93        bootc.transport = %transport,
94        bootc.allow_missing_fsverity = allow_missing_fsverity,
95        bootc.unified_storage = use_unified,
96        "Initializing composefs repository for image {}:{}",
97        transport,
98        image_name
99    );
100
101    crate::store::ensure_composefs_dir(rootfs_dir)?;
102
103    let config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512);
104    let config = if allow_missing_fsverity {
105        config.set_insecure()
106    } else {
107        config
108    };
109    let (repo, _created) =
110        crate::store::ComposefsRepository::init_path(rootfs_dir, "composefs", config)
111            .context("Failed to initialize composefs repository")?;
112
113    let imgref: containers_image_proxy::ImageReference = state
114        .source
115        .imageref
116        .to_string()
117        .as_str()
118        .try_into()
119        .context("Parsing source image reference")?;
120
121    // Ensure the compatibility symlink ostree/bootc -> ../composefs/bootc
122    // exists.  This is needed for LBI and (when unified storage is enabled)
123    // for containers-storage under composefs/bootc/storage.  The existing
124    // /usr/lib/bootc/storage symlink and all runtime code using
125    // ostree/bootc/storage depend on this link.
126    crate::store::ensure_composefs_bootc_link(rootfs_dir)?;
127
128    let repo = Arc::new(repo);
129
130    let pull_result = if use_unified {
131        // Unified path: first into containers-storage on the target
132        // rootfs, then cstor zero-copy into composefs. This ensures the image
133        // is available for `podman run` from first boot.
134        let sepolicy = state.load_policy()?;
135        let run = Dir::open_ambient_dir("/run", ambient_authority())?;
136        let imgstore = CStorage::create(rootfs_dir, &run, sepolicy.as_ref())?;
137        let storage_path = root_setup.physical_root_path.join(CStorage::subpath());
138
139        let r = pull_composefs_unified(&imgstore, storage_path.as_str(), &repo, &imgref).await?;
140
141        // SELinux-label the containers-storage now that all pulls are done.
142        imgstore
143            .ensure_labeled()
144            .context("SELinux labeling of containers-storage")?;
145        r
146    } else {
147        // Direct path: pull directly into composefs via skopeo, without
148        // containers-storage as intermediary.
149        pull_composefs_direct(&repo, &imgref).await?
150    };
151
152    // Tag the manifest as a bootc-owned GC root.
153    let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string());
154    tag_image(&*repo, &pull_result.manifest_digest, &tag)
155        .context("Tagging pulled image as bootc GC root")?;
156
157    tracing::info!(
158        message_id = COMPOSEFS_REPO_INIT_JOURNAL_ID,
159        bootc.operation = "repository_init",
160        bootc.manifest_digest = %pull_result.manifest_digest,
161        bootc.manifest_verity = pull_result.manifest_verity.to_hex(),
162        bootc.config_digest = %pull_result.config_digest,
163        bootc.config_verity = pull_result.config_verity.to_hex(),
164        bootc.tag = tag,
165        "Pulled image into composefs repository",
166    );
167
168    Ok(pull_result)
169}
170
171/// Result of pulling a composefs repository, including the OCI manifest digest
172/// needed to reconstruct image metadata from the local composefs repo.
173pub(crate) struct PullRepoResult {
174    pub(crate) repo: crate::store::ComposefsRepository,
175    pub(crate) entries: Vec<ComposefsBootEntry<Sha512HashValue>>,
176    pub(crate) id: Sha512HashValue,
177    /// The OCI manifest content digest (e.g. "sha256:abc...")
178    pub(crate) manifest_digest: String,
179}
180
181/// Pull an image directly into the composefs repository via skopeo.
182///
183/// This is the default path: the image is fetched directly from the source
184/// transport (registry, oci directory, etc.) into the composefs repo without
185/// going through containers-storage first.
186async fn pull_composefs_direct(
187    repo: &Arc<crate::store::ComposefsRepository>,
188    imgref: &containers_image_proxy::ImageReference,
189) -> Result<PullResult<Sha512HashValue>> {
190    let imgref_str = imgref.to_string();
191    tracing::info!("Direct pull: fetching {imgref_str} into composefs repository");
192
193    let mut config = crate::deploy::new_proxy_config();
194    ostree_ext::container::apply_container_proxy_opts_for_transport(&mut config, imgref.transport)?;
195
196    let pull_result = composefs_oci::pull(
197        repo,
198        &imgref_str,
199        None,
200        PullOptions {
201            img_proxy_config: Some(config),
202            ..Default::default()
203        },
204    )
205    .await
206    .context("Pulling image into composefs repository")?;
207
208    Ok(pull_result)
209}
210
211/// Pull an image via unified storage: first into bootc-owned containers-storage,
212/// then from there into the composefs repository via cstor (zero-copy
213/// reflink/hardlink).
214///
215/// The caller provides:
216/// - `imgstore`: the bootc-owned `CStorage` instance (may be on an arbitrary
217///   mount point during install, or under `/sysroot` during upgrade)
218/// - `storage_path`: the absolute filesystem path to that containers-storage
219///   directory, so cstor and skopeo can find it (e.g.
220///   `/mnt/sysroot/ostree/bootc/storage` during install, or
221///   `/sysroot/ostree/bootc/storage` during upgrade)
222///
223/// This ensures the image is available in containers-storage for `podman run`
224/// while also populating the composefs repo for booting.
225async fn pull_composefs_unified(
226    imgstore: &CStorage,
227    storage_path: &str,
228    repo: &Arc<crate::store::ComposefsRepository>,
229    imgref: &containers_image_proxy::ImageReference,
230) -> Result<PullResult<Sha512HashValue>> {
231    let image = &imgref.name;
232
233    // Stage 1: get the image into bootc-owned containers-storage.
234    if imgref.transport == containers_image_proxy::Transport::ContainerStorage {
235        // The image is in the default containers-storage (/var/lib/containers/storage).
236        // Copy it into bootc-owned storage.
237        tracing::info!("Unified pull: copying {image} from host containers-storage");
238        imgstore
239            .pull_from_host_storage(image)
240            .await
241            .context("Copying image from host containers-storage into bootc storage")?;
242    } else {
243        // For registry (docker://), oci:, docker-daemon:, etc. — pull
244        // via the native podman API with streaming progress display.
245        let pull_ref = imgref.to_string();
246        tracing::info!("Unified pull: fetching {pull_ref} into containers-storage");
247        imgstore
248            .pull_with_progress(&pull_ref)
249            .await
250            .context("Pulling image into bootc containers-storage")?;
251    }
252
253    // Stage 2: import full OCI structure (layers + config + manifest) from
254    // containers-storage into composefs via cstor (zero-copy reflink/hardlink).
255    let cstor_imgref_str = format!("containers-storage:{image}");
256    tracing::info!("Unified pull: importing from {cstor_imgref_str} (zero-copy)");
257
258    let storage = std::path::Path::new(storage_path);
259    let pull_opts = PullOptions {
260        // The image is already in bootc-owned containers-storage at this point
261        // (placed there by Stage 1 of the unified pull). Use ZeroCopy so we
262        // actually import via reflink/hardlink and fail loudly if that isn't
263        // possible — a plain copy fallback here would mean Stage 1 and Stage 2
264        // are on different filesystems or the storage root is wrong.
265        local_fetch: LocalFetchOpt::ZeroCopy,
266        storage_root: Some(storage),
267        ..Default::default()
268    };
269    let pull_result = composefs_oci::pull(repo, &cstor_imgref_str, None, pull_opts)
270        .await
271        .context("Importing from containers-storage into composefs")?;
272
273    Ok(pull_result)
274}
275
276/// Pulls an image into a composefs repository at /sysroot.
277///
278/// When `use_unified` is true, the image is first pulled into bootc-owned
279/// containers-storage (so it's available for `podman run`), then imported
280/// from there into the composefs repo via zero-copy reflinks.
281///
282/// When `use_unified` is false (the default), the image is pulled directly
283/// into the composefs repo via skopeo.
284///
285/// Checks for boot entries in the image and returns them.
286#[context("Pulling composefs repository")]
287pub(crate) async fn pull_composefs_repo(
288    spec_imgref: &crate::spec::ImageReference,
289    allow_missing_fsverity: bool,
290    use_unified: bool,
291) -> Result<PullRepoResult> {
292    const COMPOSEFS_PULL_JOURNAL_ID: &str = "4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8";
293
294    let imgref = spec_imgref.to_image_proxy_ref()?;
295
296    tracing::info!(
297        message_id = COMPOSEFS_PULL_JOURNAL_ID,
298        bootc.operation = "pull",
299        bootc.source_image = &spec_imgref.image,
300        bootc.transport = %imgref.transport,
301        bootc.allow_missing_fsverity = allow_missing_fsverity,
302        bootc.unified_storage = use_unified,
303        "Pulling composefs image {imgref}",
304    );
305
306    let rootfs_dir = Dir::open_ambient_dir("/sysroot", ambient_authority())?;
307
308    let mut repo = open_composefs_repo(&rootfs_dir).context("Opening composefs repo")?;
309    if allow_missing_fsverity {
310        repo.set_insecure();
311    }
312
313    let repo = Arc::new(repo);
314
315    // Upgrade any old-format OCI images before pulling.  Old bootc
316    // (composefs-rs ≤ 2203e8f) did not add IMAGE_REF_KEY to config
317    // splitstreams, so the new GC's tag-based stream walk cannot reach
318    // their layer objects.  upgrade_repo() rewrites those config
319    // splitstreams in place before we add a new deployment, ensuring all
320    // existing deployments are GC-safe.  It is idempotent and fast when
321    // images are already in the current format.
322    let upgrade_result =
323        composefs_oci::upgrade_repo(&repo).context("Upgrading old-format OCI images")?;
324    if upgrade_result.upgraded > 0 {
325        tracing::info!(
326            "Upgraded {} old-format OCI image(s) to current format",
327            upgrade_result.upgraded
328        );
329    }
330
331    let pull_result = if use_unified {
332        // Create bootc-owned containers-storage on the rootfs.
333        // Load SELinux policy from the running system so newly pulled layers
334        // get the correct container_var_lib_t labels.
335        let root = Dir::open_ambient_dir("/", ambient_authority())?;
336        let sepolicy = lsm::new_sepolicy_at(&root)?;
337        let run = Dir::open_ambient_dir("/run", ambient_authority())?;
338        let imgstore = CStorage::create(&rootfs_dir, &run, sepolicy.as_ref())?;
339        let storage_path = format!("/sysroot/{}", CStorage::subpath());
340
341        pull_composefs_unified(&imgstore, &storage_path, &repo, &imgref).await?
342    } else {
343        pull_composefs_direct(&repo, &imgref).await?
344    };
345
346    // Tag the manifest as a bootc-owned GC root.
347    let tag = bootc_tag_for_manifest(&pull_result.manifest_digest.to_string());
348    tag_image(&*repo, &pull_result.manifest_digest, &tag)
349        .context("Tagging pulled image as bootc GC root")?;
350
351    tracing::info!(
352        message_id = COMPOSEFS_PULL_JOURNAL_ID,
353        bootc.operation = "pull",
354        bootc.manifest_digest = %pull_result.manifest_digest,
355        bootc.manifest_verity = pull_result.manifest_verity.to_hex(),
356        bootc.config_digest = %pull_result.config_digest,
357        bootc.config_verity = pull_result.config_verity.to_hex(),
358        bootc.tag = tag,
359        "Pulled image into composefs repository",
360    );
361
362    // Generate the bootable EROFS image (idempotent).
363    let id = composefs_oci::generate_boot_image(&repo, &pull_result.manifest_digest)
364        .context("Generating bootable EROFS image")?;
365
366    // Get boot entries from the OCI filesystem (untransformed).
367    let fs = create_composefs_filesystem(&*repo, &pull_result.config_digest, None)
368        .context("Creating composefs filesystem for boot entry discovery")?;
369    let entries =
370        get_boot_resources(&fs, &*repo).context("Extracting boot entries from OCI image")?;
371
372    // Unwrap the Arc to get the owned repo back.
373    let mut repo = Arc::try_unwrap(repo).map_err(|_| {
374        anyhow::anyhow!("BUG: Arc<Repository> still has other references after pull completed")
375    })?;
376    if allow_missing_fsverity {
377        repo.set_insecure();
378    }
379
380    Ok(PullRepoResult {
381        repo,
382        entries,
383        id,
384        manifest_digest: pull_result.manifest_digest.to_string(),
385    })
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_bootc_tag_for_manifest() {
394        let digest = "sha256:abc123def456";
395        let tag = bootc_tag_for_manifest(digest);
396        assert_eq!(tag, "localhost/bootc-sha256:abc123def456");
397        assert!(tag.starts_with(BOOTC_TAG_PREFIX));
398    }
399}