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