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/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 0ce2660d4..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)] @@ -766,6 +769,8 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { } } if changed { + sysroot.update_mtime()?; + if opts.apply { crate::reboot::reboot()?; } @@ -842,6 +847,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 +904,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(()) } @@ -1100,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/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/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/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..2ba477b13 --- /dev/null +++ b/lib/src/rhsm.rs @@ -0,0 +1,134 @@ +//! 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, +} + +/// 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(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(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(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(status_to_strings)) + .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() + } + ); + } +} diff --git a/lib/src/store/mod.rs b/lib/src/store/mod.rs index 92ab8b54e..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; @@ -15,6 +17,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, @@ -82,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 { 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 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