Skip to content

Commit d218625

Browse files
committed
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 <[email protected]>
1 parent 544ce42 commit d218625

File tree

4 files changed

+207
-16
lines changed

4 files changed

+207
-16
lines changed

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,22 @@ jobs:
145145
run: |
146146
set -xeuo pipefail
147147
image=quay.io/centos-bootc/centos-bootc-dev:stream9
148+
tmpd=$(mktemp -d)
149+
# Create local /etc content
150+
echo foohost > ${tmpd}/hostname
151+
mkdir -p ${tmpd}/systemd/system
152+
echo -e '[Service]\nExecStart=true' > ${tmpd}/systemd/system/foo-local.service
148153
echo 'ssh-ed25519 ABC0123 [email protected]' > test_authorized_keys
149-
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 \
154+
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 \
155+
-v ${tmpd}:/config \
156+
--pid=host --security-opt label=disable \
150157
${image} bootc install to-filesystem --acknowledge-destructive \
158+
--copy-etc /config \
151159
--karg=foo=bar --disable-selinux --replace=alongside --root-ssh-authorized-keys=/test_authorized_keys /target
152160
ls -al /boot/loader/
153161
sudo grep foo=bar /boot/loader/entries/*.conf
154162
grep authorized_keys /ostree/deploy/default/deploy/*/etc/tmpfiles.d/bootc-root-ssh.conf
163+
grep ExecStart=true /ostree/deploy/default/deploy/*/etc/systemd/system/foo-local.service
155164
# TODO fix https://github.com/containers/bootc/pull/137
156165
sudo chattr -i /ostree/deploy/default/deploy/*
157166
sudo rm /ostree/deploy/default -rf

lib/src/install.rs

Lines changed: 178 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ use anyhow::{anyhow, Context, Result};
2424
use camino::Utf8Path;
2525
use camino::Utf8PathBuf;
2626
use cap_std::fs::{Dir, MetadataExt};
27+
use cap_std_ext::cap_primitives;
2728
use cap_std_ext::cap_std;
29+
use cap_std_ext::cap_std::io_lifetimes::AsFilelike;
2830
use cap_std_ext::prelude::CapStdExtDirExt;
2931
use chrono::prelude::*;
3032
use clap::ValueEnum;
@@ -139,6 +141,27 @@ pub(crate) struct InstallConfigOpts {
139141
#[clap(long)]
140142
karg: Option<Vec<String>>,
141143

144+
/// Inject arbitrary files into the target deployment `/etc`. One can use
145+
/// this for example to inject systemd units, or `tmpfiles.d` snippets
146+
/// which set up SSH keys.
147+
///
148+
/// Files injected this way become "unmanaged state"; they will be carried
149+
/// forward across upgrades, but will not otherwise be updated unless
150+
/// a secondary mechanism takes ownership thereafter.
151+
///
152+
/// This option can be specified multiple times; the files will be copied
153+
/// in order.
154+
///
155+
/// Any missing parent directories will be implicitly created with root ownership
156+
/// and mode 0755.
157+
///
158+
/// This option pairs well with additional bind mount
159+
/// volumes set up via the container orchestrator, e.g.:
160+
/// `podman run ... -v /path/to/config:/config <image> bootc install to-disk --copy-etc /config`
161+
#[clap(long)]
162+
#[serde(default)]
163+
pub(crate) copy_etc: Option<Vec<Utf8PathBuf>>,
164+
142165
/// The path to an `authorized_keys` that will be injected into the `root` account.
143166
///
144167
/// The implementation of this uses systemd `tmpfiles.d`, writing to a file named
@@ -697,6 +720,24 @@ async fn initialize_ostree_root_from_self(
697720
osconfig::inject_root_ssh_authorized_keys(&root, sepolicy, contents)?;
698721
}
699722

723+
// Copy unmanaged configuration
724+
let target_etc = root.open_dir("etc").context("Opening deployment /etc")?;
725+
let copy_etc = state
726+
.config_opts
727+
.copy_etc
728+
.iter()
729+
.flatten()
730+
.cloned()
731+
.collect::<Vec<_>>();
732+
for src in copy_etc {
733+
println!("Injecting configuration from {src}");
734+
let src = Dir::open_ambient_dir(&src, cap_std::ambient_authority())
735+
.with_context(|| format!("Opening {src}"))?;
736+
let mut pb = ".".into();
737+
let n = copy_unmanaged_etc(sepolicy, &src, &target_etc, &mut pb)?;
738+
tracing::debug!("Copied config files: {n}");
739+
}
740+
700741
let uname = rustix::system::uname();
701742

702743
let labels = crate::status::labels_of_config(&imgstate.configuration);
@@ -1166,6 +1207,70 @@ async fn prepare_install(
11661207
Ok(state)
11671208
}
11681209

1210+
// Backing implementation of --copy-etc; just your basic
1211+
// recursive copy algorithm. Parent directories are
1212+
// created as necessary
1213+
fn copy_unmanaged_etc(
1214+
sepolicy: Option<&ostree::SePolicy>,
1215+
src: &Dir,
1216+
dest: &Dir,
1217+
path: &mut Utf8PathBuf,
1218+
) -> Result<u64> {
1219+
let mut r = 0u64;
1220+
for ent in src.read_dir(&path)? {
1221+
let ent = ent?;
1222+
let name = ent.file_name();
1223+
let name = if let Some(name) = name.to_str() {
1224+
name
1225+
} else {
1226+
anyhow::bail!("Non-UTF8 name: {name:?}");
1227+
};
1228+
let meta = ent.metadata()?;
1229+
// Build the relative path
1230+
path.push(Utf8Path::new(name));
1231+
// And the absolute path for looking up SELinux labels
1232+
let as_path = {
1233+
let mut p = Utf8PathBuf::from("/etc");
1234+
p.push(&path);
1235+
p
1236+
};
1237+
r += 1;
1238+
if meta.is_dir() {
1239+
if let Some(parent) = path.parent() {
1240+
dest.create_dir_all(parent)
1241+
.with_context(|| format!("Creating {parent}"))?;
1242+
}
1243+
crate::lsm::ensure_dir_labeled(
1244+
dest,
1245+
&path,
1246+
Some(&as_path),
1247+
meta.mode().into(),
1248+
sepolicy,
1249+
)?;
1250+
r += copy_unmanaged_etc(sepolicy, src, dest, path)?;
1251+
} else {
1252+
dest.remove_file_optional(&path)?;
1253+
if meta.is_symlink() {
1254+
let link_target = cap_primitives::fs::read_link_contents(
1255+
&src.as_filelike_view(),
1256+
path.as_std_path(),
1257+
)
1258+
.context("Reading symlink")?;
1259+
cap_primitives::fs::symlink_contents(link_target, &dest.as_filelike_view(), &path)
1260+
.with_context(|| format!("Writing symlink {path:?}"))?;
1261+
} else {
1262+
src.copy(&path, dest, &path)
1263+
.with_context(|| format!("Copying {path:?}"))?;
1264+
}
1265+
if let Some(sepolicy) = sepolicy {
1266+
crate::lsm::ensure_labeled(dest, path, Some(&as_path), &meta, sepolicy)?;
1267+
}
1268+
}
1269+
assert!(path.pop());
1270+
}
1271+
Ok(r)
1272+
}
1273+
11691274
async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Result<()> {
11701275
if matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) {
11711276
rootfs.kargs.push("selinux=0".to_string());
@@ -1606,13 +1711,79 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) ->
16061711
install_to_filesystem(opts, true).await
16071712
}
16081713

1609-
#[test]
1610-
fn install_opts_serializable() {
1611-
let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
1612-
"device": "/dev/vda"
1613-
}))
1614-
.unwrap();
1615-
assert_eq!(c.block_opts.device, "/dev/vda");
1714+
#[cfg(test)]
1715+
mod tests {
1716+
use super::*;
1717+
1718+
#[test]
1719+
fn install_opts_serializable() {
1720+
let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
1721+
"device": "/dev/vda"
1722+
}))
1723+
.unwrap();
1724+
assert_eq!(c.block_opts.device, "/dev/vda");
1725+
}
1726+
1727+
#[test]
1728+
fn test_copy_etc() -> Result<()> {
1729+
use std::path::PathBuf;
1730+
fn impl_count(d: &Dir, path: &mut PathBuf) -> Result<u64> {
1731+
let mut c = 0u64;
1732+
for ent in d.read_dir(&path)? {
1733+
let ent = ent?;
1734+
path.push(ent.file_name());
1735+
c += 1;
1736+
if ent.file_type()?.is_dir() {
1737+
c += impl_count(d, path)?;
1738+
}
1739+
path.pop();
1740+
}
1741+
return Ok(c);
1742+
}
1743+
fn count(d: &Dir) -> Result<u64> {
1744+
let mut p = PathBuf::from(".");
1745+
impl_count(d, &mut p)
1746+
}
1747+
1748+
use cap_std_ext::cap_tempfile::TempDir;
1749+
let tmproot = TempDir::new(cap_std::ambient_authority())?;
1750+
let src_etc = TempDir::new(cap_std::ambient_authority())?;
1751+
1752+
let init_tmproot = || -> Result<()> {
1753+
tmproot.write("foo.conf", "somefoo")?;
1754+
tmproot.symlink("foo.conf", "foo-link.conf")?;
1755+
tmproot.create_dir_all("systemd/system")?;
1756+
tmproot.write("systemd/system/foo.service", "[fooservice]")?;
1757+
tmproot.write("systemd/system/other.service", "[otherservice]")?;
1758+
Ok(())
1759+
};
1760+
1761+
let mut pb = ".".into();
1762+
// First, a no-op
1763+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1764+
assert_eq!(count(&tmproot).unwrap(), 0);
1765+
1766+
init_tmproot()?;
1767+
1768+
// Another no-op but with data in dest already
1769+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1770+
assert_eq!(count(&tmproot).unwrap(), 6);
1771+
1772+
src_etc.write("injected.conf", "injected")?;
1773+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1774+
assert_eq!(count(&tmproot).unwrap(), 7);
1775+
1776+
src_etc.create_dir_all("systemd/system")?;
1777+
src_etc.write("systemd/system/foo.service", "[overwrittenfoo]")?;
1778+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1779+
assert_eq!(count(&tmproot).unwrap(), 7);
1780+
assert_eq!(
1781+
tmproot.read_to_string("systemd/system/foo.service")?,
1782+
"[overwrittenfoo]"
1783+
);
1784+
1785+
Ok(())
1786+
}
16161787
}
16171788

16181789
#[test]

lib/src/lsm.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::borrow::Cow;
12
#[cfg(feature = "install")]
23
use std::io::Write;
34
use std::os::unix::process::CommandExt;
@@ -246,12 +247,15 @@ pub(crate) fn set_security_selinux_path(root: &Dir, path: &Utf8Path, label: &[u8
246247
pub(crate) fn ensure_labeled(
247248
root: &Dir,
248249
path: &Utf8Path,
250+
as_path: Option<&Utf8Path>,
249251
metadata: &Metadata,
250252
policy: &ostree::SePolicy,
251253
) -> Result<SELinuxLabelState> {
252254
let r = has_security_selinux(root, path)?;
253255
if matches!(r, SELinuxLabelState::Unlabeled) {
254-
let abspath = Utf8Path::new("/").join(&path);
256+
let abspath = as_path
257+
.map(Cow::Borrowed)
258+
.unwrap_or_else(|| Utf8Path::new("/").join(&path).into());
255259
let label = require_label(policy, &abspath, metadata.mode())?;
256260
tracing::trace!("Setting label for {path} to {label}");
257261
set_security_selinux_path(root, &path, label.as_bytes())?;
@@ -280,7 +284,7 @@ pub(crate) fn ensure_dir_labeled_recurse(
280284
let mut n = 0u64;
281285

282286
let metadata = root.symlink_metadata(path_for_read)?;
283-
match ensure_labeled(root, path, &metadata, policy)? {
287+
match ensure_labeled(root, path, None, &metadata, policy)? {
284288
SELinuxLabelState::Unlabeled => {
285289
n += 1;
286290
}
@@ -306,7 +310,7 @@ pub(crate) fn ensure_dir_labeled_recurse(
306310
if metadata.is_dir() {
307311
ensure_dir_labeled_recurse(root, path, policy, skip)?;
308312
} else {
309-
match ensure_labeled(root, path, &metadata, policy)? {
313+
match ensure_labeled(root, path, None, &metadata, policy)? {
310314
SELinuxLabelState::Unlabeled => {
311315
n += 1;
312316
}
@@ -332,8 +336,6 @@ pub(crate) fn ensure_dir_labeled(
332336
mode: rustix::fs::Mode,
333337
policy: Option<&ostree::SePolicy>,
334338
) -> Result<()> {
335-
use std::borrow::Cow;
336-
337339
let destname = destname.as_ref();
338340
// Special case the empty string
339341
let local_destname = if destname.as_str().is_empty() {

tests/kolainst/install

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ cd $(mktemp -d)
2020
case "${AUTOPKGTEST_REBOOT_MARK:-}" in
2121
"")
2222
mkdir -p ~/.config/containers
23-
cp -a /etc/ostree/auth.json ~/.config/containers
23+
if test -f /etc/ostree/auth.json; then cp -a /etc/ostree/auth.json ~/.config/containers; fi
2424
mkdir -p usr/{lib,bin}
2525
cp -a /usr/lib/bootc usr/lib
2626
cp -a /usr/bin/bootc usr/bin
@@ -29,8 +29,13 @@ case "${AUTOPKGTEST_REBOOT_MARK:-}" in
2929
COPY usr usr
3030
EOF
3131
podman build -t localhost/testimage .
32-
podman run --rm --privileged --pid=host --env RUST_LOG=error,bootc_lib::install=debug \
33-
localhost/testimage bootc install to-disk --skip-fetch-check --karg=foo=bar ${DEV}
32+
mkdir -p injected-config/systemd/system/
33+
cat > injected-config/systemd/system/injected.service << 'EOF'
34+
[Service]
35+
ExecStart=echo injected
36+
EOF
37+
podman run --rm --privileged --pid=host -v ./injected-config:/config --env RUST_LOG=error,bootc_lib::install=debug \
38+
localhost/testimage bootc install to-disk --copy-etc /config --skip-fetch-check --karg=foo=bar ${DEV}
3439
# In theory we could e.g. wipe the bootloader setup on the primary disk, then reboot;
3540
# but for now let's just sanity test that the install command executes.
3641
lsblk ${DEV}
@@ -39,6 +44,10 @@ EOF
3944
grep localtestkarg=somevalue /var/mnt/loader/entries/*.conf
4045
grep -Ee '^linux /boot/ostree' /var/mnt/loader/entries/*.conf
4146
umount /var/mnt
47+
mount /dev/vda4 /var/mnt
48+
deploydir=$(echo /var/mnt/ostree/deploy/default/deploy/*.0)
49+
diff $deploydir/etc/systemd/system/injected.service injected-config/systemd/system/injected.service
50+
umount /var/mnt
4251
echo "ok install"
4352
mount /dev/vda4 /var/mnt
4453
ls -dZ /var/mnt |grep ':root_t:'

0 commit comments

Comments
 (0)