Skip to content

Commit 9da08da

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 d039f26 commit 9da08da

File tree

3 files changed

+199
-17
lines changed

3 files changed

+199
-17
lines changed

lib/src/install.rs

+178-7
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ use anyhow::{anyhow, Context, Result};
2323
use camino::Utf8Path;
2424
use camino::Utf8PathBuf;
2525
use cap_std::fs::{Dir, MetadataExt};
26+
use cap_std_ext::cap_primitives;
2627
use cap_std_ext::cap_std;
28+
use cap_std_ext::cap_std::io_lifetimes::AsFilelike;
2729
use cap_std_ext::prelude::CapStdExtDirExt;
2830
use chrono::prelude::*;
2931
use clap::ValueEnum;
@@ -138,6 +140,27 @@ pub(crate) struct InstallConfigOpts {
138140
#[clap(long)]
139141
karg: Option<Vec<String>>,
140142

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

698+
// Copy unmanaged configuration
699+
let target_etc = root.open_dir("etc").context("Opening deployment /etc")?;
700+
let copy_etc = state
701+
.config_opts
702+
.copy_etc
703+
.iter()
704+
.flatten()
705+
.cloned()
706+
.collect::<Vec<_>>();
707+
for src in copy_etc {
708+
println!("Injecting configuration from {src}");
709+
let src = Dir::open_ambient_dir(&src, cap_std::ambient_authority())
710+
.with_context(|| format!("Opening {src}"))?;
711+
let mut pb = ".".into();
712+
let n = copy_unmanaged_etc(sepolicy, &src, &target_etc, &mut pb)?;
713+
tracing::debug!("Copied config files: {n}");
714+
}
715+
675716
let uname = rustix::system::uname();
676717

677718
let labels = crate::status::labels_of_config(&imgstate.configuration);
@@ -1077,6 +1118,70 @@ async fn prepare_install(
10771118
Ok(state)
10781119
}
10791120

1121+
// Backing implementation of --copy-etc; just your basic
1122+
// recursive copy algorithm. Parent directories are
1123+
// created as necessary
1124+
fn copy_unmanaged_etc(
1125+
sepolicy: Option<&ostree::SePolicy>,
1126+
src: &Dir,
1127+
dest: &Dir,
1128+
path: &mut Utf8PathBuf,
1129+
) -> Result<u64> {
1130+
let mut r = 0u64;
1131+
for ent in src.read_dir(&path)? {
1132+
let ent = ent?;
1133+
let name = ent.file_name();
1134+
let name = if let Some(name) = name.to_str() {
1135+
name
1136+
} else {
1137+
anyhow::bail!("Non-UTF8 name: {name:?}");
1138+
};
1139+
let meta = ent.metadata()?;
1140+
// Build the relative path
1141+
path.push(Utf8Path::new(name));
1142+
// And the absolute path for looking up SELinux labels
1143+
let as_path = {
1144+
let mut p = Utf8PathBuf::from("/etc");
1145+
p.push(&path);
1146+
p
1147+
};
1148+
r += 1;
1149+
if meta.is_dir() {
1150+
if let Some(parent) = path.parent() {
1151+
dest.create_dir_all(parent)
1152+
.with_context(|| format!("Creating {parent}"))?;
1153+
}
1154+
crate::lsm::ensure_dir_labeled(
1155+
dest,
1156+
&path,
1157+
Some(&as_path),
1158+
meta.mode().into(),
1159+
sepolicy,
1160+
)?;
1161+
r += copy_unmanaged_etc(sepolicy, src, dest, path)?;
1162+
} else {
1163+
dest.remove_file_optional(&path)?;
1164+
if meta.is_symlink() {
1165+
let link_target = cap_primitives::fs::read_link_contents(
1166+
&src.as_filelike_view(),
1167+
path.as_std_path(),
1168+
)
1169+
.context("Reading symlink")?;
1170+
cap_primitives::fs::symlink_contents(link_target, &dest.as_filelike_view(), &path)
1171+
.with_context(|| format!("Writing symlink {path:?}"))?;
1172+
} else {
1173+
src.copy(&path, dest, &path)
1174+
.with_context(|| format!("Copying {path:?}"))?;
1175+
}
1176+
if let Some(sepolicy) = sepolicy {
1177+
crate::lsm::ensure_labeled(dest, path, Some(&as_path), &meta, sepolicy)?;
1178+
}
1179+
}
1180+
assert!(path.pop());
1181+
}
1182+
Ok(r)
1183+
}
1184+
10801185
async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Result<()> {
10811186
if state.override_disable_selinux {
10821187
rootfs.kargs.push("selinux=0".to_string());
@@ -1469,13 +1574,79 @@ pub(crate) async fn install_to_existing_root(opts: InstallToExistingRootOpts) ->
14691574
install_to_filesystem(opts, true).await
14701575
}
14711576

1472-
#[test]
1473-
fn install_opts_serializable() {
1474-
let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
1475-
"device": "/dev/vda"
1476-
}))
1477-
.unwrap();
1478-
assert_eq!(c.block_opts.device, "/dev/vda");
1577+
#[cfg(test)]
1578+
mod tests {
1579+
use super::*;
1580+
1581+
#[test]
1582+
fn install_opts_serializable() {
1583+
let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
1584+
"device": "/dev/vda"
1585+
}))
1586+
.unwrap();
1587+
assert_eq!(c.block_opts.device, "/dev/vda");
1588+
}
1589+
1590+
#[test]
1591+
fn test_copy_etc() -> Result<()> {
1592+
use std::path::PathBuf;
1593+
fn impl_count(d: &Dir, path: &mut PathBuf) -> Result<u64> {
1594+
let mut c = 0u64;
1595+
for ent in d.read_dir(&path)? {
1596+
let ent = ent?;
1597+
path.push(ent.file_name());
1598+
c += 1;
1599+
if ent.file_type()?.is_dir() {
1600+
c += impl_count(d, path)?;
1601+
}
1602+
path.pop();
1603+
}
1604+
return Ok(c);
1605+
}
1606+
fn count(d: &Dir) -> Result<u64> {
1607+
let mut p = PathBuf::from(".");
1608+
impl_count(d, &mut p)
1609+
}
1610+
1611+
use cap_std_ext::cap_tempfile::TempDir;
1612+
let tmproot = TempDir::new(cap_std::ambient_authority())?;
1613+
let src_etc = TempDir::new(cap_std::ambient_authority())?;
1614+
1615+
let init_tmproot = || -> Result<()> {
1616+
tmproot.write("foo.conf", "somefoo")?;
1617+
tmproot.symlink("foo.conf", "foo-link.conf")?;
1618+
tmproot.create_dir_all("systemd/system")?;
1619+
tmproot.write("systemd/system/foo.service", "[fooservice]")?;
1620+
tmproot.write("systemd/system/other.service", "[otherservice]")?;
1621+
Ok(())
1622+
};
1623+
1624+
let mut pb = ".".into();
1625+
// First, a no-op
1626+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1627+
assert_eq!(count(&tmproot).unwrap(), 0);
1628+
1629+
init_tmproot()?;
1630+
1631+
// Another no-op but with data in dest already
1632+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1633+
assert_eq!(count(&tmproot).unwrap(), 6);
1634+
1635+
src_etc.write("injected.conf", "injected")?;
1636+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1637+
assert_eq!(count(&tmproot).unwrap(), 7);
1638+
1639+
src_etc.create_dir_all("systemd/system")?;
1640+
src_etc.write("systemd/system/foo.service", "[overwrittenfoo]")?;
1641+
copy_unmanaged_etc(None, &src_etc, &tmproot, &mut pb).unwrap();
1642+
assert_eq!(count(&tmproot).unwrap(), 7);
1643+
assert_eq!(
1644+
tmproot.read_to_string("systemd/system/foo.service")?,
1645+
"[overwrittenfoo]"
1646+
);
1647+
1648+
Ok(())
1649+
}
14791650
}
14801651

14811652
#[test]

lib/src/lsm.rs

+9-7
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;
@@ -175,8 +176,8 @@ pub(crate) fn require_label(
175176
.label(destname.as_str(), mode, ostree::gio::Cancellable::NONE)?
176177
.ok_or_else(|| {
177178
anyhow::anyhow!(
178-
"No label found in policy '{}' for {destname})",
179-
policy.name()
179+
"No label found in policy '{:?}' for {destname})",
180+
policy.csum()
180181
)
181182
})
182183
}
@@ -229,12 +230,15 @@ pub(crate) fn set_security_selinux_path(root: &Dir, path: &Utf8Path, label: &[u8
229230
pub(crate) fn ensure_labeled(
230231
root: &Dir,
231232
path: &Utf8Path,
233+
as_path: Option<&Utf8Path>,
232234
metadata: &Metadata,
233235
policy: &ostree::SePolicy,
234236
) -> Result<SELinuxLabelState> {
235237
let r = has_security_selinux(root, path)?;
236238
if matches!(r, SELinuxLabelState::Unlabeled) {
237-
let abspath = Utf8Path::new("/").join(&path);
239+
let abspath = as_path
240+
.map(Cow::Borrowed)
241+
.unwrap_or_else(|| Utf8Path::new("/").join(&path).into());
238242
let label = require_label(policy, &abspath, metadata.mode())?;
239243
tracing::trace!("Setting label for {path} to {label}");
240244
set_security_selinux_path(root, &path, label.as_bytes())?;
@@ -263,7 +267,7 @@ pub(crate) fn ensure_dir_labeled_recurse(
263267
let mut n = 0u64;
264268

265269
let metadata = root.symlink_metadata(path_for_read)?;
266-
match ensure_labeled(root, path, &metadata, policy)? {
270+
match ensure_labeled(root, path, None, &metadata, policy)? {
267271
SELinuxLabelState::Unlabeled => {
268272
n += 1;
269273
}
@@ -289,7 +293,7 @@ pub(crate) fn ensure_dir_labeled_recurse(
289293
if metadata.is_dir() {
290294
ensure_dir_labeled_recurse(root, path, policy, skip)?;
291295
} else {
292-
match ensure_labeled(root, path, &metadata, policy)? {
296+
match ensure_labeled(root, path, None, &metadata, policy)? {
293297
SELinuxLabelState::Unlabeled => {
294298
n += 1;
295299
}
@@ -315,8 +319,6 @@ pub(crate) fn ensure_dir_labeled(
315319
mode: rustix::fs::Mode,
316320
policy: Option<&ostree::SePolicy>,
317321
) -> Result<()> {
318-
use std::borrow::Cow;
319-
320322
let destname = destname.as_ref();
321323
// Special case the empty string
322324
let local_destname = if destname.as_str().is_empty() {

tests/kolainst/install

+12-3
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 -ti --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 -ti --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)