diff --git a/crates/lib/src/applylive.rs b/crates/lib/src/applylive.rs new file mode 100644 index 000000000..7509c9e75 --- /dev/null +++ b/crates/lib/src/applylive.rs @@ -0,0 +1,855 @@ +//! # Live application of staged content +//! +//! This module implements `bootc apply-live`, which makes content from the +//! staged deployment visible on the running system without a reboot. +//! See . +//! +//! The only scope supported today is logically bound images +//! (`bootc apply-live bound-images`). The image bytes are already shared +//! between deployments via the bootc-owned container storage; what is +//! pinned to a deployment is only the *definition*: the symlink in +//! `/usr/lib/bootc/bound-images.d` and the quadlet file it references. +//! +//! Applying these live therefore does not require mounting anything. +//! Changed quadlet files are written to `/run/containers/systemd/`, which +//! podman gives precedence over `/etc` and `/usr`, and the affected units +//! are restarted. Because `/run` is transient, the override disappears on +//! the next boot, at which point the staged deployment's own `/usr` content +//! applies and the system converges without any further action. (A soft +//! reboot preserves `/run`, so the override is explicitly cleared when one +//! is prepared.) +//! +//! The state file is written *before* any units are touched: it describes +//! what is on disk, and each unit is marked `pending` until it has been +//! restarted successfully. Re-running the command retries pending units. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::Write; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; +use bootc_utils::CommandRunExt; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std_ext::cap_std::{self, fs::Dir}; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; +use ostree_ext::diff::FileTreeDiff; + +use crate::boundimage::{BOUND_IMAGE_DIR, BoundImageSpec}; +use crate::cli::ApplyLiveBoundImagesOpts; +use crate::spec::{LiveBoundImage, LiveBoundImages}; +use crate::store::{BootedOstree, Storage}; + +/// Directory (relative to `/run`) holding apply-live state. +const STATE_DIR: &str = "bootc/apply-live"; +/// State file (relative to `/run`) describing live-applied bound images. +const BOUND_IMAGES_STATE: &str = "bootc/apply-live/bound-images.json"; +/// Podman's highest-precedence quadlet search directory, relative to `/`. +/// Paths are relative to the root (not `/run`) so that SELinux labels are +/// computed for the real absolute path. +const QUADLET_RUN_DIR: &str = "run/containers/systemd"; +/// Quadlet search directories relative to a deployment root, in decreasing +/// precedence (excluding `/run`, which is what we write to). +const QUADLET_SEARCH_DIRS: &[&str] = &["etc/containers/systemd", "usr/share/containers/systemd"]; +/// Directories which may be added/removed/changed in an ostree commit diff +/// while still being in scope for `apply-live bound-images`. Note that +/// `/etc` is stored as `/usr/etc` in ostree commits. The contents of an +/// added or removed directory are checked separately, since the diff does +/// not enumerate them. +const SCOPE_DIRS: &[&str] = &[ + "/usr/lib/bootc", + "/usr/lib/bootc/bound-images.d", + "/usr/share/containers", + "/usr/share/containers/systemd", + "/usr/etc/containers", + "/usr/etc/containers/systemd", +]; +/// Maximum number of out-of-scope paths to include in an error message. +const MAX_REPORTED_PATHS: usize = 10; +/// Journal message ID for apply-live operations. +const APPLY_LIVE_JOURNAL_ID: &str = "4c9e2b7d1f0a4e8b9c6d3a2f5e7b1c0d"; + +/// How a bound image definition differs between the booted and staged deployments. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChangeKind { + /// Present in the staged deployment only + Added, + /// Present in both, with different quadlet contents + Updated, + /// Present in the booted deployment only, and the quadlet file + /// no longer exists in the staged deployment + Removed, +} + +impl std::fmt::Display for ChangeKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ChangeKind::Added => "added", + ChangeKind::Updated => "updated", + ChangeKind::Removed => "removed", + }; + f.write_str(s) + } +} + +/// A single bound image definition change to apply. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct QuadletChange { + pub(crate) kind: ChangeKind, + /// The staged spec for `Added`/`Updated`, the booted spec for `Removed`. + pub(crate) spec: BoundImageSpec, + /// Path of the quadlet relative to its search directory, if it lives in + /// one podman will find. Definitions outside a search directory are only + /// used as pull specs and have nothing to materialize. + pub(crate) quadlet: Option, + /// The systemd unit to restart, if any. + pub(crate) unit: Option, +} + +/// If `path` (relative to a root) is inside a quadlet search directory, +/// return the remainder relative to that directory. +pub(crate) fn quadlet_relpath(path: &Utf8Path) -> Option<&Utf8Path> { + QUADLET_SEARCH_DIRS + .iter() + .find_map(|d| path.strip_prefix(d).ok()) + .filter(|p| !p.as_str().is_empty()) +} + +/// Compute the systemd service unit that quadlet generates for a `.container` +/// file. Returns `None` for other quadlet types; a `.image` unit is a oneshot +/// pull which we don't need to run since bootc pulls the image itself. +pub(crate) fn quadlet_unit_name(relpath: &Utf8Path, contents: &str) -> Result> { + if relpath.extension() != Some("container") { + return Ok(None); + } + let ini = tini::Ini::from_string(contents).context("Parse to ini")?; + let name = ini + .get::("Container", "ServiceName") + .or_else(|| relpath.file_stem().map(ToOwned::to_owned)) + .ok_or_else(|| anyhow::anyhow!("Invalid quadlet name: {relpath}"))?; + Ok(Some(format!("{name}.service"))) +} + +/// Convert a deployment-relative path to the form used in an ostree commit +/// (and hence [`FileTreeDiff`]): absolute, with `/etc` stored as `/usr/etc`. +fn commit_path(path: &Utf8Path) -> Utf8PathBuf { + if let Ok(rest) = path.strip_prefix("etc") { + Utf8Path::new("/usr/etc").join(rest) + } else { + Utf8Path::new("/").join(path) + } +} + +/// Compute the set of definition changes between the booted and staged +/// bound image specs. +pub(crate) fn compute_changes( + booted: &[BoundImageSpec], + staged: &[BoundImageSpec], + staged_root: &Dir, +) -> Result> { + let booted: BTreeMap<_, _> = booted.iter().map(|s| (s.path.as_path(), s)).collect(); + let staged: BTreeMap<_, _> = staged.iter().map(|s| (s.path.as_path(), s)).collect(); + let mut changes = Vec::new(); + + let mk = |kind, spec: &BoundImageSpec| -> Result { + let quadlet = quadlet_relpath(&spec.path).map(ToOwned::to_owned); + let unit = quadlet + .as_deref() + .map(|q| quadlet_unit_name(q, &spec.contents)) + .transpose()? + .flatten(); + Ok(QuadletChange { + kind, + spec: spec.clone(), + quadlet, + unit, + }) + }; + + for (path, spec) in staged.iter() { + match booted.get(path) { + Some(b) if b.contents == spec.contents => {} + Some(_) => changes.push(mk(ChangeKind::Updated, spec)?), + None => changes.push(mk(ChangeKind::Added, spec)?), + } + } + for (path, spec) in booted.iter() { + if staged.contains_key(path) { + continue; + } + if staged_root.try_exists(path)? { + // The quadlet still exists, it's just no longer a bound image; + // there's nothing to change on the running system. + tracing::debug!("No longer bound, but still present: {path}"); + continue; + } + changes.push(mk(ChangeKind::Removed, spec)?); + } + Ok(changes) +} + +/// Recursively collect the files under `dir` (a commit-form absolute path) +/// in the given root, as commit-form absolute paths. +fn walk_files(root: &Dir, dir: &str, out: &mut Vec) -> Result<()> { + let rel = dir.trim_start_matches('/'); + let Some(d) = root.open_dir_optional(rel)? else { + return Ok(()); + }; + for entry in d.entries()? { + let entry = entry?; + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF8 filename: {name:?} in {dir}"))?; + let path = format!("{dir}/{name}"); + if entry.file_type()?.is_dir() { + walk_files(root, &path, out)?; + } else { + out.push(path); + } + } + Ok(()) +} + +/// Verify that every path which differs between the booted and staged commits +/// is accounted for by the bound image changes we are going to apply. Any other +/// change means the staged deployment is not just a bound image update, and a +/// reboot is required. +/// +/// The ostree diff does not enumerate the contents of added or removed +/// directories, so those are walked in the staged (respectively booted) +/// deployment root and checked file by file. +pub(crate) fn check_diff_scope( + diff: &FileTreeDiff, + changes: &[QuadletChange], + booted_root: &Dir, + staged_root: &Dir, +) -> Result<()> { + let bound_dir = format!("/{BOUND_IMAGE_DIR}/"); + let allowed_files: BTreeSet<_> = changes.iter().map(|c| commit_path(&c.spec.path)).collect(); + let file_in_scope = + |p: &str| -> bool { p.starts_with(&bound_dir) || allowed_files.contains(Utf8Path::new(p)) }; + let dir_in_scope = |p: &str| -> bool { SCOPE_DIRS.contains(&p) }; + + let mut out_of_scope: Vec = diff + .added_files + .iter() + .chain(&diff.removed_files) + .chain(&diff.changed_files) + .filter(|p| !file_in_scope(p)) + .chain(diff.changed_dirs.iter().filter(|p| !dir_in_scope(p))) + .cloned() + .collect(); + for (dirs, root) in [ + (&diff.added_dirs, staged_root), + (&diff.removed_dirs, booted_root), + ] { + for d in dirs { + if !dir_in_scope(d) { + out_of_scope.push(d.clone()); + continue; + } + let mut files = Vec::new(); + walk_files(root, d, &mut files)?; + out_of_scope.extend(files.into_iter().filter(|p| !file_in_scope(p))); + } + } + if out_of_scope.is_empty() { + return Ok(()); + } + out_of_scope.sort(); + let n = out_of_scope.len(); + let mut msg = String::from( + "Staged deployment contains changes outside of bound image definitions; a reboot is required to apply it:\n", + ); + for p in out_of_scope.iter().take(MAX_REPORTED_PATHS) { + msg.push_str(" "); + msg.push_str(p); + msg.push('\n'); + } + if n > MAX_REPORTED_PATHS { + msg.push_str(&format!(" ...and {} more\n", n - MAX_REPORTED_PATHS)); + } + anyhow::bail!("{}", msg.trim_end()) +} + +/// Read the live bound image state from `/run`, if any. +pub(crate) fn read_state(run: &Dir) -> Result> { + let Some(f) = run.open_optional(BOUND_IMAGES_STATE)? else { + return Ok(None); + }; + let r = serde_json::from_reader(std::io::BufReader::new(f)) + .with_context(|| format!("Parsing /run/{BOUND_IMAGES_STATE}"))?; + Ok(Some(r)) +} + +/// Read the live bound image state from the host `/run`, if any. +pub(crate) fn read_state_from_host() -> Result> { + let run = + Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?; + read_state(&run) +} + +fn write_state(run: &Dir, state: &LiveBoundImages) -> Result<()> { + run.create_dir_all(STATE_DIR)?; + run.atomic_replace_with(BOUND_IMAGES_STATE, |w| { + serde_json::to_writer_pretty(w, state).map_err(anyhow::Error::from) + }) + .with_context(|| format!("Writing /run/{BOUND_IMAGES_STATE}")) +} + +/// Remove any live-applied bound image overrides and state. This is used when +/// preparing a soft reboot, which preserves `/run` but must boot into the +/// target deployment's own definitions. +#[context("Clearing live bound image state")] +pub(crate) fn clear_state_from_host() -> Result<()> { + let run = + Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?; + let Some(state) = read_state(&run)? else { + return Ok(()); + }; + let rootfs = Dir::open_ambient_dir("/", cap_std::ambient_authority()).context("Opening /")?; + for q in state.images.iter().filter_map(|i| i.quadlet.as_deref()) { + rootfs.remove_file_optional(Utf8Path::new(QUADLET_RUN_DIR).join(q))?; + } + run.remove_file_optional(BOUND_IMAGES_STATE)?; + println!("Cleared live-applied bound image definitions"); + Ok(()) +} + +#[context("Running systemctl {}", args.join(" "))] +fn systemctl(args: &[&str]) -> Result<()> { + Command::new("systemctl").args(args).run_capture_stderr() +} + +/// Create `path` and any missing ancestors below `/run`, labeling only the +/// directories we create; existing ones (e.g. podman's `/run/containers`) +/// are left alone. +fn ensure_quadlet_dir( + rootfs: &Dir, + path: &Utf8Path, + sepolicy: Option<&ostree_ext::ostree::SePolicy>, +) -> Result<()> { + let mode = rustix::fs::Mode::from_raw_mode(0o755); + let ancestors: Vec<_> = path + .ancestors() + .take_while(|p| p.as_str().len() > "run".len()) + .collect(); + for dir in ancestors.into_iter().rev() { + if rootfs.try_exists(dir)? { + continue; + } + crate::lsm::ensure_dir_labeled(rootfs, dir, None, mode, sepolicy)?; + } + Ok(()) +} + +/// Restart every unit still marked pending in `state`, updating the state +/// file as each one succeeds so a failure part way through can be retried. +fn restart_pending(run: &Dir, state: &mut LiveBoundImages) -> Result<()> { + let mut r = Ok(()); + for img in state.images.iter_mut().filter(|i| i.pending) { + let Some(unit) = img.unit.as_deref() else { + img.pending = false; + continue; + }; + // `restart` also starts a unit which is not running (the added case). + if let Err(e) = systemctl(&["restart", unit]) { + r = Err(e); + break; + } + img.pending = false; + } + write_state(run, state)?; + r +} + +/// Implementation of `bootc apply-live bound-images`. +#[context("Applying bound images live")] +pub(crate) async fn apply_bound_images( + storage: &Storage, + booted: &BootedOstree<'_>, + opts: &ApplyLiveBoundImagesOpts, +) -> Result<()> { + let sysroot = booted.sysroot; + let booted_deployment = &booted.deployment; + let staged = sysroot.staged_deployment().ok_or_else(|| { + anyhow::anyhow!("No staged deployment; run `bootc upgrade` or `bootc switch` first") + })?; + ensure!( + staged.osname() == booted_deployment.osname(), + "Staged deployment is in a different stateroot" + ); + let booted_csum = booted_deployment.csum(); + let staged_csum = staged.csum(); + + let run = + &Dir::open_ambient_dir("/run", cap_std::ambient_authority()).context("Opening /run")?; + let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority()).context("Opening /")?; + let previous = read_state(run)?; + + // The definitions from this staged deployment are already on disk; all + // that may be left is restarting units (after `--no-restart`, or a failure). + if let Some(mut previous) = previous + .as_ref() + .filter(|p| p.checksum == staged_csum) + .cloned() + { + let pending: Vec<_> = previous + .images + .iter() + .filter(|i| i.pending) + .filter_map(|i| i.unit.as_deref()) + .collect(); + if pending.is_empty() { + println!("Bound images from staged deployment are already applied"); + return Ok(()); + } + for unit in pending { + println!("restart pending: {unit}"); + } + if opts.dry_run || opts.no_restart { + return Ok(()); + } + systemctl(&["daemon-reload"])?; + return restart_pending(run, &mut previous); + } + + let booted_root = crate::utils::deployment_fd(sysroot, booted_deployment)?; + let staged_root = crate::utils::deployment_fd(sysroot, &staged)?; + let booted_specs = crate::boundimage::query_bound_image_specs(&booted_root)?; + let staged_specs = crate::boundimage::query_bound_image_specs(&staged_root)?; + let changes = compute_changes(&booted_specs, &staged_specs, &staged_root)?; + + if booted_csum != staged_csum { + let diff = + ostree_ext::diff::diff(&sysroot.repo(), &booted_csum, &staged_csum, None::<&str>)?; + tracing::debug!("Diff booted -> staged: {diff}"); + check_diff_scope(&diff, &changes, &booted_root, &staged_root)?; + } + + // Overrides written by a previous apply-live which are not part of this + // one need to be removed so the running system converges on the staged + // definitions rather than a mix of the two. + let previous_quadlets: BTreeMap<&Utf8Path, &LiveBoundImage> = previous + .iter() + .flat_map(|p| &p.images) + .filter_map(|i| i.quadlet.as_deref().map(|q| (Utf8Path::new(q), i))) + .collect(); + let current_quadlets: BTreeSet<&Utf8Path> = changes + .iter() + .filter_map(|c| c.quadlet.as_deref()) + .collect(); + let stale: Vec<(&Utf8Path, &LiveBoundImage)> = previous_quadlets + .iter() + .filter(|(q, _)| !current_quadlets.contains(*q)) + .map(|(q, i)| (*q, *i)) + .collect(); + + if changes.is_empty() && stale.is_empty() { + println!("No bound image changes to apply"); + return Ok(()); + } + + for c in changes.iter() { + let unit = c.unit.as_deref().unwrap_or("(no unit)"); + println!("{}: {} ({unit})", c.kind, c.spec.image.image); + } + for (q, _) in stale.iter() { + println!("reverting stale override: {q}"); + } + if opts.dry_run { + return Ok(()); + } + + tracing::info!( + message_id = APPLY_LIVE_JOURNAL_ID, + bootc.deployment.checksum = staged_csum.as_str(), + bootc.bound_images_changes = changes.len(), + "Applying bound image definitions live from staged deployment" + ); + + // Ensure all images referenced by the staged deployment are present. They + // normally were pulled when the deployment was staged, but this also acts + // as a retry if that failed. + let staged_images = staged_specs.iter().map(|s| s.image.clone()).collect(); + crate::boundimage::pull_images(storage, staged_images).await?; + + // Materialize the definitions into /run. A definition may already be live + // from a previous apply, in which case its unit doesn't need a restart. + let quadlet_run_dir = Utf8Path::new(QUADLET_RUN_DIR); + let sepolicy = crate::lsm::new_sepolicy_at(&booted_root)?; + let filemode = rustix::fs::Mode::from_raw_mode(0o644); + let mut written = BTreeMap::new(); + for c in changes.iter() { + let Some(quadlet) = c.quadlet.as_deref() else { + continue; + }; + let dest = quadlet_run_dir.join(quadlet); + match c.kind { + ChangeKind::Added | ChangeKind::Updated => { + let already_live = previous_quadlets + .get(quadlet) + .is_some_and(|prev| !prev.pending) + && rootfs.read_to_string_optional(&dest)?.as_deref() + == Some(c.spec.contents.as_str()); + if !already_live { + // SAFETY: We know there's a parent + ensure_quadlet_dir(rootfs, dest.parent().unwrap(), sepolicy.as_ref())?; + crate::lsm::atomic_replace_labeled( + rootfs, + &dest, + filemode, + sepolicy.as_ref(), + |w| w.write_all(c.spec.contents.as_bytes()).map_err(Into::into), + )?; + } + written.insert(quadlet, !already_live); + } + ChangeKind::Removed => { + rootfs.remove_file_optional(&dest)?; + } + } + } + for (q, _) in stale.iter() { + rootfs.remove_file_optional(quadlet_run_dir.join(q))?; + } + + // Record all bound images of the staged deployment, not just the changed + // ones: the state file protects them from garbage collection even if the + // staged deployment is later discarded (e.g. by `bootc rollback`). + let images = staged_specs + .iter() + .map(|s| { + let quadlet = quadlet_relpath(&s.path).filter(|q| written.contains_key(q)); + let unit = quadlet + .map(|q| quadlet_unit_name(q, &s.contents)) + .transpose()? + .flatten(); + let pending = unit.is_some() && quadlet.is_some_and(|q| written[q]); + Ok(LiveBoundImage { + image: s.image.image.clone(), + quadlet: quadlet.map(|q| q.to_string()), + unit, + pending, + }) + }) + .collect::>>()?; + let mut state = LiveBoundImages { + checksum: staged_csum.to_string(), + deploy_serial: staged.deployserial() as u32, + images, + }; + write_state(run, &state)?; + + if opts.no_restart { + println!("Skipping unit restart; re-run without --no-restart to restart pending units"); + return Ok(()); + } + + // Units for removed definitions must be stopped before the daemon reload + // which makes them disappear from systemd's view. + let removed_units = changes + .iter() + .filter(|c| c.kind == ChangeKind::Removed) + .filter_map(|c| c.unit.as_deref()) + .chain(stale.iter().filter_map(|(_, img)| img.unit.as_deref())); + for unit in removed_units { + systemctl(&["stop", unit])?; + } + systemctl(&["daemon-reload"])?; + // A stale override reverts to the booted deployment's definition if + // there is one, otherwise the unit is simply gone. + for (q, img) in stale.iter() { + let Some(unit) = img.unit.as_deref() else { + continue; + }; + let booted_has = booted_specs + .iter() + .any(|s| quadlet_relpath(&s.path) == Some(q)); + if booted_has { + systemctl(&["start", unit])?; + } + } + restart_pending(run, &mut state)?; + + println!( + "Applied {} bound image definition change(s) from staged deployment", + changes.len() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::boundimage::BoundImage; + + fn spec(path: &str, image: &str, contents: &str) -> BoundImageSpec { + BoundImageSpec { + image: BoundImage { + image: image.into(), + auth_file: None, + }, + path: path.into(), + contents: contents.into(), + } + } + + fn tempdir() -> Result { + Ok(cap_std_ext::cap_tempfile::TempDir::new( + cap_std::ambient_authority(), + )?) + } + + #[test] + fn test_quadlet_relpath() { + assert_eq!( + quadlet_relpath("usr/share/containers/systemd/foo.container".into()), + Some("foo.container".into()) + ); + assert_eq!( + quadlet_relpath("etc/containers/systemd/sub/foo.image".into()), + Some("sub/foo.image".into()) + ); + assert_eq!(quadlet_relpath("usr/lib/foo/foo.container".into()), None); + assert_eq!(quadlet_relpath("usr/share/containers/systemd".into()), None); + } + + #[test] + fn test_quadlet_unit_name() -> Result<()> { + let c = "[Container]\nImage=quay.io/foo/foo:latest\n"; + assert_eq!( + quadlet_unit_name("foo.container".into(), c)?.as_deref(), + Some("foo.service") + ); + assert_eq!( + quadlet_unit_name("sub/foo.container".into(), c)?.as_deref(), + Some("foo.service") + ); + let c = "[Container]\nImage=quay.io/foo/foo:latest\nServiceName=bar\n"; + assert_eq!( + quadlet_unit_name("foo.container".into(), c)?.as_deref(), + Some("bar.service") + ); + let c = "[Image]\nImage=quay.io/foo/foo:latest\n"; + assert_eq!(quadlet_unit_name("foo.image".into(), c)?, None); + Ok(()) + } + + #[test] + fn test_commit_path() { + assert_eq!( + commit_path("etc/containers/systemd/a.container".into()), + Utf8PathBuf::from("/usr/etc/containers/systemd/a.container") + ); + assert_eq!( + commit_path("usr/share/containers/systemd/a.container".into()), + Utf8PathBuf::from("/usr/share/containers/systemd/a.container") + ); + } + + #[test] + fn test_compute_changes() -> Result<()> { + let staged_root = &tempdir()?; + const Q: &str = "usr/share/containers/systemd"; + let v1 = "[Container]\nImage=quay.io/foo/foo:v1\n"; + let v2 = "[Container]\nImage=quay.io/foo/foo:v2\n"; + let bar = "[Image]\nImage=quay.io/foo/bar:latest\n"; + let unbound = "[Container]\nImage=quay.io/foo/unbound:latest\n"; + let gone = "[Container]\nImage=quay.io/foo/gone:latest\n"; + + let booted = [ + spec(&format!("{Q}/foo.container"), "quay.io/foo/foo:v1", v1), + spec(&format!("{Q}/bar.image"), "quay.io/foo/bar:latest", bar), + spec( + &format!("{Q}/unbound.container"), + "quay.io/foo/unbound:latest", + unbound, + ), + spec( + &format!("{Q}/gone.container"), + "quay.io/foo/gone:latest", + gone, + ), + ]; + let staged = [ + spec(&format!("{Q}/foo.container"), "quay.io/foo/foo:v2", v2), + spec(&format!("{Q}/bar.image"), "quay.io/foo/bar:latest", bar), + spec("usr/lib/misc/new.container", "quay.io/foo/new:latest", v1), + ]; + // The unbound quadlet still exists in the staged root + staged_root.create_dir_all(Q)?; + staged_root.write(format!("{Q}/unbound.container"), unbound)?; + + let changes = compute_changes(&booted, &staged, staged_root)?; + let summary: Vec<_> = changes + .iter() + .map(|c| { + ( + c.kind, + c.spec.path.as_str(), + c.quadlet.as_deref().map(|q| q.as_str()), + c.unit.as_deref(), + ) + }) + .collect(); + assert_eq!( + summary, + [ + (ChangeKind::Added, "usr/lib/misc/new.container", None, None), + ( + ChangeKind::Updated, + "usr/share/containers/systemd/foo.container", + Some("foo.container"), + Some("foo.service") + ), + ( + ChangeKind::Removed, + "usr/share/containers/systemd/gone.container", + Some("gone.container"), + Some("gone.service") + ), + ] + ); + Ok(()) + } + + #[test] + fn test_check_diff_scope() -> Result<()> { + let booted_root = &tempdir()?; + let staged_root = &tempdir()?; + let changes = vec![ + QuadletChange { + kind: ChangeKind::Updated, + spec: spec( + "usr/share/containers/systemd/foo.container", + "quay.io/foo/foo:v2", + "", + ), + quadlet: Some("foo.container".into()), + unit: Some("foo.service".into()), + }, + QuadletChange { + kind: ChangeKind::Added, + spec: spec("etc/containers/systemd/bar.image", "quay.io/foo/bar:v1", ""), + quadlet: Some("bar.image".into()), + unit: None, + }, + ]; + let check = + |diff: &FileTreeDiff| check_diff_scope(diff, &changes, booted_root, staged_root); + + let mut diff = FileTreeDiff::default(); + diff.changed_files + .insert("/usr/share/containers/systemd/foo.container".into()); + diff.added_files + .insert("/usr/etc/containers/systemd/bar.image".into()); + diff.added_files + .insert("/usr/lib/bootc/bound-images.d/bar.image".into()); + diff.added_dirs + .insert("/usr/lib/bootc/bound-images.d".into()); + check(&diff).unwrap(); + + // A bound-images.d symlink target changing isn't visible in the diff + // as a file change, but the removal of the old link is. + diff.removed_files + .insert("/usr/lib/bootc/bound-images.d/old.image".into()); + check(&diff).unwrap(); + + // Anything else is out of scope + diff.changed_files.insert("/usr/bin/bash".into()); + let e = check(&diff).unwrap_err().to_string(); + assert!(e.contains("/usr/bin/bash"), "{e}"); + assert!(!e.contains("foo.container"), "{e}"); + diff.changed_files.remove("/usr/bin/bash"); + + // A quadlet we're not going to apply (not bound) is also out of scope + diff.changed_files + .insert("/usr/share/containers/systemd/other.container".into()); + assert!(check(&diff).is_err()); + diff.changed_files + .remove("/usr/share/containers/systemd/other.container"); + + // As is a new subdirectory of quadlets, since the diff doesn't recurse into it + diff.added_dirs + .insert("/usr/share/containers/systemd/sub".into()); + assert!(check(&diff).is_err()); + diff.added_dirs.remove("/usr/share/containers/systemd/sub"); + + // The quadlet dir itself being added is fine when it only contains + // what we apply (the "first bound image" case)... + const Q: &str = "usr/etc/containers/systemd"; + staged_root.create_dir_all(Q)?; + staged_root.write(format!("{Q}/bar.image"), "")?; + diff.added_dirs.insert("/usr/etc/containers".into()); + diff.added_dirs.insert(format!("/{Q}")); + check(&diff).unwrap(); + // ...but not when it contains anything else, even nested + staged_root.create_dir_all(format!("{Q}/sub"))?; + staged_root.write(format!("{Q}/sub/other.container"), "")?; + let e = check(&diff).unwrap_err().to_string(); + assert!(e.contains(&format!("/{Q}/sub/other.container")), "{e}"); + staged_root.remove_dir_all(format!("{Q}/sub"))?; + check(&diff).unwrap(); + + // Likewise for a removed directory, which is checked in the booted root + booted_root.create_dir_all("usr/lib/bootc/bound-images.d")?; + booted_root.write("usr/lib/bootc/bound-images.d/old.image", "")?; + diff.removed_dirs.insert("/usr/lib/bootc".into()); + check(&diff).unwrap(); + booted_root.write("usr/lib/bootc/other", "")?; + let e = check(&diff).unwrap_err().to_string(); + assert!(e.contains("/usr/lib/bootc/other"), "{e}"); + booted_root.remove_file("usr/lib/bootc/other")?; + + // Error message is truncated + for i in 0..20 { + diff.added_files.insert(format!("/usr/bin/tool{i}")); + } + let e = check(&diff).unwrap_err().to_string(); + assert!(e.contains("and 10 more"), "{e}"); + Ok(()) + } + + #[test] + fn test_state_roundtrip() -> Result<()> { + let run = &tempdir()?; + assert_eq!(read_state(run)?, None); + let state = LiveBoundImages { + checksum: "abc".into(), + deploy_serial: 1, + images: vec![ + LiveBoundImage { + image: "quay.io/foo/foo:v2".into(), + quadlet: Some("foo.container".into()), + unit: Some("foo.service".into()), + pending: true, + }, + LiveBoundImage { + image: "quay.io/foo/bar:v2".into(), + quadlet: None, + unit: None, + pending: false, + }, + ], + }; + write_state(run, &state)?; + assert_eq!(read_state(run)?.as_ref(), Some(&state)); + let raw = run.read_to_string(BOUND_IMAGES_STATE)?; + assert!(raw.contains("\"pending\": true"), "{raw}"); + assert_eq!(raw.matches("pending").count(), 1, "{raw}"); + Ok(()) + } + + #[test] + fn test_ensure_quadlet_dir() -> Result<()> { + let rootfs = &tempdir()?; + rootfs.create_dir("run")?; + ensure_quadlet_dir(rootfs, "run/containers/systemd/sub/deeper".into(), None)?; + assert!(rootfs.is_dir("run/containers/systemd/sub/deeper")); + // Idempotent + ensure_quadlet_dir(rootfs, "run/containers/systemd/sub/deeper".into(), None)?; + Ok(()) + } +} diff --git a/crates/lib/src/boundimage.rs b/crates/lib/src/boundimage.rs index 31b4e5fb3..0f5f24c9c 100644 --- a/crates/lib/src/boundimage.rs +++ b/crates/lib/src/boundimage.rs @@ -6,7 +6,7 @@ //! is considered ready. use anyhow::{Context, Result}; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use cap_std_ext::cap_std::fs::Dir; use cap_std_ext::dirext::CapStdExtDirExt; use fn_error_context::context; @@ -18,19 +18,30 @@ use crate::store::Storage; /// The path in a root for bound images; this directory should only contain /// symbolic links to `.container` or `.image` files. -const BOUND_IMAGE_DIR: &str = "usr/lib/bootc/bound-images.d"; +pub(crate) const BOUND_IMAGE_DIR: &str = "usr/lib/bootc/bound-images.d"; /// A subset of data parsed from a `.image` or `.container` file with /// the minimal information necessary to fetch the image. /// /// In the future this may be extended to include e.g. certificates or /// other pull options. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct BoundImage { pub(crate) image: String, pub(crate) auth_file: Option, } +/// A bound image definition together with the quadlet file that defines it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BoundImageSpec { + pub(crate) image: BoundImage, + /// Path of the quadlet file relative to the root, e.g. + /// `usr/share/containers/systemd/foo.container`. + pub(crate) path: Utf8PathBuf, + /// The raw contents of the quadlet file. + pub(crate) contents: String, +} + #[derive(Debug, PartialEq, Eq)] pub(crate) struct ResolvedBoundImage { pub(crate) image: String, @@ -70,6 +81,39 @@ pub(crate) fn query_bound_images_for_deployment( #[context("Querying bound images")] pub(crate) fn query_bound_images(root: &Dir) -> Result> { + let specs = query_bound_image_specs(root)?; + Ok(specs.into_iter().map(|s| s.image).collect()) +} + +/// Lexically resolve the target of a symlink in `dir` (relative to the root) +/// to a root-relative path, without following any further symlinks. Like +/// `RESOLVE_IN_ROOT` (which is how the file is actually read), `..` at the +/// root stays at the root. +fn resolve_link_target(dir: &Utf8Path, target: &Utf8Path) -> Result { + let mut ret = Utf8PathBuf::new(); + let base = if target.is_absolute() { + Utf8Path::new("") + } else { + dir + }; + for component in base.components().chain(target.components()) { + use camino::Utf8Component::*; + match component { + RootDir | CurDir => {} + ParentDir => { + ret.pop(); + } + Normal(n) => ret.push(n), + Prefix(_) => anyhow::bail!("Unexpected path prefix: {target}"), + } + } + Ok(ret) +} + +/// Like [`query_bound_images`], but also returns the path and contents +/// of the quadlet file defining each image. +#[context("Querying bound image specs")] +pub(crate) fn query_bound_image_specs(root: &Dir) -> Result> { let spec_dir = BOUND_IMAGE_DIR; let Some(bound_images_dir) = root.open_dir_optional(spec_dir)? else { tracing::debug!("Missing {spec_dir}"); @@ -112,7 +156,19 @@ pub(crate) fn query_bound_images(root: &Dir) -> Result> { _ => anyhow::bail!("Invalid file extension: {file_name}"), }?; - bound_images.push(bound_image); + // Record where the quadlet actually lives, so callers can relate it + // to podman's quadlet search directories. + let target = bound_images_dir + .read_link_contents(file_name) + .with_context(|| format!("Reading link {path}"))?; + let target = Utf8PathBuf::try_from(target).context("Non-UTF8 symlink target")?; + let target = resolve_link_target(Utf8Path::new(spec_dir), &target)?; + + bound_images.push(BoundImageSpec { + image: bound_image, + path: target, + contents: file_contents, + }); } Ok(bound_images) @@ -294,6 +350,32 @@ mod tests { assert_eq!(images[0].image, "quay.io/bar/bar:latest"); assert_eq!(images[1].image, "quay.io/foo/foo:latest"); + // The specs should record the resolved quadlet path and contents + let mut specs = query_bound_image_specs(td).unwrap(); + specs.sort_by(|a, b| a.path.cmp(&b.path)); + assert_eq!(specs.len(), 2); + assert_eq!(specs[0].path, format!("{CONTAINER_IMAGE_DIR}/bar.image")); + assert_eq!(specs[0].image.image, "quay.io/bar/bar:latest"); + assert!(specs[0].contents.contains("quay.io/bar/bar:latest")); + assert_eq!(specs[1].path, format!("{CONTAINER_IMAGE_DIR}/foo.image")); + + // Relative symlinks are resolved relative to the bound images directory + td.symlink( + "../../../share/containers/systemd/bar.image", + format!("{BOUND_IMAGE_DIR}/relative.image"), + ) + .unwrap(); + let specs = query_bound_image_specs(td).unwrap(); + assert_eq!(specs.len(), 3); + assert!( + specs + .iter() + .all(|s| s.path == format!("{CONTAINER_IMAGE_DIR}/bar.image") + || s.path == format!("{CONTAINER_IMAGE_DIR}/foo.image")) + ); + td.remove_file(format!("{BOUND_IMAGE_DIR}/relative.image")) + .unwrap(); + // Invalid symlink should return an error td.symlink("./blah", format!("{BOUND_IMAGE_DIR}/blah.image")) .unwrap(); @@ -308,6 +390,28 @@ mod tests { Ok(()) } + #[test] + fn test_resolve_link_target() { + let d = Utf8Path::new("usr/lib/bootc/bound-images.d"); + assert_eq!( + resolve_link_target(d, "/usr/share/containers/systemd/foo.image".into()).unwrap(), + "usr/share/containers/systemd/foo.image" + ); + assert_eq!( + resolve_link_target(d, "../../../share/containers/systemd/foo.image".into()).unwrap(), + "usr/share/containers/systemd/foo.image" + ); + assert_eq!( + resolve_link_target(d, "./foo.image".into()).unwrap(), + "usr/lib/bootc/bound-images.d/foo.image" + ); + // Excess `..` is clamped at the root, matching RESOLVE_IN_ROOT + assert_eq!( + resolve_link_target(d, "../../../../../../foo.image".into()).unwrap(), + "foo.image" + ); + } + #[test] fn test_parse_spec_value() -> Result<()> { //should parse string with no % characters diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index 97b9a9e77..1dfe266a5 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -261,6 +261,42 @@ pub(crate) enum SoftRebootMode { Auto, } +/// Options for `bootc apply-live bound-images` +#[derive(Debug, Parser, PartialEq, Eq)] +pub(crate) struct ApplyLiveBoundImagesOpts { + /// Write the definitions and ensure the images are present, but don't + /// reload systemd or restart any units. + #[clap(long)] + pub(crate) no_restart: bool, + + /// Print what would be done without changing the running system. + #[clap(long)] + pub(crate) dry_run: bool, +} + +/// Subcommands for applying staged content to the running system. +#[derive(Debug, clap::Subcommand, PartialEq, Eq)] +pub(crate) enum ApplyLiveOpts { + /// Apply the staged deployment's logically bound image definitions to the running system. + /// + /// The staged deployment must differ from the booted deployment only in + /// logically bound image definitions: the `/usr/lib/bootc/bound-images.d` + /// symlinks and the `.container` or `.image` files they reference. If + /// anything else changed, this command fails and a reboot is required. + /// + /// Changed quadlet files are written to `/run/containers/systemd/`, which + /// podman gives precedence over `/etc` and `/usr`, and the corresponding + /// units are restarted (added units are started, removed units stopped). + /// The images themselves are already present in the bootc-owned storage + /// shared by all deployments. + /// + /// Because `/run` is transient this state is discarded on reboot, at which + /// point the staged deployment's own content applies. The applied state is + /// visible in `bootc status`. + #[clap(alias = "lbi")] + BoundImages(ApplyLiveBoundImagesOpts), +} + /// Perform an status operation #[derive(Debug, Parser, PartialEq, Eq)] pub(crate) struct StatusOpts { @@ -977,6 +1013,11 @@ pub(crate) enum Opt { /// Allows temporary package installation that will be discarded on reboot. #[clap(alias = "usroverlay")] UsrOverlay(UsrOverlayOpts), + /// Apply content from the staged deployment to the running system without a reboot. + /// + /// Stability: This interface is experimental and may change in the future. + #[clap(subcommand, hide = true)] + ApplyLive(ApplyLiveOpts), /// Install the running container to a target. /// /// Takes a container image and installs it to disk in a bootable format. @@ -1117,6 +1158,9 @@ fn prepare_soft_reboot(sysroot: &SysrootLock, deployment: &ostree::Deployment) - sysroot .deployment_set_soft_reboot(deployment, false, cancellable) .context("Failed to prepare soft-reboot")?; + // A soft reboot preserves /run, so anything applied live must not leak + // into the target deployment. + crate::applylive::clear_state_from_host()?; Ok(()) } @@ -1941,6 +1985,19 @@ async fn run_from_opt(opt: Opt) -> Result { Ok(()) } Opt::Edit(opts) => edit(opts).await, + Opt::ApplyLive(opts) => { + let storage = &get_storage().await?; + match storage.kind()? { + BootedStorageKind::Ostree(booted_ostree) => match opts { + ApplyLiveOpts::BoundImages(opts) => { + crate::applylive::apply_bound_images(storage, &booted_ostree, &opts).await + } + }, + BootedStorageKind::Composefs(_) => { + anyhow::bail!("apply-live is not yet supported on composefs systems") + } + } + } Opt::UsrOverlay(opts) => { use crate::store::Environment; let env = Environment::detect()?; diff --git a/crates/lib/src/deploy.rs b/crates/lib/src/deploy.rs index b361a3b79..f9202ed88 100644 --- a/crates/lib/src/deploy.rs +++ b/crates/lib/src/deploy.rs @@ -415,6 +415,18 @@ pub(crate) async fn prune_container_store(sysroot: &Storage) -> Result<()> { }); } } + // Images applied live via `bootc apply-live bound-images` must survive + // even if the deployment they came from is discarded before reboot. + if let Some(live) = crate::applylive::read_state_from_host()? { + all_bound_images.extend( + live.images + .into_iter() + .map(|img| crate::boundimage::BoundImage { + image: img.image, + auth_file: None, + }), + ); + } // Convert to a hashset of just the image names let image_names = HashSet::from_iter(all_bound_images.iter().map(|img| img.image.as_str())); let pruned = sysroot diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index d9eccc0c8..3cb4c7be4 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -34,6 +34,7 @@ //! //! - [`image`] - Image operations and queries //! - [`boundimage`] - Logically Bound Images (LBIs) +//! - [`applylive`] - Applying staged content to the running system (`bootc apply-live`) //! - [`podstorage`] - bootc-owned container storage (`/usr/lib/bootc/storage`) //! - [`podman`] - Podman command helpers //! @@ -63,6 +64,7 @@ //! - [`linux-kernel-cmdline`](../linux_kernel_cmdline/index.html) - Cmdline parsing //! - [`etc-merge`](../etc_merge/index.html) - `/etc` three-way merge +mod applylive; mod bootc_composefs; pub(crate) mod bootc_kargs; mod bootloader; diff --git a/crates/lib/src/spec.rs b/crates/lib/src/spec.rs index 15ad1014f..90c1b74ad 100644 --- a/crates/lib/src/spec.rs +++ b/crates/lib/src/spec.rs @@ -426,6 +426,42 @@ impl Display for FilesystemOverlay { } } +/// A logically bound image definition that was applied to the running +/// system by `bootc apply-live bound-images`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LiveBoundImage { + /// The container image reference + pub image: String, + /// The quadlet file written under `/run/containers/systemd`, if any + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub quadlet: Option, + /// The systemd unit generated from the quadlet, if any + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub unit: Option, + /// True if the unit has not yet been restarted with this definition + /// (e.g. `--no-restart` was used, or the restart failed). Re-running + /// `bootc apply-live bound-images` will retry it. + #[serde(skip_serializing_if = "std::ops::Not::not")] + #[serde(default)] + pub pending: bool, +} + +/// Logically bound image definitions applied to the running system without +/// a reboot. This state lives in `/run` and is discarded on reboot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LiveBoundImages { + /// The ostree commit checksum of the deployment the definitions came from + pub checksum: String, + /// The deployment serial + pub deploy_serial: u32, + /// The applied definitions + pub images: Vec, +} + /// The status of the host system #[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -451,6 +487,12 @@ pub struct HostStatus { /// The state of the overlay mounted on /usr pub usr_overlay: Option, + /// Logically bound image definitions applied live to the booted + /// deployment via `bootc apply-live bound-images`. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub live_bound_images: Option, + /// Set to true if the physical root (`/sysroot`) is on a read-only medium /// (e.g. a live ISO) and so cannot be mutated; commands that would change /// the system (upgrade, switch, etc.) are not available. diff --git a/crates/lib/src/status.rs b/crates/lib/src/status.rs index 52ae08458..232859e26 100644 --- a/crates/lib/src/status.rs +++ b/crates/lib/src/status.rs @@ -428,6 +428,8 @@ pub(crate) fn get_status( .map(|d| d.unlocked()) .and_then(crate::spec::deployment_unlocked_state_to_usr_overlay); + let live_bound_images = crate::applylive::read_state_from_host()?; + let mut host = Host::new(spec); host.status = HostStatus { staged, @@ -437,6 +439,7 @@ pub(crate) fn get_status( rollback_queued, ty, usr_overlay, + live_bound_images, // Set by callers that have storage context (e.g. get_host). read_only: false, }; @@ -731,6 +734,7 @@ fn human_render_slot( // Show /usr overlay status write_usr_overlay(&mut out, slot, host_status, prefix_len)?; + write_live_bound_images(&mut out, slot, host_status, prefix_len)?; if verbose { // Show additional information in verbose mode similar to rpm-ostree @@ -790,6 +794,26 @@ fn write_usr_overlay( Ok(()) } +/// Helper function to render live-applied bound images +fn write_live_bound_images( + mut out: impl Write, + slot: Option, + host_status: &crate::spec::HostStatus, + prefix_len: usize, +) -> Result<()> { + // Only the booted deployment can have live-applied content + if !matches!(slot, Some(Slot::Booted)) { + return Ok(()); + } + if let Some(live) = host_status.live_bound_images.as_ref() { + write_row_name(&mut out, "Live bound images", prefix_len)?; + let n = live.images.iter().filter(|i| i.quadlet.is_some()).count(); + let short = live.checksum.get(..12).unwrap_or(&live.checksum); + writeln!(out, "{n} definition(s) applied from {short}")?; + } + Ok(()) +} + /// Output a rendering of a non-container boot entry. fn human_render_slot_ostree( mut out: impl Write, @@ -818,6 +842,7 @@ fn human_render_slot_ostree( // Show /usr overlay status write_usr_overlay(&mut out, slot, host_status, prefix_len)?; + write_live_bound_images(&mut out, slot, host_status, prefix_len)?; if verbose { // Show additional information in verbose mode similar to rpm-ostree diff --git a/docs/src/host-v1.schema.json b/docs/src/host-v1.schema.json index 686b71e04..cef7d6f15 100644 --- a/docs/src/host-v1.schema.json +++ b/docs/src/host-v1.schema.json @@ -317,6 +317,17 @@ } ] }, + "liveBoundImages": { + "description": "Logically bound image definitions applied live to the booted\ndeployment via `bootc apply-live bound-images`.", + "anyOf": [ + { + "$ref": "#/$defs/LiveBoundImages" + }, + { + "type": "null" + } + ] + }, "otherDeployments": { "description": "Other deployments (i.e. pinned)", "type": "array", @@ -485,6 +496,65 @@ "architecture" ] }, + "LiveBoundImage": { + "description": "A logically bound image definition that was applied to the running\nsystem by `bootc apply-live bound-images`.", + "type": "object", + "properties": { + "image": { + "description": "The container image reference", + "type": "string" + }, + "pending": { + "description": "True if the unit has not yet been restarted with this definition\n(e.g. `--no-restart` was used, or the restart failed). Re-running\n`bootc apply-live bound-images` will retry it.", + "type": "boolean" + }, + "quadlet": { + "description": "The quadlet file written under `/run/containers/systemd`, if any", + "type": [ + "string", + "null" + ] + }, + "unit": { + "description": "The systemd unit generated from the quadlet, if any", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "image" + ] + }, + "LiveBoundImages": { + "description": "Logically bound image definitions applied to the running system without\na reboot. This state lives in `/run` and is discarded on reboot.", + "type": "object", + "properties": { + "checksum": { + "description": "The ostree commit checksum of the deployment the definitions came from", + "type": "string" + }, + "deploySerial": { + "description": "The deployment serial", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "images": { + "description": "The applied definitions", + "type": "array", + "items": { + "$ref": "#/$defs/LiveBoundImage" + } + } + }, + "required": [ + "checksum", + "deploySerial", + "images" + ] + }, "ObjectMeta": { "type": "object", "properties": { diff --git a/docs/src/logically-bound-images.md b/docs/src/logically-bound-images.md index 6be695aef..37d39e3cf 100644 --- a/docs/src/logically-bound-images.md +++ b/docs/src/logically-bound-images.md @@ -56,6 +56,48 @@ Images are fetched using the global bootc pull secret by default (`/etc/ostree/a The bootc image store is owned by bootc; images will be garbage collected when they are no longer referenced by a file in `/usr/lib/bootc/bound-images.d`. +## Applying changes without a reboot (experimental) + +The container images for logically bound images live in a single bootc-owned +storage shared by all deployments, so after a `bootc upgrade` or `bootc switch` +the new images are already on disk. What is tied to a deployment is only the +*definition*: the symlink in `/usr/lib/bootc/bound-images.d` and the quadlet +file it references, which the running system reads from its own `/usr`. + +If the staged deployment differs from the booted one *only* in bound image +definitions, they can be applied to the running system without a reboot: + +``` +bootc apply-live bound-images +``` + +This writes the changed quadlet files to `/run/containers/systemd/`, which +podman gives precedence over `/etc` and `/usr`, reloads systemd and restarts +the affected `.container` units (removed units are stopped). Pass `--dry-run` +to see what would change, or `--no-restart` to write the definitions without +touching any units; a later plain invocation restarts the units still +pending, as does re-running after a failed restart. + +If the staged deployment contains any other change, the command fails and +lists the out-of-scope paths; a reboot is required to apply it. Note that a +locally modified quadlet in `/etc/containers/systemd` that also changed in +the image is reported this way, since the local copy wins in `/etc`. + +Because `/run` is transient, the override disappears on the next boot, at +which point the staged deployment's own content applies. (A soft reboot +preserves `/run`; the override is cleared when one is prepared.) The applied +state is shown in `bootc status` and recorded in `/run/bootc/apply-live/`; +images recorded there are protected from garbage collection until reboot, +even if the staged deployment is discarded (for example by `bootc rollback`). + +Limitations: only the `.container` or `.image` file referenced from +`bound-images.d` is applied. Quadlet drop-ins (`*.container.d/`) and other +referenced quadlet files (`.volume`, `.network`, `.pod`) are not copied, and +changes to them are treated as out of scope. A `.container` unit that +references a changed `.image` is not restarted unless its own file changed. +Removed units are stopped but not masked, so a dependency could start them +again until reboot. Only supported on ostree-backed systems. + ## Installation Logically bound images must be present in the default container store (`/var/lib/containers`) when invoking diff --git a/tmt/plans/integration.fmf b/tmt/plans/integration.fmf index 125ad4697..2c074b1de 100644 --- a/tmt/plans/integration.fmf +++ b/tmt/plans/integration.fmf @@ -314,4 +314,12 @@ execute: test: - /tmt/tests/tests/test-48-composefs-uki-dumpfile extra-skip_if_ostree: true + +/plan-49-logically-bound-apply-live: + summary: Execute logically bound images tests for bootc apply-live bound-images + discover: + how: fmf + test: + - /tmt/tests/tests/test-49-logically-bound-apply-live + extra-fixme_skip_if_composefs: true # END GENERATED PLANS diff --git a/tmt/tests/booted/test-logically-bound-apply-live.nu b/tmt/tests/booted/test-logically-bound-apply-live.nu new file mode 100644 index 000000000..b1b60a0f5 --- /dev/null +++ b/tmt/tests/booted/test-logically-bound-apply-live.nu @@ -0,0 +1,191 @@ +# number: 49 +# tmt: +# summary: Execute logically bound images tests for bootc apply-live bound-images +# duration: 30m +# extra: +# fixme_skip_if_composefs: true +# +# This test does: +# bootc switch to an image with a bound .container (v1) +# +# +# bootc upgrade to an image whose only change is the bound .container (v2) +# bootc apply-live bound-images --dry-run / --no-restart / (plain) +# +# bootc upgrade to an image with an unrelated change +# +# +# + +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 + +# Two distinct tags of the same small image so we can tell which one a +# container was started from. +const image_v1 = "registry.access.redhat.com/ubi9/ubi-minimal:9.4" +const image_v2 = "registry.access.redhat.com/ubi9/ubi-minimal:9.3" +const quadlet = "/usr/share/containers/systemd/lbi-sleeper.container" +const run_quadlet = "/run/containers/systemd/lbi-sleeper.container" +const unit = "lbi-sleeper.service" +const state_file = "/run/bootc/apply-live/bound-images.json" + +def initial_setup [] { + bootc image copy-to-storage + podman images + podman image inspect localhost/bootc | from json +} + +# Build a bootc image on top of the booted one with a single bound +# .container that just sleeps, referencing $image. If $extra is set, also +# add an unrelated file so the diff is out of scope for apply-live. +def build_image [name image extra] { + let td = mktemp -d + cd $td + mkdir usr/share/containers/systemd + $"[Container] +Image=($image) +Exec=sleep infinity +GlobalArgs=--storage-opt=additionalimagestore=/usr/lib/bootc/storage + +[Install] +WantedBy=multi-user.target +" | save usr/share/containers/systemd/lbi-sleeper.container + + mut dockerfile = "FROM localhost/bootc +COPY usr/ /usr/ +RUN ln -s /usr/share/containers/systemd/lbi-sleeper.container /usr/lib/bootc/bound-images.d/lbi-sleeper.container +" + if $extra { + $dockerfile = $dockerfile + "RUN echo unrelated > /usr/share/apply-live-out-of-scope.txt\n" + } + $dockerfile | save Dockerfile + podman build -t $name . +} + +# Return the image a running container was created from. The image lives in +# the bootc storage, so podman needs the same additional store the quadlet uses. +def running_image [] { + podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage inspect systemd-lbi-sleeper --format '{{.ImageName}}' | str trim +} + +def first_boot [] { + tap begin "bootc apply-live bound-images" + initial_setup + build_image localhost/bootc-lbi-live $image_v1 false + bootc switch --transport containers-storage localhost/bootc-lbi-live + tmt-reboot +} + +def second_boot [] { + print "verifying second boot after switch" + assert equal $booted.image.image localhost/bootc-lbi-live + systemctl is-active $unit + assert equal (running_image) $image_v1 + assert not ($run_quadlet | path exists) + assert equal $st.status.liveBoundImages? null + + # Without a staged deployment there's nothing to apply + let r = do { bootc apply-live bound-images } | complete + assert not equal $r.exit_code 0 + assert ($r.stderr | str contains "No staged deployment") + + # Stage an image whose only change is the bound container's image + print "bootc upgrade to v2 of the bound image" + build_image localhost/bootc-lbi-live $image_v2 false + bootc upgrade + let st = bootc status --json | from json + assert not equal $st.status.staged null + let staged_checksum = $st.status.staged.ostree.checksum + + # Dry run changes nothing + let out = bootc apply-live bound-images --dry-run + print $out + assert ($out | str contains $"updated: ($image_v2) \(($unit)\)") + assert not ($run_quadlet | path exists) + assert equal (running_image) $image_v1 + + # --no-restart writes the definition but leaves the unit alone + bootc apply-live lbi --no-restart + assert ($run_quadlet | path exists) + assert (open $run_quadlet | str contains $image_v2) + assert equal (getfattr --only-values -n security.selinux $run_quadlet | split row ':' | get 2) container_var_run_t + assert equal (running_image) $image_v1 + let state = open $state_file + assert equal $state.checksum $staged_checksum + let entry = $state.images | where unit? == $unit | first + assert equal $entry.pending true + let live = bootc status --json | from json | get status.liveBoundImages + assert equal $live.checksum $staged_checksum + + # A plain re-run restarts the pending unit + let out = bootc apply-live bound-images + print $out + assert ($out | str contains $"restart pending: ($unit)") + systemctl is-active $unit + assert equal (running_image) $image_v2 + let state = open $state_file + let entry = $state.images | where unit? == $unit | first + assert equal ($entry.pending? | default false) false + let human = bootc status --format humanreadable + print $human + assert ($human | str contains "Live bound images") + + # And is idempotent afterwards + let out = bootc apply-live bound-images + assert ($out | str contains "already applied") + assert equal (running_image) $image_v2 + + # The live-applied images survive garbage collection even without the + # staged deployment: rollback discards it (then revert the queued rollback + # so we still boot the default), and cleanup prunes the store. + bootc rollback + bootc rollback + let st = bootc status --json | from json + assert equal $st.status.staged null + assert equal $st.status.rollbackQueued false + bootc internals cleanup + let names = podman --storage-opt=additionalimagestore=/usr/lib/bootc/storage images --format '{{.Repository}}:{{.Tag}}' | lines + assert ($image_v2 in $names) + assert equal (running_image) $image_v2 + + # A staged deployment with an unrelated change is refused + print "bootc upgrade with an out-of-scope change" + build_image localhost/bootc-lbi-live $image_v2 true + bootc upgrade + let r = do { bootc apply-live bound-images } | complete + print $r.stderr + assert not equal $r.exit_code 0 + assert ($r.stderr | str contains "reboot is required") + assert ($r.stderr | str contains "/usr/share/apply-live-out-of-scope.txt") + # The previously applied definition is still live + assert equal (running_image) $image_v2 + + tmt-reboot +} + +def third_boot [] { + print "verifying third boot after upgrade" + # /run is fresh: no override, no state, and the deployment's own + # definition (v2) is what runs. + assert not ($run_quadlet | path exists) + assert not ($state_file | path exists) + assert equal $st.status.liveBoundImages? null + assert (open $quadlet | str contains $image_v2) + systemctl is-active $unit + assert equal (running_image) $image_v2 + tap ok +} + +def main [] { + match $env.TMT_REBOOT_COUNT? { + null | "0" => first_boot, + "1" => second_boot, + "2" => third_boot, + $o => { error make { msg: $"Invalid TMT_REBOOT_COUNT ($o)" } }, + } +} diff --git a/tmt/tests/tests.fmf b/tmt/tests/tests.fmf index 93364f28d..af57ee659 100644 --- a/tmt/tests/tests.fmf +++ b/tmt/tests/tests.fmf @@ -193,3 +193,8 @@ check: summary: Test composefs UKI dumpfile diff print duration: 30m test: nu booted/test-composefs-uki-dumpfile.nu + +/test-49-logically-bound-apply-live: + summary: Execute logically bound images tests for bootc apply-live bound-images + duration: 30m + test: nu booted/test-logically-bound-apply-live.nu