Skip to content

Commit fe7dae7

Browse files
committed
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: locks staged deployment - bootc upgrade (no flags): unlocks locked staged deployment - bootc upgrade --apply: unlocks and immediately reboots - bootc upgrade --check: read-only, doesn't change lock state - bootc status --verbose: shows "Locked: yes/no" for staged deployments Implementation details: - Internally uses OSTree finalization locking APIs - Sets opts.locked in SysrootDeployTreeOpts when staging deployments - Added change_finalization() method to SysrootLock wrapper - Handles lock state transitions for existing staged deployments - Field name in BootEntry is finalization_locked for clarity - Display name is shortened to "Locked" for user convenience - Uses deployment.is_finalization_locked() API (OSTree v2023.8+) Testing and documentation: - Added TMT integration test (test-upgrade-download-only.nu) - Test verifies 3-boot workflow: lock, reboot (stays old), unlock, reboot (applies) - Updated docs/src/upgrades.md with comprehensive workflow examples - Generated man pages and JSON schemas updated 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 e71787f commit fe7dae7

File tree

15 files changed

+428
-7
lines changed

15 files changed

+428
-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+
finalization_locked: 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: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ 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+
/// The update will be downloaded and staged, but will not be applied automatically on reboot.
106+
/// Use `bootc upgrade --apply` to apply the staged update.
107+
#[clap(long, conflicts_with_all = ["check", "apply"])]
108+
pub(crate) download_only: bool,
109+
103110
#[clap(flatten)]
104111
pub(crate) progress: ProgressOptions,
105112
}
@@ -957,6 +964,26 @@ async fn upgrade(
957964
.unwrap_or_default();
958965
if staged_unchanged {
959966
println!("Staged update present, not changed.");
967+
let staged_deployment = storage.get_ostree()?.staged_deployment();
968+
if let Some(staged) = staged_deployment {
969+
// Handle finalization locking based on flags
970+
if opts.download_only {
971+
// --download-only: lock the deployment
972+
if !staged.is_finalization_locked() {
973+
storage.get_ostree()?.change_finalization(&staged)?;
974+
println!("Locked staged deployment.");
975+
}
976+
} else if !opts.check {
977+
// --apply or no flags: unlock the deployment
978+
// (skip if --check, which is read-only)
979+
if staged.is_finalization_locked() {
980+
storage.get_ostree()?.change_finalization(&staged)?;
981+
println!("Unlocked staged deployment.");
982+
}
983+
}
984+
} else if opts.download_only || opts.apply {
985+
anyhow::bail!("No staged deployment found");
986+
}
960987
handle_staged_soft_reboot(booted_ostree, opts.soft_reboot, &host)?;
961988
if opts.apply {
962989
crate::reboot::reboot()?;
@@ -966,7 +993,15 @@ async fn upgrade(
966993
} else {
967994
let stateroot = booted_ostree.stateroot();
968995
let from = MergeState::from_stateroot(storage, &stateroot)?;
969-
crate::deploy::stage(storage, from, &fetched, &spec, prog.clone()).await?;
996+
crate::deploy::stage(
997+
storage,
998+
from,
999+
&fetched,
1000+
&spec,
1001+
prog.clone(),
1002+
opts.download_only,
1003+
)
1004+
.await?;
9701005
changed = true;
9711006
if let Some(prev) = booted_image.as_ref() {
9721007
if let Some(fetched_manifest) = fetched.get_manifest(repo)? {
@@ -1071,7 +1106,7 @@ async fn switch_ostree(
10711106

10721107
let stateroot = booted_ostree.stateroot();
10731108
let from = MergeState::from_stateroot(storage, &stateroot)?;
1074-
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone()).await?;
1109+
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
10751110

10761111
storage.update_mtime()?;
10771112

@@ -1200,7 +1235,7 @@ async fn edit_ostree(
12001235

12011236
let stateroot = booted_ostree.stateroot();
12021237
let from = MergeState::from_stateroot(storage, &stateroot)?;
1203-
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone()).await?;
1238+
crate::deploy::stage(storage, from, &fetched, &new_spec, prog.clone(), false).await?;
12041239

12051240
storage.update_mtime()?;
12061241

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());
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+
finalizationLocked: 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

crates/lib/src/install.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2383,7 +2383,7 @@ pub(crate) async fn install_reset(opts: InstallResetOpts) -> Result<()> {
23832383
stateroot: target_stateroot.clone(),
23842384
kargs,
23852385
};
2386-
crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone()).await?;
2386+
crate::deploy::stage(sysroot, from, &fetched, &spec, prog.clone(), false).await?;
23872387

23882388
// Copy /boot entry from /etc/fstab to the new stateroot if it exists
23892389
if let Some(boot_spec) = read_boot_fstab_entry(rootfs)? {

crates/lib/src/spec.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ pub struct BootEntry {
227227
/// This is true if (relative to the booted system) this is a possible target for a soft reboot
228228
#[serde(default)]
229229
pub soft_reboot_capable: bool,
230+
/// Whether this deployment is locked from automatic finalization on shutdown
231+
#[serde(default)]
232+
#[serde(skip_serializing_if = "is_false")]
233+
pub finalization_locked: bool,
230234
/// The container storage backend
231235
#[serde(default)]
232236
pub store: Option<Store>,
@@ -236,6 +240,11 @@ pub struct BootEntry {
236240
pub composefs: Option<BootEntryComposefs>,
237241
}
238242

243+
// Helper function for serde skip_serializing_if
244+
fn is_false(b: &bool) -> bool {
245+
!b
246+
}
247+
239248
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
240249
#[serde(rename_all = "camelCase")]
241250
#[non_exhaustive]
@@ -628,6 +637,7 @@ mod tests {
628637
incompatible: false,
629638
soft_reboot_capable: false,
630639
pinned: false,
640+
finalization_locked: false,
631641
store: None,
632642
ostree: None,
633643
composefs: None,

crates/lib/src/status.rs

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,14 @@ fn boot_entry_from_deployment(
267267
};
268268

269269
let soft_reboot_capable = has_soft_reboot_capability(sysroot, deployment);
270+
let finalization_locked = deployment.is_staged() && deployment.is_finalization_locked();
270271
let store = Some(crate::spec::Store::OstreeContainer);
271272
let r = BootEntry {
272273
image,
273274
cached_update,
274275
incompatible,
275276
soft_reboot_capable,
277+
finalization_locked,
276278
store,
277279
pinned: deployment.is_pinned(),
278280
ostree: Some(crate::spec::BootEntryOstree {
@@ -493,7 +495,7 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> {
493495
Ok(())
494496
}
495497

496-
#[derive(Debug)]
498+
#[derive(Debug, Clone, Copy)]
497499
pub enum Slot {
498500
Staged,
499501
Booted,
@@ -563,6 +565,33 @@ fn write_soft_reboot(
563565
Ok(())
564566
}
565567

568+
/// Helper function to render finalization lock status
569+
fn write_finalization_locked(
570+
mut out: impl Write,
571+
slot: Option<Slot>,
572+
entry: &crate::spec::BootEntry,
573+
prefix_len: usize,
574+
) -> Result<()> {
575+
// Always show lock status for staged deployments
576+
if matches!(slot, Some(Slot::Staged)) {
577+
write_row_name(&mut out, "Locked", prefix_len)?;
578+
writeln!(
579+
out,
580+
"{}",
581+
if entry.finalization_locked {
582+
"yes"
583+
} else {
584+
"no"
585+
}
586+
)?;
587+
} else if entry.finalization_locked {
588+
// For non-staged deployments, only show if locked
589+
write_row_name(&mut out, "Locked", prefix_len)?;
590+
writeln!(out, "yes")?;
591+
}
592+
Ok(())
593+
}
594+
566595
/// Write the data for a container image based status.
567596
fn human_render_slot(
568597
mut out: impl Write,
@@ -654,6 +683,9 @@ fn human_render_slot(
654683

655684
// Show soft-reboot capability
656685
write_soft_reboot(&mut out, entry, prefix_len)?;
686+
687+
// Show finalization lock status
688+
write_finalization_locked(&mut out, slot, entry, prefix_len)?;
657689
}
658690

659691
tracing::debug!("pinned={}", entry.pinned);
@@ -694,6 +726,9 @@ fn human_render_slot_ostree(
694726

695727
// Show soft-reboot capability
696728
write_soft_reboot(&mut out, entry, prefix_len)?;
729+
730+
// Show finalization lock status
731+
write_finalization_locked(&mut out, slot, entry, prefix_len)?;
697732
}
698733

699734
tracing::debug!("pinned={}", entry.pinned);
@@ -941,4 +976,46 @@ mod tests {
941976
assert!(w.contains("Commit:"));
942977
assert!(w.contains("Soft-reboot:"));
943978
}
979+
980+
#[test]
981+
fn test_human_readable_staged_locked() {
982+
// Test that locked staged deployment shows the lock status in non-verbose mode
983+
// Lock status is only shown in verbose mode per design
984+
let w = human_status_from_spec_fixture(include_str!("fixtures/spec-staged-locked.yaml"))
985+
.expect("No spec found");
986+
let expected = indoc::indoc! { r"
987+
Staged image: quay.io/example/someimage:latest
988+
Digest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 (arm64)
989+
Version: nightly (2023-10-14T19:22:15Z)
990+
991+
● Booted image: quay.io/example/someimage:latest
992+
Digest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 (arm64)
993+
Version: nightly (2023-09-30T19:22:16Z)
994+
"};
995+
similar_asserts::assert_eq!(w, expected);
996+
}
997+
998+
#[test]
999+
fn test_human_readable_staged_locked_verbose() {
1000+
// Test that locked status is shown in verbose mode for staged deployments
1001+
let w = human_status_from_spec_fixture_verbose(include_str!(
1002+
"fixtures/spec-staged-locked.yaml"
1003+
))
1004+
.expect("No spec found");
1005+
1006+
// Verbose output should include lock status
1007+
assert!(w.contains("Locked: yes"));
1008+
}
1009+
1010+
#[test]
1011+
fn test_human_readable_staged_unlocked_verbose() {
1012+
// Test that unlocked staged deployment shows "Locked: no" in verbose mode
1013+
let w = human_status_from_spec_fixture_verbose(include_str!(
1014+
"fixtures/spec-staged-booted.yaml"
1015+
))
1016+
.expect("No spec found");
1017+
1018+
// Verbose output should include lock status as "no" for unlocked staged deployments
1019+
assert!(w.contains("Locked: no"));
1020+
}
9441021
}

crates/ostree-ext/src/sysroot.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,27 @@ impl SysrootLock {
170170
unowned: true,
171171
}
172172
}
173+
174+
/// Toggle the finalization lock state of a staged deployment.
175+
/// If the deployment is currently locked, it will be unlocked, and vice versa.
176+
/// The deployment must be a staged deployment.
177+
#[allow(unsafe_code)]
178+
pub fn change_finalization(&self, deployment: &ostree::Deployment) -> Result<()> {
179+
use ostree::glib::translate::*;
180+
use std::ptr;
181+
unsafe {
182+
let mut error = ptr::null_mut();
183+
let result = ostree::ffi::ostree_sysroot_change_finalization(
184+
self.sysroot.to_glib_none().0,
185+
deployment.to_glib_none().0,
186+
&mut error,
187+
);
188+
if result == 0 {
189+
return Err(from_glib_full::<_, ostree::glib::Error>(error).into());
190+
}
191+
Ok(())
192+
}
193+
}
173194
}
174195

175196
#[cfg(test)]

docs/src/host-v1.schema.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@
6565
}
6666
]
6767
},
68+
"finalizationLocked": {
69+
"description": "Whether this deployment is locked from automatic finalization on shutdown",
70+
"type": "boolean"
71+
},
6872
"image": {
6973
"description": "The image reference",
7074
"anyOf": [
@@ -199,7 +203,7 @@
199203
"description": "Bootloader type to determine whether system was booted via Grub or Systemd",
200204
"oneOf": [
201205
{
202-
"description": "Use Grub as the booloader",
206+
"description": "Use Grub as the bootloader",
203207
"type": "string",
204208
"const": "Grub"
205209
},

docs/src/man/bootc-install-print-configuration.8.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@ filesystem type from the container image.
1919
At the current time, the only output key is `root-fs-type` which is a
2020
string-valued filesystem name suitable for passing to `mkfs.\$type`.
2121

22+
# OPTIONS
23+
2224
<!-- BEGIN GENERATED OPTIONS -->
25+
**--all**
26+
27+
Print all configuration
28+
2329
<!-- END GENERATED OPTIONS -->
2430

2531
# VERSION

0 commit comments

Comments
 (0)