Skip to content

Commit c04be5b

Browse files
committed
status: Render cached update info in human-readable output
After running `bootc upgrade --check`, the registry metadata for a newer image is cached in ostree commit metadata. The `bootc status` command already reads this into the `cachedUpdate` field and exposes it in JSON/YAML output, but the human-readable output never displayed it. This meant users had to parse structured output or re-run `upgrade --check` to see available updates. Render the cached update inline with each deployment entry, showing version, timestamp, and digest when the cached digest differs from the currently deployed image. Relates: https://issues.redhat.com/browse/RHEL-139384 Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 3c41d3d commit c04be5b

File tree

7 files changed

+337
-0
lines changed

7 files changed

+337
-0
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
apiVersion: org.containers.bootc/v1alpha1
2+
kind: BootcHost
3+
metadata:
4+
name: host
5+
spec:
6+
image:
7+
image: quay.io/centos-bootc/centos-bootc:stream9
8+
transport: registry
9+
bootOrder: default
10+
status:
11+
staged: null
12+
booted:
13+
image:
14+
image:
15+
image: quay.io/centos-bootc/centos-bootc:stream9
16+
transport: registry
17+
architecture: arm64
18+
version: stream9.20240807.0
19+
timestamp: null
20+
imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38
21+
cachedUpdate:
22+
image:
23+
image: quay.io/centos-bootc/centos-bootc:stream9
24+
transport: registry
25+
architecture: arm64
26+
version: stream9.20240807.0
27+
timestamp: null
28+
imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38
29+
incompatible: false
30+
pinned: false
31+
downloadOnly: false
32+
ostree:
33+
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
34+
deploySerial: 0
35+
stateroot: default
36+
rollback: null
37+
rollbackQueued: false
38+
type: bootcHost
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
apiVersion: org.containers.bootc/v1alpha1
2+
kind: BootcHost
3+
metadata:
4+
name: host
5+
spec:
6+
image:
7+
image: quay.io/centos-bootc/centos-bootc:stream9
8+
transport: registry
9+
bootOrder: default
10+
status:
11+
staged: null
12+
booted:
13+
image:
14+
image:
15+
image: quay.io/centos-bootc/centos-bootc:stream9
16+
transport: registry
17+
architecture: arm64
18+
version: stream9.20240807.0
19+
timestamp: null
20+
imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38
21+
cachedUpdate:
22+
image:
23+
image: quay.io/centos-bootc/centos-bootc:stream9
24+
transport: registry
25+
architecture: arm64
26+
version: null
27+
timestamp: null
28+
imageDigest: sha256:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1
29+
incompatible: false
30+
pinned: false
31+
downloadOnly: false
32+
ostree:
33+
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
34+
deploySerial: 0
35+
stateroot: default
36+
rollback: null
37+
rollbackQueued: false
38+
type: bootcHost
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
apiVersion: org.containers.bootc/v1alpha1
2+
kind: BootcHost
3+
metadata:
4+
name: host
5+
spec:
6+
image:
7+
image: quay.io/centos-bootc/centos-bootc:stream9
8+
transport: registry
9+
bootOrder: default
10+
status:
11+
staged: null
12+
booted:
13+
image:
14+
image:
15+
image: quay.io/centos-bootc/centos-bootc:stream9
16+
transport: registry
17+
architecture: arm64
18+
version: stream9.20240807.0
19+
timestamp: "2024-08-07T12:00:00Z"
20+
imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38
21+
cachedUpdate:
22+
image:
23+
image: quay.io/centos-bootc/centos-bootc:stream9
24+
transport: registry
25+
architecture: arm64
26+
version: stream9.20240901.0
27+
timestamp: "2024-09-01T12:00:00Z"
28+
imageDigest: sha256:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0
29+
incompatible: false
30+
pinned: false
31+
downloadOnly: false
32+
ostree:
33+
checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48
34+
deploySerial: 0
35+
stateroot: default
36+
rollback: null
37+
rollbackQueued: false
38+
type: bootcHost

crates/lib/src/status.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,39 @@ fn write_download_only(
606606
Ok(())
607607
}
608608

609+
/// Render cached update information, showing what update is available.
610+
///
611+
/// This is populated by a previous `bootc upgrade --check` that found
612+
/// a newer image in the registry. We only display it when the cached
613+
/// digest differs from the currently deployed image.
614+
fn render_cached_update(
615+
mut out: impl Write,
616+
cached: &crate::spec::ImageStatus,
617+
current: &crate::spec::ImageStatus,
618+
prefix_len: usize,
619+
) -> Result<()> {
620+
if cached.image_digest == current.image_digest {
621+
return Ok(());
622+
}
623+
624+
if let Some(version) = cached.version.as_deref() {
625+
write_row_name(&mut out, "UpdateVersion", prefix_len)?;
626+
let timestamp = cached.timestamp.as_ref().map(format_timestamp);
627+
if let Some(timestamp) = timestamp {
628+
writeln!(out, "{version} ({timestamp})")?;
629+
} else {
630+
writeln!(out, "{version}")?;
631+
}
632+
} else {
633+
write_row_name(&mut out, "Update", prefix_len)?;
634+
writeln!(out, "Available")?;
635+
}
636+
write_row_name(&mut out, "UpdateDigest", prefix_len)?;
637+
writeln!(out, "{}", cached.image_digest)?;
638+
639+
Ok(())
640+
}
641+
609642
/// Write the data for a container image based status.
610643
fn human_render_slot(
611644
mut out: impl Write,
@@ -664,6 +697,11 @@ fn human_render_slot(
664697
writeln!(out, "yes")?;
665698
}
666699

700+
// Show cached update information when available (from a previous `bootc upgrade --check`)
701+
if let Some(cached) = &entry.cached_update {
702+
render_cached_update(&mut out, cached, image, prefix_len)?;
703+
}
704+
667705
// Show /usr overlay status
668706
write_usr_overlay(&mut out, slot, host_status, prefix_len)?;
669707

@@ -1249,4 +1287,56 @@ mod tests {
12491287
"};
12501288
similar_asserts::assert_eq!(w, expected);
12511289
}
1290+
1291+
#[test]
1292+
fn test_human_readable_booted_with_cached_update() {
1293+
// When a cached update is present (from a previous `bootc upgrade --check`),
1294+
// the human-readable output should show the available update info.
1295+
let w =
1296+
human_status_from_spec_fixture(include_str!("fixtures/spec-booted-with-update.yaml"))
1297+
.expect("No spec found");
1298+
let expected = indoc::indoc! { r"
1299+
● Booted image: quay.io/centos-bootc/centos-bootc:stream9
1300+
Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64)
1301+
Version: stream9.20240807.0 (2024-08-07T12:00:00Z)
1302+
UpdateVersion: stream9.20240901.0 (2024-09-01T12:00:00Z)
1303+
UpdateDigest: sha256:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0
1304+
"};
1305+
similar_asserts::assert_eq!(w, expected);
1306+
}
1307+
1308+
#[test]
1309+
fn test_human_readable_cached_update_same_digest_hidden() {
1310+
// When the cached update has the same digest as the current image,
1311+
// no update line should be shown.
1312+
let w = human_status_from_spec_fixture(include_str!(
1313+
"fixtures/spec-booted-update-same-digest.yaml"
1314+
))
1315+
.expect("No spec found");
1316+
assert!(
1317+
!w.contains("UpdateVersion:"),
1318+
"Should not show update version when digest matches current"
1319+
);
1320+
assert!(
1321+
!w.contains("UpdateDigest:"),
1322+
"Should not show update digest when digest matches current"
1323+
);
1324+
}
1325+
1326+
#[test]
1327+
fn test_human_readable_cached_update_no_version() {
1328+
// When the cached update has no version label, show "Available" as fallback.
1329+
let w = human_status_from_spec_fixture(include_str!(
1330+
"fixtures/spec-booted-with-update-no-version.yaml"
1331+
))
1332+
.expect("No spec found");
1333+
let expected = indoc::indoc! { r"
1334+
● Booted image: quay.io/centos-bootc/centos-bootc:stream9
1335+
Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64)
1336+
Version: stream9.20240807.0
1337+
Update: Available
1338+
UpdateDigest: sha256:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1
1339+
"};
1340+
similar_asserts::assert_eq!(w, expected);
1341+
}
12521342
}

tmt/plans/integration.fmf

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,14 @@ execute:
203203
test:
204204
- /tmt/tests/tests/test-37-install-no-boot-dir
205205

206+
/plan-37-upgrade-check-status:
207+
summary: Verify upgrade --check populates cached update in status
208+
discover:
209+
how: fmf
210+
test:
211+
- /tmt/tests/tests/test-37-upgrade-check-status
212+
extra-fixme_skip_if_composefs: true
213+
206214
/plan-38-install-bootloader-none:
207215
summary: Test bootc install with --bootloader=none
208216
discover:
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# number: 37
2+
# tmt:
3+
# summary: Verify upgrade --check populates cached update in status
4+
# duration: 30m
5+
# extra:
6+
# fixme_skip_if_composefs: true
7+
#
8+
# TODO: This test uses containers-storage transport which is not yet
9+
# supported on the composefs backend. Remove the skip once composefs
10+
# supports copy-to-storage / switch --transport containers-storage.
11+
#
12+
# This test verifies that `bootc upgrade --check` caches registry
13+
# metadata and that `bootc status` renders the cached update.
14+
# Flow:
15+
# 1. Build derived image v1, switch to it, reboot
16+
# 2. Build v2, run `bootc upgrade --check`, verify status shows v2 as cached update
17+
# 3. Build v3, run `bootc upgrade --check` again, verify status now shows v3
18+
use std assert
19+
use tap.nu
20+
21+
# This code runs on *each* boot.
22+
bootc status
23+
let st = bootc status --json | from json
24+
let booted = $st.status.booted.image
25+
26+
def imgsrc [] {
27+
"localhost/bootc-test-check"
28+
}
29+
30+
# Run on the first boot - build v1 and switch to it
31+
def initial_build [] {
32+
tap begin "upgrade --check cached update in status"
33+
34+
bootc image copy-to-storage
35+
36+
# A simple derived container that adds a file
37+
"FROM localhost/bootc
38+
RUN echo v1 > /usr/share/test-upgrade-check
39+
" | save Dockerfile
40+
podman build -t (imgsrc) .
41+
42+
# Switch into the derived image
43+
bootc switch --transport containers-storage (imgsrc)
44+
tmt-reboot
45+
}
46+
47+
# Second boot: verify on v1, then test upgrade --check with v2 and v3
48+
def second_boot [] {
49+
print "verifying second boot - should be on v1"
50+
assert equal $booted.image.transport containers-storage
51+
assert equal $booted.image.image (imgsrc)
52+
53+
let v1_content = open /usr/share/test-upgrade-check | str trim
54+
assert equal $v1_content "v1"
55+
56+
let booted_digest = $booted.imageDigest
57+
print $"booted digest: ($booted_digest)"
58+
59+
# Initially there should be no cached update
60+
let initial_status = bootc status --json | from json
61+
assert ($initial_status.status.booted.cachedUpdate == null) "No cached update initially"
62+
63+
# Build v2 with same tag - this is a newer image
64+
"FROM localhost/bootc
65+
RUN echo v2 > /usr/share/test-upgrade-check
66+
" | save --force Dockerfile
67+
podman build -t (imgsrc) .
68+
69+
# Run upgrade --check (metadata only, no deployment)
70+
print "Running bootc upgrade --check for v2"
71+
bootc upgrade --check
72+
73+
# Verify status now shows cached update
74+
let status_after_v2 = bootc status --json | from json
75+
assert ($status_after_v2.status.booted.cachedUpdate != null) "cachedUpdate should be populated after upgrade --check"
76+
77+
let v2_cached = $status_after_v2.status.booted.cachedUpdate
78+
print $"v2 cached digest: ($v2_cached.imageDigest)"
79+
assert ($v2_cached.imageDigest != $booted_digest) "Cached update digest should differ from booted"
80+
81+
# Verify human-readable output contains update info
82+
let human_output = bootc status
83+
print $"Human output:\n($human_output)"
84+
assert ($human_output | str contains "UpdateVersion:") "Human-readable output should show UpdateVersion line"
85+
assert ($human_output | str contains "UpdateDigest:") "Human-readable output should show UpdateDigest line"
86+
87+
# Now build v3 - another update on the same tag
88+
"FROM localhost/bootc
89+
RUN echo v3 > /usr/share/test-upgrade-check
90+
" | save --force Dockerfile
91+
podman build -t (imgsrc) .
92+
93+
# Run upgrade --check again
94+
print "Running bootc upgrade --check for v3"
95+
bootc upgrade --check
96+
97+
# Verify status now shows v3 as the cached update (not v2)
98+
let status_after_v3 = bootc status --json | from json
99+
assert ($status_after_v3.status.booted.cachedUpdate != null) "cachedUpdate should still be populated"
100+
101+
let v3_cached = $status_after_v3.status.booted.cachedUpdate
102+
print $"v3 cached digest: ($v3_cached.imageDigest)"
103+
assert ($v3_cached.imageDigest != $booted_digest) "v3 cached digest should differ from booted"
104+
assert ($v3_cached.imageDigest != $v2_cached.imageDigest) "v3 cached digest should differ from v2"
105+
106+
# Verify human-readable output updated to v3
107+
let human_output_v3 = bootc status
108+
assert ($human_output_v3 | str contains "UpdateVersion:") "Human-readable output should still show UpdateVersion line after v3 check"
109+
assert ($human_output_v3 | str contains $v3_cached.imageDigest) "Human-readable output should show v3 digest"
110+
111+
tap ok
112+
}
113+
114+
def main [] {
115+
match $env.TMT_REBOOT_COUNT? {
116+
null | "0" => initial_build,
117+
"1" => second_boot,
118+
$o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } },
119+
}
120+
}

tmt/tests/tests.fmf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@
117117
duration: 30m
118118
test: nu booted/test-install-no-boot-dir.nu
119119

120+
/test-37-upgrade-check-status:
121+
summary: Verify upgrade --check populates cached update in status
122+
duration: 30m
123+
test: nu booted/test-upgrade-check-status.nu
124+
120125
/test-38-install-bootloader-none:
121126
summary: Test bootc install with --bootloader=none
122127
duration: 30m

0 commit comments

Comments
 (0)