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#[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
84pub(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 let img_id = format!("oci-config-sha256:{img_digest}");
94
95 repo.has_stream(&img_id).context("Checking stream")
97}
98
99pub(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,
129 Proceed,
131}
132
133pub(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 anyhow::bail!(
210 "Target image has the same fs-verity digest as the existing {:?} deployment.",
211 collision.ty,
212 );
213 }
214 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 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 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
273pub(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
282pub(crate) struct DoUpgradeOpts<'a> {
284 pub(crate) apply: bool,
285 pub(crate) soft_reboot: Option<SoftRebootMode>,
286 pub(crate) download_only: bool,
287 pub(crate) use_unified: bool,
289 pub(crate) quiet: bool,
291 pub(crate) prog: ProgressWriter,
293 pub(crate) delta: Option<&'a Delta>,
295 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#[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 if host.require_composefs_booted()?.boot_type == BootType::Aboot {
335 ensure_no_aboot_attempt(storage)?;
336 }
337
338 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 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 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 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 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 composefs_gc(
512 storage,
513 booted_cfs,
514 GCOpts {
515 dry_run: false,
516 prune_repo: true,
517 },
518 )
519 .await?;
520
521 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 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(¤t).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 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 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 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 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 let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref());
701
702 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 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 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 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(¤t_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}