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;
4use composefs::erofs::format::FormatVersion;
5use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
6use composefs_boot::BootOps;
7use composefs_ctl::composefs;
8use composefs_ctl::composefs_boot;
9use composefs_ctl::composefs_oci;
10use composefs_oci::image::create_filesystem;
11use etc_merge::print_unmergable_paths;
12use fn_error_context::context;
13use ocidir::cap_std::ambient_authority;
14use ostree_ext::container::ManifestDiff;
15
16use crate::bootc_composefs::finalize::get_etc_diff;
17use crate::bootc_composefs::gc::GCOpts;
18use crate::delta::Delta;
19use crate::install::UkiAddonOpts;
20use crate::spec::BootloaderKind;
21use crate::{
22    bootc_composefs::{
23        aboot,
24        boot::{
25            BootSetupType, BootType, prepare_composefs_aboot_artifacts,
26            print_uki_dumpfile_diff_on_mismatch, setup_composefs_bls_boot,
27            setup_composefs_uki_boot,
28        },
29        gc::composefs_gc,
30        repo::pull_composefs_repo,
31        service::start_finalize_stated_svc,
32        soft_reboot::prepare_soft_reboot_composefs,
33        state::{write_composefs_state, write_staged_deployment},
34        status::{
35            ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status,
36            get_container_manifest_and_config, get_imginfo, list_bootloader_entries,
37        },
38        uki_addon::global_addons_pending_removal,
39    },
40    cli::{SoftRebootMode, UpgradeOpts},
41    composefs_consts::{
42        COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE,
43        TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED,
44    },
45    progress_jsonl::ProgressWriter,
46    spec::{Host, ImageReference},
47    store::{BootedComposefs, ComposefsRepository, Storage},
48};
49
50/// Checks if a container image has been pulled to the local composefs repository.
51///
52/// This function verifies whether the specified container image exists in the local
53/// composefs repository by checking if the image's configuration digest stream is
54/// available. It retrieves the image manifest and configuration from the container
55/// registry and uses the configuration digest to perform the local availability check.
56///
57/// # Arguments
58///
59/// * `repo` - The composefs repository
60/// * `imgref` - Reference to the container image to check
61///
62/// # Returns
63///
64/// Returns a tuple containing:
65/// * `Some<Sha512HashValue>` if the image is pulled/available locally, `None` otherwise
66/// * The container image manifest
67/// * The container image configuration
68/// * The digest of the manifest, as the registry reported it
69#[context("Checking if image {} is pulled", imgref.image)]
70pub(crate) async fn is_image_pulled(
71    repo: &ComposefsRepository,
72    imgref: &ImageReference,
73) -> Result<(Option<Sha512HashValue>, ImgConfigManifest, String)> {
74    let imgref_repr = imgref.to_image_proxy_ref()?;
75    let (img_config_manifest, manifest_digest) =
76        get_container_manifest_and_config(&imgref_repr).await?;
77
78    let container_pulled =
79        lookup_config_splitstream(repo, img_config_manifest.manifest.config().digest())?;
80
81    Ok((container_pulled, img_config_manifest, manifest_digest))
82}
83
84/// Look up the config splitstream for an image config digest, which is present
85/// exactly when that image has been imported into `repo`.
86pub(crate) fn lookup_config_splitstream(
87    repo: &ComposefsRepository,
88    config_digest: &composefs_oci::OciDigest,
89) -> Result<Option<Sha512HashValue>> {
90    let img_digest = config_digest.digest();
91
92    // TODO: export config_identifier function from composefs-oci/src/lib.rs and use it here
93    let img_id = format!("oci-config-sha256:{img_digest}");
94
95    // NB: add deep checking?
96    repo.has_stream(&img_id).context("Checking stream")
97}
98
99/// A delta only carries the layers that changed; the rest have to come from the
100/// image it was built against, which must therefore already be in `repo`.
101///
102/// composefs would notice this eventually, but only once it starts importing, which is
103/// pretty late for a nice experience.
104pub(crate) fn ensure_delta_source_present(repo: &ComposefsRepository, delta: &Delta) -> Result<()> {
105    let source = delta.source_config_digest();
106    anyhow::ensure!(
107        lookup_config_splitstream(repo, source)?.is_some(),
108        "Delta {} was built against the image with config {source}, which is not present in \
109         this system's composefs repository.",
110        delta.path,
111    );
112    Ok(())
113}
114
115fn rm_staged_type1_ent(boot_dir: &Dir) -> Result<()> {
116    if boot_dir.exists(TYPE1_ENT_PATH_STAGED) {
117        boot_dir
118            .remove_dir_all(TYPE1_ENT_PATH_STAGED)
119            .context("Removing staged bootloader entry")?;
120    }
121
122    Ok(())
123}
124
125#[derive(Debug)]
126pub(crate) enum UpdateAction {
127    /// Skip the update. We probably have the update in our deployments
128    Skip,
129    /// Proceed with the update
130    Proceed,
131}
132
133/// Determines what action should be taken for the update
134///
135/// Cases:
136///
137/// - The verity is the same as that of the currently booted deployment
138///
139///    Nothing to do here as we're currently booted
140///
141/// - The verity is the same as that of the staged deployment
142///
143///    Nothing to do, as we only get a "staged" deployment if we have
144///    /run/composefs/staged-deployment which is the last thing we create while upgrading
145///
146/// - The verity is the same as that of the rollback deployment
147///
148///    Nothing to do since this is a rollback deployment which means this was unstaged at some
149///    point
150///
151/// - The verity is not found
152///
153///    The update/switch might've been canceled before /run/composefs/staged-deployment
154///    was created, or at any other point in time, or it's a new one.
155///    Any which way, we can overwrite everything
156///
157/// # Arguments
158///
159/// * `storage`       - The global storage object
160/// * `booted_cfs`    - Reference to the booted composefs deployment
161/// * `host`          - Object returned by `get_composefs_status`
162/// * `img_digest`    - The SHA256 sum of the target image
163/// * `config_verity` - The verity of the Image config splitstream
164/// * `is_switch`     - Whether this is an update operation or a switch operation
165///
166/// # Returns
167/// * UpdateAction::Skip    - Skip the update/switch as we have it as a deployment
168/// * UpdateAction::Proceed - Proceed with the update
169pub(crate) fn validate_update(
170    storage: &Storage,
171    booted_cfs: &BootedComposefs,
172    host: &Host,
173    img_digest: &str,
174    config_verity: &Sha512HashValue,
175    is_switch: bool,
176) -> Result<UpdateAction> {
177    let repo = &*booted_cfs.repo;
178
179    let oci_digest: composefs_oci::OciDigest = img_digest
180        .parse()
181        .with_context(|| format!("Parsing config digest {img_digest}"))?;
182    let mut fs = create_filesystem(
183        repo,
184        &oci_digest,
185        Some(config_verity),
186        &composefs_oci::OciTransformOptions::default(),
187    )?;
188    fs.transform_for_boot(&repo)?;
189
190    let image_ids = [
191        fs.compute_image_id(FormatVersion::V1),
192        fs.compute_image_id(FormatVersion::V2),
193    ];
194
195    let all_deployments = host.all_composefs_deployments()?;
196
197    let found_depl = all_deployments.iter().find(|d| {
198        image_ids
199            .iter()
200            .any(|id| d.deployment.verity == id.to_hex())
201    });
202
203    if let Some(collision) = found_depl {
204        if is_switch {
205            // For `bootc switch`, any digest collision is an error: two images
206            // from different sources can produce identical composefs roots and we
207            // cannot safely reuse an existing state directory seeded from a
208            // different image.
209            anyhow::bail!(
210                "Target image has the same fs-verity digest as the existing {:?} deployment.",
211                collision.ty,
212            );
213        }
214        // For `bootc upgrade`, matching the booted deployment means nothing to
215        // do; matching a non-booted deployment (staged/rollback) means skip.
216        return Ok(UpdateAction::Skip);
217    }
218
219    let booted = host.require_composefs_booted()?;
220    if booted.boot_type == BootType::Aboot {
221        ensure_no_aboot_attempt(storage)?;
222    } else {
223        // Remove staged bootloader entries, if any
224        // GC should take care of the UKI PEs and other binaries
225        let boot_dir = storage.require_boot_dir()?;
226        match get_bootloader()?.kind()? {
227            BootloaderKind::GRUBClassic => match booted.boot_type {
228                BootType::Bls => rm_staged_type1_ent(boot_dir)?,
229                BootType::Uki => {
230                    let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?;
231                    if grub.exists(USER_CFG_STAGED) {
232                        grub.remove_file(USER_CFG_STAGED)
233                            .context("Removing staged grub user config")?;
234                    }
235                }
236                BootType::Aboot => unreachable!(),
237            },
238            BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?,
239        }
240    }
241
242    // Remove state directories for either serialisation of the same rootfs.
243    let state_dir = storage
244        .physical_root
245        .open_dir(STATE_DIR_RELATIVE)
246        .context("Opening state dir")?;
247
248    for image_id in image_ids {
249        let image_id = image_id.to_hex();
250        if state_dir.exists(&image_id) {
251            state_dir
252                .remove_dir_all(&image_id)
253                .context("Removing state")?;
254        }
255    }
256
257    Ok(UpdateAction::Proceed)
258}
259
260fn ensure_no_aboot_attempt(storage: &Storage) -> Result<()> {
261    let state = aboot::AbootState::open(&storage.physical_root)?;
262    anyhow::ensure!(
263        state.read_attempted()?.is_none(),
264        "An attempted aboot update has not been reconciled"
265    );
266    anyhow::ensure!(
267        state.queued_rollback()?.is_none(),
268        "An aboot rollback is queued"
269    );
270    Ok(())
271}
272
273/// Drop a failed aboot candidate before constructing the next update's target and state.
274pub(crate) fn discard_failed_aboot_update(storage: &Storage, host: &Host) -> Result<bool> {
275    let booted = host.require_composefs_booted()?;
276    if booted.boot_type != BootType::Aboot {
277        return Ok(false);
278    }
279    aboot::AbootState::open(&storage.physical_root)?.discard_failed_attempt(&booted.verity)
280}
281
282/// This is just an intersection of SwitchOpts and UpgradeOpts
283pub(crate) struct DoUpgradeOpts<'a> {
284    pub(crate) apply: bool,
285    pub(crate) soft_reboot: Option<SoftRebootMode>,
286    pub(crate) download_only: bool,
287    /// Whether to use unified storage (containers-storage + composefs).
288    pub(crate) use_unified: bool,
289    /// Suppress interactive progress output.
290    pub(crate) quiet: bool,
291    /// Structured (JSON-Lines) progress sink; see `--progress-fd`.
292    pub(crate) prog: ProgressWriter,
293    /// Take the image content from this local delta rather than the network.
294    pub(crate) delta: Option<&'a Delta>,
295    /// The UKI Addons to install from the new image (if any)
296    pub(crate) uki_addon_opts: UkiAddonOpts,
297}
298
299async fn apply_upgrade(
300    storage: &Storage,
301    booted_cfs: &BootedComposefs,
302    depl_id: &String,
303    opts: &DoUpgradeOpts<'_>,
304) -> Result<()> {
305    if let Some(soft_reboot_mode) = opts.soft_reboot {
306        return prepare_soft_reboot_composefs(
307            storage,
308            booted_cfs,
309            Some(depl_id),
310            soft_reboot_mode,
311            opts.apply,
312        )
313        .await;
314    };
315
316    if opts.apply {
317        return crate::reboot::reboot();
318    }
319
320    Ok(())
321}
322
323/// Performs the Update or Switch operation
324#[context("Performing Upgrade Operation")]
325pub(crate) async fn do_upgrade(
326    storage: &Storage,
327    booted_cfs: &BootedComposefs,
328    host: &Host,
329    imgref: &ImageReference,
330    opts: &DoUpgradeOpts<'_>,
331    manifest: &ostree_ext::oci_spec::image::ImageManifest,
332) -> Result<()> {
333    // Unpulled images bypass validate_update(), so check aboot state here too.
334    if host.require_composefs_booted()?.boot_type == BootType::Aboot {
335        ensure_no_aboot_attempt(storage)?;
336    }
337
338    // Pre-flight disk space check before pulling.
339    crate::deploy::check_disk_space_composefs(&*booted_cfs.repo, manifest, imgref)?;
340
341    start_finalize_stated_svc()?;
342
343    let crate::bootc_composefs::repo::PullRepoResult {
344        repo,
345        entries,
346        id,
347        boot_ids,
348        manifest_digest,
349        fs: oci_fs,
350    } = pull_composefs_repo(
351        imgref,
352        opts.delta,
353        booted_cfs.cmdline.allow_missing_fsverity,
354        opts.use_unified,
355        opts.quiet,
356        opts.prog.clone(),
357    )
358    .await?;
359
360    // We validated the delta by reading the file ourselves; composefs read it
361    // independently. Validate they are the same.
362    if let Some(delta) = opts.delta {
363        let expected = delta.target_manifest_digest().to_string();
364        anyhow::ensure!(
365            manifest_digest == expected,
366            "Delta {} imported manifest {manifest_digest}, but it records {expected} as its target",
367            delta.path,
368        );
369    }
370
371    // If the target image produces the same fs-verity digest as any existing
372    // deployment (booted, staged, rollback, or pinned), error out.  Two images
373    // from different sources can have identical content; we cannot silently reuse
374    // an existing state directory whose /etc was seeded from a different image.
375    let all_deployments = host.all_composefs_deployments()?;
376    if let Some(collision) = all_deployments
377        .iter()
378        .find(|d| d.deployment.verity == id.to_hex())
379    {
380        anyhow::bail!(
381            "Target image has the same fs-verity digest as the existing {:?} deployment.",
382            collision.ty,
383        );
384    }
385
386    let entry = super::boot::primary_boot_entry(&entries)?;
387    let boot_type = BootType::from(entry);
388    let booted = host.require_composefs_booted()?;
389    let bootloader = booted.bootloader;
390    let booted_type = booted.boot_type;
391    if boot_type == BootType::Aboot || booted_type == BootType::Aboot {
392        anyhow::ensure!(
393            boot_type == booted_type,
394            "Cannot switch between aboot and non-aboot boot layouts"
395        );
396    }
397    super::boot::validate_bootloader(entry, bootloader)?;
398
399    let mounted_fs = Dir::reopen_dir(
400        &repo
401            .mount(&id.to_hex())
402            .context("Failed to mount composefs image")?,
403    )?;
404
405    if boot_type == BootType::Aboot {
406        aboot::require_aboot_deploy_in_image(&mounted_fs)?;
407    }
408
409    // Check if three way etc merge is possible without conflicts
410    let new_etc = mounted_fs
411        .open_dir("etc")
412        .context("Opening deployment's etc")?;
413
414    let diff = get_etc_diff(storage, booted_cfs, Some(&new_etc)).await?;
415
416    if !diff.unmergable_paths.is_empty() {
417        print_unmergable_paths(&diff, &mut std::io::stderr());
418        anyhow::bail!("Merge conflicts found in etc");
419    }
420
421    let (provisional_deploy_id, provisional_format) = (id.clone(), repo.erofs_version());
422    let mut aboot_artifacts = None;
423
424    let (boot_digest, deploy_id) = match boot_type {
425        BootType::Bls => (
426            setup_composefs_bls_boot(
427                BootSetupType::Upgrade((storage, booted_cfs, &host, None)),
428                &repo,
429                &provisional_deploy_id,
430                provisional_format,
431                entry,
432                &mounted_fs,
433            )?,
434            provisional_deploy_id,
435        ),
436
437        BootType::Uki => print_uki_dumpfile_diff_on_mismatch(
438            setup_composefs_uki_boot(
439                BootSetupType::Upgrade((storage, booted_cfs, &host, Some(&opts.uki_addon_opts))),
440                &repo,
441                &provisional_deploy_id,
442                &boot_ids,
443                entries,
444            ),
445            &repo,
446            &oci_fs,
447        )?,
448        BootType::Aboot => {
449            let prepared = prepare_composefs_aboot_artifacts(
450                &repo,
451                &id,
452                &boot_ids,
453                entry,
454                booted_cfs.cmdline.allow_missing_fsverity,
455            )?;
456            let boot_digest = prepared.boot_digest.clone();
457            aboot_artifacts = Some(prepared);
458            (boot_digest, provisional_deploy_id)
459        }
460    };
461
462    // `repo` holds its own flock(LOCK_SH) on /sysroot/composefs, taken out by
463    // pull_composefs_repo() on a *different* open-file-description than the
464    // one `booted_cfs.repo` uses. Below, composefs_gc() will ask `booted_cfs.repo`
465    // to take flock(LOCK_EX) on the same underlying file. Since two independent
466    // opens of the same file by one process don't share flock() state, that
467    // exclusive lock would block forever on the shared lock we're still holding
468    // here unless we drop `repo` first. `mounted_fs` is a mount of `repo`'s
469    // content, so it's dropped alongside it for the same reason. (`entries` is
470    // already consumed by this point in the BootType::Uki arm above, so there's
471    // nothing left to drop there.)
472    // See https://github.com/bootc-dev/bootc/issues/2364.
473    drop(mounted_fs);
474    drop(repo);
475
476    let staged_state = StagedDeployment {
477        depl_id: deploy_id.to_hex(),
478        finalization_locked: opts.download_only,
479    };
480
481    write_composefs_state(
482        &Utf8PathBuf::from("/sysroot"),
483        &deploy_id,
484        imgref,
485        false,
486        boot_type,
487        boot_digest,
488        &manifest_digest,
489        booted_cfs.cmdline.allow_missing_fsverity,
490    )
491    .await?;
492
493    if let Some(artifacts) = aboot_artifacts {
494        let mut state = aboot::AbootState::open(&storage.physical_root)?;
495        let pending = state.stage_artifacts(
496            &staged_state.depl_id,
497            staged_state.finalization_locked,
498            &artifacts.payload,
499            artifacts.vbmeta.as_deref(),
500        )?;
501        state.write_pending(&pending)?;
502    }
503    write_staged_deployment(&staged_state)?;
504
505    // We take into account the staged bootloader entries so this won't remove
506    // the currently staged entry
507    //
508    // Note: this takes an exclusive flock via `booted_cfs.repo`; no other
509    // `Repository` handle on the same underlying file may still be alive here
510    // (see the `drop(repo)` above for why).
511    composefs_gc(
512        storage,
513        booted_cfs,
514        GCOpts {
515            dry_run: false,
516            prune_repo: true,
517        },
518    )
519    .await?;
520
521    // Warn about global UKI addons that the newly staged image no longer provides.
522    // They survive this GC (still referenced by the booted deployment) but will be
523    // garbage collected on the next update/switch once that deployment rolls off.
524    if boot_type == BootType::Uki {
525        let pinned_verities: Vec<String> = host
526            .all_composefs_deployments()?
527            .into_iter()
528            .filter(|d| d.pinned)
529            .map(|d| d.deployment.verity.clone())
530            .collect();
531
532        let bootloader_entries = list_bootloader_entries(storage)?;
533
534        let pending = global_addons_pending_removal(
535            storage,
536            booted_cfs,
537            &deploy_id.to_hex(),
538            &pinned_verities,
539            &bootloader_entries,
540        )?;
541
542        if !pending.is_empty() {
543            println!(
544                "notice: these global UKI addons are not provided by the new image and will be \
545                 removed on a future update/switch unless re-added with \
546                 `bootc uki-addon add <name> global`:"
547            );
548            for addon in &pending {
549                println!("  - {addon}");
550            }
551        }
552    }
553
554    apply_upgrade(storage, booted_cfs, &deploy_id.to_hex(), opts).await
555}
556
557#[context("Applying downloaded upgrade")]
558pub(crate) async fn apply_upgrade_from_downloaded(
559    storage: &Storage,
560    composefs: &BootedComposefs,
561    host: &Host,
562    do_upgrade_opts: &DoUpgradeOpts<'_>,
563) -> Result<()> {
564    let staged = host
565        .status
566        .staged
567        .as_ref()
568        .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
569
570    // Staged deployment exists, but it will be finalized
571    if !staged.download_only {
572        println!("Staged deployment is present and not in download only mode.");
573        println!("Use `bootc update --apply` to apply the update.");
574        return Ok(());
575    }
576
577    let staged_id = &staged.require_composefs()?.verity;
578    if host.require_composefs_booted()?.boot_type == BootType::Aboot {
579        let mut state = aboot::AbootState::open(&storage.physical_root)?;
580        let pending = state.unlock_pending(staged_id)?;
581        write_staged_deployment(&pending.staged())?;
582    } else {
583        let staged_depl_dir =
584            Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority())
585                .context("Opening transient state directory")?;
586        let current = staged_depl_dir
587            .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME)
588            .context("Reading staged file")?;
589        let mut new_staged: StagedDeployment =
590            serde_json::from_str(&current).context("Deserializing staged file")?;
591        new_staged.finalization_locked = false;
592        write_staged_deployment(&new_staged)?;
593    }
594
595    start_finalize_stated_svc()?;
596
597    return apply_upgrade(storage, composefs, staged_id, &do_upgrade_opts).await;
598}
599
600#[context("Upgrading composefs")]
601pub(crate) async fn upgrade_composefs(
602    opts: UpgradeOpts,
603    storage: &Storage,
604    composefs: &BootedComposefs,
605) -> Result<()> {
606    const COMPOSEFS_UPGRADE_JOURNAL_ID: &str = "9c8d7f6e5a4b3c2d1e0f9a8b7c6d5e4f3";
607
608    tracing::info!(
609        message_id = COMPOSEFS_UPGRADE_JOURNAL_ID,
610        bootc.operation = "upgrade",
611        bootc.apply_mode = opts.apply,
612        bootc.download_only = opts.download_opts.download_only,
613        bootc.from_downloaded = opts.download_opts.from_downloaded,
614        "Starting composefs upgrade operation"
615    );
616
617    let mut host = get_composefs_status(storage, composefs)
618        .await
619        .context("Getting composefs deployment status")?;
620
621    // Preserve the selected source when discarding a failed staged switch.
622    let update_image = host.spec.image.clone();
623    if !opts.check
624        && !opts.download_opts.from_downloaded
625        && discard_failed_aboot_update(storage, &host)?
626    {
627        host = get_composefs_status(storage, composefs).await?;
628    }
629
630    let current_image = update_image.as_ref();
631
632    // Handle --tag: derive target from current image + new tag
633    let derived_image = if let Some(ref tag) = opts.tag {
634        let image = current_image.ok_or_else(|| {
635            anyhow::anyhow!("--tag requires a booted image with a specified source")
636        })?;
637        Some(image.with_tag(tag)?)
638    } else {
639        None
640    };
641
642    let delta = crate::delta::open_opt(opts.from_delta.as_deref()).await?;
643
644    let prog: ProgressWriter = opts.progress.try_into()?;
645
646    let mut do_upgrade_opts = DoUpgradeOpts {
647        soft_reboot: opts.soft_reboot,
648        apply: opts.apply,
649        download_only: opts.download_opts.download_only,
650        use_unified: false,
651        quiet: opts.quiet,
652        prog,
653        delta: delta.as_ref(),
654        uki_addon_opts: opts.uki_addon_opts,
655    };
656
657    if opts.download_opts.from_downloaded {
658        return apply_upgrade_from_downloaded(storage, composefs, &host, &do_upgrade_opts).await;
659    }
660
661    let imgref = derived_image.as_ref().or(current_image);
662    let mut booted_imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
663    if let Some(delta) = delta.as_ref() {
664        delta.validate_image_reference(booted_imgref)?;
665    }
666
667    // Auto-detect unified storage: use the unified path if the target image is
668    // already in bootc-owned containers-storage, OR if the booted image is —
669    // the latter means the user has opted into unified storage and all
670    // subsequent operations should use it.
671    let current_unified = if let Some(current) = current_image {
672        crate::deploy::image_exists_in_unified_storage(storage, current).await?
673    } else {
674        false
675    };
676    do_upgrade_opts.use_unified = current_unified
677        || crate::deploy::image_exists_in_unified_storage(storage, booted_imgref).await?;
678
679    let repo = &*composefs.repo;
680
681    // With a delta the target is whatever the delta says it is, and we can look
682    // it up locally; without one we have to ask the registry.
683    let (img_pulled, mut manifest, manifest_digest) = match &delta {
684        Some(delta) => {
685            crate::delta::reject_unified_storage(delta, do_upgrade_opts.use_unified)?;
686            ensure_delta_source_present(repo, delta)?;
687            (
688                lookup_config_splitstream(repo, delta.target_manifest().config().digest())?,
689                delta.target_manifest().clone(),
690                delta.target_manifest_digest().to_string(),
691            )
692        }
693        None => is_image_pulled(&repo, booted_imgref)
694            .await
695            .map(|(pulled, img, digest)| (pulled, img.manifest, digest))?,
696    };
697
698    // Check if we already have this update staged
699    // Or if we have another staged deployment with a different image
700    let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref());
701
702    // When applying deltas, only handle the staged image if it matches the delta,
703    // because with deltas we always want the specific version, and don't want to do
704    // any network resolve here.
705    let staged_image =
706        staged_image.filter(|staged| delta.is_none() || staged.image_digest == manifest_digest);
707
708    if let Some(staged_image) = staged_image {
709        // We have a staged image and it has the same digest as the currently booted image's latest
710        // digest
711        if staged_image.image_digest == manifest_digest {
712            if opts.apply {
713                return crate::reboot::reboot();
714            }
715
716            println!("Update already staged. To apply update run `bootc update --apply`");
717
718            return Ok(());
719        }
720
721        // We have a staged image but it's not the update image.
722        // Maybe it's something we got by `bootc switch`
723        // Switch takes precedence over update, so we change the imgref
724        booted_imgref = &staged_image.image;
725
726        let (img_pulled, staged_img_config, _) = is_image_pulled(&repo, booted_imgref).await?;
727        manifest = staged_img_config.manifest;
728
729        if let Some(cfg_verity) = img_pulled {
730            let action = validate_update(
731                storage,
732                composefs,
733                &host,
734                manifest.config().digest().as_ref(),
735                &cfg_verity,
736                false,
737            )?;
738
739            match action {
740                UpdateAction::Skip => {
741                    println!("No changes in staged image: {booted_imgref:#}");
742                    return Ok(());
743                }
744
745                UpdateAction::Proceed => {
746                    return do_upgrade(
747                        storage,
748                        composefs,
749                        &host,
750                        booted_imgref,
751                        &do_upgrade_opts,
752                        &manifest,
753                    )
754                    .await;
755                }
756            }
757        }
758    }
759
760    // We already have this container config
761    if let Some(cfg_verity) = img_pulled {
762        let action = validate_update(
763            storage,
764            composefs,
765            &host,
766            manifest.config().digest().as_ref(),
767            &cfg_verity,
768            false,
769        )?;
770
771        match action {
772            UpdateAction::Skip => {
773                println!("No changes in: {booted_imgref:#}");
774                return Ok(());
775            }
776
777            UpdateAction::Proceed => {
778                return do_upgrade(
779                    storage,
780                    composefs,
781                    &host,
782                    booted_imgref,
783                    &do_upgrade_opts,
784                    &manifest,
785                )
786                .await;
787            }
788        }
789    }
790
791    if opts.check {
792        let (current_manifest, _) = get_imginfo(storage, &*composefs.cmdline.digest)?;
793        let diff = ManifestDiff::new(&current_manifest.manifest, &manifest);
794        diff.print();
795        return Ok(());
796    }
797
798    do_upgrade(
799        storage,
800        composefs,
801        &host,
802        booted_imgref,
803        &do_upgrade_opts,
804        &manifest,
805    )
806    .await?;
807
808    Ok(())
809}