diff --git a/crates/lib/src/bootc_composefs/status.rs b/crates/lib/src/bootc_composefs/status.rs index 40ec1b757..0194d0c33 100644 --- a/crates/lib/src/bootc_composefs/status.rs +++ b/crates/lib/src/bootc_composefs/status.rs @@ -234,6 +234,7 @@ async fn boot_entry_from_composefs_deployment( cached_update: None, incompatible: false, pinned: false, + download_only: false, // Not yet supported for composefs backend store: None, ostree: None, composefs: Some(crate::spec::BootEntryComposefs { diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index c59b4dad1..d3e50ab36 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -100,6 +100,14 @@ 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, + #[clap(flatten)] pub(crate) progress: ProgressOptions, } @@ -956,7 +964,35 @@ async fn upgrade( .map(|img| &img.manifest_digest == fetched_digest) .unwrap_or_default(); if staged_unchanged { - println!("Staged update present, not changed."); + let staged_deployment = storage.get_ostree()?.staged_deployment(); + let mut download_only_changed = false; + + if let Some(staged) = staged_deployment { + // Handle download-only mode based on flags + if opts.download_only { + // --download-only: set download-only mode + if !staged.is_finalization_locked() { + storage.get_ostree()?.change_finalization(&staged)?; + println!("Image downloaded, but will not be applied on reboot"); + download_only_changed = true; + } + } else if !opts.check { + // --apply or no flags: clear download-only mode + // (skip if --check, which is read-only) + if staged.is_finalization_locked() { + storage.get_ostree()?.change_finalization(&staged)?; + println!("Staged deployment will now be applied on reboot"); + download_only_changed = true; + } + } + } else if opts.download_only || opts.apply { + anyhow::bail!("No staged deployment found"); + } + + if !download_only_changed { + println!("Staged update present, not changed"); + } + handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?; if opts.apply { crate::reboot::reboot()?; @@ -966,7 +1002,15 @@ async fn upgrade( } else { let stateroot = booted_ostree.stateroot(); let from = MergeState::from_stateroot(storage, &stateroot)?; - crate::deploy::stage(storage, from, &fetched, &spec, prog.clone()).await?; + crate::deploy::stage( + storage, + from, + &fetched, + &spec, + prog.clone(), + opts.download_only, + ) + .await?; changed = true; if let Some(prev) = booted_image.as_ref() { if let Some(fetched_manifest) = fetched.get_manifest(repo)? { @@ -1071,7 +1115,7 @@ 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()).await?; + crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?; storage.update_mtime()?; @@ -1200,7 +1244,7 @@ async fn edit_ostree( let stateroot = booted_ostree.stateroot(); let from = MergeState::from_stateroot(storage, &stateroot)?; - crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone()).await?; + crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?; storage.update_mtime()?; diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index 031700fc1..0fad38474 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -581,6 +581,7 @@ async fn deploy( from: MergeState, image: &ImageState, origin: &glib::KeyFile, + lock_finalization: bool, ) -> Result { // Compute the kernel argument overrides. In practice today this API is always expecting // a merge deployment. The kargs code also always looks at the booted root (which @@ -608,6 +609,9 @@ async fn deploy( let stateroot = Some(stateroot); let mut opts = ostree::SysrootDeployTreeOpts::default(); + // Set finalization lock if requested + opts.locked = lock_finalization; + // Because the C API expects a Vec<&str>, convert the Cmdline to string slices. // The references borrow from the Cmdline, which outlives this usage. let override_kargs_refs = override_kargs @@ -691,6 +695,7 @@ pub(crate) async fn stage( image: &ImageState, spec: &RequiredHostSpec<'_>, prog: ProgressWriter, + lock_finalization: bool, ) -> Result<()> { // Log the staging operation to systemd journal with comprehensive upgrade information const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c"; @@ -748,7 +753,8 @@ pub(crate) async fn stage( }) .await; let origin = origin_from_imageref(spec.image)?; - let deployment = crate::deploy::deploy(sysroot, from, image, &origin).await?; + let deployment = + crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?; subtask.completed = true; subtasks.push(subtask.clone()); diff --git a/crates/lib/src/fixtures/spec-booted-pinned.yaml b/crates/lib/src/fixtures/spec-booted-pinned.yaml index 4d460e41c..304394662 100644 --- a/crates/lib/src/fixtures/spec-booted-pinned.yaml +++ b/crates/lib/src/fixtures/spec-booted-pinned.yaml @@ -21,6 +21,7 @@ status: cachedUpdate: null incompatible: false pinned: true + downloadOnly: false ostree: checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 deploySerial: 0 @@ -37,6 +38,7 @@ status: cachedUpdate: null incompatible: false pinned: true + downloadOnly: false ostree: checksum: 99b2cc3b6edce9ebaef6a6076effa5ee3e1dcff3523016ffc94a1b27c6c67e12 deploySerial: 0 diff --git a/crates/lib/src/fixtures/spec-only-booted.yaml b/crates/lib/src/fixtures/spec-only-booted.yaml index 2adbf5e91..212250e4d 100644 --- a/crates/lib/src/fixtures/spec-only-booted.yaml +++ b/crates/lib/src/fixtures/spec-only-booted.yaml @@ -21,6 +21,7 @@ status: cachedUpdate: null incompatible: false pinned: false + downloadOnly: false ostree: checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 deploySerial: 0 diff --git a/crates/lib/src/fixtures/spec-ostree-remote.yaml b/crates/lib/src/fixtures/spec-ostree-remote.yaml index 2a9770de9..49185940d 100644 --- a/crates/lib/src/fixtures/spec-ostree-remote.yaml +++ b/crates/lib/src/fixtures/spec-ostree-remote.yaml @@ -20,6 +20,7 @@ status: imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c incompatible: false pinned: false + downloadOnly: false ostree: checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3 deploySerial: 0 diff --git a/crates/lib/src/fixtures/spec-ostree-to-bootc.yaml b/crates/lib/src/fixtures/spec-ostree-to-bootc.yaml index 8adcd6c94..e37a05586 100644 --- a/crates/lib/src/fixtures/spec-ostree-to-bootc.yaml +++ b/crates/lib/src/fixtures/spec-ostree-to-bootc.yaml @@ -20,6 +20,7 @@ status: cachedUpdate: null incompatible: false pinned: false + downloadOnly: false store: ostreeContainer ostree: checksum: 05cbf6dcae32e7a1c5a0774a648a073a5834a305ca92204b53fb6c281fe49db1 @@ -30,6 +31,7 @@ status: cachedUpdate: null incompatible: false pinned: false + downloadOnly: false store: null ostree: checksum: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791 diff --git a/crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml b/crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml index c38ac5e72..89a0d5107 100644 --- a/crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml +++ b/crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml @@ -11,6 +11,7 @@ status: cachedUpdate: null incompatible: true pinned: false + downloadOnly: false store: null ostree: checksum: 1c24260fdd1be20f72a4a97a75c582834ee3431fbb0fa8e4f482bb219d633a45 @@ -21,6 +22,7 @@ status: cachedUpdate: null incompatible: false pinned: false + downloadOnly: false store: null ostree: checksum: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791 diff --git a/crates/lib/src/fixtures/spec-staged-booted.yaml b/crates/lib/src/fixtures/spec-staged-booted.yaml index c85fb1b93..2982c2b1d 100644 --- a/crates/lib/src/fixtures/spec-staged-booted.yaml +++ b/crates/lib/src/fixtures/spec-staged-booted.yaml @@ -21,6 +21,7 @@ status: imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 incompatible: false pinned: false + downloadOnly: false ostree: checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d deploySerial: 0 @@ -37,6 +38,7 @@ status: imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 incompatible: false pinned: false + downloadOnly: false ostree: checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c deploySerial: 0 diff --git a/crates/lib/src/fixtures/spec-staged-download-only.yaml b/crates/lib/src/fixtures/spec-staged-download-only.yaml new file mode 100644 index 000000000..6c71d15e1 --- /dev/null +++ b/crates/lib/src/fixtures/spec-staged-download-only.yaml @@ -0,0 +1,45 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure +status: + staged: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + architecture: arm64 + version: nightly + timestamp: 2023-10-14T19:22:15.42Z + imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 + incompatible: false + pinned: false + downloadOnly: true + ostree: + checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d + deploySerial: 0 + stateroot: default + booted: + image: + image: + image: quay.io/example/someimage:latest + transport: registry + signature: insecure + architecture: arm64 + version: nightly + timestamp: 2023-09-30T19:22:16Z + imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 + incompatible: false + pinned: false + ostree: + checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c + deploySerial: 0 + stateroot: default + rollback: null + isContainer: false diff --git a/crates/lib/src/fixtures/spec-staged-rollback.yaml b/crates/lib/src/fixtures/spec-staged-rollback.yaml index 9b53d61fa..12322b593 100644 --- a/crates/lib/src/fixtures/spec-staged-rollback.yaml +++ b/crates/lib/src/fixtures/spec-staged-rollback.yaml @@ -20,6 +20,7 @@ status: imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 incompatible: false pinned: false + downloadOnly: false ostree: checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d deploySerial: 0 @@ -37,6 +38,7 @@ status: imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 incompatible: false pinned: false + downloadOnly: false ostree: checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c deploySerial: 0 diff --git a/crates/lib/src/fixtures/spec-v1a1-orig.yaml b/crates/lib/src/fixtures/spec-v1a1-orig.yaml index 3419dd344..c936dccd3 100644 --- a/crates/lib/src/fixtures/spec-v1a1-orig.yaml +++ b/crates/lib/src/fixtures/spec-v1a1-orig.yaml @@ -20,6 +20,7 @@ status: architecture: amd64 incompatible: false pinned: false + downloadOnly: false ostree: checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d deploySerial: 0 @@ -36,6 +37,7 @@ status: architecture: amd64 incompatible: false pinned: false + downloadOnly: false ostree: checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c deploySerial: 0 diff --git a/crates/lib/src/fixtures/spec-v1a1.yaml b/crates/lib/src/fixtures/spec-v1a1.yaml index e98da73f4..47a93922b 100644 --- a/crates/lib/src/fixtures/spec-v1a1.yaml +++ b/crates/lib/src/fixtures/spec-v1a1.yaml @@ -19,6 +19,7 @@ status: imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c incompatible: false pinned: false + downloadOnly: false ostree: checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3 deploySerial: 0 diff --git a/crates/lib/src/fixtures/spec-via-local-oci.yaml b/crates/lib/src/fixtures/spec-via-local-oci.yaml index 65b076925..f972c8515 100644 --- a/crates/lib/src/fixtures/spec-via-local-oci.yaml +++ b/crates/lib/src/fixtures/spec-via-local-oci.yaml @@ -21,6 +21,7 @@ status: cachedUpdate: null incompatible: false pinned: false + downloadOnly: false ostree: checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 deploySerial: 0 diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 386c4b4dc..f16d12092 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -2383,7 +2383,7 @@ pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> { stateroot: target_stateroot.clone(), kargs, }; - crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone()).await?; + crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone(), false).await?; // Copy /boot entry from /etc/fstab to the new stateroot if it exists if let Some(boot_spec) = read_boot_fstab_entry(rootfs)? { diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 86b8556a1..8d5ba90e1 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -227,6 +227,10 @@ pub struct BootEntry { /// This is true if (relative to the booted system) this is a possible target for a soft reboot #[serde(default)] pub soft_reboot_capable: bool, + /// Whether this deployment is in download-only mode (prevented from automatic finalization on shutdown). + /// This is set via --download-only on the CLI. + #[serde(default)] + pub download_only: bool, /// The container storage backend #[serde(default)] pub store: Option, @@ -628,6 +632,7 @@ mod tests { incompatible: false, soft_reboot_capable: false, pinned: false, + download_only: false, store: None, ostree: None, composefs: None, diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index f35330ef4..95eb04be2 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -267,12 +267,14 @@ fn boot_entry_from_deployment( }; let soft_reboot_capable = has_soft_reboot_capability(sysroot, deployment); + let download_only = deployment.is_staged() && deployment.is_finalization_locked(); let store = Some(crate::spec::Store::OstreeContainer); let r = BootEntry { image, cached_update, incompatible, soft_reboot_capable, + download_only, store, pinned: deployment.is_pinned(), ostree: Some(crate::spec::BootEntryOstree { @@ -493,7 +495,7 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { Ok(()) } -#[derive(Debug)] +#[derive(Debug, Clone, Copy)] pub enum Slot { Staged, Booted, @@ -563,6 +565,21 @@ fn write_soft_reboot( Ok(()) } +/// Helper function to render download-only lock status +fn write_download_only( + mut out: impl Write, + slot: Option, + entry: &crate::spec::BootEntry, + prefix_len: usize, +) -> Result<()> { + // Only staged deployments can have download-only status + if matches!(slot, Some(Slot::Staged)) { + write_row_name(&mut out, "Download-only", prefix_len)?; + writeln!(out, "{}", if entry.download_only { "yes" } else { "no" })?; + } + Ok(()) +} + /// Write the data for a container image based status. fn human_render_slot( mut out: impl Write, @@ -654,6 +671,9 @@ fn human_render_slot( // Show soft-reboot capability write_soft_reboot(&mut out, entry, prefix_len)?; + + // Show download-only lock status + write_download_only(&mut out, slot, entry, prefix_len)?; } tracing::debug!("pinned={}", entry.pinned); @@ -694,6 +714,9 @@ fn human_render_slot_ostree( // Show soft-reboot capability write_soft_reboot(&mut out, entry, prefix_len)?; + + // Show download-only lock status + write_download_only(&mut out, slot, entry, prefix_len)?; } tracing::debug!("pinned={}", entry.pinned); @@ -941,4 +964,47 @@ mod tests { assert!(w.contains("Commit:")); assert!(w.contains("Soft-reboot:")); } + + #[test] + fn test_human_readable_staged_download_only() { + // Test that download-only staged deployment shows the status in non-verbose mode + // Download-only status is only shown in verbose mode per design + let w = + human_status_from_spec_fixture(include_str!("fixtures/spec-staged-download-only.yaml")) + .expect("No spec found"); + let expected = indoc::indoc! { r" + Staged image: quay.io/example/someimage:latest + Digest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 (arm64) + Version: nightly (2023-10-14T19:22:15Z) + + ● Booted image: quay.io/example/someimage:latest + Digest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 (arm64) + Version: nightly (2023-09-30T19:22:16Z) + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_human_readable_staged_download_only_verbose() { + // Test that download-only status is shown in verbose mode for staged deployments + let w = human_status_from_spec_fixture_verbose(include_str!( + "fixtures/spec-staged-download-only.yaml" + )) + .expect("No spec found"); + + // Verbose output should include download-only status + assert!(w.contains("Download-only: yes")); + } + + #[test] + fn test_human_readable_staged_not_download_only_verbose() { + // Test that staged deployment not in download-only mode shows "Download-only: no" in verbose mode + let w = human_status_from_spec_fixture_verbose(include_str!( + "fixtures/spec-staged-booted.yaml" + )) + .expect("No spec found"); + + // Verbose output should include download-only status as "no" for normal staged deployments + assert!(w.contains("Download-only: no")); + } } diff --git a/crates/ostree-ext/src/sysroot.rs b/crates/ostree-ext/src/sysroot.rs index ec1e0be9f..f2a1ae3eb 100644 --- a/crates/ostree-ext/src/sysroot.rs +++ b/crates/ostree-ext/src/sysroot.rs @@ -170,6 +170,27 @@ impl SysrootLock { unowned: true, } } + + /// Toggle the finalization lock state of a staged deployment. + /// If the deployment is currently locked, it will be unlocked, and vice versa. + /// The deployment must be a staged deployment. + #[allow(unsafe_code)] + pub fn change_finalization(&self, deployment: &ostree::Deployment) -> Result<()> { + use ostree::glib::translate::*; + use std::ptr; + unsafe { + let mut error = ptr::null_mut(); + let result = ostree::ffi::ostree_sysroot_change_finalization( + self.sysroot.to_glib_none().0, + deployment.to_glib_none().0, + &mut error, + ); + if result == 0 { + return Err(from_glib_full::<_, ostree::glib::Error>(error).into()); + } + Ok(()) + } + } } #[cfg(test)] diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index e0bdf2880..75f602c8f 100644 --- a/docs/src/host-v1.schema.json +++ b/docs/src/host-v1.schema.json @@ -65,6 +65,11 @@ } ] }, + "downloadOnly": { + "description": "Whether this deployment is in download-only mode (prevented from automatic finalization on shutdown).\nThis is set via --download-only on the CLI.", + "type": "boolean", + "default": false + }, "image": { "description": "The image reference", "anyOf": [ @@ -199,7 +204,7 @@ "description": "Bootloader type to determine whether system was booted via Grub or Systemd", "oneOf": [ { - "description": "Use Grub as the booloader", + "description": "Use Grub as the bootloader", "type": "string", "const": "Grub" }, diff --git a/docs/src/man/bootc-install-print-configuration.8.md b/docs/src/man/bootc-install-print-configuration.8.md index bf50664a5..67928c9f1 100644 --- a/docs/src/man/bootc-install-print-configuration.8.md +++ b/docs/src/man/bootc-install-print-configuration.8.md @@ -19,7 +19,13 @@ filesystem type from the container image. At the current time, the only output key is `root-fs-type` which is a string-valued filesystem name suitable for passing to `mkfs.\$type`. +# OPTIONS + +**--all** + + Print all configuration + # VERSION diff --git a/docs/src/man/bootc-upgrade.8.md b/docs/src/man/bootc-upgrade.8.md index 451cdf82a..c9b0f7237 100644 --- a/docs/src/man/bootc-upgrade.8.md +++ b/docs/src/man/bootc-upgrade.8.md @@ -61,6 +61,10 @@ Soft reboot allows faster system restart by avoiding full hardware reboot when p - required - auto +**--download-only** + + Download and stage the update without applying it + # EXAMPLES diff --git a/docs/src/upgrades.md b/docs/src/upgrades.md index efe10eea1..1befc9fa0 100644 --- a/docs/src/upgrades.md +++ b/docs/src/upgrades.md @@ -14,6 +14,94 @@ changed by default. Use `bootc upgrade --apply` to auto-apply if there are queued changes. +### Staged updates with `--download-only` + +The `--download-only` flag allows you to prepare updates without automatically applying +them on the next reboot: + +```shell +bootc upgrade --download-only +``` + +This will pull the new container image from the registry and create a staged deployment +in download-only mode. The deployment will not be applied on shutdown or reboot until +you explicitly apply it. + +#### Checking download-only status + +To see whether a staged deployment is in download-only mode, use: + +```shell +bootc status --verbose +``` + +In the output, you'll see `Download-only: yes` for deployments in download-only mode or +`Download-only: no` for deployments that will apply automatically. This status is only shown in verbose mode. + +#### Applying download-only updates + +There are two ways to apply a staged update that is in download-only mode: + +**Option 1: Apply immediately with reboot** + +```shell +bootc upgrade --apply +``` + +This will clear the download-only flag and immediately reboot into the staged deployment. + +**Option 2: Clear download-only for automatic application** + +```shell +bootc upgrade +``` + +Running `bootc upgrade` without flags on a staged deployment in download-only mode will +clear the flag. The deployment will then be applied automatically on the next shutdown or reboot. + +#### Checking for updates without side effects + +To check if updates are available without modifying the download-only state: + +```shell +bootc upgrade --check +``` + +This only downloads updated metadata without changing the download-only state. + +#### Example workflow + +A typical workflow for controlled updates: + +```shell +# 1. Download the update in download-only mode +bootc upgrade --download-only + +# 2. Verify the staged deployment +bootc status --verbose +# Output shows: Download-only: yes + +# 3. Test or wait for maintenance window... + +# 4. Apply the update (choose one): +# Option A: Clear download-only flag and let it apply on next shutdown +bootc upgrade + +# Option B: Apply immediately with reboot +bootc upgrade --apply +``` + +**Important notes**: + +- If you reboot before applying a download-only update, the system will boot into the + current deployment and the staged deployment will be discarded. However, the downloaded image + data remains cached, so re-running `bootc upgrade --download-only` will be fast and won't + re-download the container image. + +- If you switch to a different image (using `bootc switch` or `bootc upgrade` to a different + image), the new staged deployment will replace the previous download-only deployment, and the + previously cached image will become eligible for garbage collection. + There is also an opinionated `bootc-fetch-apply-updates.timer` and corresponding service available in upstream for operating systems and distributions to enable. diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 87dfb10c3..2482e9f0b 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -95,6 +95,13 @@ execute: test: - /tmt/tests/tests/test-25-soft-reboot +/plan-25-download-only-upgrade: + summary: Execute download-only upgrade tests + discover: + how: fmf + test: + - /tmt/tests/tests/test-25-download-only-upgrade + /plan-26-examples-build: summary: Test bootc examples build scripts discover: diff --git a/tmt/tests/booted/bootc_testlib.nu b/tmt/tests/booted/bootc_testlib.nu index 5f15586ab..197d8f350 100644 --- a/tmt/tests/booted/bootc_testlib.nu +++ b/tmt/tests/booted/bootc_testlib.nu @@ -19,3 +19,10 @@ export def reboot [] { export def have_hostexports [] { $env.BCVK_EXPORT? == "1" } + +# Parse the kernel commandline into a list. +# This is not a proper parser, but good enough +# for what we need here. +export def parse_cmdline [] { + open /proc/cmdline | str trim | split row " " +} diff --git a/tmt/tests/booted/test-25-download-only-upgrade.nu b/tmt/tests/booted/test-25-download-only-upgrade.nu new file mode 100644 index 000000000..92c8dbb88 --- /dev/null +++ b/tmt/tests/booted/test-25-download-only-upgrade.nu @@ -0,0 +1,150 @@ +# number: 25 +# tmt: +# summary: Execute download-only upgrade tests +# duration: 40m +# +# This test does: +# bootc image copy-to-storage +# podman build (v1) +# bootc switch +# Verify we boot into the new image (v1) +# podman build updated image (v2) +# bootc upgrade --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 upgrade --download-only (re-stage v2 in download-only mode) +# bootc upgrade (clear download-only mode) +# 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 upgrade 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-upgrade-apply + + # A simple derived container (v1) that adds a file + "FROM localhost/bootc +COPY testing-bootc-upgrade-apply /usr/share/testing-bootc-upgrade-apply +" | 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 +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-upgrade-apply" | path exists) "v1 file should exist" + let v1_content = open /usr/share/testing-bootc-upgrade-apply | str trim + assert equal $v1_content "v1" + + # Build v2 - updated derived image with same tag + let imgsrc = imgsrc + # Create test file v2 on host + "v2" | save --force testing-bootc-upgrade-apply + + "FROM localhost/bootc +COPY testing-bootc-upgrade-apply /usr/share/testing-bootc-upgrade-apply +" | save --force Dockerfile + podman build -t $imgsrc . + + # Now upgrade with --download-only (should set deployment to download-only mode) + print $"Upgrading with --download-only to v2" + bootc upgrade --download-only + + # 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-upgrade-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 upgrade --download-only to re-stage the deployment + print "Re-staging with upgrade --download-only" + bootc upgrade --download-only + + # 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 upgrade without flags + print "Clearing download-only mode with bootc upgrade" + bootc upgrade + + # 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 + assert equal $booted.image.image $"(imgsrc)" + + # Verify v2 file content + let v2_content = open /usr/share/testing-bootc-upgrade-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 b867456a4..4ae361550 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -41,6 +41,11 @@ duration: 30m test: nu booted/test-soft-reboot.nu +/test-25-download-only-upgrade: + summary: Execute download-only upgrade tests + duration: 40m + test: nu booted/test-25-download-only-upgrade.nu + /test-26-examples-build: summary: Test bootc examples build scripts duration: 45m