diff --git a/crates/lib/src/install.rs b/crates/lib/src/install.rs index 6ab5a3cc0..19a49f17d 100644 --- a/crates/lib/src/install.rs +++ b/crates/lib/src/install.rs @@ -1209,7 +1209,12 @@ async fn install_container( osconfig::inject_root_ssh_authorized_keys(&root, sepolicy, contents)?; } - let aleph = InstallAleph::new(&src_imageref, &imgstate, &state.selinux_state)?; + let aleph = InstallAleph::new( + &src_imageref, + &state.target_imgref, + &imgstate, + &state.selinux_state, + )?; Ok((deployment, aleph)) } diff --git a/crates/lib/src/install/aleph.rs b/crates/lib/src/install/aleph.rs index 5983513da..d126905db 100644 --- a/crates/lib/src/install/aleph.rs +++ b/crates/lib/src/install/aleph.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use anyhow::{Context as _, Result}; use canon_json::CanonJsonSerialize as _; use cap_std_ext::{cap_std::fs::Dir, dirext::CapStdExtDirExt as _}; @@ -15,9 +17,19 @@ pub(crate) const BOOTC_ALEPH_PATH: &str = ".bootc-aleph.json"; /// be used to trace things like the specific version of `mkfs.ext4` or /// kernel version that was used. #[derive(Debug, Serialize)] +#[serde(rename_all = "kebab-case")] pub(crate) struct InstallAleph { /// Digested pull spec for installed image - pub(crate) image: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) image: Option, + /// The manifest digest of the installed image + pub(crate) digest: String, + /// The target image reference, used for subsequent updates + #[serde(rename = "target-image")] + pub(crate) target_image: String, + /// The OCI image labels from the installed image + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub(crate) labels: BTreeMap, /// The version number pub(crate) version: Option, /// The timestamp @@ -32,19 +44,34 @@ impl InstallAleph { #[context("Creating aleph data")] pub(crate) fn new( src_imageref: &ostree_container::OstreeImageReference, + target_imgref: &ostree_container::OstreeImageReference, imgstate: &ostree_container::store::LayeredImageState, selinux_state: &SELinuxFinalState, ) -> Result { let uname = rustix::system::uname(); - let labels = crate::status::labels_of_config(&imgstate.configuration); - let timestamp = labels + let oci_labels = crate::status::labels_of_config(&imgstate.configuration); + let timestamp = oci_labels .and_then(|l| { l.get(oci_spec::image::ANNOTATION_CREATED) .map(|s| s.as_str()) }) .and_then(bootc_utils::try_deserialize_timestamp); + let labels: BTreeMap = oci_labels + .map(|l| l.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + // When installing via osbuild, the source image is usually a + // temporary local container storage path (e.g. `/tmp/...`) which is not useful. + let image = if src_imageref.imgref.name.starts_with("/tmp") { + tracing::debug!("Not serializing the source imageref as it's a local temporary image."); + None + } else { + Some(src_imageref.imgref.name.clone()) + }; let r = InstallAleph { - image: src_imageref.imgref.name.clone(), + image, + target_image: target_imgref.imgref.name.clone(), + digest: imgstate.manifest_digest.to_string(), + labels, version: imgstate.version().as_ref().map(|s| s.to_string()), timestamp, kernel: uname.release().to_str()?.to_string(), diff --git a/docs/src/bootc-install.md b/docs/src/bootc-install.md index 87e709e7b..f5ccb1001 100644 --- a/docs/src/bootc-install.md +++ b/docs/src/bootc-install.md @@ -535,6 +535,8 @@ After installation, bootc writes a JSON file at the root of the physical filesystem (`.bootc-aleph.json`) containing installation provenance information: - The source image reference and digest +- The target image reference (if provided) +- The OCI image labels from the installed image - Installation timestamp - bootc version - Kernel version diff --git a/tmt/tests/booted/readonly/013-test-aleph.nu b/tmt/tests/booted/readonly/013-test-aleph.nu new file mode 100644 index 000000000..f7b0a76cd --- /dev/null +++ b/tmt/tests/booted/readonly/013-test-aleph.nu @@ -0,0 +1,49 @@ +use std assert +use tap.nu + +tap begin "verify bootc aleph file contents" + +let aleph_path = "/sysroot/.bootc-aleph.json" +let aleph = open $aleph_path + +# Verify required fields exist and are non-empty +assert ($aleph.kernel | is-not-empty) "kernel field should be non-empty" +assert ($aleph.selinux | is-not-empty) "selinux field should be non-empty" + +# Cross-check aleph fields against the booted image from bootc status +let st = bootc status --json | from json +let booted = $st.status.booted + +# Verify the digest field matches the booted image digest +assert ($aleph.digest | is-not-empty) "digest field should be non-empty" +let booted_digest = $booted.image.imageDigest +assert equal $aleph.digest $booted_digest "digest should match the booted image digest" + +# Verify the target-image field matches the booted image reference +let target_image = $aleph | get "target-image" +assert ($target_image | is-not-empty) "target-image field should be non-empty" +let booted_imgref = $booted.image.image.image +assert equal $target_image $booted_imgref "target-image should match the booted image reference" + +# The image field is optional (skipped when source is a /tmp path), +# but if present it should be non-empty. +let image = $aleph.image? | default null +if $image != null { + assert ($image | is-not-empty) "image field, if present, should be non-empty" + let booted_imgref = $booted.image.image.image + assert equal $image $booted_imgref "target-image should match the booted image reference" + +} + +# The labels field may be absent if empty (skip_serializing_if), but if +# present it should be a record and contain the bootc marker label. +let labels = $aleph.labels? | default null +if $labels != null { + # Verify labels is a record (table-like key-value structure) + assert (($labels | describe) =~ "record") "labels should be a record" + # A bootc image should always carry the containers.bootc label + let bootc_label = $labels | get "containers.bootc" + assert ($bootc_label | is-not-empty) "containers.bootc label should be present" +} + +tap ok