Skip to content

Commit 8313658

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 7a79280 commit 8313658

7 files changed

Lines changed: 340 additions & 0 deletions

File tree

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_str = cached
627+
.timestamp
628+
.as_ref()
629+
.map(|t| format!(" ({})", format_timestamp(t)))
630+
.unwrap_or_default();
631+
writeln!(out, "{version}{timestamp_str}")?;
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
@@ -211,6 +211,14 @@ execute:
211211
test:
212212
- /tmt/tests/tests/test-37-install-no-boot-dir
213213

214+
/plan-37-upgrade-check-status:
215+
summary: Verify upgrade --check populates cached update in status
216+
discover:
217+
how: fmf
218+
test:
219+
- /tmt/tests/tests/test-37-upgrade-check-status
220+
extra-fixme_skip_if_composefs: true
221+
214222
/plan-38-install-bootloader-none:
215223
summary: Test bootc install with --bootloader=none
216224
discover:
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 with a version label
37+
"FROM localhost/bootc
38+
LABEL org.opencontainers.image.version=v1
39+
RUN echo v1 > /usr/share/test-upgrade-check
40+
" | save Dockerfile
41+
podman build -t (imgsrc) .
42+
43+
# Switch into the derived image
44+
bootc switch --transport containers-storage (imgsrc)
45+
tmt-reboot
46+
}
47+
48+
# Second boot: verify on v1, then test upgrade --check with v2 and v3
49+
def second_boot [] {
50+
print "verifying second boot - should be on v1"
51+
assert equal $booted.image.transport containers-storage
52+
assert equal $booted.image.image (imgsrc)
53+
54+
let v1_content = open /usr/share/test-upgrade-check | str trim
55+
assert equal $v1_content "v1"
56+
57+
let booted_digest = $booted.imageDigest
58+
print $"booted digest: ($booted_digest)"
59+
60+
# Initially there should be no cached update
61+
let initial_status = bootc status --json | from json
62+
assert ($initial_status.status.booted.cachedUpdate == null) "No cached update initially"
63+
64+
# Build v2 with same tag - this is a newer image
65+
"FROM localhost/bootc
66+
LABEL org.opencontainers.image.version=v2
67+
RUN echo v2 > /usr/share/test-upgrade-check
68+
" | save --force Dockerfile
69+
podman build -t (imgsrc) .
70+
71+
# Run upgrade --check (metadata only, no deployment)
72+
print "Running bootc upgrade --check for v2"
73+
bootc upgrade --check
74+
75+
# Verify status now shows cached update
76+
let status_after_v2 = bootc status --json | from json
77+
assert ($status_after_v2.status.booted.cachedUpdate != null) "cachedUpdate should be populated after upgrade --check"
78+
79+
let v2_cached = $status_after_v2.status.booted.cachedUpdate
80+
print $"v2 cached digest: ($v2_cached.imageDigest)"
81+
assert ($v2_cached.imageDigest != $booted_digest) "Cached update digest should differ from booted"
82+
83+
# Verify human-readable output contains update info
84+
let human_output = bootc status --format humanreadable
85+
print $"Human output:\n($human_output)"
86+
assert ($human_output | str contains "UpdateVersion:") "Human-readable output should show UpdateVersion line"
87+
assert ($human_output | str contains "UpdateDigest:") "Human-readable output should show UpdateDigest line"
88+
89+
# Now build v3 - another update on the same tag
90+
"FROM localhost/bootc
91+
LABEL org.opencontainers.image.version=v3
92+
RUN echo v3 > /usr/share/test-upgrade-check
93+
" | save --force Dockerfile
94+
podman build -t (imgsrc) .
95+
96+
# Run upgrade --check again
97+
print "Running bootc upgrade --check for v3"
98+
bootc upgrade --check
99+
100+
# Verify status now shows v3 as the cached update (not v2)
101+
let status_after_v3 = bootc status --json | from json
102+
assert ($status_after_v3.status.booted.cachedUpdate != null) "cachedUpdate should still be populated"
103+
104+
let v3_cached = $status_after_v3.status.booted.cachedUpdate
105+
print $"v3 cached digest: ($v3_cached.imageDigest)"
106+
assert ($v3_cached.imageDigest != $booted_digest) "v3 cached digest should differ from booted"
107+
assert ($v3_cached.imageDigest != $v2_cached.imageDigest) "v3 cached digest should differ from v2"
108+
109+
# Verify human-readable output updated to v3
110+
let human_output_v3 = bootc status --format humanreadable
111+
assert ($human_output_v3 | str contains "UpdateVersion:") "Human-readable output should show UpdateVersion line after v3 check"
112+
assert ($human_output_v3 | str contains $v3_cached.imageDigest) "Human-readable output should show v3 digest"
113+
114+
tap ok
115+
}
116+
117+
def main [] {
118+
match $env.TMT_REBOOT_COUNT? {
119+
null | "0" => initial_build,
120+
"1" => second_boot,
121+
$o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } },
122+
}
123+
}

tmt/tests/tests.fmf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@
122122
duration: 30m
123123
test: nu booted/test-install-no-boot-dir.nu
124124

125+
/test-37-upgrade-check-status:
126+
summary: Verify upgrade --check populates cached update in status
127+
duration: 30m
128+
test: nu booted/test-upgrade-check-status.nu
129+
125130
/test-38-install-bootloader-none:
126131
summary: Test bootc install with --bootloader=none
127132
duration: 30m

0 commit comments

Comments
 (0)