diff --git a/crates/lib/src/bootc_composefs/switch.rs b/crates/lib/src/bootc_composefs/switch.rs index ed325c497..0cb0b9ebe 100644 --- a/crates/lib/src/bootc_composefs/switch.rs +++ b/crates/lib/src/bootc_composefs/switch.rs @@ -4,7 +4,10 @@ use fn_error_context::context; use crate::{ bootc_composefs::{ status::get_composefs_status, - update::{DoUpgradeOpts, UpdateAction, do_upgrade, is_image_pulled, validate_update}, + update::{ + DoUpgradeOpts, UpdateAction, apply_upgrade_from_downloaded, do_upgrade, + is_image_pulled, validate_update, + }, }, cli::{SwitchOpts, imgref_for_switch}, progress_jsonl::ProgressWriter, @@ -17,13 +20,28 @@ pub(crate) async fn switch_composefs( storage: &Storage, booted_cfs: &BootedComposefs, ) -> Result<()> { - let target = imgref_for_switch(&opts)?; - // TODO: Handle in-place let host = get_composefs_status(storage, booted_cfs) .await .context("Getting composefs deployment status")?; + let prog: ProgressWriter = opts.progress.clone().try_into()?; + + let mut do_upgrade_opts = DoUpgradeOpts { + soft_reboot: opts.soft_reboot, + apply: opts.apply, + download_only: opts.download_opts.download_only, + use_unified: false, + quiet: opts.quiet, + prog, + }; + + if opts.download_opts.from_downloaded { + return apply_upgrade_from_downloaded(storage, booted_cfs, &host, &do_upgrade_opts).await; + } + + let target = imgref_for_switch(&opts)?; + let new_spec = { let mut new_spec = host.spec.clone(); new_spec.image = Some(target.clone()); @@ -49,17 +67,18 @@ pub(crate) async fn switch_composefs( bootc.operation = "switch", bootc.target_image = target_imgref.to_string(), bootc.apply_mode = opts.apply, + bootc.download_only = opts.download_opts.download_only, + bootc.from_downloaded = opts.download_opts.from_downloaded, "Starting composefs switch operation", ); let repo = &*booted_cfs.repo; - let (image, img_config) = is_image_pulled(repo, &target_imgref).await?; // Use unified storage if explicitly requested, or auto-detect: either the // target image is already in bootc-owned containers-storage, OR the booted // image is — which means the user has opted into unified storage and all // subsequent operations (including switch to a new image) should use it. - let use_unified = if opts.unified_storage_exp { + do_upgrade_opts.use_unified = if opts.unified_storage_exp { true } else { let booted_imgref = host.spec.image.as_ref(); @@ -73,16 +92,7 @@ pub(crate) async fn switch_composefs( booted_unified || target_unified }; - let prog: ProgressWriter = opts.progress.try_into()?; - - let do_upgrade_opts = DoUpgradeOpts { - soft_reboot: opts.soft_reboot, - apply: opts.apply, - download_only: false, - use_unified, - quiet: opts.quiet, - prog, - }; + let (image, img_config) = is_image_pulled(repo, &target_imgref).await?; if let Some(cfg_verity) = image { let action = validate_update( diff --git a/crates/lib/src/bootc_composefs/update.rs b/crates/lib/src/bootc_composefs/update.rs index 8ee1a8ddd..92a53f5ec 100644 --- a/crates/lib/src/bootc_composefs/update.rs +++ b/crates/lib/src/bootc_composefs/update.rs @@ -360,6 +360,59 @@ pub(crate) async fn do_upgrade( apply_upgrade(storage, booted_cfs, &id.to_hex(), opts).await } +#[context("Applying downloaded upgrade")] +pub(crate) async fn apply_upgrade_from_downloaded( + storage: &Storage, + composefs: &BootedComposefs, + host: &Host, + do_upgrade_opts: &DoUpgradeOpts, +) -> Result<()> { + let staged = host + .status + .staged + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; + + // Staged deployment exists, but it will be finalized + if !staged.download_only { + println!("Staged deployment is present and not in download only mode."); + println!("Use `bootc update --apply` to apply the update."); + return Ok(()); + } + + start_finalize_stated_svc()?; + + let staged_depl_dir = Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) + .context("Opening transient state directory")?; + + let current = staged_depl_dir + .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME) + .context("Reading staged file")?; + + let mut new_staged: StagedDeployment = + serde_json::from_str(¤t).context("Deserialzing staged file")?; + + // Make the staged deployment not download_only + new_staged.finalization_locked = false; + + staged_depl_dir + .atomic_replace_with( + COMPOSEFS_STAGED_DEPLOYMENT_FNAME, + |f| -> std::io::Result<()> { + serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from) + }, + ) + .context("Writing staged file")?; + + return apply_upgrade( + storage, + composefs, + &staged.require_composefs()?.verity, + &do_upgrade_opts, + ) + .await; +} + #[context("Upgrading composefs")] pub(crate) async fn upgrade_composefs( opts: UpgradeOpts, @@ -372,8 +425,8 @@ pub(crate) async fn upgrade_composefs( message_id = COMPOSEFS_UPGRADE_JOURNAL_ID, bootc.operation = "upgrade", bootc.apply_mode = opts.apply, - bootc.download_only = opts.download_only, - bootc.from_downloaded = opts.from_downloaded, + bootc.download_only = opts.download_opts.download_only, + bootc.from_downloaded = opts.download_opts.from_downloaded, "Starting composefs upgrade operation" ); @@ -398,58 +451,14 @@ pub(crate) async fn upgrade_composefs( let mut do_upgrade_opts = DoUpgradeOpts { soft_reboot: opts.soft_reboot, apply: opts.apply, - download_only: opts.download_only, + download_only: opts.download_opts.download_only, use_unified: false, quiet: opts.quiet, prog, }; - if opts.from_downloaded { - let staged = host - .status - .staged - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; - - // Staged deployment exists, but it will be finalized - if !staged.download_only { - println!("Staged deployment is present and not in download only mode."); - println!("Use `bootc update --apply` to apply the update."); - return Ok(()); - } - - start_finalize_stated_svc()?; - - let staged_depl_dir = - Dir::open_ambient_dir(COMPOSEFS_TRANSIENT_STATE_DIR, ambient_authority()) - .context("Opening transient state directory")?; - - let current = staged_depl_dir - .read_to_string(COMPOSEFS_STAGED_DEPLOYMENT_FNAME) - .context("Reading staged file")?; - - let mut new_staged: StagedDeployment = - serde_json::from_str(¤t).context("Deserialzing staged file")?; - - // Make the staged deployment not download_only - new_staged.finalization_locked = false; - - staged_depl_dir - .atomic_replace_with( - COMPOSEFS_STAGED_DEPLOYMENT_FNAME, - |f| -> std::io::Result<()> { - serde_json::to_writer(f, &new_staged).map_err(std::io::Error::from) - }, - ) - .context("Writing staged file")?; - - return apply_upgrade( - storage, - composefs, - &staged.require_composefs()?.verity, - &do_upgrade_opts, - ) - .await; + if opts.download_opts.from_downloaded { + return apply_upgrade_from_downloaded(storage, composefs, &host, &do_upgrade_opts).await; } let imgref = derived_image.as_ref().or(current_image); diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 31567f7f9..eba4d0625 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -62,7 +62,7 @@ use crate::utils::sigpolicy_from_opt; use crate::{bootc_composefs, lints}; /// Shared progress options -#[derive(Debug, Parser, PartialEq, Eq)] +#[derive(Clone, Debug, Parser, PartialEq, Eq)] pub(crate) struct ProgressOptions { /// File descriptor number which must refer to an open pipe. /// @@ -84,6 +84,25 @@ impl TryFrom for ProgressWriter { } } +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct DownloadOnlyOpts { + /// Download and stage the update without applying it. + /// + /// Download the image and ensure it's retained on disk for the lifetime of this system boot, + /// but it will not be applied on reboot. If the system is rebooted without applying the update, + /// the image will be eligible for garbage collection again. + #[clap(long, conflicts_with = "apply")] + pub(crate) download_only: bool, + + /// Apply a staged deployment that was previously downloaded with --download-only. + /// + /// This unlocks the staged deployment without fetching updates from the container image source. + /// The deployment will be applied on the next shutdown or reboot. Use with --apply to + /// reboot immediately. + #[clap(long, conflicts_with = "download_only")] + pub(crate) from_downloaded: bool, +} + /// Perform an upgrade operation #[derive(Debug, Parser, PartialEq, Eq)] pub(crate) struct UpgradeOpts { @@ -94,7 +113,7 @@ pub(crate) struct UpgradeOpts { /// Check if an update is available without applying it. /// /// This only downloads updated metadata, not the full image layers. - #[clap(long, conflicts_with = "apply")] + #[clap(long, conflicts_with_all = ["apply", "download_only", "from_downloaded"])] pub(crate) check: bool, /// Restart or reboot into the new target image. @@ -109,21 +128,8 @@ pub(crate) struct UpgradeOpts { #[clap(long = "soft-reboot", conflicts_with = "check")] pub(crate) soft_reboot: Option, - /// Download and stage the update without applying it. - /// - /// Download the update and ensure it's retained on disk for the lifetime of this system boot, - /// but it will not be applied on reboot. If the system is rebooted without applying the update, - /// the image will be eligible for garbage collection again. - #[clap(long, conflicts_with_all = ["check", "apply"])] - pub(crate) download_only: bool, - - /// Apply a staged deployment that was previously downloaded with --download-only. - /// - /// This unlocks the staged deployment without fetching updates from the container image source. - /// The deployment will be applied on the next shutdown or reboot. Use with --apply to - /// reboot immediately. - #[clap(long, conflicts_with_all = ["check", "download_only"])] - pub(crate) from_downloaded: bool, + #[clap(flatten)] + pub(crate) download_opts: DownloadOnlyOpts, /// Upgrade to a different tag of the currently booted image. /// @@ -159,6 +165,9 @@ pub(crate) struct SwitchOpts { #[clap(long, default_value = "registry")] pub(crate) transport: String, + #[clap(flatten)] + pub(crate) download_opts: DownloadOnlyOpts, + /// This argument is deprecated and does nothing. #[clap(long, hide = true)] pub(crate) no_signature_verification: bool, @@ -191,7 +200,12 @@ pub(crate) struct SwitchOpts { pub(crate) unified_storage_exp: bool, /// Target image to use for the next boot. - pub(crate) target: String, + /// Required unless `--from-downloaded` is present. + #[clap( + required_unless_present = "from_downloaded", + conflicts_with = "from_downloaded" + )] + pub(crate) target: Option, #[clap(flatten)] pub(crate) progress: ProgressOptions, @@ -1183,6 +1197,36 @@ pub(crate) fn prepare_for_write() -> Result<()> { Ok(()) } +struct ApplyFromDownloadedOpts { + soft_reboot: Option, + apply: bool, +} + +fn apply_from_downloaded_ostree( + storage: &Storage, + booted_ostree: &BootedOstree<'_>, + host: &crate::spec::Host, + opts: &ApplyFromDownloadedOpts, +) -> Result<()> { + let ostree = storage.get_ostree()?; + let staged_deployment = ostree + .staged_deployment() + .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; + + if staged_deployment.is_finalization_locked() { + ostree.change_finalization(&staged_deployment)?; + println!("Staged deployment will now be applied on reboot"); + } else { + println!("Staged deployment is already set to apply on reboot"); + } + + handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?; + if opts.apply { + crate::reboot::reboot()?; + } + return Ok(()); +} + /// Implementation of the `bootc upgrade` CLI command. #[context("Upgrading")] async fn upgrade( @@ -1237,24 +1281,16 @@ async fn upgrade( let mut changed = false; // Handle --from-downloaded: unlock existing staged deployment without fetching from image source - if opts.from_downloaded { - let ostree = storage.get_ostree()?; - let staged_deployment = ostree - .staged_deployment() - .ok_or_else(|| anyhow::anyhow!("No staged deployment found"))?; - - if staged_deployment.is_finalization_locked() { - ostree.change_finalization(&staged_deployment)?; - println!("Staged deployment will now be applied on reboot"); - } else { - println!("Staged deployment is already set to apply on reboot"); - } - - handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?; - if opts.apply { - crate::reboot::reboot()?; - } - return Ok(()); + if opts.download_opts.from_downloaded { + return apply_from_downloaded_ostree( + storage, + booted_ostree, + &host, + &ApplyFromDownloadedOpts { + soft_reboot: opts.soft_reboot, + apply: opts.apply, + }, + ); } // Ensure the bootc storage directory is initialized; the --check path @@ -1327,7 +1363,7 @@ async fn upgrade( if let Some(staged) = staged_deployment { // Handle download-only mode based on flags - if opts.download_only { + if opts.download_opts.download_only { // --download-only: set download-only mode if !staged.is_finalization_locked() { storage.get_ostree()?.change_finalization(&staged)?; @@ -1343,7 +1379,7 @@ async fn upgrade( download_only_changed = true; } } - } else if opts.download_only || opts.apply { + } else if opts.download_opts.download_only || opts.apply { anyhow::bail!("No staged deployment found"); } @@ -1366,7 +1402,7 @@ async fn upgrade( &fetched, &spec, prog.clone(), - opts.download_only, + opts.download_opts.download_only, ) .await?; changed = true; @@ -1398,11 +1434,17 @@ async fn upgrade( Ok(()) } + +#[context("Getting imgref for switch")] pub(crate) fn imgref_for_switch(opts: &SwitchOpts) -> Result { let transport = ostree_container::Transport::try_from(opts.transport.as_str())?; let imgref = ostree_container::ImageReference { transport, - name: opts.target.to_string(), + name: opts + .target + .as_ref() + .ok_or_else(|| anyhow!("Target image not found"))? + .to_string(), }; let sigverify = sigpolicy_from_opt(opts.enforce_container_sigpolicy); let target = ostree_container::OstreeImageReference { sigverify, imgref }; @@ -1418,12 +1460,25 @@ async fn switch_ostree( storage: &Storage, booted_ostree: &BootedOstree<'_>, ) -> Result<()> { + let (_, host) = crate::status::get_status(booted_ostree)?; + + if opts.download_opts.from_downloaded { + return apply_from_downloaded_ostree( + storage, + booted_ostree, + &host, + &ApplyFromDownloadedOpts { + soft_reboot: opts.soft_reboot, + apply: opts.apply, + }, + ); + } + let target = imgref_for_switch(&opts)?; let prog: ProgressWriter = opts.progress.try_into()?; let cancellable = gio::Cancellable::NONE; let repo = &booted_ostree.repo(); - let (_, host) = crate::status::get_status(booted_ostree)?; let new_spec = { let mut new_spec = host.spec.clone(); @@ -1505,10 +1560,22 @@ async fn switch_ostree( let stateroot = booted_ostree.stateroot(); let from = MergeState::from_stateroot(storage, &stateroot)?; - crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?; + crate::deploy::stage( + storage, + from, + &fetched, + &new_spec, + prog.clone(), + opts.download_opts.download_only, + ) + .await?; storage.update_mtime()?; + if opts.download_opts.download_only { + return Ok(()); + } + if opts.soft_reboot.is_some() { // At this point we have staged the deployment and the host definition has changed. // We need the updated host status before we check if we can prepare the soft-reboot. @@ -1516,6 +1583,8 @@ async fn switch_ostree( handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &updated_host)?; } + // `--apply` cannot be passed along with `--download-only` (handled by clap) + // but for sanity nonetheless if opts.apply { crate::reboot::reboot()?; } diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 739f54d81..b361a3b79 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -1119,7 +1119,13 @@ pub(crate) async fn stage( }) .await; crate::deploy::cleanup(sysroot).await?; - println!("Queued for next boot: {:#}", spec.image); + + if !lock_finalization { + println!("Queued for next boot: {:#}", spec.image); + } else { + println!("Staged but not queued for next boot: {:#}", spec.image); + } + if let Some(version) = image.version.as_deref() { println!(" Version: {version}"); } diff --git a/docs/src/man/bootc-switch.8.md b/docs/src/man/bootc-switch.8.md index 116f98553..407aa0bc0 100644 --- a/docs/src/man/bootc-switch.8.md +++ b/docs/src/man/bootc-switch.8.md @@ -39,9 +39,7 @@ Soft reboot allows faster system restart by avoiding full hardware reboot when p **TARGET** - Target image to use for the next boot - - This argument is required. + Target image to use for the next boot. Required unless `--from-downloaded` is present **--quiet** @@ -65,6 +63,14 @@ Soft reboot allows faster system restart by avoiding full hardware reboot when p Default: registry +**--download-only** + + Download and stage the update without applying it + +**--from-downloaded** + + Apply a staged deployment that was previously downloaded with --download-only + **--enforce-container-sigpolicy** This is the inverse of the previous `--target-no-signature-verification` (which is now a no-op) diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index e032999f6..3756a931c 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -292,4 +292,11 @@ execute: how: fmf test: - /tmt/tests/tests/test-46-etc-merge-conflict + +/plan-47-download-only-switch: + summary: Execute download-only switch tests + discover: + how: fmf + test: + - /tmt/tests/tests/test-47-download-only-switch # END GENERATED PLANS diff --git a/tmt/tests/booted/test-download-only-switch.nu b/tmt/tests/booted/test-download-only-switch.nu new file mode 100644 index 000000000..ca7aa2be2 --- /dev/null +++ b/tmt/tests/booted/test-download-only-switch.nu @@ -0,0 +1,158 @@ +# number: 47 +# tmt: +# summary: Execute download-only switch tests +# duration: 40m +# +# This test does: +# bootc image copy-to-storage +# podman build (v1) +# bootc switch (v1) +# Verify we boot into the new image (v1) +# podman build updated image (v2) +# bootc switch --download-only (stage v2 in download-only mode) +# reboot (should still boot into v1, staged deployment discarded) +# verify staged deployment is null (discarded on reboot) +# bootc switch --download-only (re-stage v2 in download-only mode) +# bootc switch --from-downloaded (clear download-only mode without fetching from image source) +# reboot (should boot into v2) +# +use std assert +use tap.nu +use bootc_testlib.nu + +# This code runs on *each* boot. +# Here we just capture information. +bootc status +journalctl --list-boots + +let st = bootc status --json | from json +let booted = $st.status.booted.image + +def imgsrc [] { + $env.BOOTC_upgrade_image? | default "localhost/bootc-derived-local" +} + +# Run on the first boot - build v1 and switch to it +def initial_build [] { + tap begin "download-only switch test" + + let imgsrc = imgsrc + # This test only works in local mode + assert ($imgsrc | str ends-with "-local") "This test requires local mode" + + bootc image copy-to-storage + + # Create test file v1 on host + "v1" | save testing-bootc-switch-apply + + # A simple derived container (v1) that adds a file + let dockerfile = $"FROM localhost/bootc as base +COPY testing-bootc-switch-apply /usr/share/testing-bootc-switch-apply +" + (tap make_uki_containerfile $dockerfile) | save Dockerfile + # Build it + podman build -t $imgsrc . + + # Now, switch into the new image + print $"Applying ($imgsrc)" + bootc switch --transport containers-storage ($imgsrc) + tmt-reboot +} + +# Check we have the updated image (v1), then test --download-only with switch +def second_boot [] { + print "verifying second boot - should be on v1" + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image $"(imgsrc)" + + # Verify the v1 file exists + assert ("/usr/share/testing-bootc-switch-apply" | path exists) "v1 file should exist" + let v1_content = open /usr/share/testing-bootc-switch-apply | str trim + assert equal $v1_content "v1" + + # Build v2 - a different derived image with a different tag + let imgsrc = imgsrc + let v2_image = $"($imgsrc)-v2" + # Create test file v2 on host + "v2" | save --force testing-bootc-switch-apply + + let dockerfile = $"FROM localhost/bootc as base +COPY testing-bootc-switch-apply /usr/share/testing-bootc-switch-apply +" + (tap make_uki_containerfile $dockerfile) | save --force Dockerfile + podman build -t $v2_image . + + # Now switch with --download-only (should set deployment to download-only mode) + print $"Switching with --download-only to v2" + bootc switch --download-only --transport containers-storage ($v2_image) + + # Verify deployment is staged and in download-only mode + let status_json = bootc status --json | from json + assert ($status_json.status.staged != null) "Staged deployment should exist" + assert ($status_json.status.staged.downloadOnly) "Staged deployment should be in download-only mode" + + # Reboot - should still boot into v1 since deployment is in download-only mode + tmt-reboot +} + +# Third boot - verify still on v1, staged deployment discarded, re-stage and clear download-only mode +def third_boot [] { + print "verifying third boot - should still be on v1 (download-only deployment was discarded)" + + # Verify we're still on v1 + let v1_content = open /usr/share/testing-bootc-switch-apply | str trim + assert equal $v1_content "v1" "Should still be on v1 after download-only reboot" + + # Verify that the staged deployment was discarded on reboot, as is expected for download-only deployments + let status_before = bootc status --json | from json + assert ($status_before.status.staged == null) "Staged deployment should be discarded after rebooting with a download-only deployment" + + # Re-run switch --download-only to re-stage the deployment + let imgsrc = imgsrc + let v2_image = $"($imgsrc)-v2" + print "Re-staging with switch --download-only" + bootc switch --download-only --transport containers-storage ($v2_image) + + # Verify via JSON that deployment is in download-only mode again + let status_json = bootc status --json | from json + assert ($status_json.status.staged != null) "Staged deployment should exist" + assert ($status_json.status.staged.downloadOnly) "Staged deployment should be in download-only mode" + + # Now clear download-only mode by running switch --from-downloaded (without fetching from image source) + print "Clearing download-only mode with bootc switch --from-downloaded" + bootc switch --from-downloaded + + # Verify via JSON that deployment is not in download-only mode + let status_after_json = bootc status --json | from json + assert (not $status_after_json.status.staged.downloadOnly) "Staged deployment should not be in download-only mode" + + # Reboot to apply the update + tmt-reboot +} + +# Fourth boot - verify we're on v2 +def fourth_boot [] { + print "verifying fourth boot - should be on v2" + assert equal $booted.image.transport containers-storage + + let imgsrc = imgsrc + let v2_image = $"($imgsrc)-v2" + assert equal $booted.image.image $v2_image + + # Verify v2 file content + let v2_content = open /usr/share/testing-bootc-switch-apply | str trim + assert equal $v2_content "v2" "Should be on v2 after clearing download-only and reboot" + + tap ok +} + +def main [] { + # See https://tmt.readthedocs.io/en/stable/stories/features.html#reboot-during-test + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + "2" => third_boot, + "3" => fourth_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 0c12b5d4a..f5fd788aa 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -183,3 +183,8 @@ check: summary: Verify etc merge conflicts are caught during upgrade, not finalization duration: 15m test: nu booted/test-etc-merge-conflict.nu + +/test-47-download-only-switch: + summary: Execute download-only switch tests + duration: 40m + test: nu booted/test-download-only-switch.nu