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