From 3d50b17155feae535caf04119513fc7c7c34c460 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Thu, 19 Oct 2023 17:22:27 -0400 Subject: [PATCH] wip --- lib/Cargo.toml | 1 + lib/src/cli.rs | 10 + lib/src/config.rs | 565 +++++++++++++++++++++++++++++++++++ lib/src/deploy.rs | 174 +++++++++-- lib/src/k8sapitypes.rs | 64 ++++ lib/src/lib.rs | 3 + lib/src/lsm.rs | 19 ++ lib/src/ostree_generation.rs | 95 ++++++ lib/src/spec.rs | 11 + lib/src/status.rs | 20 +- 10 files changed, 925 insertions(+), 37 deletions(-) create mode 100644 lib/src/config.rs create mode 100644 lib/src/ostree_generation.rs diff --git a/lib/Cargo.toml b/lib/Cargo.toml index dfee6a003..d42c0eb53 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -42,6 +42,7 @@ tempfile = "3.3.0" toml = "0.7.2" xshell = { version = "0.2", optional = true } uuid = { version = "1.2.2", features = ["v4"] } +containers-image-proxy = { version = "0.5.7", features = ["proxy_v0_2_4" ] } [features] default = ["install"] diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 44f688b5c..66af9f334 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -14,6 +14,7 @@ use ostree_ext::container as ostree_container; use ostree_ext::container::SignatureSource; use ostree_ext::keyfileext::KeyFileExt; use ostree_ext::ostree; +use ostree_ext::sysroot::SysrootLock; use std::ffi::OsString; use std::io::Seek; use std::os::unix::process::CommandExt; @@ -143,6 +144,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), @@ -278,6 +282,11 @@ pub(crate) 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<()> { @@ -478,6 +487,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> { 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 000000000..37d9e63a8 --- /dev/null +++ b/lib/src/config.rs @@ -0,0 +1,565 @@ +use std::collections::HashMap; +use std::io::Read; + +use crate::deploy::RequiredHostSpec; +use crate::k8sapitypes::ConfigMap; +use anyhow::{anyhow, Context, Result}; +use camino::Utf8Path; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use containers_image_proxy::ImageProxy; +use fn_error_context::context; +use ostree_ext::container as ostree_container; +use ostree_ext::oci_spec; +use ostree_ext::prelude::{Cast, FileExt, InputStreamExtManual, ToVariant}; +use ostree_ext::{gio, glib, ostree}; +use ostree_ext::{ostree::Deployment, sysroot::SysrootLock}; +use rustix::fd::AsRawFd; +use tokio::io::AsyncReadExt; + +/// The media type of a configmap stored in a registry as an OCI artifact +const MEDIA_TYPE_CONFIGMAP: &str = "application/containers.configmap+json"; + +const CONFIGMAP_SIZE_LIMIT: u32 = 1_048_576; + +/// 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_MANIFEST_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) imgref: ostree_container::ImageReference, +} + +pub(crate) struct ConfigMapObject { + manifest: oci_spec::image::ImageManifest, + config: ConfigMap, +} + +impl ConfigSpec { + const KEY_IMAGE: &str = "imageref"; + + /// 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 imgref = kf.string(&group, Self::KEY_IMAGE)?; + let imgref = imgref.as_str().try_into()?; + Ok(Self { + imgref, + 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_IMAGE, &self.imgref.to_string()); + } + + /// 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 + Add { + /// Container registry pull specification; this must refer to an OCI artifact + imgref: String, + + /// The transport; e.g. oci, oci-archive. Defaults to `registry`. + #[clap(long, default_value = "registry")] + transport: 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::Add { + imgref, + transport, + name, + } => { + let transport = ostree_container::Transport::try_from(transport.as_str())?; + let imgref = ostree_container::ImageReference { + transport, + name: imgref, + }; + add(sysroot, &imgref, 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, + } +} + +async fn new_proxy() -> Result { + let mut config = containers_image_proxy::ImageProxyConfig::default(); + ostree_container::merge_default_container_proxy_opts(&mut config)?; + containers_image_proxy::ImageProxy::new_with_config(config).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 { + println!("{} {}", config.name.as_str(), config.imgref); + } + } + 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_MANIFEST_KEY, Some(glib::VariantTy::STRING)) + .ok_or_else(|| anyhow!("Missing metadata key {CONFIGMAP_MANIFEST_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)?; + + Ok(()) +} + +#[context("Writing configmap")] +fn write_configmap( + sysroot: &SysrootLock, + sepolicy: Option<&ostree::SePolicy>, + spec: &ConfigSpec, + cfgobj: &ConfigMapObject, + 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)?; + { + let serialized = serde_json::to_string(&cfgobj.config).context("Serializing")?; + write_file( + repo, + tree, + "config.json".into(), + &dirmeta, + serialized.as_bytes(), + DEFAULT_MODE, + sepolicy, + )?; + } + let mut metadata = HashMap::new(); + let serialized_manifest = + serde_json::to_string(&cfgobj.manifest).context("Serializing manifest")?; + metadata.insert(CONFIGMAP_MANIFEST_KEY, serialized_manifest.to_variant()); + let timestamp = cfgobj + .manifest + .annotations() + .as_ref() + .and_then(|m| m.get(oci_spec::image::ANNOTATION_CREATED)) + .map(|v| chrono::DateTime::parse_from_rfc3339(v)) + .transpose() + .context("Parsing created annotation")? + .map(|t| t.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("Reading configmap")] +fn read_configmap_data( + repo: &ostree::Repo, + rev: &str, + cancellable: Option<&gio::Cancellable>, +) -> Result { + let root = repo.read_commit(rev, cancellable)?.0; + let reader = root.child("config.json").read(cancellable)?; + let mut reader = reader.into_read(); + serde_json::from_reader(&mut reader).map_err(anyhow::Error::msg) +} + +#[context("Applying configmap")] +pub(crate) fn apply_configmap( + repo: &ostree::Repo, + root: &ostree::MutableTree, + sepolicy: Option<&ostree::SePolicy>, + name: &str, + cancellable: Option<&gio::Cancellable>, +) -> Result<()> { + let oref = name_to_ostree_ref(name)?; + let map = &read_configmap_data(repo, &oref, cancellable)?; + let dirmeta = crate::ostree_generation::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}"); + crate::ostree_generation::write_file( + repo, + root, + &path, + &dirmeta, + v, + DEFAULT_MODE, + sepolicy, + )?; + has_content = true; + } + if !has_content { + anyhow::bail!("ConfigMap has no data"); + } + Ok(()) +} + +/// Parse a manifest, returning the single configmap descriptor (layer) +fn configmap_object_from_manifest( + manifest: &oci_spec::image::ImageManifest, +) -> Result<&oci_spec::image::Descriptor> { + let l = match manifest.layers().as_slice() { + [] => anyhow::bail!("No layers in configmap manifest"), + [l] => l, + o => anyhow::bail!( + "Expected exactly one layer in configmap manifest, found: {}", + o.len() + ), + }; + match l.media_type() { + oci_spec::image::MediaType::Other(o) if o.as_str() == MEDIA_TYPE_CONFIGMAP => Ok(l), + o => anyhow::bail!("Expected media type {MEDIA_TYPE_CONFIGMAP} but found: {o}"), + } +} + +#[context("Fetching configmap from {imgref}")] +/// Download a configmap, honoring a previous manifest digest. If the digest +/// hasn't changed, then this function will return None. +async fn fetch_configmap( + proxy: &ImageProxy, + imgref: &ostree_container::ImageReference, + previous_manifest_digest: Option<&str>, +) -> Result>> { + tracing::debug!("Fetching {imgref}"); + let imgref = imgref.to_string(); + let oimg = proxy.open_image(&imgref).await?; + let (digest, manifest) = proxy.fetch_manifest(&oimg).await?; + if previous_manifest_digest == Some(digest.as_str()) { + return Ok(None); + } + let layer = configmap_object_from_manifest(&manifest)?; + // Layer sizes shouldn't be negative + let layer_size = u64::try_from(layer.size()).unwrap(); + let layer_size = u32::try_from(layer_size)?; + if layer_size > CONFIGMAP_SIZE_LIMIT { + anyhow::bail!( + "configmap size limit is {CONFIGMAP_SIZE_LIMIT} bytes, found: {}", + glib::format_size(layer_size.into()) + ) + } + let (mut configmap_reader, driver) = proxy + .get_blob(&oimg, layer.digest(), layer_size.into()) + .await?; + let mut configmap_blob = String::new(); + let reader = configmap_reader.read_to_string(&mut configmap_blob); + let (reader, driver) = tokio::join!(reader, driver); + let _ = reader?; + driver?; + + let config: ConfigMap = serde_json::from_str(&configmap_blob).context("Parsing configmap")?; + Ok(Some(Box::new(ConfigMapObject { manifest, config }))) +} + +/// Download a configmap. +async fn fetch_required_configmap( + proxy: &containers_image_proxy::ImageProxy, + imgref: &ostree_container::ImageReference, +) -> Result> { + // SAFETY: We must get a new configmap here + fetch_configmap(proxy, imgref, None) + .await + .map(|v| v.expect("internal error: expected configmap")) +} + +/// 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() + .into_iter() + .try_fold(Vec::new(), |mut acc, name| { + let name = name.to_str(); + 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( + sysroot: &SysrootLock, + imgref: &ostree_container::ImageReference, + name: Option<&str>, +) -> Result<()> { + let cancellable = gio::Cancellable::NONE; + let (booted_deployment, _deployments, host) = + crate::status::get_status_require_booted(sysroot)?; + let spec = RequiredHostSpec::from_spec(&host.spec)?; + let repo = &sysroot.repo(); + let importer = new_proxy().await?; + let cfgobj = fetch_required_configmap(&importer, imgref).await?; + let name = name + .or_else(|| cfgobj.config.metadata.name.as_deref()) + .ok_or_else(|| anyhow!("Missing metadata.name and no name provided"))?; + if spec.configmaps.iter().any(|v| v == name) { + anyhow::bail!("Config with name '{name}' already attached"); + } + let spec = ConfigSpec { + name: name.to_owned(), + imgref: imgref.clone(), + }; + 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, &cfgobj, 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, + proxy: &ImageProxy, +) -> 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 cfgobj = match fetch_configmap(proxy, &cfgspec.imgref, 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, &cfgobj, cancellable)?; + Ok(true) +} + +async fn update>( + sysroot: &SysrootLock, + names: impl Iterator, +) -> Result<()> { + let proxy = &new_proxy().await?; + 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 mut changed = false; + for name in names { + let name = name.as_ref(); + if update_one_config(sysroot, merge_deployment, configs.as_slice(), name, proxy).await? { + println!("Updated configmap {name}"); + changed = true; + } else { + println!("No changes in configmap {name}"); + } + } + + if !changed { + return Ok(()); + } + + Ok(()) +} diff --git a/lib/src/deploy.rs b/lib/src/deploy.rs index 45936a071..db969483b 100644 --- a/lib/src/deploy.rs +++ b/lib/src/deploy.rs @@ -4,20 +4,28 @@ use anyhow::{Context, Result}; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::cap_tempfile; + use fn_error_context::context; +use glib::prelude::{Cast, ToVariant}; 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::sysroot::SysrootLock; +use std::borrow::Cow; +use std::collections::HashMap; use crate::spec::HostSpec; use crate::spec::ImageReference; // 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"; @@ -25,6 +33,7 @@ const BOOTC_DERIVED_KEY: &str = "bootc.derived"; /// Variant of HostSpec but required to be filled out pub(crate) struct RequiredHostSpec<'a> { pub(crate) image: &'a ImageReference, + pub(crate) configmaps: &'a Vec, } impl<'a> RequiredHostSpec<'a> { @@ -35,7 +44,8 @@ impl<'a> RequiredHostSpec<'a> { .image .as_ref() .ok_or_else(|| anyhow::anyhow!("Missing image in specification"))?; - Ok(Self { image }) + let configmaps = &spec.configmap_sources; + Ok(Self { image, configmaps }) } } @@ -85,27 +95,17 @@ pub(crate) fn get_base_commit(repo: &ostree::Repo, commit: &str) -> Result, - stateroot: &str, - image: &LayeredImageState, - 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(()) +/// 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) } /// Stage (queue deployment of) a fetched container image. @@ -116,22 +116,131 @@ pub(crate) async fn stage( image: &LayeredImageState, spec: &RequiredHostSpec<'_>, ) -> Result<()> { + let repo = sysroot.repo(); let merge_deployment = sysroot.merge_deployment(Some(stateroot)); let origin = glib::KeyFile::new(); let imgref = OstreeImageReference::from(spec.image.clone()); + let base_commit = require_base_commit(&repo, &image.merge_commit)?; origin.set_string( "origin", ostree_container::deploy::ORIGIN_CONTAINER, imgref.to_string().as_str(), ); - crate::deploy::deploy( - sysroot, - merge_deployment.as_ref(), - stateroot, - &image, - &origin, - ) - .await?; + if spec.configmaps.is_empty() { + let stateroot = Some(stateroot); + 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.as_ref(), + &Default::default(), + cancellable, + )?; + } else { + tracing::debug!("Configmaps to overlay: {}", spec.configmaps.len()); + // Create cloned data to pass into thread + let configmaps = spec.configmaps.clone(); + let image_merge_commit = image.merge_commit.clone(); + let base_commit = base_commit.into_owned(); + 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 = Dir::reopen_dir(&repo.dfd_borrow())?; + 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")?; + + let sepolicy = ostree::SePolicy::new_at((*td).as_raw_fd(), cancellable)?; + let mt = ostree::MutableTree::new(); + + // Layer all configmaps + checkout_opts.process_whiteouts = true; + for name in configmaps { + crate::config::apply_configmap(repo, &mt, Some(&sepolicy), &name, cancellable)?; + } + + let modifier = + ostree::RepoCommitModifier::new(ostree::RepoCommitModifierFlags::CONSUME, None); + modifier.set_devino_cache(&devino); + + 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( + Some(stateroot), + merge_commit.as_str(), + Some(&origin), + merge_deployment.as_ref(), + &Default::default(), + cancellable, + )?; + } + } crate::deploy::cleanup(sysroot).await?; println!("Queued for next boot: {imgref}"); if let Some(version) = image @@ -143,6 +252,5 @@ pub(crate) async fn stage( } println!(" Digest: {}", image.manifest_digest); ostree_container::deploy::remove_undeployed_images(sysroot).context("Pruning images")?; - - Ok(()) + anyhow::Ok(()) } diff --git a/lib/src/k8sapitypes.rs b/lib/src/k8sapitypes.rs index c2b8a87a7..41489b87e 100644 --- a/lib/src/k8sapitypes.rs +++ b/lib/src/k8sapitypes.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; +use openssl::base64; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -26,3 +27,66 @@ pub struct ObjectMeta { #[serde(skip_serializing_if = "Option::is_none")] pub namespace: Option, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ByteString(pub Vec); + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConfigMap { + pub binary_data: Option>, + pub data: Option>, + pub immutable: Option, + pub metadata: ObjectMeta, +} + +impl<'de> serde::Deserialize<'de> for ByteString { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = ByteString; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a base64-encoded string") + } + + fn visit_none(self) -> Result + where + E: serde::de::Error, + { + Ok(ByteString(vec![])) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_str(self) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + let v = base64::decode_block(v).map_err(serde::de::Error::custom)?; + Ok(ByteString(v)) + } + } + + deserializer.deserialize_option(Visitor) + } +} + +impl serde::Serialize for ByteString { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + let s = base64::encode_block(&self.0); + s.serialize(serializer) + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index a725301cb..d71f7daba 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -16,10 +16,13 @@ pub mod cli; pub(crate) mod deploy; mod lsm; +mod ostree_generation; mod reexec; mod status; mod utils; +pub(crate) mod config; + #[cfg(feature = "internal-testing-api")] mod privtests; diff --git a/lib/src/lsm.rs b/lib/src/lsm.rs index a37f8f71f..f5d209730 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,21 @@ 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 { + use ostree_ext::prelude::ToVariant; + + 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 000000000..729a84f3b --- /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 2e7e53dc2..f8b7eeda6 100644 --- a/lib/src/spec.rs +++ b/lib/src/spec.rs @@ -29,6 +29,15 @@ pub struct Host { 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)] @@ -91,6 +100,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 29079bf7e..2008d4ff3 100644 --- a/lib/src/status.rs +++ b/lib/src/status.rs @@ -109,6 +109,10 @@ fn boot_entry_from_deployment( deployment: &ostree::Deployment, ) -> Result { let repo = &sysroot.repo(); + let configmaps = crate::config::configs_for_deployment(sysroot, deployment)? + .into_iter() + .map(|v| v.name) + .collect(); let (image, incompatible) = if let Some(origin) = deployment.origin().as_ref() { if let Some(image) = get_image_origin(origin)? { let image = ImageReference::from(image); @@ -142,8 +146,10 @@ fn boot_entry_from_deployment( } else { (None, false) }; + let r = BootEntry { image, + configmaps, incompatible, pinned: deployment.is_pinned(), ostree: Some(crate::spec::BootEntryOstree { @@ -230,9 +236,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); @@ -252,7 +264,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 = status; r } else {