Skip to main content

bootc_lib/bootc_composefs/
update.rs

1use anyhow::{Context, Result};
2use camino::Utf8PathBuf;
3use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
4use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
5use composefs_boot::BootOps;
6use composefs_ctl::composefs;
7use composefs_ctl::composefs_boot;
8use composefs_ctl::composefs_oci;
9use composefs_oci::image::create_filesystem;
10use etc_merge::print_unmergable_paths;
11use fn_error_context::context;
12use ocidir::cap_std::ambient_authority;
13use ostree_ext::container::ManifestDiff;
14
15use crate::bootc_composefs::finalize::get_etc_diff;
16use crate::bootc_composefs::gc::GCOpts;
17use crate::spec::BootloaderKind;
18use crate::{
19    bootc_composefs::{
20        boot::{
21            BootSetupType, BootType, UKIDigestMismatch, print_uki_dumpfile_diff,
22            setup_composefs_bls_boot, setup_composefs_uki_boot,
23        },
24        gc::composefs_gc,
25        repo::pull_composefs_repo,
26        service::start_finalize_stated_svc,
27        soft_reboot::prepare_soft_reboot_composefs,
28        state::write_composefs_state,
29        status::{
30            ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status,
31            get_container_manifest_and_config, get_imginfo,
32        },
33    },
34    cli::{SoftRebootMode, UpgradeOpts},
35    composefs_consts::{
36        COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE,
37        TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED,
38    },
39    progress_jsonl::ProgressWriter,
40    spec::{Host, ImageReference},
41    store::{BootedComposefs, ComposefsRepository, Storage},
42};
43
44/// Checks if a container image has been pulled to the local composefs repository.
45///
46/// This function verifies whether the specified container image exists in the local
47/// composefs repository by checking if the image's configuration digest stream is
48/// available. It retrieves the image manifest and configuration from the container
49/// registry and uses the configuration digest to perform the local availability check.
50///
51/// # Arguments
52///
53/// * `repo` - The composefs repository
54/// * `imgref` - Reference to the container image to check
55///
56/// # Returns
57///
58/// Returns a tuple containing:
59/// * `Some<Sha512HashValue>` if the image is pulled/available locally, `None` otherwise
60/// * The container image manifest
61/// * The container image configuration
62#[context("Checking if image {} is pulled", imgref.image)]
63pub(crate) async fn is_image_pulled(
64    repo: &ComposefsRepository,
65    imgref: &ImageReference,
66) -> Result<(Option<Sha512HashValue>, ImgConfigManifest)> {
67    let imgref_repr = imgref.to_image_proxy_ref()?;
68    let img_config_manifest = get_container_manifest_and_config(&imgref_repr).await?;
69
70    let img_digest = img_config_manifest.manifest.config().digest().digest();
71
72    // TODO: export config_identifier function from composefs-oci/src/lib.rs and use it here
73    let img_id = format!("oci-config-sha256:{img_digest}");
74
75    // NB: add deep checking?
76    let container_pulled = repo.has_stream(&img_id).context("Checking stream")?;
77
78    Ok((container_pulled, img_config_manifest))
79}
80
81fn rm_staged_type1_ent(boot_dir: &Dir) -> Result<()> {
82    if boot_dir.exists(TYPE1_ENT_PATH_STAGED) {
83        boot_dir
84            .remove_dir_all(TYPE1_ENT_PATH_STAGED)
85            .context("Removing staged bootloader entry")?;
86    }
87
88    Ok(())
89}
90
91#[derive(Debug)]
92pub(crate) enum UpdateAction {
93    /// Skip the update. We probably have the update in our deployments
94    Skip,
95    /// Proceed with the update
96    Proceed,
97}
98
99/// Determines what action should be taken for the update
100///
101/// Cases:
102///
103/// - The verity is the same as that of the currently booted deployment
104///
105///    Nothing to do here as we're currently booted
106///
107/// - The verity is the same as that of the staged deployment
108///
109///    Nothing to do, as we only get a "staged" deployment if we have
110///    /run/composefs/staged-deployment which is the last thing we create while upgrading
111///
112/// - The verity is the same as that of the rollback deployment
113///
114///    Nothing to do since this is a rollback deployment which means this was unstaged at some
115///    point
116///
117/// - The verity is not found
118///
119///    The update/switch might've been canceled before /run/composefs/staged-deployment
120///    was created, or at any other point in time, or it's a new one.
121///    Any which way, we can overwrite everything
122///
123/// # Arguments
124///
125/// * `storage`       - The global storage object
126/// * `booted_cfs`    - Reference to the booted composefs deployment
127/// * `host`          - Object returned by `get_composefs_status`
128/// * `img_digest`    - The SHA256 sum of the target image
129/// * `config_verity` - The verity of the Image config splitstream
130/// * `is_switch`     - Whether this is an update operation or a switch operation
131///
132/// # Returns
133/// * UpdateAction::Skip    - Skip the update/switch as we have it as a deployment
134/// * UpdateAction::Proceed - Proceed with the update
135pub(crate) fn validate_update(
136    storage: &Storage,
137    booted_cfs: &BootedComposefs,
138    host: &Host,
139    img_digest: &str,
140    config_verity: &Sha512HashValue,
141    is_switch: bool,
142) -> Result<UpdateAction> {
143    let repo = &*booted_cfs.repo;
144
145    let oci_digest: composefs_oci::OciDigest = img_digest
146        .parse()
147        .with_context(|| format!("Parsing config digest {img_digest}"))?;
148    let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity), &Default::default())?;
149    fs.transform_for_boot(&repo)?;
150
151    let image_id = fs.compute_image_id(repo.erofs_version());
152
153    let all_deployments = host.all_composefs_deployments()?;
154
155    let found_depl = all_deployments
156        .iter()
157        .find(|d| d.deployment.verity == image_id.to_hex());
158
159    if let Some(collision) = found_depl {
160        if is_switch {
161            // For `bootc switch`, any digest collision is an error: two images
162            // from different sources can produce identical composefs roots and we
163            // cannot safely reuse an existing state directory seeded from a
164            // different image.
165            anyhow::bail!(
166                "Target image has the same fs-verity digest as the existing {:?} deployment.",
167                collision.ty,
168            );
169        }
170        // For `bootc upgrade`, matching the booted deployment means nothing to
171        // do; matching a non-booted deployment (staged/rollback) means skip.
172        return Ok(UpdateAction::Skip);
173    }
174
175    let booted = host.require_composefs_booted()?;
176    let boot_dir = storage.require_boot_dir()?;
177
178    // Remove staged bootloader entries, if any
179    // GC should take care of the UKI PEs and other binaries
180    match get_bootloader()?.kind()? {
181        BootloaderKind::GRUBClassic => match booted.boot_type {
182            BootType::Bls => rm_staged_type1_ent(boot_dir)?,
183
184            BootType::Uki => {
185                let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?;
186
187                if grub.exists(USER_CFG_STAGED) {
188                    grub.remove_file(USER_CFG_STAGED)
189                        .context("Removing staged grub user config")?;
190                }
191            }
192        },
193
194        BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?,
195    }
196
197    // Remove state directory
198    let state_dir = storage
199        .physical_root
200        .open_dir(STATE_DIR_RELATIVE)
201        .context("Opening state dir")?;
202
203    if state_dir.exists(image_id.to_hex()) {
204        state_dir
205            .remove_dir_all(image_id.to_hex())
206            .context("Removing state")?;
207    }
208
209    Ok(UpdateAction::Proceed)
210}
211
212/// This is just an intersection of SwitchOpts and UpgradeOpts
213pub(crate) struct DoUpgradeOpts {
214    pub(crate) apply: bool,
215    pub(crate) soft_reboot: Option<SoftRebootMode>,
216    pub(crate) download_only: bool,
217    /// Whether to use unified storage (containers-storage + composefs).
218    pub(crate) use_unified: bool,
219    /// Suppress interactive progress output.
220    pub(crate) quiet: bool,
221    /// Structured (JSON-Lines) progress sink; see `--progress-fd`.
222    pub(crate) prog: ProgressWriter,
223}
224
225async fn apply_upgrade(
226    storage: &Storage,
227    booted_cfs: &BootedComposefs,
228    depl_id: &String,
229    opts: &DoUpgradeOpts,
230) -> Result<()> {
231    if let Some(soft_reboot_mode) = opts.soft_reboot {
232        return prepare_soft_reboot_composefs(
233            storage,
234            booted_cfs,
235            Some(depl_id),
236            soft_reboot_mode,
237            opts.apply,
238        )
239        .await;
240    };
241
242    if opts.apply {
243        return crate::reboot::reboot();
244    }
245
246    Ok(())
247}
248
249/// Performs the Update or Switch operation
250#[context("Performing Upgrade Operation")]
251pub(crate) async fn do_upgrade(
252    storage: &Storage,
253    booted_cfs: &BootedComposefs,
254    host: &Host,
255    imgref: &ImageReference,
256    opts: &DoUpgradeOpts,
257    manifest: &ostree_ext::oci_spec::image::ImageManifest,
258) -> Result<()> {
259    // Pre-flight disk space check before pulling.
260    crate::deploy::check_disk_space_composefs(&*booted_cfs.repo, manifest, imgref)?;
261
262    start_finalize_stated_svc()?;
263
264    let crate::bootc_composefs::repo::PullRepoResult {
265        repo,
266        entries,
267        id,
268        manifest_digest,
269        fs: oci_fs,
270    } = pull_composefs_repo(
271        imgref,
272        booted_cfs.cmdline.allow_missing_fsverity,
273        opts.use_unified,
274        opts.quiet,
275        opts.prog.clone(),
276    )
277    .await?;
278
279    // If the target image produces the same fs-verity digest as any existing
280    // deployment (booted, staged, rollback, or pinned), error out.  Two images
281    // from different sources can have identical content; we cannot silently reuse
282    // an existing state directory whose /etc was seeded from a different image.
283    let all_deployments = host.all_composefs_deployments()?;
284    if let Some(collision) = all_deployments
285        .iter()
286        .find(|d| d.deployment.verity == id.to_hex())
287    {
288        anyhow::bail!(
289            "Target image has the same fs-verity digest as the existing {:?} deployment.",
290            collision.ty,
291        );
292    }
293
294    let Some(entry) = entries.iter().next() else {
295        anyhow::bail!("No boot entries!");
296    };
297
298    let mounted_fs = Dir::reopen_dir(
299        &repo
300            .mount(&id.to_hex())
301            .context("Failed to mount composefs image")?,
302    )?;
303
304    // Check if three way etc merge is possible without conflicts
305    let new_etc = mounted_fs
306        .open_dir("etc")
307        .context("Opening deployment's etc")?;
308
309    let diff = get_etc_diff(storage, booted_cfs, Some(&new_etc)).await?;
310
311    if !diff.unmergable_paths.is_empty() {
312        print_unmergable_paths(&diff, &mut std::io::stderr());
313        anyhow::bail!("Merge conflicts found in etc");
314    }
315
316    let boot_type = BootType::from(entry);
317
318    let boot_digest = match boot_type {
319        BootType::Bls => setup_composefs_bls_boot(
320            BootSetupType::Upgrade((storage, booted_cfs, &host)),
321            &repo,
322            &id,
323            entry,
324            &mounted_fs,
325        )?,
326
327        BootType::Uki => {
328            let uki_setup_result = setup_composefs_uki_boot(
329                BootSetupType::Upgrade((storage, booted_cfs, &host)),
330                &repo,
331                &id,
332                entries,
333            );
334
335            match uki_setup_result {
336                Ok(boot_digest) => boot_digest,
337                Err(e) => match e.downcast::<UKIDigestMismatch>() {
338                    Ok(mismatch) => {
339                        print_uki_dumpfile_diff(&mismatch, &repo, &oci_fs);
340                        return Err(mismatch.into());
341                    }
342                    Err(e) => Err(e)?,
343                },
344            }
345        }
346    };
347
348    // `repo` holds its own flock(LOCK_SH) on /sysroot/composefs, taken out by
349    // pull_composefs_repo() on a *different* open-file-description than the
350    // one `booted_cfs.repo` uses. Below, composefs_gc() will ask `booted_cfs.repo`
351    // to take flock(LOCK_EX) on the same underlying file. Since two independent
352    // opens of the same file by one process don't share flock() state, that
353    // exclusive lock would block forever on the shared lock we're still holding
354    // here unless we drop `repo` first. `mounted_fs` is a mount of `repo`'s
355    // content, so it's dropped alongside it for the same reason. (`entries` is
356    // already consumed by this point in the BootType::Uki arm above, so there's
357    // nothing left to drop there.)
358    // See https://github.com/bootc-dev/bootc/issues/2364.
359    drop(mounted_fs);
360    drop(repo);
361
362    let staged_state = StagedDeployment {
363        depl_id: id.to_hex(),
364        finalization_locked: opts.download_only,
365    };
366
367    write_composefs_state(
368        &Utf8PathBuf::from("/sysroot"),
369        &id,
370        imgref,
371        Some(staged_state),
372        boot_type,
373        boot_digest,
374        &manifest_digest,
375        booted_cfs.cmdline.allow_missing_fsverity,
376    )
377    .await?;
378
379    // We take into account the staged bootloader entries so this won't remove
380    // the currently staged entry
381    //
382    // Note: this takes an exclusive flock via `booted_cfs.repo`; no other
383    // `Repository` handle on the same underlying file may still be alive here
384    // (see the `drop(repo)` above for why).
385    composefs_gc(
386        storage,
387        booted_cfs,
388        GCOpts {
389            dry_run: false,
390            prune_repo: true,
391        },
392    )
393    .await?;
394
395    apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await
396}
397
398#[context("Upgrading composefs")]
399pub(crate) async fn upgrade_composefs(
400    opts: UpgradeOpts,
401    storage: &Storage,
402    composefs: &BootedComposefs,
403) -> Result<()> {
404    const COMPOSEFS_UPGRADE_JOURNAL_ID: &str = "9c8d7f6e5a4b3c2d1e0f9a8b7c6d5e4f3";
405
406    tracing::info!(
407        message_id = COMPOSEFS_UPGRADE_JOURNAL_ID,
408        bootc.operation = "upgrade",
409        bootc.apply_mode = opts.apply,
410        bootc.download_only = opts.download_only,
411        bootc.from_downloaded = opts.from_downloaded,
412        "Starting composefs upgrade operation"
413    );
414
415    let host = get_composefs_status(storage, composefs)
416        .await
417        .context("Getting composefs deployment status")?;
418
419    let current_image = host.spec.image.as_ref();
420
421    // Handle --tag: derive target from current image + new tag
422    let derived_image = if let Some(ref tag) = opts.tag {
423        let image = current_image.ok_or_else(|| {
424            anyhow::anyhow!("--tag requires a booted image with a specified source")
425        })?;
426        Some(image.with_tag(tag)?)
427    } else {
428        None
429    };
430
431    let prog: ProgressWriter = opts.progress.try_into()?;
432
433    let mut do_upgrade_opts = DoUpgradeOpts {
434        soft_reboot: opts.soft_reboot,
435        apply: opts.apply,
436        download_only: opts.download_only,
437        use_unified: false,
438        quiet: opts.quiet,
439        prog,
440    };
441
442    if opts.from_downloaded {
443        let staged = host
444            .status
445            .staged
446            .as_ref()
447            .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
448
449        // Staged deployment exists, but it will be finalized
450        if !staged.download_only {
451            println!("Staged deployment is present and not in download only mode.");
452            println!("Use `bootc update --apply` to apply the update.");
453            return Ok(());
454        }
455
456        start_finalize_stated_svc()?;
457
458        let staged_depl_dir =
459            Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority())
460                .context("Opening transient state directory")?;
461
462        let current = staged_depl_dir
463            .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME)
464            .context("Reading staged file")?;
465
466        let mut new_staged: StagedDeployment =
467            serde_json::from_str(&current).context("Deserialzing staged file")?;
468
469        // Make the staged deployment not download_only
470        new_staged.finalization_locked = false;
471
472        staged_depl_dir
473            .atomic_replace_with(
474                COMPOSEFS_STAGED_DEPLOYMENT_FNAME,
475                |f| -> std::io::Result<()> {
476                    serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from)
477                },
478            )
479            .context("Writing staged file")?;
480
481        return apply_upgrade(
482            storage,
483            composefs,
484            &staged.require_composefs()?.verity,
485            &do_upgrade_opts,
486        )
487        .await;
488    }
489
490    let imgref = derived_image.as_ref().or(current_image);
491    let mut booted_imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
492
493    // Auto-detect unified storage: use the unified path if the target image is
494    // already in bootc-owned containers-storage, OR if the booted image is —
495    // the latter means the user has opted into unified storage and all
496    // subsequent operations should use it.
497    let current_unified = if let Some(current) = current_image {
498        crate::deploy::image_exists_in_unified_storage(storage, current).await?
499    } else {
500        false
501    };
502    do_upgrade_opts.use_unified = current_unified
503        || crate::deploy::image_exists_in_unified_storage(storage, booted_imgref).await?;
504
505    let repo = &*composefs.repo;
506
507    let (img_pulled, mut img_config) = is_image_pulled(&repo, booted_imgref).await?;
508    let booted_img_digest = img_config.manifest.config().digest().to_string();
509
510    // Check if we already have this update staged
511    // Or if we have another staged deployment with a different image
512    let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref());
513
514    if let Some(staged_image) = staged_image {
515        // We have a staged image and it has the same digest as the currently booted image's latest
516        // digest
517        if staged_image.image_digest == booted_img_digest {
518            if opts.apply {
519                return crate::reboot::reboot();
520            }
521
522            println!("Update already staged. To apply update run `bootc update --apply`");
523
524            return Ok(());
525        }
526
527        // We have a staged image but it's not the update image.
528        // Maybe it's something we got by `bootc switch`
529        // Switch takes precedence over update, so we change the imgref
530        booted_imgref = &staged_image.image;
531
532        let (img_pulled, staged_img_config) = is_image_pulled(&repo, booted_imgref).await?;
533        img_config = staged_img_config;
534
535        if let Some(cfg_verity) = img_pulled {
536            let action = validate_update(
537                storage,
538                composefs,
539                &host,
540                img_config.manifest.config().digest().as_ref(),
541                &cfg_verity,
542                false,
543            )?;
544
545            match action {
546                UpdateAction::Skip => {
547                    println!("No changes in staged image: {booted_imgref:#}");
548                    return Ok(());
549                }
550
551                UpdateAction::Proceed => {
552                    return do_upgrade(
553                        storage,
554                        composefs,
555                        &host,
556                        booted_imgref,
557                        &do_upgrade_opts,
558                        &img_config.manifest,
559                    )
560                    .await;
561                }
562            }
563        }
564    }
565
566    // We already have this container config
567    if let Some(cfg_verity) = img_pulled {
568        let action = validate_update(
569            storage,
570            composefs,
571            &host,
572            &booted_img_digest,
573            &cfg_verity,
574            false,
575        )?;
576
577        match action {
578            UpdateAction::Skip => {
579                println!("No changes in: {booted_imgref:#}");
580                return Ok(());
581            }
582
583            UpdateAction::Proceed => {
584                return do_upgrade(
585                    storage,
586                    composefs,
587                    &host,
588                    booted_imgref,
589                    &do_upgrade_opts,
590                    &img_config.manifest,
591                )
592                .await;
593            }
594        }
595    }
596
597    if opts.check {
598        let (current_manifest, _) = get_imginfo(storage, &*composefs.cmdline.digest)?;
599        let diff = ManifestDiff::new(&current_manifest.manifest, &img_config.manifest);
600        diff.print();
601        return Ok(());
602    }
603
604    do_upgrade(
605        storage,
606        composefs,
607        &host,
608        booted_imgref,
609        &do_upgrade_opts,
610        &img_config.manifest,
611    )
612    .await?;
613
614    Ok(())
615}