Skip to main content

bootc_lib/bootc_composefs/
repo.rs

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