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