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#[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 let img_id = format!("oci-config-sha256:{img_digest}");
71
72 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,
92 Proceed,
94}
95
96pub(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 anyhow::bail!(
163 "Target image has the same fs-verity digest as the existing {:?} deployment.",
164 collision.ty,
165 );
166 }
167 return Ok(UpdateAction::Skip);
170 }
171
172 let booted = host.require_composefs_booted()?;
173 let boot_dir = storage.require_boot_dir()?;
174
175 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 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
209pub(crate) struct DoUpgradeOpts {
211 pub(crate) apply: bool,
212 pub(crate) soft_reboot: Option<SoftRebootMode>,
213 pub(crate) download_only: bool,
214 pub(crate) use_unified: bool,
216 pub(crate) quiet: bool,
218 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#[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 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 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 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 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 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 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(¤t).context("Deserialzing staged file")?;
433
434 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 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 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 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 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 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(¤t_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}