bootc_lib/bootc_composefs/
update.rs

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