1use anyhow::{Context, Result};
2use camino::Utf8PathBuf;
3use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt};
4use cfsctl::composefs;
5use cfsctl::composefs_boot;
6use cfsctl::composefs_oci;
7use composefs::fsverity::{FsVerityHashValue, Sha512HashValue};
8use composefs_boot::BootOps;
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::{
15 bootc_composefs::{
16 boot::{BootSetupType, BootType, setup_composefs_bls_boot, setup_composefs_uki_boot},
17 gc::composefs_gc,
18 repo::{get_imgref, pull_composefs_repo},
19 service::start_finalize_stated_svc,
20 soft_reboot::prepare_soft_reboot_composefs,
21 state::write_composefs_state,
22 status::{
23 ImgConfigManifest, StagedDeployment, get_bootloader, get_composefs_status,
24 get_container_manifest_and_config, get_imginfo,
25 },
26 },
27 cli::{SoftRebootMode, UpgradeOpts},
28 composefs_consts::{
29 COMPOSEFS_STAGED_DEPLOYMENT_FNAME, COMPOSEFS_TRANSIENT_STATE_DIR, STATE_DIR_RELATIVE,
30 TYPE1_ENT_PATH_STAGED, USER_CFG_STAGED,
31 },
32 spec::{Bootloader, Host, ImageReference},
33 store::{BootedComposefs, ComposefsRepository, Storage},
34};
35
36#[context("Checking if image {} is pulled", imgref.image)]
55pub(crate) async fn is_image_pulled(
56 repo: &ComposefsRepository,
57 imgref: &ImageReference,
58) -> Result<(Option<Sha512HashValue>, ImgConfigManifest)> {
59 let imgref_repr = get_imgref(&imgref.transport, &imgref.image);
60 let img_config_manifest = get_container_manifest_and_config(&imgref_repr).await?;
61
62 let img_digest = img_config_manifest.manifest.config().digest().digest();
63
64 let img_id = format!("oci-config-sha256:{img_digest}");
66
67 let container_pulled = repo.has_stream(&img_id).context("Checking stream")?;
69
70 Ok((container_pulled, img_config_manifest))
71}
72
73fn rm_staged_type1_ent(boot_dir: &Dir) -> Result<()> {
74 if boot_dir.exists(TYPE1_ENT_PATH_STAGED) {
75 boot_dir
76 .remove_dir_all(TYPE1_ENT_PATH_STAGED)
77 .context("Removing staged bootloader entry")?;
78 }
79
80 Ok(())
81}
82
83#[derive(Debug)]
84pub(crate) enum UpdateAction {
85 Skip,
87 Proceed,
89 UpdateOrigin,
92}
93
94pub(crate) fn validate_update(
132 storage: &Storage,
133 booted_cfs: &BootedComposefs,
134 host: &Host,
135 img_digest: &str,
136 config_verity: &Sha512HashValue,
137 is_switch: bool,
138) -> Result<UpdateAction> {
139 let repo = &*booted_cfs.repo;
140
141 let mut fs = create_filesystem(repo, img_digest, Some(config_verity))?;
142 fs.transform_for_boot(&repo)?;
143
144 let image_id = fs.compute_image_id();
145
146 if image_id.to_hex() == *booted_cfs.cmdline.digest {
156 let ret = if is_switch {
157 UpdateAction::UpdateOrigin
158 } else {
159 UpdateAction::Skip
160 };
161
162 return Ok(ret);
163 }
164
165 let all_deployments = host.all_composefs_deployments()?;
166
167 let found_depl = all_deployments
168 .iter()
169 .find(|d| d.deployment.verity == image_id.to_hex());
170
171 if found_depl.is_some() {
173 return Ok(UpdateAction::Skip);
174 }
175
176 let booted = host.require_composefs_booted()?;
177 let boot_dir = storage.require_boot_dir()?;
178
179 match get_bootloader()? {
182 Bootloader::Grub => match booted.boot_type {
183 BootType::Bls => rm_staged_type1_ent(boot_dir)?,
184
185 BootType::Uki => {
186 let grub = boot_dir.open_dir("grub2").context("Opening grub dir")?;
187
188 if grub.exists(USER_CFG_STAGED) {
189 grub.remove_file(USER_CFG_STAGED)
190 .context("Removing staged grub user config")?;
191 }
192 }
193 },
194
195 Bootloader::Systemd => rm_staged_type1_ent(boot_dir)?,
196
197 Bootloader::None => unreachable!("Checked at install time"),
198 }
199
200 let state_dir = storage
202 .physical_root
203 .open_dir(STATE_DIR_RELATIVE)
204 .context("Opening state dir")?;
205
206 if state_dir.exists(image_id.to_hex()) {
207 state_dir
208 .remove_dir_all(image_id.to_hex())
209 .context("Removing state")?;
210 }
211
212 Ok(UpdateAction::Proceed)
213}
214
215pub(crate) struct DoUpgradeOpts {
217 pub(crate) apply: bool,
218 pub(crate) soft_reboot: Option<SoftRebootMode>,
219 pub(crate) download_only: bool,
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 img_manifest_config: &ImgConfigManifest,
254 opts: &DoUpgradeOpts,
255) -> Result<()> {
256 start_finalize_stated_svc()?;
257
258 crate::deploy::check_disk_space_composefs(
260 &booted_cfs.repo,
261 &img_manifest_config.manifest,
262 imgref,
263 )?;
264
265 let (repo, entries, id, fs) = pull_composefs_repo(
266 &imgref.transport,
267 &imgref.image,
268 booted_cfs.cmdline.allow_missing_fsverity,
269 )
270 .await?;
271
272 let Some(entry) = entries.iter().next() else {
273 anyhow::bail!("No boot entries!");
274 };
275
276 let mounted_fs = Dir::reopen_dir(
277 &repo
278 .mount(&id.to_hex())
279 .context("Failed to mount composefs image")?,
280 )?;
281
282 let boot_type = BootType::from(entry);
283
284 let boot_digest = match boot_type {
285 BootType::Bls => setup_composefs_bls_boot(
286 BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)),
287 repo,
288 &id,
289 entry,
290 &mounted_fs,
291 )?,
292
293 BootType::Uki => setup_composefs_uki_boot(
294 BootSetupType::Upgrade((storage, booted_cfs, &fs, &host)),
295 repo,
296 &id,
297 entries,
298 )?,
299 };
300
301 write_composefs_state(
302 &Utf8PathBuf::from("/sysroot"),
303 &id,
304 imgref,
305 Some(StagedDeployment {
306 depl_id: id.to_hex(),
307 finalization_locked: opts.download_only,
308 }),
309 boot_type,
310 boot_digest,
311 img_manifest_config,
312 booted_cfs.cmdline.allow_missing_fsverity,
313 )
314 .await?;
315
316 composefs_gc(storage, booted_cfs, false).await?;
319
320 apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await
321}
322
323#[context("Upgrading composefs")]
324pub(crate) async fn upgrade_composefs(
325 opts: UpgradeOpts,
326 storage: &Storage,
327 composefs: &BootedComposefs,
328) -> Result<()> {
329 const COMPOSEFS_UPGRADE_JOURNAL_ID: &str = "9c8d7f6e5a4b3c2d1e0f9a8b7c6d5e4f3";
330
331 tracing::info!(
332 message_id = COMPOSEFS_UPGRADE_JOURNAL_ID,
333 bootc.operation = "upgrade",
334 bootc.apply_mode = opts.apply,
335 bootc.download_only = opts.download_only,
336 bootc.from_downloaded = opts.from_downloaded,
337 "Starting composefs upgrade operation"
338 );
339
340 let host = get_composefs_status(storage, composefs)
341 .await
342 .context("Getting composefs deployment status")?;
343
344 let current_image = host.spec.image.as_ref();
345
346 let derived_image = if let Some(ref tag) = opts.tag {
348 let image = current_image.ok_or_else(|| {
349 anyhow::anyhow!("--tag requires a booted image with a specified source")
350 })?;
351 Some(image.with_tag(tag)?)
352 } else {
353 None
354 };
355
356 let do_upgrade_opts = DoUpgradeOpts {
357 soft_reboot: opts.soft_reboot,
358 apply: opts.apply,
359 download_only: opts.download_only,
360 };
361
362 if opts.from_downloaded {
363 let staged = host
364 .status
365 .staged
366 .as_ref()
367 .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?;
368
369 if !staged.download_only {
371 println!("Staged deployment is present and not in download only mode.");
372 println!("Use `bootc update --apply` to apply the update.");
373 return Ok(());
374 }
375
376 start_finalize_stated_svc()?;
377
378 let new_staged = StagedDeployment {
380 depl_id: staged.require_composefs()?.verity.clone(),
381 finalization_locked: false,
382 };
383
384 let staged_depl_dir =
385 Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority())
386 .context("Opening transient state directory")?;
387
388 staged_depl_dir
389 .atomic_replace_with(
390 COMPOSEFS_STAGED_DEPLOYMENT_FNAME,
391 |f| -> std::io::Result<()> {
392 serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from)
393 },
394 )
395 .context("Writing staged file")?;
396
397 return apply_upgrade(
398 storage,
399 composefs,
400 &staged.require_composefs()?.verity,
401 &do_upgrade_opts,
402 )
403 .await;
404 }
405
406 let imgref = derived_image.as_ref().or(current_image);
407 let mut booted_imgref = imgref.ok_or_else(|| anyhow::anyhow!("No image source specified"))?;
408
409 let repo = &*composefs.repo;
410
411 let (img_pulled, mut img_config) = is_image_pulled(&repo, booted_imgref).await?;
412 let booted_img_digest = img_config.manifest.config().digest().digest().to_owned();
413
414 let staged_image = host.status.staged.as_ref().and_then(|i| i.image.as_ref());
417
418 if let Some(staged_image) = staged_image {
419 if staged_image.image_digest == booted_img_digest {
422 if opts.apply {
423 return crate::reboot::reboot();
424 }
425
426 println!("Update already staged. To apply update run `bootc update --apply`");
427
428 return Ok(());
429 }
430
431 booted_imgref = &staged_image.image;
435
436 let (img_pulled, staged_img_config) = is_image_pulled(&repo, booted_imgref).await?;
437 img_config = staged_img_config;
438
439 if let Some(cfg_verity) = img_pulled {
440 let action = validate_update(
441 storage,
442 composefs,
443 &host,
444 img_config.manifest.config().digest().digest(),
445 &cfg_verity,
446 false,
447 )?;
448
449 match action {
450 UpdateAction::Skip => {
451 println!("No changes in staged image: {booted_imgref:#}");
452 return Ok(());
453 }
454
455 UpdateAction::Proceed => {
456 return do_upgrade(
457 storage,
458 composefs,
459 &host,
460 booted_imgref,
461 &img_config,
462 &do_upgrade_opts,
463 )
464 .await;
465 }
466
467 UpdateAction::UpdateOrigin => {
468 anyhow::bail!("Updating origin not supported for update operation")
469 }
470 }
471 }
472 }
473
474 if let Some(cfg_verity) = img_pulled {
476 let action = validate_update(
477 storage,
478 composefs,
479 &host,
480 &booted_img_digest,
481 &cfg_verity,
482 false,
483 )?;
484
485 match action {
486 UpdateAction::Skip => {
487 println!("No changes in: {booted_imgref:#}");
488 return Ok(());
489 }
490
491 UpdateAction::Proceed => {
492 return do_upgrade(
493 storage,
494 composefs,
495 &host,
496 booted_imgref,
497 &img_config,
498 &do_upgrade_opts,
499 )
500 .await;
501 }
502
503 UpdateAction::UpdateOrigin => {
504 anyhow::bail!("Updating origin not supported for update operation")
505 }
506 }
507 }
508
509 if opts.check {
510 let current_manifest =
511 get_imginfo(storage, &*composefs.cmdline.digest, Some(booted_imgref)).await?;
512 let diff = ManifestDiff::new(¤t_manifest.manifest, &img_config.manifest);
513 diff.print();
514 return Ok(());
515 }
516
517 do_upgrade(
518 storage,
519 composefs,
520 &host,
521 booted_imgref,
522 &img_config,
523 &do_upgrade_opts,
524 )
525 .await?;
526
527 Ok(())
528}