Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
855 changes: 855 additions & 0 deletions crates/lib/src/applylive.rs

Large diffs are not rendered by default.

112 changes: 108 additions & 4 deletions crates/lib/src/boundimage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String>,
}

/// 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,
Expand Down Expand Up @@ -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<Vec<BoundImage>> {
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<Utf8PathBuf> {
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<Vec<BoundImageSpec>> {
let spec_dir = BOUND_IMAGE_DIR;
let Some(bound_images_dir) = root.open_dir_optional(spec_dir)? else {
tracing::debug!("Missing {spec_dir}");
Expand Down Expand Up @@ -112,7 +156,19 @@ pub(crate) fn query_bound_images(root: &Dir) -> Result<Vec<BoundImage>> {
_ => 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)
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -1941,6 +1985,19 @@ async fn run_from_opt(opt: Opt) -> Result<CliExitStatus> {
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()?;
Expand Down
12 changes: 12 additions & 0 deletions crates/lib/src/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand Down Expand Up @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions crates/lib/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// The systemd unit generated from the quadlet, if any
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub unit: Option<String>,
/// 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<LiveBoundImage>,
}

/// The status of the host system
#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "camelCase")]
Expand All @@ -451,6 +487,12 @@ pub struct HostStatus {
/// The state of the overlay mounted on /usr
pub usr_overlay: Option<FilesystemOverlay>,

/// 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<LiveBoundImages>,

/// 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.
Expand Down
Loading
Loading