diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a04ff0e4..6a5104ba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,13 +145,22 @@ jobs: run: | set -xeuo pipefail image=quay.io/centos-bootc/centos-bootc-dev:stream9 + tmpd=$(mktemp -d) + # Create local /etc content + echo foohost > ${tmpd}/hostname + mkdir -p ${tmpd}/systemd/system + echo -e '[Service]\nExecStart=true' > ${tmpd}/systemd/system/foo-local.service echo 'ssh-ed25519 ABC0123 testcase@example.com' > test_authorized_keys - sudo podman run --rm --privileged -v ./test_authorized_keys:/test_authorized_keys --env RUST_LOG=debug -v /:/target -v /var/lib/containers:/var/lib/containers -v ./usr/bin/bootc:/usr/bin/bootc --pid=host --security-opt label=disable \ + sudo podman run --rm --privileged -v ./test_authorized_keys:/test_authorized_keys --env RUST_LOG=debug -v /:/target -v /var/lib/containers:/var/lib/containers -v ./usr/bin/bootc:/usr/bin/bootc \ + -v ${tmpd}:/config \ + --pid=host --security-opt label=disable \ ${image} bootc install to-filesystem --acknowledge-destructive \ + --copy-etc /config \ --karg=foo=bar --disable-selinux --replace=alongside --root-ssh-authorized-keys=/test_authorized_keys /target ls -al /boot/loader/ sudo grep foo=bar /boot/loader/entries/*.conf grep authorized_keys /ostree/deploy/default/deploy/*/etc/tmpfiles.d/bootc-root-ssh.conf + grep ExecStart=true /ostree/deploy/default/deploy/*/etc/systemd/system/foo-local.service # TODO fix https://github.com/containers/bootc/pull/137 sudo chattr -i /ostree/deploy/default/deploy/* sudo rm /ostree/deploy/default -rf diff --git a/lib/src/install.rs b/lib/src/install.rs index 94a8074e2..539dceb7e 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -24,7 +24,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; @@ -139,6 +141,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:/config bootc install to-disk --copy-etc /config` + #[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 @@ -697,6 +720,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); @@ -1166,6 +1207,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 matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) { rootfs.kargs.push("selinux=0".to_string()); @@ -1606,13 +1711,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 4d8b5428b..8eb9b2dda 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; @@ -246,12 +247,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())?; @@ -280,7 +284,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; } @@ -306,7 +310,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; } @@ -332,8 +336,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 51cf8698f..fb5b33640 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 --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 --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:'