From 168dcceb93320732576321ecb8e6c497592710f1 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Mon, 2 Mar 2026 21:46:44 +0000 Subject: [PATCH 1/3] docs: Regenerate man pages and tmt plans Run update-generated to sync man pages and tmt plans with current CLI options and test summaries. Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters --- docs/src/man/bootc-container-ukify.8.md | 4 ++++ docs/src/man/bootc-install-to-disk.8.md | 2 +- docs/src/man/bootc-install-to-existing-root.8.md | 2 +- docs/src/man/bootc-install-to-filesystem.8.md | 2 +- tmt/plans/integration.fmf | 9 ++++++++- tmt/tests/tests.fmf | 4 ++-- 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/src/man/bootc-container-ukify.8.md b/docs/src/man/bootc-container-ukify.8.md index 1e2f27488..83b24a9c0 100644 --- a/docs/src/man/bootc-container-ukify.8.md +++ b/docs/src/man/bootc-container-ukify.8.md @@ -27,6 +27,10 @@ Any additional arguments after `--` are passed through to ukify unchanged. Default: / +**--allow-missing-verity** + + Make fs-verity validation optional in case the filesystem doesn't support it + # EXAMPLES diff --git a/docs/src/man/bootc-install-to-disk.8.md b/docs/src/man/bootc-install-to-disk.8.md index cae2803b3..3ee99e0d8 100644 --- a/docs/src/man/bootc-install-to-disk.8.md +++ b/docs/src/man/bootc-install-to-disk.8.md @@ -165,7 +165,7 @@ its DPS type GUID, without requiring an explicit `root=` kernel argument. Default: false -**--insecure** +**--allow-missing-verity** Make fs-verity validation optional in case the filesystem doesn't support it diff --git a/docs/src/man/bootc-install-to-existing-root.8.md b/docs/src/man/bootc-install-to-existing-root.8.md index 52152d9fe..edaf87d76 100644 --- a/docs/src/man/bootc-install-to-existing-root.8.md +++ b/docs/src/man/bootc-install-to-existing-root.8.md @@ -225,7 +225,7 @@ of migrating the fstab entries. See the "Injecting kernel arguments" section abo Default: false -**--insecure** +**--allow-missing-verity** Make fs-verity validation optional in case the filesystem doesn't support it diff --git a/docs/src/man/bootc-install-to-filesystem.8.md b/docs/src/man/bootc-install-to-filesystem.8.md index d3e557c7c..56c76c6e5 100644 --- a/docs/src/man/bootc-install-to-filesystem.8.md +++ b/docs/src/man/bootc-install-to-filesystem.8.md @@ -125,7 +125,7 @@ is currently expected to be empty by default. Default: false -**--insecure** +**--allow-missing-verity** Make fs-verity validation optional in case the filesystem doesn't support it diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index ae6374690..4a9772778 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -182,8 +182,15 @@ execute: test: - /tmt/tests/tests/test-34-user-agent +/plan-35-upgrade-preflight-disk-check: + summary: Verify pre-flight disk space check rejects images with inflated layer sizes + discover: + how: fmf + test: + - /tmt/tests/tests/test-35-upgrade-preflight-disk-check + /plan-36-rollback: - summary: Test bootc rollback functionality through image switch and rollback cycle + summary: Test bootc rollback functionality discover: how: fmf test: diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 851d6b293..ee5eefbf6 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -104,11 +104,11 @@ /test-35-upgrade-preflight-disk-check: summary: Verify pre-flight disk space check rejects images with inflated layer sizes - duration: 20m + duration: 10m test: nu booted/test-upgrade-preflight-disk-check.nu /test-36-rollback: - summary: Test bootc rollback functionality through image switch and rollback cycle + summary: Test bootc rollback functionality duration: 30m test: nu booted/test-rollback.nu From 3c41d3d734d41a70f5f11a9ed2778fb2b49b1181 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 4 Mar 2026 23:39:42 +0000 Subject: [PATCH 2/3] status: Extract format_timestamp helper Deduplicate the timestamp formatting code used in human-readable status output. The format string was inlined with a multi-line comment explaining it; extract it into a named function. Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters --- crates/lib/src/status.rs | 52 ++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 03b9ddf72..470619065 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -540,6 +540,14 @@ fn write_row_name(mut out: impl Write, s: &str, prefix_len: usize) -> Result<()> Ok(()) } +/// Format a timestamp for human display, without nanoseconds. +/// +/// Nanoseconds are irrelevant noise for container build timestamps; +/// this produces the same format as RFC3339 but truncated to seconds. +fn format_timestamp(t: &chrono::DateTime) -> impl std::fmt::Display { + t.format("%Y-%m-%dT%H:%M:%SZ") +} + /// Helper function to render verbose ostree information fn render_verbose_ostree_info( mut out: impl Write, @@ -636,13 +644,7 @@ fn human_render_slot( writeln!(out, "{}", composefs.verity)?; } - // Format the timestamp without nanoseconds since those are just irrelevant noise for human - // consumption - that time scale should basically never matter for container builds. - let timestamp = image - .timestamp - .as_ref() - // This format is the same as RFC3339, just without nanos. - .map(|t| t.to_utc().format("%Y-%m-%dT%H:%M:%SZ")); + let timestamp = image.timestamp.as_ref().map(format_timestamp); // If we have a version, combine with timestamp if let Some(version) = image.version.as_deref() { write_row_name(&mut out, "Version", prefix_len)?; @@ -939,6 +941,42 @@ pub(crate) fn container_inspect( mod tests { use super::*; + #[test] + fn test_format_timestamp() { + use chrono::TimeZone; + let cases = [ + // Standard case + ( + chrono::Utc.with_ymd_and_hms(2024, 8, 7, 12, 0, 0).unwrap(), + "2024-08-07T12:00:00Z", + ), + // Midnight + ( + chrono::Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(), + "2023-01-01T00:00:00Z", + ), + // End of day + ( + chrono::Utc + .with_ymd_and_hms(2025, 12, 31, 23, 59, 59) + .unwrap(), + "2025-12-31T23:59:59Z", + ), + // Subsecond precision should be dropped + ( + chrono::Utc + .with_ymd_and_hms(2024, 6, 15, 10, 30, 45) + .unwrap() + + chrono::Duration::nanoseconds(123_456_789), + "2024-06-15T10:30:45Z", + ), + ]; + for (input, expected) in cases { + let result = format_timestamp(&input).to_string(); + assert_eq!(result, expected, "Failed for input {input:?}"); + } + } + fn human_status_from_spec_fixture(spec_fixture: &str) -> Result { let host: Host = serde_yaml::from_str(spec_fixture).unwrap(); let mut w = Vec::new(); From c04be5bf6e3c77fabadf5dbcf15556d06d1e9e87 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 4 Mar 2026 23:40:00 +0000 Subject: [PATCH 3/3] 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 --- .../spec-booted-update-same-digest.yaml | 38 ++++++ .../spec-booted-with-update-no-version.yaml | 38 ++++++ .../src/fixtures/spec-booted-with-update.yaml | 38 ++++++ crates/lib/src/status.rs | 90 +++++++++++++ tmt/plans/integration.fmf | 8 ++ tmt/tests/booted/test-upgrade-check-status.nu | 120 ++++++++++++++++++ tmt/tests/tests.fmf | 5 + 7 files changed, 337 insertions(+) create mode 100644 crates/lib/src/fixtures/spec-booted-update-same-digest.yaml create mode 100644 crates/lib/src/fixtures/spec-booted-with-update-no-version.yaml create mode 100644 crates/lib/src/fixtures/spec-booted-with-update.yaml create mode 100644 tmt/tests/booted/test-upgrade-check-status.nu diff --git a/crates/lib/src/fixtures/spec-booted-update-same-digest.yaml b/crates/lib/src/fixtures/spec-booted-update-same-digest.yaml new file mode 100644 index 000000000..3b3b5eaef --- /dev/null +++ b/crates/lib/src/fixtures/spec-booted-update-same-digest.yaml @@ -0,0 +1,38 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + bootOrder: default +status: + staged: null + booted: + image: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + cachedUpdate: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + incompatible: false + pinned: false + downloadOnly: false + ostree: + checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: bootcHost diff --git a/crates/lib/src/fixtures/spec-booted-with-update-no-version.yaml b/crates/lib/src/fixtures/spec-booted-with-update-no-version.yaml new file mode 100644 index 000000000..b76624be5 --- /dev/null +++ b/crates/lib/src/fixtures/spec-booted-with-update-no-version.yaml @@ -0,0 +1,38 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + bootOrder: default +status: + staged: null + booted: + image: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: stream9.20240807.0 + timestamp: null + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + cachedUpdate: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: null + timestamp: null + imageDigest: sha256:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1 + incompatible: false + pinned: false + downloadOnly: false + ostree: + checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: bootcHost diff --git a/crates/lib/src/fixtures/spec-booted-with-update.yaml b/crates/lib/src/fixtures/spec-booted-with-update.yaml new file mode 100644 index 000000000..66d056255 --- /dev/null +++ b/crates/lib/src/fixtures/spec-booted-with-update.yaml @@ -0,0 +1,38 @@ +apiVersion: org.containers.bootc/v1alpha1 +kind: BootcHost +metadata: + name: host +spec: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + bootOrder: default +status: + staged: null + booted: + image: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: stream9.20240807.0 + timestamp: "2024-08-07T12:00:00Z" + imageDigest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 + cachedUpdate: + image: + image: quay.io/centos-bootc/centos-bootc:stream9 + transport: registry + architecture: arm64 + version: stream9.20240901.0 + timestamp: "2024-09-01T12:00:00Z" + imageDigest: sha256:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 + incompatible: false + pinned: false + downloadOnly: false + ostree: + checksum: 439f6bd2e2361bee292c1f31840d798c5ac5ba76483b8021dc9f7b0164ac0f48 + deploySerial: 0 + stateroot: default + rollback: null + rollbackQueued: false + type: bootcHost diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 470619065..83062520b 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -606,6 +606,39 @@ fn write_download_only( Ok(()) } +/// Render cached update information, showing what update is available. +/// +/// This is populated by a previous `bootc upgrade --check` that found +/// a newer image in the registry. We only display it when the cached +/// digest differs from the currently deployed image. +fn render_cached_update( + mut out: impl Write, + cached: &crate::spec::ImageStatus, + current: &crate::spec::ImageStatus, + prefix_len: usize, +) -> Result<()> { + if cached.image_digest == current.image_digest { + return Ok(()); + } + + if let Some(version) = cached.version.as_deref() { + write_row_name(&mut out, "UpdateVersion", prefix_len)?; + let timestamp = cached.timestamp.as_ref().map(format_timestamp); + if let Some(timestamp) = timestamp { + writeln!(out, "{version} ({timestamp})")?; + } else { + writeln!(out, "{version}")?; + } + } else { + write_row_name(&mut out, "Update", prefix_len)?; + writeln!(out, "Available")?; + } + write_row_name(&mut out, "UpdateDigest", prefix_len)?; + writeln!(out, "{}", cached.image_digest)?; + + Ok(()) +} + /// Write the data for a container image based status. fn human_render_slot( mut out: impl Write, @@ -664,6 +697,11 @@ fn human_render_slot( writeln!(out, "yes")?; } + // Show cached update information when available (from a previous `bootc upgrade --check`) + if let Some(cached) = &entry.cached_update { + render_cached_update(&mut out, cached, image, prefix_len)?; + } + // Show /usr overlay status write_usr_overlay(&mut out, slot, host_status, prefix_len)?; @@ -1249,4 +1287,56 @@ mod tests { "}; similar_asserts::assert_eq!(w, expected); } + + #[test] + fn test_human_readable_booted_with_cached_update() { + // When a cached update is present (from a previous `bootc upgrade --check`), + // the human-readable output should show the available update info. + let w = + human_status_from_spec_fixture(include_str!("fixtures/spec-booted-with-update.yaml")) + .expect("No spec found"); + let expected = indoc::indoc! { r" + ● Booted image: quay.io/centos-bootc/centos-bootc:stream9 + Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64) + Version: stream9.20240807.0 (2024-08-07T12:00:00Z) + UpdateVersion: stream9.20240901.0 (2024-09-01T12:00:00Z) + UpdateDigest: sha256:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0 + "}; + similar_asserts::assert_eq!(w, expected); + } + + #[test] + fn test_human_readable_cached_update_same_digest_hidden() { + // When the cached update has the same digest as the current image, + // no update line should be shown. + let w = human_status_from_spec_fixture(include_str!( + "fixtures/spec-booted-update-same-digest.yaml" + )) + .expect("No spec found"); + assert!( + !w.contains("UpdateVersion:"), + "Should not show update version when digest matches current" + ); + assert!( + !w.contains("UpdateDigest:"), + "Should not show update digest when digest matches current" + ); + } + + #[test] + fn test_human_readable_cached_update_no_version() { + // When the cached update has no version label, show "Available" as fallback. + let w = human_status_from_spec_fixture(include_str!( + "fixtures/spec-booted-with-update-no-version.yaml" + )) + .expect("No spec found"); + let expected = indoc::indoc! { r" + ● Booted image: quay.io/centos-bootc/centos-bootc:stream9 + Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64) + Version: stream9.20240807.0 + Update: Available + UpdateDigest: sha256:b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1 + "}; + similar_asserts::assert_eq!(w, expected); + } } diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 4a9772778..5403a7466 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -203,6 +203,14 @@ execute: test: - /tmt/tests/tests/test-37-install-no-boot-dir +/plan-37-upgrade-check-status: + summary: Verify upgrade --check populates cached update in status + discover: + how: fmf + test: + - /tmt/tests/tests/test-37-upgrade-check-status + extra-fixme_skip_if_composefs: true + /plan-38-install-bootloader-none: summary: Test bootc install with --bootloader=none discover: diff --git a/tmt/tests/booted/test-upgrade-check-status.nu b/tmt/tests/booted/test-upgrade-check-status.nu new file mode 100644 index 000000000..e78660f1a --- /dev/null +++ b/tmt/tests/booted/test-upgrade-check-status.nu @@ -0,0 +1,120 @@ +# number: 37 +# tmt: +# summary: Verify upgrade --check populates cached update in status +# duration: 30m +# extra: +# fixme_skip_if_composefs: true +# +# TODO: This test uses containers-storage transport which is not yet +# supported on the composefs backend. Remove the skip once composefs +# supports copy-to-storage / switch --transport containers-storage. +# +# This test verifies that `bootc upgrade --check` caches registry +# metadata and that `bootc status` renders the cached update. +# Flow: +# 1. Build derived image v1, switch to it, reboot +# 2. Build v2, run `bootc upgrade --check`, verify status shows v2 as cached update +# 3. Build v3, run `bootc upgrade --check` again, verify status now shows v3 +use std assert +use tap.nu + +# This code runs on *each* boot. +bootc status +let st = bootc status --json | from json +let booted = $st.status.booted.image + +def imgsrc [] { + "localhost/bootc-test-check" +} + +# Run on the first boot - build v1 and switch to it +def initial_build [] { + tap begin "upgrade --check cached update in status" + + bootc image copy-to-storage + + # A simple derived container that adds a file + "FROM localhost/bootc +RUN echo v1 > /usr/share/test-upgrade-check +" | save Dockerfile + podman build -t (imgsrc) . + + # Switch into the derived image + bootc switch --transport containers-storage (imgsrc) + tmt-reboot +} + +# Second boot: verify on v1, then test upgrade --check with v2 and v3 +def second_boot [] { + print "verifying second boot - should be on v1" + assert equal $booted.image.transport containers-storage + assert equal $booted.image.image (imgsrc) + + let v1_content = open /usr/share/test-upgrade-check | str trim + assert equal $v1_content "v1" + + let booted_digest = $booted.imageDigest + print $"booted digest: ($booted_digest)" + + # Initially there should be no cached update + let initial_status = bootc status --json | from json + assert ($initial_status.status.booted.cachedUpdate == null) "No cached update initially" + + # Build v2 with same tag - this is a newer image + "FROM localhost/bootc +RUN echo v2 > /usr/share/test-upgrade-check +" | save --force Dockerfile + podman build -t (imgsrc) . + + # Run upgrade --check (metadata only, no deployment) + print "Running bootc upgrade --check for v2" + bootc upgrade --check + + # Verify status now shows cached update + let status_after_v2 = bootc status --json | from json + assert ($status_after_v2.status.booted.cachedUpdate != null) "cachedUpdate should be populated after upgrade --check" + + let v2_cached = $status_after_v2.status.booted.cachedUpdate + print $"v2 cached digest: ($v2_cached.imageDigest)" + assert ($v2_cached.imageDigest != $booted_digest) "Cached update digest should differ from booted" + + # Verify human-readable output contains update info + let human_output = bootc status + print $"Human output:\n($human_output)" + assert ($human_output | str contains "UpdateVersion:") "Human-readable output should show UpdateVersion line" + assert ($human_output | str contains "UpdateDigest:") "Human-readable output should show UpdateDigest line" + + # Now build v3 - another update on the same tag + "FROM localhost/bootc +RUN echo v3 > /usr/share/test-upgrade-check +" | save --force Dockerfile + podman build -t (imgsrc) . + + # Run upgrade --check again + print "Running bootc upgrade --check for v3" + bootc upgrade --check + + # Verify status now shows v3 as the cached update (not v2) + let status_after_v3 = bootc status --json | from json + assert ($status_after_v3.status.booted.cachedUpdate != null) "cachedUpdate should still be populated" + + let v3_cached = $status_after_v3.status.booted.cachedUpdate + print $"v3 cached digest: ($v3_cached.imageDigest)" + assert ($v3_cached.imageDigest != $booted_digest) "v3 cached digest should differ from booted" + assert ($v3_cached.imageDigest != $v2_cached.imageDigest) "v3 cached digest should differ from v2" + + # Verify human-readable output updated to v3 + let human_output_v3 = bootc status + assert ($human_output_v3 | str contains "UpdateVersion:") "Human-readable output should still show UpdateVersion line after v3 check" + assert ($human_output_v3 | str contains $v3_cached.imageDigest) "Human-readable output should show v3 digest" + + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => initial_build, + "1" => second_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index ee5eefbf6..55705db05 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -117,6 +117,11 @@ duration: 30m test: nu booted/test-install-no-boot-dir.nu +/test-37-upgrade-check-status: + summary: Verify upgrade --check populates cached update in status + duration: 30m + test: nu booted/test-upgrade-check-status.nu + /test-38-install-bootloader-none: summary: Test bootc install with --bootloader=none duration: 30m