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 Jan 17, 2024
1 parent 0910876 commit 19d3890
Show file tree
Hide file tree
Showing 3 changed files with 239 additions and 21 deletions.
224 changes: 205 additions & 19 deletions lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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;

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

// 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.
Expand Down Expand Up @@ -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<InstallAleph> {
) -> Result<DeploymentComplete> {
let rootfs_dir = &root_setup.rootfs_fd;
let rootfs = root_setup.rootfs.as_path();
let cancellable = gio::Cancellable::NONE;
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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<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()?;
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());
Expand All @@ -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::<Vec<_>>();
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");
Expand All @@ -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");
Expand Down Expand Up @@ -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<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();
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(())
}
}
24 changes: 24 additions & 0 deletions lib/src/lsm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ pub(crate) fn lsm_label(target: &Utf8Path, as_path: &Utf8Path, recurse: bool) ->
.run()
}

pub(crate) struct Labeler<'a> {
_sepolicy: &'a ostree::SePolicy,
}

impl<'a> Labeler<'a> {
pub(crate) fn new(
sepolicy: &'a ostree::SePolicy,
path: &'_ Utf8Path,
mode: u32,
) -> Result<Self> {
sepolicy.setfscreatecon(path.as_str(), mode)?;
Ok(Self {
_sepolicy: sepolicy,
})
}
}

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();
Expand Down
12 changes: 10 additions & 2 deletions tests/kolainst/install
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down

0 comments on commit 19d3890

Please sign in to comment.