Skip to content

Commit c325582

Browse files
shi2wei3cgwalters
authored andcommitted
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>
1 parent 3a7dd85 commit c325582

25 files changed

+477
-7
lines changed

crates/lib/src/bootc_composefs/status.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ async fn boot_entry_from_composefs_deployment(
234234
cached_update: None,
235235
incompatible: false,
236236
pinned: false,
237+
download_only: false, // Not yet supported for composefs backend
237238
store: None,
238239
ostree: None,
239240
composefs: Some(crate::spec::BootEntryComposefs {

crates/lib/src/cli.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ pub(crate) struct UpgradeOpts {
100100
#[clap(long = "soft-reboot", conflicts_with = "check")]
101101
pub(crate) soft_reboot: Option<SoftRebootMode>,
102102

103+
/// Download and stage the update without applying it.
104+
///
105+
/// Download the update and ensure it's retained on disk for the lifetime of this system boot,
106+
/// but it will not be applied on reboot. If the system is rebooted without applying the update,
107+
/// the image will be eligible for garbage collection again.
108+
#[clap(long, conflicts_with_all = ["check", "apply"])]
109+
pub(crate) download_only: bool,
110+
103111
#[clap(flatten)]
104112
pub(crate) progress: ProgressOptions,
105113
}
@@ -962,7 +970,35 @@ async fn upgrade(
962970
.map(|img| &img.manifest_digest == fetched_digest)
963971
.unwrap_or_default();
964972
if staged_unchanged {
965-
println!("Staged update present, not changed.");
973+
let staged_deployment = storage.get_ostree()?.staged_deployment();
974+
let mut download_only_changed = false;
975+
976+
if let Some(staged) = staged_deployment {
977+
// Handle download-only mode based on flags
978+
if opts.download_only {
979+
// --download-only: set download-only mode
980+
if !staged.is_finalization_locked() {
981+
storage.get_ostree()?.change_finalization(&staged)?;
982+
println!("Image downloaded, but will not be applied on reboot");
983+
download_only_changed = true;
984+
}
985+
} else if !opts.check {
986+
// --apply or no flags: clear download-only mode
987+
// (skip if --check, which is read-only)
988+
if staged.is_finalization_locked() {
989+
storage.get_ostree()?.change_finalization(&staged)?;
990+
println!("Staged deployment will now be applied on reboot");
991+
download_only_changed = true;
992+
}
993+
}
994+
} else if opts.download_only || opts.apply {
995+
anyhow::bail!("No staged deployment found");
996+
}
997+
998+
if !download_only_changed {
999+
println!("Staged update present, not changed");
1000+
}
1001+
9661002
handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
9671003
if opts.apply {
9681004
crate::reboot::reboot()?;
@@ -972,7 +1008,15 @@ async fn upgrade(
9721008
} else {
9731009
let stateroot = booted_ostree.stateroot();
9741010
let from = MergeState::from_stateroot(storage, &stateroot)?;
975-
crate::deploy::stage(storage, from, &fetched, &spec, prog.clone()).await?;
1011+
crate::deploy::stage(
1012+
storage,
1013+
from,
1014+
&fetched,
1015+
&spec,
1016+
prog.clone(),
1017+
opts.download_only,
1018+
)
1019+
.await?;
9761020
changed = true;
9771021
if let Some(prev) = booted_image.as_ref() {
9781022
if let Some(fetched_manifest) = fetched.get_manifest(repo)? {
@@ -1077,7 +1121,7 @@ async fn switch_ostree(
10771121

10781122
let stateroot = booted_ostree.stateroot();
10791123
let from = MergeState::from_stateroot(storage, &stateroot)?;
1080-
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone()).await?;
1124+
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
10811125

10821126
storage.update_mtime()?;
10831127

@@ -1206,7 +1250,7 @@ async fn edit_ostree(
12061250

12071251
let stateroot = booted_ostree.stateroot();
12081252
let from = MergeState::from_stateroot(storage, &stateroot)?;
1209-
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone()).await?;
1253+
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
12101254

12111255
storage.update_mtime()?;
12121256

crates/lib/src/deploy.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,7 @@ async fn deploy(
581581
from: MergeState,
582582
image: &ImageState,
583583
origin: &glib::KeyFile,
584+
lock_finalization: bool,
584585
) -> Result<Deployment> {
585586
// Compute the kernel argument overrides. In practice today this API is always expecting
586587
// a merge deployment. The kargs code also always looks at the booted root (which
@@ -608,6 +609,9 @@ async fn deploy(
608609
let stateroot = Some(stateroot);
609610
let mut opts = ostree::SysrootDeployTreeOpts::default();
610611

612+
// Set finalization lock if requested
613+
opts.locked = lock_finalization;
614+
611615
// Because the C API expects a Vec<&str>, convert the Cmdline to string slices.
612616
// The references borrow from the Cmdline, which outlives this usage.
613617
let override_kargs_refs = override_kargs
@@ -691,6 +695,7 @@ pub(crate) async fn stage(
691695
image: &ImageState,
692696
spec: &RequiredHostSpec<'_>,
693697
prog: ProgressWriter,
698+
lock_finalization: bool,
694699
) -> Result<()> {
695700
// Log the staging operation to systemd journal with comprehensive upgrade information
696701
const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c";
@@ -748,7 +753,8 @@ pub(crate) async fn stage(
748753
})
749754
.await;
750755
let origin = origin_from_imageref(spec.image)?;
751-
let deployment = crate::deploy::deploy(sysroot, from, image, &origin).await?;
756+
let deployment =
757+
crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?;
752758

753759
subtask.completed = true;
754760
subtasks.push(subtask.clone());

crates/lib/src/fixtures/spec-booted-pinned.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ status:
2121
cachedUpdate: null
2222
incompatible: false
2323
pinned: true
24+
downloadOnly: false
2425
ostree:
2526
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
2627
deploySerial: 0
@@ -37,6 +38,7 @@ status:
3738
cachedUpdate: null
3839
incompatible: false
3940
pinned: true
41+
downloadOnly: false
4042
ostree:
4143
checksum: 99b2cc3b6edce9ebaef6a6076effa5ee3e1dcff3523016ffc94a1b27c6c67e12
4244
deploySerial: 0

crates/lib/src/fixtures/spec-only-booted.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ status:
2121
cachedUpdate: null
2222
incompatible: false
2323
pinned: false
24+
downloadOnly: false
2425
ostree:
2526
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
2627
deploySerial: 0

crates/lib/src/fixtures/spec-ostree-remote.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ status:
2020
imageDigest: sha256:b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c
2121
incompatible: false
2222
pinned: false
23+
downloadOnly: false
2324
ostree:
2425
checksum: 41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3
2526
deploySerial: 0

crates/lib/src/fixtures/spec-ostree-to-bootc.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ status:
2020
cachedUpdate: null
2121
incompatible: false
2222
pinned: false
23+
downloadOnly: false
2324
store: ostreeContainer
2425
ostree:
2526
checksum: 05cbf6dcae32e7a1c5a0774a648a073a5834a305ca92204b53fb6c281fe49db1
@@ -30,6 +31,7 @@ status:
3031
cachedUpdate: null
3132
incompatible: false
3233
pinned: false
34+
downloadOnly: false
3335
store: null
3436
ostree:
3537
checksum: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791

crates/lib/src/fixtures/spec-rfe-ostree-deployment.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ status:
1111
cachedUpdate: null
1212
incompatible: true
1313
pinned: false
14+
downloadOnly: false
1415
store: null
1516
ostree:
1617
checksum: 1c24260fdd1be20f72a4a97a75c582834ee3431fbb0fa8e4f482bb219d633a45
@@ -21,6 +22,7 @@ status:
2122
cachedUpdate: null
2223
incompatible: false
2324
pinned: false
25+
downloadOnly: false
2426
store: null
2527
ostree:
2628
checksum: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791

crates/lib/src/fixtures/spec-staged-booted.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ status:
2121
imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566
2222
incompatible: false
2323
pinned: false
24+
downloadOnly: false
2425
ostree:
2526
checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d
2627
deploySerial: 0
@@ -37,6 +38,7 @@ status:
3738
imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34
3839
incompatible: false
3940
pinned: false
41+
downloadOnly: false
4042
ostree:
4143
checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c
4244
deploySerial: 0
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
apiVersion: org.containers.bootc/v1alpha1
2+
kind: BootcHost
3+
metadata:
4+
name: host
5+
spec:
6+
image:
7+
image: quay.io/example/someimage:latest
8+
transport: registry
9+
signature: insecure
10+
status:
11+
staged:
12+
image:
13+
image:
14+
image: quay.io/example/someimage:latest
15+
transport: registry
16+
signature: insecure
17+
architecture: arm64
18+
version: nightly
19+
timestamp: 2023-10-14T19:22:15.42Z
20+
imageDigest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566
21+
incompatible: false
22+
pinned: false
23+
downloadOnly: true
24+
ostree:
25+
checksum: 3c6dad657109522e0b2e49bf44b5420f16f0b438b5b9357e5132211cfbad135d
26+
deploySerial: 0
27+
stateroot: default
28+
booted:
29+
image:
30+
image:
31+
image: quay.io/example/someimage:latest
32+
transport: registry
33+
signature: insecure
34+
architecture: arm64
35+
version: nightly
36+
timestamp: 2023-09-30T19:22:16Z
37+
imageDigest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34
38+
incompatible: false
39+
pinned: false
40+
ostree:
41+
checksum: 26836632adf6228d64ef07a26fd3efaf177104efd1f341a2cf7909a3e4e2c72c
42+
deploySerial: 0
43+
stateroot: default
44+
rollback: null
45+
isContainer: false

0 commit comments

Comments
 (0)