From 608d68170229d400f8aa8b0a4469ec6aad8f8ada Mon Sep 17 00:00:00 2001 From: John Eckersberg Date: Mon, 16 Dec 2024 15:59:26 -0500 Subject: [PATCH 1/6] store: add BOOTC_ROOT Also refactor imgstore to base its subpath to be relative to BOOTC_ROOT. This is prep for store::Storage to update the mtime on BOOTC_ROOT. Signed-off-by: John Eckersberg --- lib/src/imgstorage.rs | 20 +++++++++++++------- lib/src/store/mod.rs | 4 ++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/src/imgstorage.rs b/lib/src/imgstorage.rs index 93ec0f144..0fe01f6f6 100644 --- a/lib/src/imgstorage.rs +++ b/lib/src/imgstorage.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use bootc_utils::{AsyncCommandRunExt, CommandRunExt, ExitStatusExt}; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::cap_std; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::cap_tempfile::TempDir; @@ -35,8 +35,8 @@ pub(crate) const STORAGE_ALIAS_DIR: &str = "/run/bootc/storage"; /// We pass this via /proc/self/fd to the child process. const STORAGE_RUN_FD: i32 = 3; -/// The path to the storage, relative to the physical system root. -pub(crate) const SUBPATH: &str = "ostree/bootc/storage"; +/// The path to the image storage, relative to the bootc root directory. +pub(crate) const SUBPATH: &str = "storage"; /// The path to the "runroot" with transient runtime state; this is /// relative to the /run directory const RUNROOT: &str = "bootc/storage"; @@ -139,14 +139,15 @@ impl Storage { #[context("Creating imgstorage")] pub(crate) fn create(sysroot: &Dir, run: &Dir) -> Result { Self::init_globals()?; - let subpath = Utf8Path::new(SUBPATH); + let subpath = &Self::subpath(); + // SAFETY: We know there's a parent let parent = subpath.parent().unwrap(); if !sysroot .try_exists(subpath) .with_context(|| format!("Querying {subpath}"))? { - let tmp = format!("{SUBPATH}.tmp"); + let tmp = format!("{subpath}.tmp"); sysroot.remove_all_optional(&tmp).context("Removing tmp")?; sysroot .create_dir_all(parent) @@ -174,9 +175,10 @@ impl Storage { pub(crate) fn open(sysroot: &Dir, run: &Dir) -> Result { tracing::trace!("Opening container image store"); Self::init_globals()?; + let subpath = &Self::subpath(); let storage_root = sysroot - .open_dir(SUBPATH) - .with_context(|| format!("Opening {SUBPATH}"))?; + .open_dir(subpath) + .with_context(|| format!("Opening {subpath}"))?; // Always auto-create this if missing run.create_dir_all(RUNROOT) .with_context(|| format!("Creating {RUNROOT}"))?; @@ -303,6 +305,10 @@ impl Storage { temp_runroot.close()?; Ok(()) } + + fn subpath() -> Utf8PathBuf { + Utf8Path::new(crate::store::BOOTC_ROOT).join(SUBPATH) + } } #[cfg(test)] diff --git a/lib/src/store/mod.rs b/lib/src/store/mod.rs index 92ab8b54e..a546e3f90 100644 --- a/lib/src/store/mod.rs +++ b/lib/src/store/mod.rs @@ -15,6 +15,10 @@ use crate::spec::ImageStatus; mod ostree_container; +/// The path to the bootc root directory, relative to the physical +/// system root +pub(crate) const BOOTC_ROOT: &str = "ostree/bootc"; + pub(crate) struct Storage { pub sysroot: SysrootLock, run: Dir, From 93079bbd86b38be486e4d250acad2639ecc7a127 Mon Sep 17 00:00:00 2001 From: John Eckersberg Date: Mon, 16 Dec 2024 16:06:09 -0500 Subject: [PATCH 2/6] Update mtime of /ostree/bootc when status is modified This updates the mtime after a successful invocation of upgrade/switch/edit/rollback Signed-off-by: John Eckersberg --- lib/src/cli.rs | 6 ++++++ lib/src/deploy.rs | 3 +++ lib/src/store/mod.rs | 16 +++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 0ce2660d4..68f15d011 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -766,6 +766,8 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { } } if changed { + sysroot.update_mtime()?; + if opts.apply { crate::reboot::reboot()?; } @@ -842,6 +844,8 @@ async fn switch(opts: SwitchOpts) -> Result<()> { let stateroot = booted_deployment.osname(); crate::deploy::stage(sysroot, &stateroot, &fetched, &new_spec, prog.clone()).await?; + sysroot.update_mtime()?; + if opts.apply { crate::reboot::reboot()?; } @@ -897,6 +901,8 @@ async fn edit(opts: EditOpts) -> Result<()> { let stateroot = booted_deployment.osname(); crate::deploy::stage(sysroot, &stateroot, &fetched, &new_spec, prog.clone()).await?; + sysroot.update_mtime()?; + Ok(()) } diff --git a/lib/src/deploy.rs b/lib/src/deploy.rs index 7196e2881..59e82e3be 100644 --- a/lib/src/deploy.rs +++ b/lib/src/deploy.rs @@ -747,6 +747,9 @@ pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> { } else { println!("Next boot: rollback deployment"); } + + sysroot.update_mtime()?; + Ok(()) } diff --git a/lib/src/store/mod.rs b/lib/src/store/mod.rs index a546e3f90..9ebe25dfe 100644 --- a/lib/src/store/mod.rs +++ b/lib/src/store/mod.rs @@ -2,9 +2,11 @@ use std::cell::OnceCell; use std::env; use std::ops::Deref; -use anyhow::Result; +use anyhow::{Context, Result}; use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; use clap::ValueEnum; +use fn_error_context::context; use ostree_ext::container::OstreeImageReference; use ostree_ext::keyfileext::KeyFileExt; @@ -86,6 +88,18 @@ impl Storage { let imgstore = crate::imgstorage::Storage::create(&sysroot_dir, &self.run)?; Ok(self.imgstore.get_or_init(|| imgstore)) } + + /// Update the mtime on the storage root directory + #[context("Updating storage root mtime")] + pub(crate) fn update_mtime(&self) -> Result<()> { + let sysroot_dir = + crate::utils::sysroot_dir(&self.sysroot).context("Reopen sysroot directory")?; + + sysroot_dir + .update_timestamps(std::path::Path::new(BOOTC_ROOT)) + .context("update_timestamps") + .map_err(Into::into) + } } impl ContainerImageStore for ostree::Deployment { From caecb64db7f76b8cf8ec9769eb5e38260cd6094e Mon Sep 17 00:00:00 2001 From: John Eckersberg Date: Mon, 16 Dec 2024 16:38:29 -0500 Subject: [PATCH 3/6] systemd: Add bootc-status-updated.{path,target} units This adds a new systemd path unit which activates on bootc status changing, and in turn triggers a new systemd target. This allows adding arbitrary new systemd services with `WantedBy = bootc-status-updated.target` that will be activated each time the bootc status is updated. Signed-off-by: John Eckersberg --- Makefile | 2 +- docs/src/SUMMARY.md | 2 ++ docs/src/man-md/bootc-status-updated.path.md | 19 +++++++++++++++ .../src/man-md/bootc-status-updated.target.md | 23 +++++++++++++++++++ systemd/bootc-status-updated.path | 10 ++++++++ systemd/bootc-status-updated.target | 4 ++++ 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 docs/src/man-md/bootc-status-updated.path.md create mode 100644 docs/src/man-md/bootc-status-updated.target.md create mode 100644 systemd/bootc-status-updated.path create mode 100644 systemd/bootc-status-updated.target diff --git a/Makefile b/Makefile index 1a96f63a8..6443534c4 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ install: install -D -m 0644 -t $(DESTDIR)$(prefix)/share/man/man8 $$d/*.8; \ fi; \ done - install -D -m 0644 -t $(DESTDIR)/$(prefix)/lib/systemd/system systemd/*.service systemd/*.timer + install -D -m 0644 -t $(DESTDIR)/$(prefix)/lib/systemd/system systemd/*.service systemd/*.timer systemd/*.path systemd/*.target # Run this to also take over the functionality of `ostree container` for example. # Only needed for OS/distros that have callers invoking `ostree container` and not bootc. diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index d8b330b15..659bf211e 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -28,6 +28,8 @@ - [`man bootc-rollback`](man/bootc-rollback.md) - [`man bootc-usr-overlay`](man/bootc-usr-overlay.md) - [`man bootc-fetch-apply-updates.service`](man-md/bootc-fetch-apply-updates.service.md) +- [`man bootc-status-updated.path`](man-md/bootc-status-updated.path.md) +- [`man bootc-status-updated.target`](man-md/bootc-status-updated.target.md) - [Controlling bootc via API](bootc-via-api.md) # Using `bootc install` diff --git a/docs/src/man-md/bootc-status-updated.path.md b/docs/src/man-md/bootc-status-updated.path.md new file mode 100644 index 000000000..d05831e00 --- /dev/null +++ b/docs/src/man-md/bootc-status-updated.path.md @@ -0,0 +1,19 @@ +% bootc-status-updated.path(8) + +# NAME + +bootc-status-updated.path + +# DESCRIPTION + +This unit watches the `bootc` root directory (/ostree/bootc) for +modification, and triggers the companion `bootc-status-updated.target` +systemd unit. + +The `bootc` program updates the mtime on its root directory when the +contents of `bootc status` changes as a result of an +update/upgrade/edit/switch/rollback operation. + +# SEE ALSO + +**bootc**(1), **bootc-status-updated.target**(8) diff --git a/docs/src/man-md/bootc-status-updated.target.md b/docs/src/man-md/bootc-status-updated.target.md new file mode 100644 index 000000000..cef1c976b --- /dev/null +++ b/docs/src/man-md/bootc-status-updated.target.md @@ -0,0 +1,23 @@ +% bootc-status-updated.target(8) + +# NAME + +bootc-status-updated.target + +# DESCRIPTION + +This unit is triggered by the companion `bootc-status-updated.path` +systemd unit. This target is intended to enable users to add custom +services to trigger as a result of `bootc status` changing. + +Add the following to your unit configuration to active it when `bootc +status` changes: + +``` +[Install] +WantedBy=bootc-status-updated.target +``` + +# SEE ALSO + +**bootc**(1), **bootc-status-updated.path**(8) diff --git a/systemd/bootc-status-updated.path b/systemd/bootc-status-updated.path new file mode 100644 index 000000000..296a782b3 --- /dev/null +++ b/systemd/bootc-status-updated.path @@ -0,0 +1,10 @@ +[Unit] +Description=Monitor bootc for status changes +Documentation=man:bootc-status-updated.path(8) + +[Path] +PathChanged=/ostree/bootc +Unit=bootc-status-updated.target + +[Install] +WantedBy=multi-user.target diff --git a/systemd/bootc-status-updated.target b/systemd/bootc-status-updated.target new file mode 100644 index 000000000..a0c134172 --- /dev/null +++ b/systemd/bootc-status-updated.target @@ -0,0 +1,4 @@ +[Unit] +Description=Target for bootc status changes +Documentation=man:bootc-status-updated.target(8) +StopWhenUnneeded=true From 0b097284aadf5977fd440d3e48896455d9b0d501 Mon Sep 17 00:00:00 2001 From: John Eckersberg Date: Mon, 16 Dec 2024 16:37:55 -0500 Subject: [PATCH 4/6] Add "rhsm" feature for integration with Red Hat Subscription Manager This adds a new subcommand `bootc internals publish-rhsm-facts` which writes out facts data to /etc/rhsm/facts/bootc.json Signed-off-by: John Eckersberg --- lib/Cargo.toml | 3 + lib/src/cli.rs | 5 ++ lib/src/lib.rs | 3 + lib/src/rhsm.rs | 157 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 lib/src/rhsm.rs diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 793aafcba..01bc92176 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -56,6 +56,9 @@ static_assertions = { workspace = true } default = ["install"] # This feature enables `bootc install`. Disable if you always want to use an external installer. install = [] +# This featuares enables `bootc internals publish-rhsm-facts` to integrate with +# Red Hat Subscription Manager +rhsm = [] # Implementation detail of man page generation. docgen = ["clap_mangen"] diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 68f15d011..4e2e38d48 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -393,6 +393,9 @@ pub(crate) enum InternalsOpts { // The stateroot stateroot: String, }, + #[cfg(feature = "rhsm")] + /// Publish subscription-manager facts to /etc/rhsm/facts/bootc.json + PublishRhsmFacts, } #[derive(Debug, clap::Subcommand, PartialEq, Eq)] @@ -1106,6 +1109,8 @@ async fn run_from_opt(opt: Opt) -> Result<()> { let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?; crate::install::completion::run_from_ostree(rootfs, &sysroot, &stateroot).await } + #[cfg(feature = "rhsm")] + InternalsOpts::PublishRhsmFacts => crate::rhsm::publish_facts(&root).await, }, #[cfg(feature = "docgen")] Opt::Man(manopts) => crate::docgen::generate_manpages(&manopts.directory), diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 67aedfbca..daa4bfc43 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -42,3 +42,6 @@ mod install; mod kernel; #[cfg(feature = "install")] pub(crate) mod mount; + +#[cfg(feature = "rhsm")] +mod rhsm; diff --git a/lib/src/rhsm.rs b/lib/src/rhsm.rs new file mode 100644 index 000000000..cb5fd4a9c --- /dev/null +++ b/lib/src/rhsm.rs @@ -0,0 +1,157 @@ +//! Integration with Red Hat Subscription Manager + +use anyhow::Result; +use cap_std::fs::{Dir, OpenOptions}; +use cap_std_ext::cap_std; +use fn_error_context::context; +use serde::Serialize; + +const FACTS_PATH: &str = "etc/rhsm/facts/bootc.json"; + +#[derive(Serialize, PartialEq, Eq, Debug, Default)] +struct RhsmFacts { + #[serde(rename = "bootc.booted.image")] + booted_image: String, + #[serde(rename = "bootc.booted.version")] + booted_version: String, + #[serde(rename = "bootc.booted.digest")] + booted_digest: String, + #[serde(rename = "bootc.staged.image")] + staged_image: String, + #[serde(rename = "bootc.staged.version")] + staged_version: String, + #[serde(rename = "bootc.staged.digest")] + staged_digest: String, + #[serde(rename = "bootc.rollback.image")] + rollback_image: String, + #[serde(rename = "bootc.rollback.version")] + rollback_version: String, + #[serde(rename = "bootc.rollback.digest")] + rollback_digest: String, + #[serde(rename = "bootc.available.image")] + available_image: String, + #[serde(rename = "bootc.available.version")] + available_version: String, + #[serde(rename = "bootc.available.digest")] + available_digest: String, +} + +impl From for RhsmFacts { + fn from(hoststatus: crate::spec::HostStatus) -> Self { + let (booted_image, booted_version, booted_digest) = hoststatus + .booted + .as_ref() + .and_then(|boot_entry| { + boot_entry.image.as_ref().map(|imagestatus| { + let image = imagestatus.image.image.clone(); + let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); + let digest = imagestatus.image_digest.clone(); + + (image, version, digest) + }) + }) + .unwrap_or_default(); + + let (staged_image, staged_version, staged_digest) = hoststatus + .staged + .as_ref() + .and_then(|boot_entry| { + boot_entry.image.as_ref().map(|imagestatus| { + let image = imagestatus.image.image.clone(); + let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); + let digest = imagestatus.image_digest.clone(); + + (image, version, digest) + }) + }) + .unwrap_or_default(); + + let (rollback_image, rollback_version, rollback_digest) = hoststatus + .rollback + .as_ref() + .and_then(|boot_entry| { + boot_entry.image.as_ref().map(|imagestatus| { + let image = imagestatus.image.image.clone(); + let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); + let digest = imagestatus.image_digest.clone(); + + (image, version, digest) + }) + }) + .unwrap_or_default(); + + let (available_image, available_version, available_digest) = hoststatus + .booted + .as_ref() + .and_then(|boot_entry| { + boot_entry.cached_update.as_ref().map(|imagestatus| { + let image = imagestatus.image.image.clone(); + let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); + let digest = imagestatus.image_digest.clone(); + + (image, version, digest) + }) + }) + .unwrap_or_default(); + + Self { + booted_image, + booted_version, + booted_digest, + staged_image, + staged_version, + staged_digest, + rollback_image, + rollback_version, + rollback_digest, + available_image, + available_version, + available_digest, + } + } +} + +/// Publish facts for subscription-manager consumption +#[context("Publishing facts")] +pub(crate) async fn publish_facts(root: &Dir) -> Result<()> { + let sysroot = super::cli::get_storage().await?; + let booted_deployment = sysroot.booted_deployment(); + let (_deployments, host) = crate::status::get_status(&sysroot, booted_deployment.as_ref())?; + + let facts = RhsmFacts::from(host.status); + let mut bootc_facts_file = root.open_with( + FACTS_PATH, + OpenOptions::new().write(true).create(true).truncate(true), + )?; + serde_json::to_writer_pretty(&mut bootc_facts_file, &facts)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::spec::Host; + + #[test] + fn test_rhsm_facts_from_host() { + let host: Host = serde_yaml::from_str(include_str!("fixtures/spec-staged-booted.yaml")) + .expect("No spec found"); + let facts = RhsmFacts::from(host.status); + + assert_eq!( + facts, + RhsmFacts { + booted_image: "quay.io/example/someimage:latest".into(), + booted_version: "nightly".into(), + booted_digest: + "sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34".into(), + staged_image: "quay.io/example/someimage:latest".into(), + staged_version: "nightly".into(), + staged_digest: + "sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566".into(), + ..Default::default() + } + ); + } +} From e5ed45b430777a4182e3b5d068f260b84e5d06c0 Mon Sep 17 00:00:00 2001 From: John Eckersberg Date: Mon, 16 Dec 2024 16:40:36 -0500 Subject: [PATCH 5/6] systemd: Add bootc-publish-rhsm-facts.service When enabled, this triggers on bootc-status-updated.target to update the facts file for Red Hat Subscription Manager integration via `bootc internals publish-rhsm-facts` Signed-off-by: John Eckersberg --- systemd/bootc-publish-rhsm-facts.service | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 systemd/bootc-publish-rhsm-facts.service diff --git a/systemd/bootc-publish-rhsm-facts.service b/systemd/bootc-publish-rhsm-facts.service new file mode 100644 index 000000000..6520ebd18 --- /dev/null +++ b/systemd/bootc-publish-rhsm-facts.service @@ -0,0 +1,11 @@ +[Unit] +Description=Publish bootc facts to Red Hat Subscription Manager +Documentation=man:bootc(8) +ConditionPathExists=/etc/rhsm/facts + +[Service] +Type=oneshot +ExecStart=/usr/bin/bootc internals publish-rhsm-facts + +[Install] +WantedBy=bootc-status-updated.target From 898bff0024da0a81cc1afaf486b988e8eb7b429c Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 18 Dec 2024 15:02:12 -0500 Subject: [PATCH 6/6] rhsm: Factor out a helper Signed-off-by: Colin Walters --- lib/src/rhsm.rs | 49 +++++++++++++------------------------------------ 1 file changed, 13 insertions(+), 36 deletions(-) diff --git a/lib/src/rhsm.rs b/lib/src/rhsm.rs index cb5fd4a9c..2ba477b13 100644 --- a/lib/src/rhsm.rs +++ b/lib/src/rhsm.rs @@ -36,62 +36,39 @@ struct RhsmFacts { available_digest: String, } +/// Return the image reference, version and digest as owned strings. +/// A missing version is serialized as the empty string. +fn status_to_strings(imagestatus: &crate::spec::ImageStatus) -> (String, String, String) { + let image = imagestatus.image.image.clone(); + let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); + let digest = imagestatus.image_digest.clone(); + (image, version, digest) +} + impl From for RhsmFacts { fn from(hoststatus: crate::spec::HostStatus) -> Self { let (booted_image, booted_version, booted_digest) = hoststatus .booted .as_ref() - .and_then(|boot_entry| { - boot_entry.image.as_ref().map(|imagestatus| { - let image = imagestatus.image.image.clone(); - let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); - let digest = imagestatus.image_digest.clone(); - - (image, version, digest) - }) - }) + .and_then(|boot_entry| boot_entry.image.as_ref().map(status_to_strings)) .unwrap_or_default(); let (staged_image, staged_version, staged_digest) = hoststatus .staged .as_ref() - .and_then(|boot_entry| { - boot_entry.image.as_ref().map(|imagestatus| { - let image = imagestatus.image.image.clone(); - let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); - let digest = imagestatus.image_digest.clone(); - - (image, version, digest) - }) - }) + .and_then(|boot_entry| boot_entry.image.as_ref().map(status_to_strings)) .unwrap_or_default(); let (rollback_image, rollback_version, rollback_digest) = hoststatus .rollback .as_ref() - .and_then(|boot_entry| { - boot_entry.image.as_ref().map(|imagestatus| { - let image = imagestatus.image.image.clone(); - let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); - let digest = imagestatus.image_digest.clone(); - - (image, version, digest) - }) - }) + .and_then(|boot_entry| boot_entry.image.as_ref().map(status_to_strings)) .unwrap_or_default(); let (available_image, available_version, available_digest) = hoststatus .booted .as_ref() - .and_then(|boot_entry| { - boot_entry.cached_update.as_ref().map(|imagestatus| { - let image = imagestatus.image.image.clone(); - let version = imagestatus.version.as_ref().cloned().unwrap_or_default(); - let digest = imagestatus.image_digest.clone(); - - (image, version, digest) - }) - }) + .and_then(|boot_entry| boot_entry.cached_update.as_ref().map(status_to_strings)) .unwrap_or_default(); Self {