diff --git a/lib/Cargo.toml b/lib/Cargo.toml index cd38dd95..12212a21 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -40,6 +40,7 @@ tempfile = "3.3.0" toml = "0.7.2" xshell = { version = "0.2", optional = true } uuid = { version = "1.2.2", features = ["v4"] } +reqwest = { version = "0.11.14", features = ["json"] } [features] default = ["install"] diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 1da7a95f..cc9f19ab 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use camino::Utf8PathBuf; use clap::Parser; use fn_error_context::context; -use ostree::{gio, glib}; +use ostree::gio; use ostree_container::store::LayeredImageState; use ostree_container::store::PrepareResult; use ostree_container::OstreeImageReference; @@ -21,7 +21,6 @@ use std::os::unix::process::CommandExt; use std::process::Command; use crate::spec::Host; -use crate::spec::HostSpec; use crate::spec::ImageReference; /// Perform an upgrade operation @@ -131,6 +130,9 @@ pub(crate) enum Opt { /// Add a transient writable overlayfs on `/usr` that will be discarded on reboot. #[clap(alias = "usroverlay")] UsrOverlay, + /// Manipulate configuration + #[clap(subcommand)] + Config(crate::config::ConfigOpts), /// Install to the target block device #[cfg(feature = "install")] Install(crate::install::InstallOpts), @@ -229,45 +231,6 @@ async fn pull( Ok(import) } -/// Stage (queue deployment of) a fetched container image. -#[context("Staging")] -async fn stage( - sysroot: &SysrootLock, - stateroot: &str, - image: Box, - spec: &HostSpec, -) -> Result<()> { - let cancellable = gio::Cancellable::NONE; - let stateroot = Some(stateroot); - let merge_deployment = sysroot.merge_deployment(stateroot); - let origin = glib::KeyFile::new(); - let ostree_imgref = spec - .image - .as_ref() - .map(|imgref| OstreeImageReference::from(imgref.clone())); - if let Some(imgref) = ostree_imgref.as_ref() { - origin.set_string( - "origin", - ostree_container::deploy::ORIGIN_CONTAINER, - imgref.to_string().as_str(), - ); - } - let _new_deployment = sysroot.stage_tree_with_options( - stateroot, - image.merge_commit.as_str(), - Some(&origin), - merge_deployment.as_ref(), - &Default::default(), - cancellable, - )?; - if let Some(imgref) = ostree_imgref.as_ref() { - println!("Queued for next boot: {imgref}"); - } - ostree_container::deploy::remove_undeployed_images(sysroot).context("Pruning images")?; - - Ok(()) -} - #[context("Querying root privilege")] pub(crate) fn require_root() -> Result<()> { let uid = rustix::process::getuid(); @@ -282,7 +245,7 @@ pub(crate) fn require_root() -> Result<()> { /// A few process changes that need to be made for writing. #[context("Preparing for write")] -async fn prepare_for_write() -> Result<()> { +pub(crate) async fn prepare_for_write() -> Result<()> { if ostree_ext::container_utils::is_ostree_container()? { anyhow::bail!( "Detected container (ostree base); this command requires a booted host system." @@ -295,6 +258,11 @@ async fn prepare_for_write() -> Result<()> { Ok(()) } +pub(crate) fn target_deployment(sysroot: &SysrootLock) -> Result { + let booted_deployment = sysroot.require_booted_deployment()?; + Ok(sysroot.staged_deployment().unwrap_or(booted_deployment)) +} + /// Implementation of the `bootc upgrade` CLI command. #[context("Upgrading")] async fn upgrade(opts: UpgradeOpts) -> Result<()> { @@ -351,7 +319,7 @@ async fn upgrade(opts: UpgradeOpts) -> Result<()> { } let osname = booted_deployment.osname(); - stage(sysroot, &osname, fetched, &host.spec).await?; + crate::deploy::stage(sysroot, &osname, fetched, &host.spec).await?; } if let Some(path) = opts.touch_if_changed { std::fs::write(&path, "").with_context(|| format!("Writing {path}"))?; @@ -410,7 +378,7 @@ async fn switch(opts: SwitchOpts) -> Result<()> { } let stateroot = booted_deployment.osname(); - stage(sysroot, &stateroot, fetched, &new_spec).await?; + crate::deploy::stage(sysroot, &stateroot, fetched, &new_spec).await?; Ok(()) } @@ -448,7 +416,7 @@ async fn edit(opts: EditOpts) -> Result<()> { // TODO gc old layers here let stateroot = booted_deployment.osname(); - stage(sysroot, &stateroot, fetched, &new_host.spec).await?; + crate::deploy::stage(sysroot, &stateroot, fetched, &new_host.spec).await?; Ok(()) } @@ -476,6 +444,7 @@ where Opt::Switch(opts) => switch(opts).await, Opt::Edit(opts) => edit(opts).await, Opt::UsrOverlay => usroverlay().await, + Opt::Config(opts) => crate::config::run(opts).await, #[cfg(feature = "install")] Opt::Install(opts) => crate::install::install(opts).await, #[cfg(feature = "install")] diff --git a/lib/src/config.rs b/lib/src/config.rs new file mode 100644 index 00000000..3c2b7e5e --- /dev/null +++ b/lib/src/config.rs @@ -0,0 +1,529 @@ +use std::collections::{BTreeMap, HashMap}; +use std::io::Read; + +use anyhow::{anyhow, Context, Result}; +use camino::Utf8Path; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use fn_error_context::context; +use k8s_openapi::{api::core::v1::ConfigMap, http::HeaderValue}; +use ostree_ext::container as ostree_container; +use ostree_ext::prelude::{Cast, FileExt, InputStreamExtManual, ToVariant}; +use ostree_ext::{gio, glib, ostree}; +use ostree_ext::{ostree::Deployment, sysroot::SysrootLock}; +use reqwest::StatusCode; +use rustix::fd::AsRawFd; + +use crate::deploy::require_base_commit; + +/// The prefix used to store configmaps +const REF_PREFIX: &str = "bootc/config"; + +/// The key used to configure the file prefix; the default is `/etc`. +const CONFIGMAP_PREFIX_ANNOTATION_KEY: &str = "bootc.prefix"; +/// The default prefix for configmaps and secrets. +const DEFAULT_MOUNT_PREFIX: &str = "etc"; + +/// The key used to store the configmap metadata +const CONFIGMAP_METADATA_KEY: &str = "bootc.configmap.metadata"; +/// The key used to store the etag from the HTTP request +const CONFIGMAP_ETAG_KEY: &str = "bootc.configmap.etag"; + +/// Default to world-readable for configmaps +const DEFAULT_MODE: u32 = 0o644; + +const ORIGIN_BOOTC_CONFIG_PREFIX: &str = "bootc.config."; + +/// The serialized metadata about configmaps attached to a deployment +pub(crate) struct ConfigSpec { + pub(crate) name: String, + pub(crate) url: String, +} + +impl ConfigSpec { + const KEY_URL: &str = "url"; + + /// Return the keyfile group name + fn group(name: &str) -> String { + format!("{ORIGIN_BOOTC_CONFIG_PREFIX}{name}") + } + + /// Parse a config specification from a keyfile + #[context("Parsing config spec")] + fn from_keyfile(kf: &glib::KeyFile, name: &str) -> Result { + let group = Self::group(name); + let url = kf.string(&group, Self::KEY_URL)?.to_string(); + Ok(Self { + url, + name: name.to_string(), + }) + } + + /// Serialize this config spec into the target keyfile + fn store(&self, kf: &glib::KeyFile) { + let group = &Self::group(&self.name); + // Ignore errors if the group didn't exist + let _ = kf.remove_group(group); + kf.set_string(group, Self::KEY_URL, &self.url); + } + + /// Remove this config from the target; returns `true` if the value was present + fn remove(&self, kf: &glib::KeyFile) -> bool { + let group = &Self::group(&self.name); + kf.remove_group(group).is_ok() + } + + pub(crate) fn ostree_ref(&self) -> Result { + name_to_ostree_ref(&self.name) + } +} + +/// Options for internal testing +#[derive(Debug, clap::Subcommand)] +pub(crate) enum ConfigOpts { + /// Add a remote configmap + AddFromURL { + /// Remote URL for configmap + url: String, + + #[clap(long)] + /// Provide an explicit name for the map + name: Option, + }, + /// Show a configmap (in YAML format) + Show { + /// Name of the configmap to show + name: String, + }, + /// Add a remote configmap + Remove { + /// Name of the configmap to remove + name: String, + }, + /// Check for updates for an individual configmap + Update { + /// Name of the configmap to update + names: Vec, + }, + /// List attached configmaps + List, +} + +/// Implementation of the `boot config` CLI. +pub(crate) async fn run(opts: ConfigOpts) -> Result<()> { + crate::cli::prepare_for_write().await?; + let sysroot = &crate::cli::get_locked_sysroot().await?; + match opts { + ConfigOpts::AddFromURL { url, name } => add_from_url(sysroot, &url, name.as_deref()).await, + ConfigOpts::Remove { name } => remove(sysroot, name.as_str()).await, + ConfigOpts::Update { names } => update(sysroot, names.into_iter()).await, + ConfigOpts::Show { name } => show(sysroot, &name).await, + ConfigOpts::List => list(sysroot).await, + } +} + +#[context("Converting configmap name to ostree ref")] +fn name_to_ostree_ref(name: &str) -> Result { + ostree_ext::refescape::prefix_escape_for_ref(REF_PREFIX, name) +} + +/// Retrieve the "mount prefix" for the configmap +fn get_prefix(map: &ConfigMap) -> &str { + map.metadata + .annotations + .as_ref() + .and_then(|m| m.get(CONFIGMAP_PREFIX_ANNOTATION_KEY).map(|s| s.as_str())) + .unwrap_or(DEFAULT_MOUNT_PREFIX) +} + +async fn list(sysroot: &SysrootLock) -> Result<()> { + let merge_deployment = &crate::cli::target_deployment(sysroot)?; + let configs = configs_for_deployment(sysroot, merge_deployment)?; + if configs.len() == 0 { + println!("No dynamic ConfigMap objects attached"); + } else { + for config in configs { + let name = config.name; + let url = config.url; + println!("{name} {url}"); + } + } + Ok(()) +} + +fn load_config(sysroot: &SysrootLock, name: &str) -> Result { + let cancellable = gio::Cancellable::NONE; + let configref = name_to_ostree_ref(name)?; + let (r, rev) = sysroot.repo().read_commit(&configref, cancellable)?; + tracing::debug!("Inspecting {rev}"); + let commitv = sysroot.repo().load_commit(&rev)?.0; + let commitmeta = commitv.child_value(0); + let commitmeta = &glib::VariantDict::new(Some(&commitmeta)); + let cfgdata = commitmeta + .lookup_value(CONFIGMAP_METADATA_KEY, Some(glib::VariantTy::STRING)) + .ok_or_else(|| anyhow!("Missing metadata key {CONFIGMAP_METADATA_KEY}"))?; + let cfgdata = cfgdata.str().unwrap(); + let mut cfg: ConfigMap = serde_json::from_str(cfgdata)?; + let prefix = Utf8Path::new(get_prefix(&cfg).trim_start_matches('/')); + let d = r.child(prefix); + if let Some(v) = cfg.binary_data.as_mut() { + for (k, v) in v.iter_mut() { + let k = k.trim_start_matches('/'); + d.child(k) + .read(cancellable)? + .into_read() + .read_to_end(&mut v.0)?; + } + } + if let Some(v) = cfg.data.as_mut() { + for (k, v) in v.iter_mut() { + let k = k.trim_start_matches('/'); + d.child(k) + .read(cancellable)? + .into_read() + .read_to_string(v)?; + } + } + Ok(cfg) +} + +async fn show(sysroot: &SysrootLock, name: &str) -> Result<()> { + let config = load_config(sysroot, name)?; + let mut stdout = std::io::stdout().lock(); + serde_yaml::to_writer(&mut stdout, &config)?; + Ok(()) +} + +async fn remove(sysroot: &SysrootLock, name: &str) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let repo = &sysroot.repo(); + let merge_deployment = &crate::cli::target_deployment(sysroot)?; + let stateroot = merge_deployment.osname(); + let origin = merge_deployment + .origin() + .ok_or_else(|| anyhow::anyhow!("Deployment is missing an origin"))?; + let configs = configs_for_deployment(sysroot, merge_deployment)?; + let cfgspec = configs + .iter() + .find(|v| v.name == name) + .ok_or_else(|| anyhow::anyhow!("No config with name {name}"))?; + let removed = cfgspec.remove(&origin); + assert!(removed); + + let cfgref = cfgspec.ostree_ref()?; + tracing::debug!("Removing ref {cfgref}"); + repo.set_ref_immediate(None, &cfgref, None, cancellable)?; + + let merge_commit = merge_deployment.csum(); + let commit = require_base_commit(repo, &merge_commit)?; + let state = ostree_container::store::query_image_commit(repo, &commit)?; + crate::deploy::deploy(sysroot, Some(merge_deployment), &stateroot, state, &origin).await?; + crate::deploy::cleanup(sysroot).await?; + println!("Queued changes for next boot"); + + Ok(()) +} + +#[derive(Debug)] +struct HttpCachableReply { + content: T, + etag: Option, +} + +#[context("Writing configmap")] +fn write_configmap( + sysroot: &SysrootLock, + sepolicy: Option<&ostree::SePolicy>, + spec: &ConfigSpec, + map: &ConfigMap, + etag: Option<&str>, + cancellable: Option<&gio::Cancellable>, +) -> Result<()> { + use crate::ostree_generation::{create_and_commit_dirmeta, write_file}; + let name = spec.name.as_str(); + tracing::debug!("Writing configmap {name}"); + let oref = name_to_ostree_ref(&spec.name)?; + let repo = &sysroot.repo(); + let tx = repo.auto_transaction(cancellable)?; + let tree = &ostree::MutableTree::new(); + let dirmeta = + create_and_commit_dirmeta(&repo, "/etc/some-unshipped-config-file".into(), sepolicy)?; + // Create an iterator over the string data + let string_data = map.data.iter().flatten().map(|(k, v)| (k, v.as_bytes())); + // Create an iterator over the binary data + let binary_data = map + .binary_data + .iter() + .flatten() + .map(|(k, v)| (k, v.0.as_slice())); + let prefix = get_prefix(map); + tracing::trace!("prefix={prefix}"); + // For each string and binary value, write a file + let mut has_content = false; + for (k, v) in string_data.chain(binary_data) { + let path = Utf8Path::new(prefix).join(k); + tracing::trace!("Writing {path}"); + write_file(repo, tree, &path, &dirmeta, v, DEFAULT_MODE, sepolicy)?; + has_content = true; + } + if !has_content { + anyhow::bail!("ConfigMap has no data"); + } + // Empty out the values, since we wrote them into the ostree commit on the filesystem + let binary_data = map.binary_data.as_ref().map(|v| { + v.keys() + .map(|k| (k.clone(), k8s_openapi::ByteString(Vec::new()))) + .collect::>() + }); + let data = map.data.as_ref().map(|v| { + v.keys() + .map(|k| (k.clone(), "".to_string())) + .collect::>() + }); + let rest = ConfigMap { + binary_data, + data, + immutable: map.immutable.clone(), + metadata: map.metadata.clone(), + }; + let serialized_map_metadata = + serde_json::to_string(&rest).context("Serializing configmap metadata")?; + let mut metadata = HashMap::new(); + metadata.insert(CONFIGMAP_METADATA_KEY, serialized_map_metadata.to_variant()); + if let Some(etag) = etag { + metadata.insert(CONFIGMAP_ETAG_KEY, etag.to_variant()); + } + let timestamp = map + .metadata + .creation_timestamp + .as_ref() + .map(|t| t.0.timestamp() as u64) + .unwrap_or_default(); + tracing::trace!("Writing commit with ts {timestamp}"); + + let root = repo.write_mtree(&tree, cancellable)?; + let root = root.downcast_ref::().unwrap(); + let commit = repo.write_commit_with_time( + None, + None, + None, + Some(&metadata.to_variant()), + root, + timestamp, + cancellable, + )?; + repo.transaction_set_ref(None, &oref, Some(commit.as_str())); + tx.commit(cancellable)?; + + Ok(()) +} + +#[context("Fetching configmap from {url}")] +/// Download a configmap, honoring an optional ETag. If the server says the resource +/// is unmodified, this returns `Ok(None)`. +async fn fetch_configmap( + client: &reqwest::Client, + url: &str, + etag: Option<&str>, +) -> Result>> { + tracing::debug!("Fetching {url}"); + let mut req = client.get(url); + if let Some(etag) = etag { + tracing::trace!("Providing etag {etag}"); + let val = HeaderValue::from_str(etag).context("Parsing etag")?; + req = req.header(reqwest::header::IF_NONE_MATCH, val); + } + let reply = req.send().await?; + if reply.status() == StatusCode::NOT_MODIFIED { + tracing::debug!("Server returned NOT_MODIFIED"); + return Ok(None); + } + let etag = reply + .headers() + .get(reqwest::header::ETAG) + .map(|v| v.to_str()) + .transpose() + .context("Parsing etag")? + .map(ToOwned::to_owned); + // TODO: streaming deserialize + let buf = reply.bytes().await?; + tracing::trace!("Parsing server reply of {} bytes", buf.len()); + serde_yaml::from_slice(&buf) + .context("Deserializing configmap") + .map(|v| Some(HttpCachableReply { content: v, etag })) +} + +/// Download a configmap. +async fn fetch_required_configmap( + client: &reqwest::Client, + url: &str, +) -> Result> { + fetch_configmap(client, url, None) + .await? + .ok_or_else(|| anyhow::anyhow!("Server unexpectedly returned unmodified status")) +} + +/// Return the attached configmaps for a deployment. +#[context("Querying config names")] +pub(crate) fn configs_for_deployment( + _sysroot: &SysrootLock, + deployment: &Deployment, +) -> Result> { + let origin = deployment + .origin() + .ok_or_else(|| anyhow::anyhow!("Deployment is missing an origin"))?; + origin + .groups() + .0 + .into_iter() + .try_fold(Vec::new(), |mut acc, name| { + if let Some(name) = name.strip_prefix(ORIGIN_BOOTC_CONFIG_PREFIX) { + let spec = ConfigSpec::from_keyfile(&origin, name)?; + acc.push(spec); + } + anyhow::Ok(acc) + }) +} + +async fn add_from_url(sysroot: &SysrootLock, url: &str, name: Option<&str>) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let repo = &sysroot.repo(); + let merge_deployment = &crate::cli::target_deployment(sysroot)?; + let stateroot = merge_deployment.osname(); + let client = crate::utils::new_http_client().build()?; + let reply = fetch_required_configmap(&client, url).await?; + let configmap = reply.content; + let origin = merge_deployment + .origin() + .ok_or_else(|| anyhow::anyhow!("Deployment is missing an origin"))?; + let dirpath = sysroot.deployment_dirpath(merge_deployment); + // SAFETY: None of this should be NULL + let dirpath = sysroot.path().path().unwrap().join(dirpath); + let deployment_fd = Dir::open_ambient_dir(&dirpath, cap_std::ambient_authority()) + .with_context(|| format!("Opening deployment directory {dirpath:?}"))?; + let sepolicy = ostree::SePolicy::new_at(deployment_fd.as_raw_fd(), cancellable)?; + let name = name + .or_else(|| configmap.metadata.name.as_deref()) + .ok_or_else(|| anyhow!("Missing metadata.name and no name provided"))?; + let configs = configs_for_deployment(sysroot, merge_deployment)?; + if configs.iter().any(|v| v.name == name) { + anyhow::bail!("Already have a config with name {name}"); + } + let spec = ConfigSpec { + name: name.to_owned(), + url: url.to_owned(), + }; + let oref = name_to_ostree_ref(name)?; + tracing::trace!("configmap {name} => {oref}"); + // TODO use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + // once https://github.com/ostreedev/ostree/pull/2824 lands + write_configmap( + sysroot, + Some(&sepolicy), + &spec, + &configmap, + reply.etag.as_deref(), + cancellable, + )?; + println!("Stored configmap: {name}"); + + spec.store(&origin); + + let merge_commit = merge_deployment.csum(); + let commit = require_base_commit(repo, &merge_commit)?; + let state = ostree_container::store::query_image_commit(repo, &commit)?; + crate::deploy::deploy(sysroot, Some(merge_deployment), &stateroot, state, &origin).await?; + crate::deploy::cleanup(sysroot).await?; + println!("Queued changes for next boot"); + + Ok(()) +} + +async fn update_one_config( + sysroot: &SysrootLock, + merge_deployment: &ostree::Deployment, + configs: &[&ConfigSpec], + name: &str, + httpclient: &reqwest::Client, +) -> Result { + let cancellable = gio::Cancellable::NONE; + let repo = &sysroot.repo(); + let cfgspec = configs + .into_iter() + .find(|v| v.name == name) + .ok_or_else(|| anyhow::anyhow!("No config with name {name}"))?; + let cfgref = cfgspec.ostree_ref()?; + let cfg_commit = repo.require_rev(&cfgref)?; + let cfg_commitv = repo.load_commit(&cfg_commit)?.0; + let cfg_commitmeta = glib::VariantDict::new(Some(&cfg_commitv.child_value(0))); + let etag = cfg_commitmeta + .lookup::(CONFIGMAP_ETAG_KEY)? + .ok_or_else(|| anyhow!("Missing {CONFIGMAP_ETAG_KEY}"))?; + let reply = match fetch_configmap(httpclient, &cfgspec.url, Some(etag.as_str())).await? { + Some(v) => v, + None => { + return Ok(false); + } + }; + let dirpath = sysroot.deployment_dirpath(merge_deployment); + // SAFETY: None of this should be NULL + let dirpath = sysroot.path().path().unwrap().join(dirpath); + let deployment_fd = Dir::open_ambient_dir(&dirpath, cap_std::ambient_authority()) + .with_context(|| format!("Opening deployment directory {dirpath:?}"))?; + let sepolicy = ostree::SePolicy::new_at(deployment_fd.as_raw_fd(), cancellable)?; + write_configmap( + sysroot, + Some(&sepolicy), + cfgspec, + &reply.content, + reply.etag.as_deref(), + cancellable, + )?; + Ok(true) +} + +async fn update>( + sysroot: &SysrootLock, + names: impl Iterator, +) -> Result<()> { + let merge_deployment = &crate::cli::target_deployment(sysroot)?; + let origin = merge_deployment + .origin() + .ok_or_else(|| anyhow::anyhow!("Deployment is missing an origin"))?; + let configs = configs_for_deployment(sysroot, merge_deployment)?; + let configs = configs.iter().collect::>(); + let httpclient = &crate::utils::new_http_client().build()?; + let mut changed = false; + for name in names { + let name = name.as_ref(); + if update_one_config( + sysroot, + merge_deployment, + configs.as_slice(), + name, + httpclient, + ) + .await? + { + println!("Updated configmap {name}"); + changed = true; + } else { + println!("No changes in configmap {name}"); + } + } + + if !changed { + return Ok(()); + } + + let repo = &sysroot.repo(); + let stateroot = &merge_deployment.osname(); + let merge_commit = merge_deployment.csum(); + let commit = require_base_commit(repo, &merge_commit)?; + let state = ostree_container::store::query_image_commit(repo, &commit)?; + crate::deploy::deploy(sysroot, Some(merge_deployment), &stateroot, state, &origin).await?; + crate::deploy::cleanup(sysroot).await?; + println!("Queued changes for next boot"); + + Ok(()) +} diff --git a/lib/src/deploy.rs b/lib/src/deploy.rs new file mode 100644 index 00000000..f8d98418 --- /dev/null +++ b/lib/src/deploy.rs @@ -0,0 +1,265 @@ +//! # Write deployments merging image with configmap +//! +//! Create a merged filesystem tree with the image and mounted configmaps. + +use anyhow::{Context, Result}; + +use cap_std_ext::cap_tempfile; + +use fn_error_context::context; +use ostree::{gio, glib}; +use ostree_container::store::LayeredImageState; +use ostree_container::OstreeImageReference; +use ostree_ext::container as ostree_container; +use ostree_ext::ostree; +use ostree_ext::ostree::Deployment; +use ostree_ext::prelude::Cast; +use ostree_ext::prelude::ToVariant; +use ostree_ext::sysroot::SysrootLock; +use std::borrow::Cow; +use std::collections::HashMap; + +use crate::spec::HostSpec; + +// TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a +const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc"; +/// This is a temporary pointer used until a deployment is committed to +/// hold a strong reference to the base image. +const TMP_REF: &str = "tmp"; + +/// Set on an ostree commit if this is a derived commit +const BOOTC_DERIVED_KEY: &str = "bootc.derived"; + +pub(crate) async fn cleanup(sysroot: &SysrootLock) -> Result<()> { + let repo = sysroot.repo(); + let sysroot = sysroot.sysroot.clone(); + ostree_ext::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + let cancellable = Some(cancellable); + let repo = &repo; + let txn = repo.auto_transaction(cancellable)?; + let repo = txn.repo(); + + // Regenerate our base references. First, we delete the ones that exist + for ref_entry in repo + .list_refs_ext( + Some(BASE_IMAGE_PREFIX), + ostree::RepoListRefsExtFlags::NONE, + cancellable, + ) + .context("Listing refs")? + .keys() + { + repo.transaction_set_refspec(ref_entry, None); + } + + // Then, for each deployment which is derived (e.g. has configmaps) we synthesize + // a base ref to ensure that it's not GC'd. + for (i, deployment) in sysroot.deployments().into_iter().enumerate() { + let commit = deployment.csum(); + if let Some(base) = get_base_commit(repo, &commit)? { + repo.transaction_set_refspec(&format!("{BASE_IMAGE_PREFIX}/{i}"), Some(&base)); + } + } + + Ok(()) + }) + .await +} + +/// If commit is a bootc-derived commit (e.g. has configmaps), return its base. +#[context("Finding base commit")] +pub(crate) fn get_base_commit<'a>(repo: &ostree::Repo, commit: &'a str) -> Result> { + let commitv = repo.load_commit(&commit)?.0; + let commitmeta = commitv.child_value(0); + let commitmeta = &glib::VariantDict::new(Some(&commitmeta)); + let r = commitmeta + .lookup::(BOOTC_DERIVED_KEY)? + .map(|v| v.to_string()); + Ok(r) +} + +/// If commit is a bootc-derived commit (e.g. has configmaps), return its base. +/// Otherwise, return the commit input unchanged. +#[context("Finding base commit")] +pub(crate) fn require_base_commit<'a>( + repo: &ostree::Repo, + commit: &'a str, +) -> Result> { + let r = get_base_commit(repo, commit)? + .map(Cow::Owned) + .unwrap_or_else(|| Cow::Borrowed(commit)); + Ok(r) +} + +#[context("Writing deployment")] +pub(crate) async fn deploy( + sysroot: &SysrootLock, + merge_deployment: Option<&Deployment>, + stateroot: &str, + image: Box, + origin: &glib::KeyFile, +) -> Result<()> { + let stateroot = Some(stateroot); + // Copy to move into thread + let base_commit = image.get_commit().to_owned(); + let cancellable = gio::Cancellable::NONE; + let _new_deployment = sysroot.stage_tree_with_options( + stateroot, + &base_commit, + Some(origin), + merge_deployment, + &Default::default(), + cancellable, + )?; + Ok(()) +} + +/// Stage (queue deployment of) a fetched container image. +#[context("Staging")] +pub(crate) async fn stage( + sysroot: &SysrootLock, + stateroot: &str, + image: Box, + spec: &HostSpec, +) -> Result<()> { + let merge_deployment = sysroot.merge_deployment(Some(stateroot)); + let origin = glib::KeyFile::new(); + let ostree_imgref = spec + .image + .as_ref() + .map(|imgref| OstreeImageReference::from(imgref.clone())); + if let Some(imgref) = ostree_imgref.as_ref() { + origin.set_string( + "origin", + ostree_container::deploy::ORIGIN_CONTAINER, + imgref.to_string().as_str(), + ); + } + let repo = sysroot.repo(); + let configs = if let Some(merge_deployment) = merge_deployment.as_ref() { + crate::config::configs_for_deployment(sysroot, merge_deployment)? + } else { + Vec::new() + }; + let stateroot = Some(stateroot); + // Copy to move into thread + let base_commit = image.get_commit().to_owned(); + // If there's no configmaps, then all we need to do is deploy the commit. + if configs.is_empty() { + tracing::debug!("No configmaps to overlay"); + let cancellable = gio::Cancellable::NONE; + let _new_deployment = sysroot.stage_tree_with_options( + stateroot, + &base_commit, + Some(&origin), + merge_deployment.as_ref(), + &Default::default(), + cancellable, + )?; + // And we're done! + return Ok(()); + } + + tracing::debug!("Configmaps to overlay: {}", configs.len()); + let merge_commit = + ostree_ext::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + use rustix::fd::AsRawFd; + let cancellable = Some(cancellable); + let repo = &repo; + let txn = repo.auto_transaction(cancellable)?; + + let tmp_baseref = format!("{BASE_IMAGE_PREFIX}/{TMP_REF}"); + txn.repo() + .transaction_set_ref(None, &tmp_baseref, Some(image.merge_commit.as_str())); + drop(tmp_baseref); + + let devino = ostree::RepoDevInoCache::new(); + let repodir = repo.dfd_as_dir()?; + let repo_tmp = repodir.open_dir("tmp")?; + let td = cap_tempfile::TempDir::new_in(&repo_tmp)?; + + let rootpath = "root"; + let checkout_mode = if repo.mode() == ostree::RepoMode::Bare { + ostree::RepoCheckoutMode::None + } else { + ostree::RepoCheckoutMode::User + }; + let mut checkout_opts = ostree::RepoCheckoutAtOptions { + mode: checkout_mode, + overwrite_mode: ostree::RepoCheckoutOverwriteMode::UnionFiles, + devino_to_csum_cache: Some(devino.clone()), + no_copy_fallback: true, + force_copy_zerosized: true, + process_whiteouts: false, + ..Default::default() + }; + repo.checkout_at( + Some(&checkout_opts), + (*td).as_raw_fd(), + rootpath, + &base_commit, + cancellable, + ) + .context("Checking out base commit")?; + + // Layer all configmaps + checkout_opts.process_whiteouts = true; + for config in configs { + let oref = config.ostree_ref()?; + let commit = repo.require_rev(&oref)?; + repo.checkout_at( + Some(&checkout_opts), + (*td).as_raw_fd(), + rootpath, + &commit, + cancellable, + ) + .with_context(|| format!("Checking out layer {commit}"))?; + } + + let modifier = + ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::CONSUME, None); + modifier.set_devino_cache(&devino); + + let mt = ostree::MutableTree::new(); + repo.write_dfd_to_mtree( + (*td).as_raw_fd(), + rootpath, + &mt, + Some(&modifier), + cancellable, + ) + .context("Writing merged filesystem to mtree")?; + + let mut metadata = HashMap::new(); + metadata.insert(BOOTC_DERIVED_KEY, base_commit.to_variant()); + let metadata = metadata.to_variant(); + + let merged_root = repo + .write_mtree(&mt, cancellable) + .context("Writing mtree")?; + let merged_root = merged_root.downcast::().unwrap(); + let merged_commit = repo + .write_commit(None, None, None, Some(&metadata), &merged_root, cancellable) + .context("Writing commit")?; + txn.commit(cancellable)?; + + anyhow::Ok(merged_commit.to_string()) + }) + .await?; + // TODO spawn once origin files are Send + // let origin = origin.clone(); + // ostree_ext::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| { + { + let cancellable = gio::Cancellable::NONE; + let _new_deployment = sysroot.stage_tree_with_options( + stateroot, + merge_commit.as_str(), + Some(&origin), + merge_deployment.as_ref(), + &Default::default(), + cancellable, + )?; + anyhow::Ok(()) + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 58f27a3d..62aa77f1 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -14,11 +14,15 @@ #![deny(clippy::todo)] pub mod cli; +pub(crate) mod deploy; mod lsm; mod reexec; mod status; mod utils; +pub(crate) mod config; +pub(crate) mod ostree_generation; + #[cfg(feature = "internal-testing-api")] mod privtests; diff --git a/lib/src/lsm.rs b/lib/src/lsm.rs index a37f8f71..8183a024 100644 --- a/lib/src/lsm.rs +++ b/lib/src/lsm.rs @@ -11,6 +11,7 @@ use fn_error_context::context; use gvariant::{aligned_bytes::TryAsAligned, Marker, Structure}; #[cfg(feature = "install")] use ostree_ext::ostree; +use ostree_ext::prelude::ToVariant; #[cfg(feature = "install")] use crate::task::Task; @@ -171,3 +172,19 @@ pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool { } false } + +#[cfg(feature = "install")] +/// Given a SELinux policy and path, return a new set of extended attributes +/// including the SELinux label corresponding to that path, if any. +pub(crate) fn new_xattrs_with_selinux( + policy: &ostree::SePolicy, + path: &Utf8Path, + mode: u32, +) -> Result { + let label = policy.label(path.as_str(), mode, ostree_ext::gio::Cancellable::NONE)?; + let r = label + .iter() + .map(|label| (SELINUX_XATTR, label.as_bytes())) + .collect::>(); + Ok(r.to_variant()) +} diff --git a/lib/src/ostree_generation.rs b/lib/src/ostree_generation.rs new file mode 100644 index 00000000..729a84f3 --- /dev/null +++ b/lib/src/ostree_generation.rs @@ -0,0 +1,95 @@ +use anyhow::{anyhow, Result}; +use camino::{Utf8Component, Utf8Path}; +use fn_error_context::context; +use ostree_ext::{gio, glib, ostree}; + +/// The default access mode for directories: rwxr-xr-x +const DEFAULT_DIRECTORY_MODE: u32 = 0o755; + +/// Generate directory metadata variant for root/root 0755 directory with an optional SELinux label. +#[context("Creating dirmeta")] +pub(crate) fn create_dirmeta( + path: &Utf8Path, + sepolicy: Option<&ostree::SePolicy>, +) -> Result { + let finfo = gio::FileInfo::new(); + finfo.set_attribute_uint32("unix::uid", 0); + finfo.set_attribute_uint32("unix::gid", 0); + finfo.set_attribute_uint32("unix::mode", libc::S_IFDIR | DEFAULT_DIRECTORY_MODE); + let xattrs = sepolicy + .map(|policy| crate::lsm::new_xattrs_with_selinux(policy, path, 0o644)) + .transpose()?; + Ok(ostree::create_directory_metadata(&finfo, xattrs.as_ref())) +} + +/// Wraps [`create_dirmeta`] and commits it, returning the digest. +#[context("Committing dirmeta")] +pub(crate) fn create_and_commit_dirmeta( + repo: &ostree::Repo, + path: &Utf8Path, + sepolicy: Option<&ostree::SePolicy>, +) -> Result { + let v = create_dirmeta(path, sepolicy)?; + let r = repo.write_metadata( + ostree::ObjectType::DirMeta, + None, + &v, + gio::Cancellable::NONE, + )?; + Ok(r.to_hex()) +} + +// Drop any leading / or . from the path, +fn relative_path_components(p: &Utf8Path) -> impl Iterator { + p.components() + .filter(|p| matches!(p, Utf8Component::Normal(_))) +} + +#[context("Creating parents")] +fn ensure_parent_dirs( + mt: &ostree::MutableTree, + path: &Utf8Path, + metadata_checksum: &str, +) -> Result { + let parts = relative_path_components(path) + .map(|s| s.as_str()) + .collect::>(); + mt.ensure_parent_dirs(&parts, metadata_checksum) + .map_err(Into::into) +} + +#[context("Writing file to ostree repo")] +pub fn write_file( + repo: &ostree::Repo, + root: &ostree::MutableTree, + path: &Utf8Path, + parent_dirmeta: &str, + contents: &[u8], + mode: u32, + sepolicy: Option<&ostree::SePolicy>, +) -> Result<()> { + let name = path + .file_name() + .ok_or_else(|| anyhow!("Expecting a filename in {path}"))?; + let parent = if path.parent().is_some() { + Some(ensure_parent_dirs(root, &path, parent_dirmeta)?) + } else { + None + }; + let parent = parent.as_ref().unwrap_or(root); + let xattrs = sepolicy + .map(|policy| crate::lsm::new_xattrs_with_selinux(policy, path, 0o644)) + .transpose()?; + let xattrs = xattrs.as_ref(); + let checksum = repo.write_regfile_inline( + None, + 0, + 0, + libc::S_IFREG | mode, + xattrs, + contents, + gio::Cancellable::NONE, + )?; + parent.replace_file(name, checksum.as_str())?; + Ok(()) +} diff --git a/lib/src/spec.rs b/lib/src/spec.rs index e5c40e49..2fe07b84 100644 --- a/lib/src/spec.rs +++ b/lib/src/spec.rs @@ -22,6 +22,15 @@ use serde::{Deserialize, Serialize}; pub struct HostSpec { /// The host image pub image: Option, + /// Attached configs + pub configmap_sources: Vec, +} + +/// Remote location for a configmap +#[derive(Debug, Clone)] +pub struct ConfigReference { + /// URL for configmap + pub url: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -80,6 +89,8 @@ pub struct BootEntry { pub pinned: bool, /// If this boot entry is ostree based, the corresponding state pub ostree: Option, + /// Attached configmap objects + pub configmaps: Vec, } /// The status of the host system diff --git a/lib/src/status.rs b/lib/src/status.rs index 31d59ed9..45f962e9 100644 --- a/lib/src/status.rs +++ b/lib/src/status.rs @@ -92,27 +92,28 @@ fn boot_entry_from_deployment( deployment: &ostree::Deployment, ) -> Result { let repo = &sysroot.repo(); - let (image, incompatible) = if let Some(origin) = deployment.origin().as_ref() { - if let Some(image) = get_image_origin(origin)? { - let image = ImageReference::from(image); + let mut incompatible = false; + let configmaps = crate::config::configs_for_deployment(sysroot, deployment)? + .into_iter() + .map(|v| v.name) + .collect(); + let mut image = None; + if let Some(origin) = deployment.origin().as_ref() { + if let Some(imgref) = get_image_origin(origin)? { + let imgref = ImageReference::from(imgref); let csum = deployment.csum(); - let incompatible = crate::utils::origin_has_rpmostree_stuff(origin); + incompatible = crate::utils::origin_has_rpmostree_stuff(origin); let imgstate = ostree_container::store::query_image_commit(repo, &csum)?; - ( - Some(ImageStatus { - image, - image_digest: imgstate.manifest_digest, - }), - incompatible, - ) - } else { - (None, false) + image = Some(ImageStatus { + image: imgref, + image_digest: imgstate.manifest_digest, + }); } - } else { - (None, false) - }; + } + let r = BootEntry { image, + configmaps, incompatible, pinned: deployment.is_pinned(), ostree: Some(crate::spec::BootEntryOstree { @@ -173,9 +174,15 @@ pub(crate) fn get_status( let spec = staged .as_ref() .or(booted.as_ref()) - .and_then(|entry| entry.image.as_ref()) - .map(|img| HostSpec { - image: Some(img.image.clone()), + .and_then(|entry| { + if let Some(img) = entry.image.as_ref() { + Some(HostSpec { + image: Some(img.image.clone()), + configmap_sources: entry.configmaps.iter().cloned().collect(), + }) + } else { + None + } }) .unwrap_or_default(); let mut host = Host::new(OBJECT_NAME, spec); @@ -195,7 +202,7 @@ pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> { is_container: true, ..Default::default() }; - let mut r = Host::new(OBJECT_NAME, HostSpec { image: None }); + let mut r = Host::new(OBJECT_NAME, HostSpec::default()); r.status = Some(status); r } else { diff --git a/lib/src/utils.rs b/lib/src/utils.rs index bed352d3..b3b180b4 100644 --- a/lib/src/utils.rs +++ b/lib/src/utils.rs @@ -5,6 +5,11 @@ use anyhow::{Context, Result}; use ostree::glib; use ostree_ext::ostree; +pub(crate) fn new_http_client() -> reqwest::ClientBuilder { + const USER_AGENT: &str = env!("CARGO_PKG_VERSION"); + reqwest::Client::builder().user_agent(USER_AGENT) +} + /// Try to look for keys injected by e.g. rpm-ostree requesting machine-local /// changes; if any are present, return `true`. pub(crate) fn origin_has_rpmostree_stuff(kf: &glib::KeyFile) -> bool {