Skip to content

Commit

Permalink
install: Add --copy-etc
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
cgwalters committed Mar 19, 2024
1 parent d039f26 commit 9da08da
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 17 deletions.
185 changes: 178 additions & 7 deletions lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -138,6 +140,27 @@ pub(crate) struct InstallConfigOpts {
#[clap(long)]
karg: Option<Vec<String>>,

/// 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 <image> bootc install to-disk --copy-etc /tmp/etc`
#[clap(long)]
#[serde(default)]
pub(crate) copy_etc: Option<Vec<Utf8PathBuf>>,

/// 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
Expand Down Expand Up @@ -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::<Vec<_>>();
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);
Expand Down Expand Up @@ -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<u64> {
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());
Expand Down Expand Up @@ -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<u64> {
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<u64> {
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]
Expand Down
16 changes: 9 additions & 7 deletions lib/src/lsm.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::borrow::Cow;
#[cfg(feature = "install")]
use std::io::Write;
use std::os::unix::process::CommandExt;
Expand Down Expand Up @@ -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()
)
})
}
Expand Down Expand Up @@ -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<SELinuxLabelState> {
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())?;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand All @@ -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() {
Expand Down
15 changes: 12 additions & 3 deletions tests/kolainst/install
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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:'
Expand Down

0 comments on commit 9da08da

Please sign in to comment.