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::{
21 BootSetupType, BootType, UKIDigestMismatch, print_uki_dumpfile_diff,
22 setup_composefs_bls_boot, setup_composefs_uki_boot,
23 },
24 gc::composefs_gc,
25 repo::pull_composefs_repo,
26 service::start_finalize_stated_svc,
27 soft_reboot::prepare_soft_reboot_composefs,
28 state::write_composefs_state,
29 status::{
30 ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status,
31 get_container_manifest_and_config, get_imginfo,
32 },
33 },
34 cli::{SoftRebootMode, UpgradeOpts},
35 composefs_consts::{
36 COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE,
37 TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED,
38 },
39 progress_jsonl::ProgressWriter,
40 spec::{Host, ImageReference},
41 store::{BootedComposefs, ComposefsRepository, Storage},
42};
43
44#[context("Checking if image {} is pulled", imgref.image)]
63pub(crate) async fn is_image_pulled(
64 repo: &ComposefsRepository,
65 imgref: &ImageReference,
66) -> Result<(Option<Sha512HashValue>, ImgConfigManifest)> {
67 let imgref_repr = imgref.to_image_proxy_ref()?;
68 let img_config_manifest = get_container_manifest_and_config(&imgref_repr).await?;
69
70 let img_digest = img_config_manifest.manifest.config().digest().digest();
71
72 let img_id = format!("oci-config-sha256:{img_digest}");
74
75 let container_pulled = repo.has_stream(&img_id).context("Checking stream")?;
77
78 Ok((container_pulled, img_config_manifest))
79}
80
81fn rm_staged_type1_ent(boot_dir: &Dir) -> Result<()> {
82 if boot_dir.exists(TYPE1_ENT_PATH_STAGED) {
83 boot_dir
84 .remove_dir_all(TYPE1_ENT_PATH_STAGED)
85 .context("Removing staged bootloader entry")?;
86 }
87
88 Ok(())
89}
90
91#[derive(Debug)]
92pub(crate) enum UpdateAction {
93 Skip,
95 Proceed,
97}
98
99pub(crate) fn validate_update(
136 storage: &Storage,
137 booted_cfs: &BootedComposefs,
138 host: &Host,
139 img_digest: &str,
140 config_verity: &Sha512HashValue,
141 is_switch: bool,
142) -> Result<UpdateAction> {
143 let repo = &*booted_cfs.repo;
144
145 let oci_digest: composefs_oci::OciDigest = img_digest
146 .parse()
147 .with_context(|| format!("Parsing config digest {img_digest}"))?;
148 let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity), &Default::default())?;
149 fs.transform_for_boot(&repo)?;
150
151 let image_id = fs.compute_image_id(repo.erofs_version());
152
153 let all_deployments = host.all_composefs_deployments()?;
154
155 let found_depl = all_deployments
156 .iter()
157 .find(|d| d.deployment.verity == image_id.to_hex());
158
159 if let Some(collision) = found_depl {
160 if is_switch {
161 anyhow::bail!(
166 "Target image has the same fs-verity digest as the existing {:?} deployment.",
167 collision.ty,
168 );
169 }
170 return Ok(UpdateAction::Skip);
173 }
174
175 let booted = host.require_composefs_booted()?;
176 let boot_dir = storage.require_boot_dir()?;
177
178 match get_bootloader()?.kind()? {
181 BootloaderKind::GRUBClassic => match booted.boot_type {
182 BootType::Bls => rm_staged_type1_ent(boot_dir)?,
183
184 BootType::Uki => {
185 let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?;
186
187 if grub.exists(USER_CFG_STAGED) {
188 grub.remove_file(USER_CFG_STAGED)
189 .context("Removing staged grub user config")?;
190 }
191 }
192 },
193
194 BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?,
195 }
196
197 let state_dir = storage
199 .physical_root
200 .open_dir(STATE_DIR_RELATIVE)
201 .context("Opening state dir")?;
202
203 if state_dir.exists(image_id.to_hex()) {
204 state_dir
205 .remove_dir_all(image_id.to_hex())
206 .context("Removing state")?;
207 }
208
209 Ok(UpdateAction::Proceed)
210}
211
212pub(crate) struct DoUpgradeOpts {
214 pub(crate) apply: bool,
215 pub(crate) soft_reboot: Option<SoftRebootMode>,
216 pub(crate) download_only: bool,
217 pub(crate) use_unified: bool,
219 pub(crate) quiet: bool,
221 pub(crate) prog: ProgressWriter,
223}
224
225async fn apply_upgrade(
226 storage: &Storage,
227 booted_cfs: &BootedComposefs,
228 depl_id: &String,
229 opts: &DoUpgradeOpts,
230) -> Result<()> {
231 if let Some(soft_reboot_mode) = opts.soft_reboot {
232 return prepare_soft_reboot_composefs(
233 storage,
234 booted_cfs,
235 Some(depl_id),
236 soft_reboot_mode,
237 opts.apply,
238 )
239 .await;
240 };
241
242 if opts.apply {
243 return crate::reboot::reboot();
244 }
245
246 Ok(())
247}
248
249#[context("Performing Upgrade Operation")]
251pub(crate) async fn do_upgrade(
252 storage: &Storage,
253 booted_cfs: &BootedComposefs,
254 host: &Host,
255 imgref: &ImageReference,
256 opts: &DoUpgradeOpts,
257 manifest: &ostree_ext::oci_spec::image::ImageManifest,
258) -> Result<()> {
259 crate::deploy::check_disk_space_composefs(&*booted_cfs.repo, manifest, imgref)?;
261
262 start_finalize_stated_svc()?;
263
264 let crate::bootc_composefs::repo::PullRepoResult {
265 repo,
266 entries,
267 id,
268 manifest_digest,
269 fs: oci_fs,
270 } = pull_composefs_repo(
271 imgref,
272 booted_cfs.cmdline.allow_missing_fsverity,
273 opts.use_unified,
274 opts.quiet,
275 opts.prog.clone(),
276 )
277 .await?;
278
279 let all_deployments = host.all_composefs_deployments()?;
284 if let Some(collision) = all_deployments
285 .iter()
286 .find(|d| d.deployment.verity == id.to_hex())
287 {
288 anyhow::bail!(
289 "Target image has the same fs-verity digest as the existing {:?} deployment.",
290 collision.ty,
291 );
292 }
293
294 let Some(entry) = entries.iter().next() else {
295 anyhow::bail!("No boot entries!");
296 };
297
298 let mounted_fs = Dir::reopen_dir(
299 &repo
300 .mount(&id.to_hex())
301 .context("Failed to mount composefs image")?,
302 )?;
303
304 let new_etc = mounted_fs
306 .open_dir("etc")
307 .context("Opening deployment's etc")?;
308
309 let diff = get_etc_diff(storage, booted_cfs, Some(&new_etc)).await?;
310
311 if !diff.unmergable_paths.is_empty() {
312 print_unmergable_paths(&diff, &mut std::io::stderr());
313 anyhow::bail!("Merge conflicts found in etc");
314 }
315
316 let boot_type = BootType::from(entry);
317
318 let boot_digest = match boot_type {
319 BootType::Bls => setup_composefs_bls_boot(
320 BootSetupType::Upgrade((storage, booted_cfs, &host)),
321 &repo,
322 &id,
323 entry,
324 &mounted_fs,
325 )?,
326
327 BootType::Uki => {
328 let uki_setup_result = setup_composefs_uki_boot(
329 BootSetupType::Upgrade((storage, booted_cfs, &host)),
330 &repo,
331 &id,
332 entries,
333 );
334
335 match uki_setup_result {
336 Ok(boot_digest) => boot_digest,
337 Err(e) => match e.downcast::<UKIDigestMismatch>() {
338 Ok(mismatch) => {
339 print_uki_dumpfile_diff(&mismatch, &repo, &oci_fs);
340 return Err(mismatch.into());
341 }
342 Err(e) => Err(e)?,
343 },
344 }
345 }
346 };
347
348 drop(mounted_fs);
360 drop(repo);
361
362 let staged_state = StagedDeployment {
363 depl_id: id.to_hex(),
364 finalization_locked: opts.download_only,
365 };
366
367 write_composefs_state(
368 &Utf8PathBuf::from("/sysroot"),
369 &id,
370 imgref,
371 Some(staged_state),
372 boot_type,
373 boot_digest,
374 &manifest_digest,
375 booted_cfs.cmdline.allow_missing_fsverity,
376 )
377 .await?;
378
379 composefs_gc(
386 storage,
387 booted_cfs,
388 GCOpts {
389 dry_run: false,
390 prune_repo: true,
391 },
392 )
393 .await?;
394
395 apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await
396}
397
398#[context("Upgrading composefs")]
399pub(crate) async fn upgrade_composefs(
400 opts: UpgradeOpts,
401 storage: &Storage,
402 composefs: &BootedComposefs,
403) -> Result<()> {
404 const COMPOSEFS_UPGRADE_JOURNAL_ID: &str = "9c8d7f6e5a4b3c2d1e0f9a8b7c6d5e4f3";
405
406 tracing::info!(
407 message_id = COMPOSEFS_UPGRADE_JOURNAL_ID,
408 bootc.operation = "upgrade",
409 bootc.apply_mode = opts.apply,
410 bootc.download_only = opts.download_only,
411 bootc.from_downloaded = opts.from_downloaded,
412 "Starting composefs upgrade operation"
413 );
414
415 let host = get_composefs_status(storage, composefs)
416 .await
417 .context("Getting composefs deployment status")?;
418
419 let current_image = host.spec.image.as_ref();
420
421 let derived_image = if let Some(ref tag) = opts.tag {
423 let image = current_image.ok_or_else(|| {
424 anyhow::anyhow!("--tag requires a booted image with a specified source")
425 })?;
426 Some(image.with_tag(tag)?)
427 } else {
428 None
429 };
430
431 let prog: ProgressWriter = opts.progress.try_into()?;
432
433 let mut do_upgrade_opts = DoUpgradeOpts {
434 soft_reboot: opts.soft_reboot,
435 apply: opts.apply,
436 download_only: opts.download_only,
437 use_unified: false,
438 quiet: opts.quiet,
439 prog,
440 };
441
442 if opts.from_downloaded {
443 let staged = host
444 .status
445 .staged
446 .as_ref()
447 .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
448
449 if !staged.download_only {
451 println!("Staged deployment is present and not in download only mode.");
452 println!("Use `bootc update --apply` to apply the update.");
453 return Ok(());
454 }
455
456 start_finalize_stated_svc()?;
457
458 let staged_depl_dir =
459 Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority())
460 .context("Opening transient state directory")?;
461
462 let current = staged_depl_dir
463 .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME)
464 .context("Reading staged file")?;
465
466 let mut new_staged: StagedDeployment =
467 serde_json::from_str(¤t).context("Deserialzing staged file")?;
468
469 new_staged.finalization_locked = false;
471
472 staged_depl_dir
473 .atomic_replace_with(
474 COMPOSEFS_STAGED_DEPLOYMENT_FNAME,
475 |f| -> std::io::Result<()> {
476 serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from)
477 },
478 )
479 .context("Writing staged file")?;
480
481 return apply_upgrade(
482 storage,
483 composefs,
484 &staged.require_composefs()?.verity,
485 &do_upgrade_opts,
486 )
487 .await;
488 }
489
490 let imgref = derived_image.as_ref().or(current_image);
491 let mut booted_imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
492
493 let current_unified = if let Some(current) = current_image {
498 crate::deploy::image_exists_in_unified_storage(storage, current).await?
499 } else {
500 false
501 };
502 do_upgrade_opts.use_unified = current_unified
503 || crate::deploy::image_exists_in_unified_storage(storage, booted_imgref).await?;
504
505 let repo = &*composefs.repo;
506
507 let (img_pulled, mut img_config) = is_image_pulled(&repo, booted_imgref).await?;
508 let booted_img_digest = img_config.manifest.config().digest().to_string();
509
510 let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref());
513
514 if let Some(staged_image) = staged_image {
515 if staged_image.image_digest == booted_img_digest {
518 if opts.apply {
519 return crate::reboot::reboot();
520 }
521
522 println!("Update already staged. To apply update run `bootc update --apply`");
523
524 return Ok(());
525 }
526
527 booted_imgref = &staged_image.image;
531
532 let (img_pulled, staged_img_config) = is_image_pulled(&repo, booted_imgref).await?;
533 img_config = staged_img_config;
534
535 if let Some(cfg_verity) = img_pulled {
536 let action = validate_update(
537 storage,
538 composefs,
539 &host,
540 img_config.manifest.config().digest().as_ref(),
541 &cfg_verity,
542 false,
543 )?;
544
545 match action {
546 UpdateAction::Skip => {
547 println!("No changes in staged image: {booted_imgref:#}");
548 return Ok(());
549 }
550
551 UpdateAction::Proceed => {
552 return do_upgrade(
553 storage,
554 composefs,
555 &host,
556 booted_imgref,
557 &do_upgrade_opts,
558 &img_config.manifest,
559 )
560 .await;
561 }
562 }
563 }
564 }
565
566 if let Some(cfg_verity) = img_pulled {
568 let action = validate_update(
569 storage,
570 composefs,
571 &host,
572 &booted_img_digest,
573 &cfg_verity,
574 false,
575 )?;
576
577 match action {
578 UpdateAction::Skip => {
579 println!("No changes in: {booted_imgref:#}");
580 return Ok(());
581 }
582
583 UpdateAction::Proceed => {
584 return do_upgrade(
585 storage,
586 composefs,
587 &host,
588 booted_imgref,
589 &do_upgrade_opts,
590 &img_config.manifest,
591 )
592 .await;
593 }
594 }
595 }
596
597 if opts.check {
598 let (current_manifest, _) = get_imginfo(storage, &*composefs.cmdline.digest)?;
599 let diff = ManifestDiff::new(¤t_manifest.manifest, &img_config.manifest);
600 diff.print();
601 return Ok(());
602 }
603
604 do_upgrade(
605 storage,
606 composefs,
607 &host,
608 booted_imgref,
609 &do_upgrade_opts,
610 &img_config.manifest,
611 )
612 .await?;
613
614 Ok(())
615}