From 310a6fa6b393694b10f2c667d2dc44512e756c52 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 | 224 +++++++++++++++++++++++++++++++++++++---- lib/src/lsm.rs | 27 +++++ tests/kolainst/install | 12 ++- 3 files changed, 242 insertions(+), 21 deletions(-) diff --git a/lib/src/install.rs b/lib/src/install.rs index 3a26db230..c754a09f8 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -11,6 +11,7 @@ mod baseline; use std::io::BufWriter; use std::io::Write; use std::os::fd::AsFd; +use std::os::unix::fs::DirBuilderExt; use std::os::unix::process::CommandExt; use std::process::Command; use std::str::FromStr; @@ -21,11 +22,15 @@ use anyhow::{anyhow, Context, Result}; use camino::Utf8Path; use camino::Utf8PathBuf; use cap_std::fs::Dir; +use cap_std_ext::cap_primitives; use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::DirBuilder; +use cap_std_ext::cap_std::io_lifetimes::AsFilelike; use cap_std_ext::prelude::CapStdExtDirExt; use chrono::prelude::*; use clap::ValueEnum; use ostree_ext::oci_spec; +use rustix::fd::AsRawFd; use rustix::fs::FileTypeExt; use rustix::fs::MetadataExt; @@ -38,6 +43,7 @@ use serde::{Deserialize, Serialize}; use self::baseline::InstallBlockDeviceOpts; use crate::containerenv::ContainerExecutionInfo; +use crate::lsm::Labeler; use crate::task::Task; use crate::utils::sigpolicy_from_opts; @@ -124,6 +130,27 @@ pub(crate) struct InstallConfigOpts { #[serde(default)] pub(crate) disable_selinux: bool, + /// 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>, + // Only occupy at most this much space (if no units are provided, GB is assumed). // Using this option reserves space for partitions created dynamically on the // next boot, or by subsequent tools. @@ -564,11 +591,16 @@ kargs = ["console=ttyS0", "foo=bar"] } } +struct DeploymentComplete { + aleph: InstallAleph, + deployment: Dir, +} + #[context("Creating ostree deployment")] async fn initialize_ostree_root_from_self( state: &State, root_setup: &RootSetup, -) -> Result { +) -> Result { let rootfs_dir = &root_setup.rootfs_fd; let rootfs = root_setup.rootfs.as_path(); let cancellable = gio::Cancellable::NONE; @@ -714,7 +746,10 @@ async fn initialize_ostree_root_from_self( kernel: uname.release().to_str()?.to_string(), }; - Ok(aleph) + Ok(DeploymentComplete { + aleph, + deployment: root, + }) } #[context("Copying to oci")] @@ -1058,6 +1093,63 @@ 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: &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()?; + path.push(Utf8Path::new(name)); + r += 1; + if meta.is_dir() { + if let Some(parent) = path.parent() { + dest.create_dir_all(parent) + .with_context(|| format!("Creating {parent}"))?; + } + if !dest.try_exists(&path)? { + let mut db = DirBuilder::new(); + db.mode(meta.mode()); + let label = Labeler::new(sepolicy, path, meta.mode())?; + dest.create_dir_with(&path, &db) + .with_context(|| format!("Creating {path:?}"))?; + drop(label); + } + r += copy_unmanaged_etc(sepolicy, src, dest, path)?; + } else { + dest.remove_file_optional(&path)?; + let label = Labeler::new(sepolicy, path, meta.mode())?; + 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:?}"))?; + } + drop(label); + } + 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()); @@ -1071,16 +1163,41 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re tracing::debug!("boot uuid={boot_uuid}"); // Write the aleph data that captures the system state at the time of provisioning for aid in future debugging. - { - let aleph = initialize_ostree_root_from_self(state, rootfs).await?; - rootfs - .rootfs_fd - .atomic_replace_with(BOOTC_ALEPH_PATH, |f| { - serde_json::to_writer(f, &aleph)?; - anyhow::Ok(()) - }) - .context("Writing aleph version")?; - } + let deployresult = initialize_ostree_root_from_self(state, rootfs).await?; + rootfs + .rootfs_fd + .atomic_replace_with(BOOTC_ALEPH_PATH, |f| { + serde_json::to_writer(f, &deployresult.aleph)?; + anyhow::Ok(()) + }) + .context("Writing aleph version")?; + let sepolicy = + ostree::SePolicy::new_at(deployresult.deployment.as_raw_fd(), gio::Cancellable::NONE)?; + + // Copy unmanaged configuration + let target_etc = deployresult + .deployment + .open_dir("etc") + .context("Opening deployment /etc")?; + let copy_etc = state + .config_opts + .copy_etc + .iter() + .flatten() + .cloned() + .collect::>(); + tokio::task::spawn_blocking(move || { + 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}"); + } + anyhow::Ok(()) + }) + .await??; crate::bootloader::install_via_bootupd(&rootfs.device, &rootfs.rootfs, &state.config_opts)?; tracing::debug!("Installed bootloader"); @@ -1092,6 +1209,8 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re .args(["+i", "."]) .run()?; + drop(deployresult); + // Finalize mounted filesystems if !rootfs.is_alongside { let bootfs = rootfs.rootfs.join("boot"); @@ -1369,11 +1488,78 @@ pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Resu Ok(()) } -#[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(); + let sepolicy = &ostree::SePolicy::new_at(tmproot.as_raw_fd(), gio::Cancellable::NONE)?; + // First, a no-op + copy_unmanaged_etc(sepolicy, &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(sepolicy, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 6); + + src_etc.write("injected.conf", "injected")?; + copy_unmanaged_etc(sepolicy, &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(sepolicy, &src_etc, &tmproot, &mut pb).unwrap(); + assert_eq!(count(&tmproot).unwrap(), 7); + assert_eq!( + tmproot.read_to_string("systemd/system/foo.service")?, + "[overwrittenfoo]" + ); + + Ok(()) + } } diff --git a/lib/src/lsm.rs b/lib/src/lsm.rs index af93985ef..c0b2b187e 100644 --- a/lib/src/lsm.rs +++ b/lib/src/lsm.rs @@ -161,6 +161,33 @@ pub(crate) fn lsm_label(target: &Utf8Path, as_path: &Utf8Path, recurse: bool) -> .run() } +#[cfg(feature = "install")] +pub(crate) struct Labeler<'a> { + _sepolicy: &'a ostree::SePolicy, +} + +#[cfg(feature = "install")] +impl<'a> Labeler<'a> { + pub(crate) fn new( + sepolicy: &'a ostree::SePolicy, + path: &'_ Utf8Path, + mode: u32, + ) -> Result { + sepolicy.setfscreatecon(path.as_str(), mode)?; + Ok(Self { + _sepolicy: sepolicy, + }) + } +} + +#[cfg(feature = "install")] +impl<'a> Drop for Labeler<'a> { + fn drop(&mut self) { + // TODO: add better bindings for only calling this if we did find a label + ostree::SePolicy::fscreatecon_cleanup() + } +} + #[cfg(feature = "install")] pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool { let v = xattrs.data_as_bytes(); diff --git a/tests/kolainst/install b/tests/kolainst/install index 121dc2af7..d5c4f3939 100755 --- a/tests/kolainst/install +++ b/tests/kolainst/install @@ -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/etc/systemd/system/ + cat > injected-config/etc/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,9 @@ 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 + diff /var/mnt/ostree/deploy/default/deploy/*.0/etc/systemd/system/injected.service injected-config/etc/systemd/system/injected.service + umount /var/mnt echo "ok install" # Now test install to-filesystem