mirror of
https://github.com/containers/bootc.git
synced 2026-02-06 09:45:32 +01:00
lib: Add --download-only flag for upgrade
Add support for downloading and staging updates without automatic application on reboot. This allows users to prepare updates and apply them at a controlled time. User-facing changes: - Add --download-only flag to bootc upgrade command - bootc upgrade --download-only: stages deployment in download-only mode - bootc upgrade (no flags): clears download-only mode if present - bootc upgrade --apply: clears download-only mode and immediately reboots - bootc upgrade --check: read-only, doesn't change download-only state - bootc status shows "Download-only: yes/no" for staged deployments in verbose mode - Garbage collection automatically cleans up unreferenced images after staging Implementation details: - Internally uses OSTree finalization locking APIs - Sets opts.locked in SysrootDeployTreeOpts when staging deployments - Added change_finalization() method to SysrootLock wrapper - Tracks lock state changes separately from image digest changes - Field name in BootEntry is download_only (Rust), downloadOnly (JSON) - Verbose status display uses "Download-only" label (matches Soft-reboot pattern) - Uses deployment.is_finalization_locked() API (OSTree v2023.8+) - Always emits downloadOnly field in JSON output for consistency Testing and documentation: - New dedicated test: test-25-download-only-upgrade.nu (4-boot workflow) - Test verifies: switch → upgrade --download-only → reboot (stays old) → re-stage → upgrade (clear) → reboot (applies) - Updated docs/src/upgrades.md with comprehensive workflow examples - Includes notes about reboot behavior and image switching - Generated man pages and JSON schemas updated - All test fixtures updated with downloadOnly field The download-only flag is only available for upgrade, not switch. The implementation is designed to support future composefs backend. Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Wei Shi <wshi@redhat.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -100,6 +100,14 @@ pub(crate) struct UpgradeOpts {
|
||||
#[clap(long = "soft-reboot", conflicts_with = "check")]
|
||||
pub(crate) soft_reboot: Option<SoftRebootMode>,
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -962,7 +970,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()?;
|
||||
@@ -972,7 +1008,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)? {
|
||||
@@ -1077,7 +1121,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()?;
|
||||
|
||||
@@ -1206,7 +1250,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()?;
|
||||
|
||||
|
||||
@@ -581,6 +581,7 @@ async fn deploy(
|
||||
from: MergeState,
|
||||
image: &ImageState,
|
||||
origin: &glib::KeyFile,
|
||||
lock_finalization: bool,
|
||||
) -> Result<Deployment> {
|
||||
// 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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ status:
|
||||
cachedUpdate: null
|
||||
incompatible: false
|
||||
pinned: false
|
||||
downloadOnly: false
|
||||
ostree:
|
||||
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
|
||||
deploySerial: 0
|
||||
|
||||
@@ -20,6 +20,7 @@ status:
|
||||
imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
|
||||
incompatible: false
|
||||
pinned: false
|
||||
downloadOnly: false
|
||||
ostree:
|
||||
checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3
|
||||
deploySerial: 0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
45
crates/lib/src/fixtures/spec-staged-download-only.yaml
Normal file
45
crates/lib/src/fixtures/spec-staged-download-only.yaml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ status:
|
||||
imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
|
||||
incompatible: false
|
||||
pinned: false
|
||||
downloadOnly: false
|
||||
ostree:
|
||||
checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3
|
||||
deploySerial: 0
|
||||
|
||||
@@ -21,6 +21,7 @@ status:
|
||||
cachedUpdate: null
|
||||
incompatible: false
|
||||
pinned: false
|
||||
downloadOnly: false
|
||||
ostree:
|
||||
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
|
||||
deploySerial: 0
|
||||
|
||||
@@ -2417,7 +2417,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)? {
|
||||
|
||||
@@ -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<Store>,
|
||||
@@ -636,6 +640,7 @@ mod tests {
|
||||
incompatible: false,
|
||||
soft_reboot_capable: false,
|
||||
pinned: false,
|
||||
download_only: false,
|
||||
store: None,
|
||||
ostree: None,
|
||||
composefs: None,
|
||||
|
||||
@@ -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<Slot>,
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
<!-- END GENERATED OPTIONS -->
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -16,3 +16,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 " "
|
||||
}
|
||||
|
||||
150
tmt/tests/booted/test-25-download-only-upgrade.nu
Normal file
150
tmt/tests/booted/test-25-download-only-upgrade.nu
Normal file
@@ -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 <from that image> (v1)
|
||||
# bootc switch <into that image>
|
||||
# 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)" } },
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user