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 fn_error_context::context;
11use ocidir::cap_std::ambient_authority;
12use ostree_ext::container::ManifestDiff;
13
14use crate::bootc_composefs::gc::GCOpts;
15use crate::spec::BootloaderKind;
16use crate::{
17 bootc_composefs::{
18 boot::{BootSetupType, BootType, setup_composefs_bls_boot, setup_composefs_uki_boot},
19 gc::composefs_gc,
20 repo::pull_composefs_repo,
21 service::start_finalize_stated_svc,
22 soft_reboot::prepare_soft_reboot_composefs,
23 state::write_composefs_state,
24 status::{
25 ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status,
26 get_container_manifest_and_config, get_imginfo,
27 },
28 },
29 cli::{SoftRebootMode, UpgradeOpts},
30 composefs_consts::{
31 COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE,
32 TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED,
33 },
34 spec::{Host, ImageReference},
35 store::{BootedComposefs, ComposefsRepository, Storage},
36};
37
38#[context("Checking if image {} is pulled", imgref.image)]
57pub(crate) async fn is_image_pulled(
58 repo: &ComposefsRepository,
59 imgref: &ImageReference,
60) -> Result<(Option<Sha512HashValue>, ImgConfigManifest)> {
61 let imgref_repr = imgref.to_image_proxy_ref()?;
62 let img_config_manifest = get_container_manifest_and_config(&imgref_repr).await?;
63
64 let img_digest = img_config_manifest.manifest.config().digest().digest();
65
66 let img_id = format!("oci-config-sha256:{img_digest}");
68
69 let container_pulled = repo.has_stream(&img_id).context("Checking stream")?;
71
72 Ok((container_pulled, img_config_manifest))
73}
74
75fn rm_staged_type1_ent(boot_dir: &Dir) -> Result<()> {
76 if boot_dir.exists(TYPE1_ENT_PATH_STAGED) {
77 boot_dir
78 .remove_dir_all(TYPE1_ENT_PATH_STAGED)
79 .context("Removing staged bootloader entry")?;
80 }
81
82 Ok(())
83}
84
85#[derive(Debug)]
86pub(crate) enum UpdateAction {
87 Skip,
89 Proceed,
91}
92
93pub(crate) fn validate_update(
130 storage: &Storage,
131 booted_cfs: &BootedComposefs,
132 host: &Host,
133 img_digest: &str,
134 config_verity: &Sha512HashValue,
135 is_switch: bool,
136) -> Result<UpdateAction> {
137 let repo = &*booted_cfs.repo;
138
139 let oci_digest: composefs_oci::OciDigest = img_digest
140 .parse()
141 .with_context(|| format!("Parsing config digest {img_digest}"))?;
142 let mut fs = create_filesystem(repo, &oci_digest, Some(config_verity))?;
143 fs.transform_for_boot(&repo)?;
144
145 let image_id = fs.compute_image_id(repo.erofs_version());
146
147 let all_deployments = host.all_composefs_deployments()?;
148
149 let found_depl = all_deployments
150 .iter()
151 .find(|d| d.deployment.verity == image_id.to_hex());
152
153 if let Some(collision) = found_depl {
154 if is_switch {
155 anyhow::bail!(
160 "Target image has the same fs-verity digest as the existing {:?} deployment.",
161 collision.ty,
162 );
163 }
164 return Ok(UpdateAction::Skip);
167 }
168
169 let booted = host.require_composefs_booted()?;
170 let boot_dir = storage.require_boot_dir()?;
171
172 match get_bootloader()?.kind()? {
175 BootloaderKind::GRUBClassic => match booted.boot_type {
176 BootType::Bls => rm_staged_type1_ent(boot_dir)?,
177
178 BootType::Uki => {
179 let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?;
180
181 if grub.exists(USER_CFG_STAGED) {
182 grub.remove_file(USER_CFG_STAGED)
183 .context("Removing staged grub user config")?;
184 }
185 }
186 },
187
188 BootloaderKind::BLSCompatible => rm_staged_type1_ent(boot_dir)?,
189 }
190
191 let state_dir = storage
193 .physical_root
194 .open_dir(STATE_DIR_RELATIVE)
195 .context("Opening state dir")?;
196
197 if state_dir.exists(image_id.to_hex()) {
198 state_dir
199 .remove_dir_all(image_id.to_hex())
200 .context("Removing state")?;
201 }
202
203 Ok(UpdateAction::Proceed)
204}
205
206pub(crate) struct DoUpgradeOpts {
208 pub(crate) apply: bool,
209 pub(crate) soft_reboot: Option<SoftRebootMode>,
210 pub(crate) download_only: bool,
211 pub(crate) use_unified: bool,
213}
214
215async fn apply_upgrade(
216 storage: &Storage,
217 booted_cfs: &BootedComposefs,
218 depl_id: &String,
219 opts: &DoUpgradeOpts,
220) -> Result<()> {
221 if let Some(soft_reboot_mode) = opts.soft_reboot {
222 return prepare_soft_reboot_composefs(
223 storage,
224 booted_cfs,
225 Some(depl_id),
226 soft_reboot_mode,
227 opts.apply,
228 )
229 .await;
230 };
231
232 if opts.apply {
233 return crate::reboot::reboot();
234 }
235
236 Ok(())
237}
238
239#[context("Performing Upgrade Operation")]
241pub(crate) async fn do_upgrade(
242 storage: &Storage,
243 booted_cfs: &BootedComposefs,
244 host: &Host,
245 imgref: &ImageReference,
246 opts: &DoUpgradeOpts,
247 manifest: &ostree_ext::oci_spec::image::ImageManifest,
248) -> Result<()> {
249 crate::deploy::check_disk_space_composefs(&*booted_cfs.repo, manifest, imgref)?;
251
252 start_finalize_stated_svc()?;
253
254 let crate::bootc_composefs::repo::PullRepoResult {
255 repo,
256 entries,
257 id,
258 manifest_digest,
259 } = pull_composefs_repo(
260 imgref,
261 booted_cfs.cmdline.allow_missing_fsverity,
262 opts.use_unified,
263 )
264 .await?;
265
266 let all_deployments = host.all_composefs_deployments()?;
271 if let Some(collision) = all_deployments
272 .iter()
273 .find(|d| d.deployment.verity == id.to_hex())
274 {
275 anyhow::bail!(
276 "Target image has the same fs-verity digest as the existing {:?} deployment.",
277 collision.ty,
278 );
279 }
280
281 let Some(entry) = entries.iter().next() else {
282 anyhow::bail!("No boot entries!");
283 };
284
285 let mounted_fs = Dir::reopen_dir(
286 &repo
287 .mount(&id.to_hex())
288 .context("Failed to mount composefs image")?,
289 )?;
290
291 let boot_type = BootType::from(entry);
292
293 let boot_digest = match boot_type {
294 BootType::Bls => setup_composefs_bls_boot(
295 BootSetupType::Upgrade((storage, booted_cfs, &host)),
296 repo,
297 &id,
298 entry,
299 &mounted_fs,
300 )?,
301
302 BootType::Uki => setup_composefs_uki_boot(
303 BootSetupType::Upgrade((storage, booted_cfs, &host)),
304 repo,
305 &id,
306 entries,
307 )?,
308 };
309
310 write_composefs_state(
311 &Utf8PathBuf::from("/sysroot"),
312 &id,
313 imgref,
314 Some(StagedDeployment {
315 depl_id: id.to_hex(),
316 finalization_locked: opts.download_only,
317 }),
318 boot_type,
319 boot_digest,
320 &manifest_digest,
321 booted_cfs.cmdline.allow_missing_fsverity,
322 )
323 .await?;
324
325 composefs_gc(
328 storage,
329 booted_cfs,
330 GCOpts {
331 dry_run: false,
332 prune_repo: true,
333 },
334 )
335 .await?;
336
337 apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await
338}
339
340#[context("Upgrading composefs")]
341pub(crate) async fn upgrade_composefs(
342 opts: UpgradeOpts,
343 storage: &Storage,
344 composefs: &BootedComposefs,
345) -> Result<()> {
346 const COMPOSEFS_UPGRADE_JOURNAL_ID: &str = "9c8d7f6e5a4b3c2d1e0f9a8b7c6d5e4f3";
347
348 tracing::info!(
349 message_id = COMPOSEFS_UPGRADE_JOURNAL_ID,
350 bootc.operation = "upgrade",
351 bootc.apply_mode = opts.apply,
352 bootc.download_only = opts.download_only,
353 bootc.from_downloaded = opts.from_downloaded,
354 "Starting composefs upgrade operation"
355 );
356
357 let host = get_composefs_status(storage, composefs)
358 .await
359 .context("Getting composefs deployment status")?;
360
361 let current_image = host.spec.image.as_ref();
362
363 let derived_image = if let Some(ref tag) = opts.tag {
365 let image = current_image.ok_or_else(|| {
366 anyhow::anyhow!("--tag requires a booted image with a specified source")
367 })?;
368 Some(image.with_tag(tag)?)
369 } else {
370 None
371 };
372
373 let mut do_upgrade_opts = DoUpgradeOpts {
374 soft_reboot: opts.soft_reboot,
375 apply: opts.apply,
376 download_only: opts.download_only,
377 use_unified: false,
378 };
379
380 if opts.from_downloaded {
381 let staged = host
382 .status
383 .staged
384 .as_ref()
385 .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
386
387 if !staged.download_only {
389 println!("Staged deployment is present and not in download only mode.");
390 println!("Use `bootc update --apply` to apply the update.");
391 return Ok(());
392 }
393
394 start_finalize_stated_svc()?;
395
396 let new_staged = StagedDeployment {
398 depl_id: staged.require_composefs()?.verity.clone(),
399 finalization_locked: false,
400 };
401
402 let staged_depl_dir =
403 Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority())
404 .context("Opening transient state directory")?;
405
406 staged_depl_dir
407 .atomic_replace_with(
408 COMPOSEFS_STAGED_DEPLOYMENT_FNAME,
409 |f| -> std::io::Result<()> {
410 serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from)
411 },
412 )
413 .context("Writing staged file")?;
414
415 return apply_upgrade(
416 storage,
417 composefs,
418 &staged.require_composefs()?.verity,
419 &do_upgrade_opts,
420 )
421 .await;
422 }
423
424 let imgref = derived_image.as_ref().or(current_image);
425 let mut booted_imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
426
427 let current_unified = if let Some(current) = current_image {
432 crate::deploy::image_exists_in_unified_storage(storage, current).await?
433 } else {
434 false
435 };
436 do_upgrade_opts.use_unified = current_unified
437 || crate::deploy::image_exists_in_unified_storage(storage, booted_imgref).await?;
438
439 let repo = &*composefs.repo;
440
441 let (img_pulled, mut img_config) = is_image_pulled(&repo, booted_imgref).await?;
442 let booted_img_digest = img_config.manifest.config().digest().to_string();
443
444 let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref());
447
448 if let Some(staged_image) = staged_image {
449 if staged_image.image_digest == booted_img_digest {
452 if opts.apply {
453 return crate::reboot::reboot();
454 }
455
456 println!("Update already staged. To apply update run `bootc update --apply`");
457
458 return Ok(());
459 }
460
461 booted_imgref = &staged_image.image;
465
466 let (img_pulled, staged_img_config) = is_image_pulled(&repo, booted_imgref).await?;
467 img_config = staged_img_config;
468
469 if let Some(cfg_verity) = img_pulled {
470 let action = validate_update(
471 storage,
472 composefs,
473 &host,
474 img_config.manifest.config().digest().as_ref(),
475 &cfg_verity,
476 false,
477 )?;
478
479 match action {
480 UpdateAction::Skip => {
481 println!("No changes in staged image: {booted_imgref:#}");
482 return Ok(());
483 }
484
485 UpdateAction::Proceed => {
486 return do_upgrade(
487 storage,
488 composefs,
489 &host,
490 booted_imgref,
491 &do_upgrade_opts,
492 &img_config.manifest,
493 )
494 .await;
495 }
496 }
497 }
498 }
499
500 if let Some(cfg_verity) = img_pulled {
502 let action = validate_update(
503 storage,
504 composefs,
505 &host,
506 &booted_img_digest,
507 &cfg_verity,
508 false,
509 )?;
510
511 match action {
512 UpdateAction::Skip => {
513 println!("No changes in: {booted_imgref:#}");
514 return Ok(());
515 }
516
517 UpdateAction::Proceed => {
518 return do_upgrade(
519 storage,
520 composefs,
521 &host,
522 booted_imgref,
523 &do_upgrade_opts,
524 &img_config.manifest,
525 )
526 .await;
527 }
528 }
529 }
530
531 if opts.check {
532 let (current_manifest, _) = get_imginfo(storage, &*composefs.cmdline.digest)?;
533 let diff = ManifestDiff::new(¤t_manifest.manifest, &img_config.manifest);
534 diff.print();
535 return Ok(());
536 }
537
538 do_upgrade(
539 storage,
540 composefs,
541 &host,
542 booted_imgref,
543 &do_upgrade_opts,
544 &img_config.manifest,
545 )
546 .await?;
547
548 Ok(())
549}