From 9da08da3739b32fca6098c8ad1190faa320d8dab Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Wed, 17 Jan 2024 14:05:15 -0500 Subject: [PATCH] install: Add `--copy-etc` This allows injection of arbitrary config files from an external source into the target root. This is pretty low tech...I'd really like to also support structured, cleanly "day 2" updatable configmaps, etc. But there is simply no getting away from the generally wanting the ability to inject arbitrary machine-local external state today. It's the lowest common denominitator that applies across many use cases. We're agnostic to *how* the data is provided; that could be fetched from cloud instance metadata, the hypervisor, a USB stick, config state provided for bootc-image-builder, etc. Just one technical implementation point, we do handle SELinux labeling here in a consistent way at least. Signed-off-by: Colin Walters --- lib/src/install.rs | 185 +++++++++++++++++++++++++++++++++++++++-- lib/src/lsm.rs | 16 ++-- tests/kolainst/install | 15 +++- 3 files changed, 199 insertions(+), 17 deletions(-) diff --git a/lib/src/install.rs b/lib/src/install.rs index e3726c4c..c93a6d0f 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -23,7 +23,9 @@ use anyhow::{anyhow, Context, Result}; use camino::Utf8Path; use camino::Utf8PathBuf; use cap_std::fs::{Dir, MetadataExt}; +use cap_std_ext::cap_primitives; use cap_std_ext::cap_std; +use cap_std_ext::cap_std::io_lifetimes::AsFilelike; use cap_std_ext::prelude::CapStdExtDirExt; use chrono::prelude::*; use clap::ValueEnum; @@ -138,6 +140,27 @@ pub(crate) struct InstallConfigOpts { #[clap(long)] karg: Option>, + /// Inject arbitrary files into the target deployment `/etc`. One can use + /// this for example to inject systemd units, or `tmpfiles.d` snippets + /// which set up SSH keys. + /// + /// Files injected this way become "unmanaged state"; they will be carried + /// forward across upgrades, but will not otherwise be updated unless + /// a secondary mechanism takes ownership thereafter. + /// + /// This option can be specified multiple times; the files will be copied + /// in order. + /// + /// Any missing parent directories will be implicitly created with root ownership + /// and mode 0755. + /// + /// This option pairs well with additional bind mount + /// volumes set up via the container orchestrator, e.g.: + /// `podman run ... -v /path/to/config:/tmp/etc bootc install to-disk --copy-etc /tmp/etc` + #[clap(long)] + #[serde(default)] + pub(crate) copy_etc: Option>, + /// The path to an `authorized_keys` that will be injected into the `root` account. /// /// The implementation of this uses systemd `tmpfiles.d`, writing to a file named @@ -672,6 +695,24 @@ async fn initialize_ostree_root_from_self( osconfig::inject_root_ssh_authorized_keys(&root, sepolicy, contents)?; } + // Copy unmanaged configuration + let target_etc = root.open_dir("etc").context("Opening deployment /etc")?; + let copy_etc = state + .config_opts + .copy_etc + .iter() + .flatten() + .cloned() + .collect::>(); + for src in copy_etc { + println!("Injecting configuration from {src}"); + let src = Dir::open_ambient_dir(&src, cap_std::ambient_authority()) + .with_context(|| format!("Opening {src}"))?; + let mut pb = ".".into(); + let n = copy_unmanaged_etc(sepolicy, &src, &target_etc, &mut pb)?; + tracing::debug!("Copied config files: {n}"); + } + let uname = rustix::system::uname(); let labels = crate::status::labels_of_config(&imgstate.configuration); @@ -1077,6 +1118,70 @@ async fn prepare_install( Ok(state) } +// Backing implementation of --copy-etc; just your basic +// recursive copy algorithm. Parent directories are +// created as necessary +fn copy_unmanaged_etc( + sepolicy: Option<&ostree::SePolicy>, + src: &Dir, + dest: &Dir, + path: &mut Utf8PathBuf, +) -> Result { + let mut r = 0u64; + for ent in src.read_dir(&path)? { + let ent = ent?; + let name = ent.file_name(); + let name = if let Some(name) = name.to_str() { + name + } else { + anyhow::bail!("Non-UTF8 name: {name:?}"); + }; + let meta = ent.metadata()?; + // Build the relative path + path.push(Utf8Path::new(name)); + // And the absolute path for looking up SELinux labels + let as_path = { + let mut p = Utf8PathBuf::from("/etc"); + p.push(&path); + p + }; + r += 1; + if meta.is_dir() { + if let Some(parent) = path.parent() { + dest.create_dir_all(parent) + .with_context(|| format!("Creating {parent}"))?; + } + crate::lsm::ensure_dir_labeled( + dest, + &path, + Some(&as_path), + meta.mode().into(), + sepolicy, + )?; + r += copy_unmanaged_etc(sepolicy, src, dest, path)?; + } else { + dest.remove_file_optional(&path)?; + if meta.is_symlink() { + let link_target = cap_primitives::fs::read_link_contents( + &src.as_filelike_view(), + path.as_std_path(), + ) + .context("Reading symlink")?; + cap_primitives::fs::symlink_contents(link_target, &dest.as_filelike_view(), &path) + .with_context(|| format!("Writing symlink {path:?}"))?; + } else { + src.copy(&path, dest, &path) + .with_context(|| format!("Copying {path:?}"))?; + } + if let Some(sepolicy) = sepolicy { + crate::lsm::ensure_labeled(dest, path, Some(&as_path), &meta, sepolicy)?; + } + } + assert!(path.pop()); + } + Ok(r) +} + async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Result<()> { if state.override_disable_selinux { rootfs.kargs.push("selinux=0".to_string()); @@ -1469,13 +1574,79 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) -> install_to_filesystem(opts, true).await } -#[test] -fn install_opts_serializable() { - let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({ - "device": "/dev/vda" - })) - .unwrap(); - assert_eq!(c.block_opts.device, "/dev/vda"); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn install_opts_serializable() { + let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({ + "device": "/dev/vda" + })) + .unwrap(); + assert_eq!(c.block_opts.device, "/dev/vda"); + } + + #[test] + fn test_copy_etc() -> Result<()> { + use std::path::PathBuf; + fn impl_count(d: &Dir, path: &mut PathBuf) -> Result { + let mut c = 0u64; + for ent in d.read_dir(&path)? { + let ent = ent?; + path.push(ent.file_name()); + c += 1; + if ent.file_type()?.is_dir() { + c += impl_count(d, path)?; + } + path.pop(); + } + return Ok(c); + } + fn count(d: &Dir) -> Result { + let mut p = PathBuf::from("."); + impl_count(d, &mut p) + } + + use cap_std_ext::cap_tempfile::TempDir; + let tmproot = TempDir::new(cap_std::ambient_authority())?; + let src_etc = TempDir::new(cap_std::ambient_authority())?; + + let init_tmproot = || -> Result<()> { + tmproot.write("foo.conf", "somefoo")?; + tmproot.symlink("foo.conf", "foo-link.conf")?; + tmproot.create_dir_all("systemd/system")?; + tmproot.write("systemd/system/foo.service", "[fooservice]")?; + tmproot.write("systemd/system/other.service", "[otherservice]")?; + Ok(()) + }; + + let mut pb = ".".into(); + // First, a no-op + copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 0); + + init_tmproot()?; + + // Another no-op but with data in dest already + copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 6); + + src_etc.write("injected.conf", "injected")?; + copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 7); + + src_etc.create_dir_all("systemd/system")?; + src_etc.write("systemd/system/foo.service", "[overwrittenfoo]")?; + copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 7); + assert_eq!( + tmproot.read_to_string("systemd/system/foo.service")?, + "[overwrittenfoo]" + ); + + Ok(()) + } } #[test] diff --git a/lib/src/lsm.rs b/lib/src/lsm.rs index b5eb7ecb..86a3a8f0 100644 --- a/lib/src/lsm.rs +++ b/lib/src/lsm.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; #[cfg(feature = "install")] use std::io::Write; use std::os::unix::process::CommandExt; @@ -175,8 +176,8 @@ pub(crate) fn require_label( .label(destname.as_str(), mode, ostree::gio::Cancellable::NONE)? .ok_or_else(|| { anyhow::anyhow!( - "No label found in policy '{}' for {destname})", - policy.name() + "No label found in policy '{:?}' for {destname})", + policy.csum() ) }) } @@ -229,12 +230,15 @@ pub(crate) fn set_security_selinux_path(root: &Dir, path: &Utf8Path, label: &[u8 pub(crate) fn ensure_labeled( root: &Dir, path: &Utf8Path, + as_path: Option<&Utf8Path>, metadata: &Metadata, policy: &ostree::SePolicy, ) -> Result { let r = has_security_selinux(root, path)?; if matches!(r, SELinuxLabelState::Unlabeled) { - let abspath = Utf8Path::new("/").join(&path); + let abspath = as_path + .map(Cow::Borrowed) + .unwrap_or_else(|| Utf8Path::new("/").join(&path).into()); let label = require_label(policy, &abspath, metadata.mode())?; tracing::trace!("Setting label for {path} to {label}"); set_security_selinux_path(root, &path, label.as_bytes())?; @@ -263,7 +267,7 @@ pub(crate) fn ensure_dir_labeled_recurse( let mut n = 0u64; let metadata = root.symlink_metadata(path_for_read)?; - match ensure_labeled(root, path, &metadata, policy)? { + match ensure_labeled(root, path, None, &metadata, policy)? { SELinuxLabelState::Unlabeled => { n += 1; } @@ -289,7 +293,7 @@ pub(crate) fn ensure_dir_labeled_recurse( if metadata.is_dir() { ensure_dir_labeled_recurse(root, path, policy, skip)?; } else { - match ensure_labeled(root, path, &metadata, policy)? { + match ensure_labeled(root, path, None, &metadata, policy)? { SELinuxLabelState::Unlabeled => { n += 1; } @@ -315,8 +319,6 @@ pub(crate) fn ensure_dir_labeled( mode: rustix::fs::Mode, policy: Option<&ostree::SePolicy>, ) -> Result<()> { - use std::borrow::Cow; - let destname = destname.as_ref(); // Special case the empty string let local_destname = if destname.as_str().is_empty() { diff --git a/tests/kolainst/install b/tests/kolainst/install index b4fce5c6..1a34ad45 100755 --- a/tests/kolainst/install +++ b/tests/kolainst/install @@ -20,7 +20,7 @@ cd $(mktemp -d) case "${AUTOPKGTEST_REBOOT_MARK:-}" in "") mkdir -p ~/.config/containers - cp -a /etc/ostree/auth.json ~/.config/containers + if test -f /etc/ostree/auth.json; then cp -a /etc/ostree/auth.json ~/.config/containers; fi mkdir -p usr/{lib,bin} cp -a /usr/lib/bootc usr/lib cp -a /usr/bin/bootc usr/bin @@ -29,8 +29,13 @@ case "${AUTOPKGTEST_REBOOT_MARK:-}" in COPY usr usr EOF podman build -t localhost/testimage . - podman run --rm -ti --privileged --pid=host --env RUST_LOG=error,bootc_lib::install=debug \ - localhost/testimage bootc install to-disk --skip-fetch-check --karg=foo=bar ${DEV} + mkdir -p injected-config/systemd/system/ + cat > injected-config/systemd/system/injected.service << 'EOF' +[Service] +ExecStart=echo injected +EOF + podman run --rm -ti --privileged --pid=host -v ./injected-config:/config --env RUST_LOG=error,bootc_lib::install=debug \ + localhost/testimage bootc install to-disk --copy-etc /config --skip-fetch-check --karg=foo=bar ${DEV} # In theory we could e.g. wipe the bootloader setup on the primary disk, then reboot; # but for now let's just sanity test that the install command executes. lsblk ${DEV} @@ -39,6 +44,10 @@ EOF grep localtestkarg=somevalue /var/mnt/loader/entries/*.conf grep -Ee '^linux /boot/ostree' /var/mnt/loader/entries/*.conf umount /var/mnt + mount /dev/vda4 /var/mnt + deploydir=$(echo /var/mnt/ostree/deploy/default/deploy/*.0) + diff $deploydir/etc/systemd/system/injected.service injected-config/systemd/system/injected.service + umount /var/mnt echo "ok install" mount /dev/vda4 /var/mnt ls -dZ /var/mnt |grep ':root_t:'