diff --git a/crates/lib/src/install/aleph.rs b/crates/lib/src/install/aleph.rs index d126905db..25fc913af 100644 --- a/crates/lib/src/install/aleph.rs +++ b/crates/lib/src/install/aleph.rs @@ -8,9 +8,15 @@ use ostree_ext::{container as ostree_container, oci_spec}; use serde::Serialize; use super::SELinuxFinalState; +use crate::utils::symlink_idempotent; /// Path to initially deployed version information pub(crate) const BOOTC_ALEPH_PATH: &str = ".bootc-aleph.json"; +/// Compatibility symlink target. +/// Legacy from coreOS and osbuild where the +/// aleph data is accessible at this expected path. +/// See +const ALEPH_SYMLINK: &str = ".aleph-version.json"; /// The "aleph" version information is injected into /root/.bootc-aleph.json /// and contains the image ID that was initially used to install. This can @@ -80,11 +86,16 @@ impl InstallAleph { Ok(r) } - /// Serialize to a file in the target root. + /// Serialize to a file in the target root, and create a compatibility symlink. pub(crate) fn write_to(&self, root: &Dir) -> Result<()> { root.atomic_replace_with(BOOTC_ALEPH_PATH, |f| { anyhow::Ok(self.to_canon_json_writer(f)?) }) - .context("Writing aleph version") + .context("Writing aleph version")?; + // Create a symlink so the aleph data is also accessible at the + // legacy path. + symlink_idempotent(root, BOOTC_ALEPH_PATH, ALEPH_SYMLINK) + .context("Creating aleph symlink")?; + Ok(()) } } diff --git a/crates/lib/src/utils.rs b/crates/lib/src/utils.rs index 876b2e9f0..1c1d1b690 100644 --- a/crates/lib/src/utils.rs +++ b/crates/lib/src/utils.rs @@ -80,6 +80,29 @@ pub fn have_executable(name: &str) -> Result { Ok(false) } +/// Idempotently ensure a symlink `link` -> `target` exists in `dir`. +/// +/// If the symlink already exists and points to the expected target, this is a +/// no-op. If it exists but points elsewhere, it is replaced. +pub(crate) fn symlink_idempotent(dir: &Dir, target: &str, link: &str) -> Result<()> { + match dir.symlink(target, link) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let existing = dir + .read_link(link) + .with_context(|| format!("Reading existing symlink {link}"))?; + if existing == std::path::Path::new(target) { + return Ok(()); + } + dir.remove_file(link) + .with_context(|| format!("Removing stale symlink {link}"))?; + dir.symlink(target, link) + .with_context(|| format!("Creating symlink {link} -> {target}")) + } + Err(e) => Err(e).with_context(|| format!("Creating symlink {link} -> {target}")), + } +} + /// Given a target directory, if it's a read-only mount, then remount it writable #[context("Opening {target} with writable mount")] pub(crate) fn open_dir_remount_rw(root: &Dir, target: &Utf8Path) -> Result { @@ -336,4 +359,23 @@ mod tests { assert!(have_executable("true").unwrap()); assert!(!have_executable("someexethatdoesnotexist").unwrap()); } + + #[test] + fn test_symlink_idempotent() { + let td = cap_std_ext::cap_tempfile::TempDir::new( + cap_std_ext::cap_std::ambient_authority(), + ) + .unwrap(); + // First call creates the symlink + symlink_idempotent(&td, "target-a", "link").unwrap(); + assert_eq!(td.read_link("link").unwrap(), Path::new("target-a")); + + // Second call with the same target is a no-op + symlink_idempotent(&td, "target-a", "link").unwrap(); + assert_eq!(td.read_link("link").unwrap(), Path::new("target-a")); + + // Call with a different target replaces the symlink + symlink_idempotent(&td, "target-b", "link").unwrap(); + assert_eq!(td.read_link("link").unwrap(), Path::new("target-b")); + } } diff --git a/docs/src/bootc-install.md b/docs/src/bootc-install.md index 55816ec68..3345e4375 100644 --- a/docs/src/bootc-install.md +++ b/docs/src/bootc-install.md @@ -544,3 +544,5 @@ filesystem (`.bootc-aleph.json`) containing installation provenance information: This file is useful for auditing and understanding how a system was provisioned. From the booted system, this file is accessible at `/sysroot/.bootc-aleph.json`. +A compatibility symlink `.aleph-version.json` is also created, pointing to +`.bootc-aleph.json`. diff --git a/tmt/tests/booted/readonly/013-test-aleph.nu b/tmt/tests/booted/readonly/013-test-aleph.nu index ea8d7d706..786d7bdc0 100644 --- a/tmt/tests/booted/readonly/013-test-aleph.nu +++ b/tmt/tests/booted/readonly/013-test-aleph.nu @@ -28,8 +28,14 @@ if $is_composefs { } else { let aleph_path = "/sysroot/.bootc-aleph.json" + let symlink_path = "/sysroot/.aleph-version.json" let aleph = open $aleph_path + # Verify that the compatibility symlink exists and points to the aleph file + assert (($symlink_path | path exists)) "aleph symlink should exist" + let aleph_via_symlink = open $symlink_path + assert equal $aleph $aleph_via_symlink "symlink content should match aleph file" + # 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"