1
0
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:
Wei Shi
2025-12-08 21:07:37 +08:00
committed by Colin Walters
parent 3a7dd85ff1
commit c325582f50
25 changed files with 477 additions and 7 deletions

View File

@@ -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 {

View File

@@ -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()?;

View File

@@ -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());

View File

@@ -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

View File

@@ -21,6 +21,7 @@ status:
cachedUpdate: null
incompatible: false
pinned: false
downloadOnly: false
ostree:
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
deploySerial: 0

View File

@@ -20,6 +20,7 @@ status:
imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
incompatible: false
pinned: false
downloadOnly: false
ostree:
checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3
deploySerial: 0

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -19,6 +19,7 @@ status:
imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
incompatible: false
pinned: false
downloadOnly: false
ostree:
checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3
deploySerial: 0

View File

@@ -21,6 +21,7 @@ status:
cachedUpdate: null
incompatible: false
pinned: false
downloadOnly: false
ostree:
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
deploySerial: 0

View File

@@ -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)? {

View File

@@ -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,

View File

@@ -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"));
}
}

View File

@@ -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)]

View File

@@ -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": [

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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 " "
}

View 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)" } },
}
}

View File

@@ -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