Skip to content

Commit 310a6fa

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 0910876 commit 310a6fa

File tree

3 files changed

+242
-21
lines changed

3 files changed

+242
-21
lines changed

lib/src/install.rs

+205-19
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod baseline;
1111
use std::io::BufWriter;
1212
use std::io::Write;
1313
use std::os::fd::AsFd;
14+
use std::os::unix::fs::DirBuilderExt;
1415
use std::os::unix::process::CommandExt;
1516
use std::process::Command;
1617
use std::str::FromStr;
@@ -21,11 +22,15 @@ use anyhow::{anyhow, Context, Result};
2122
use camino::Utf8Path;
2223
use camino::Utf8PathBuf;
2324
use cap_std::fs::Dir;
25+
use cap_std_ext::cap_primitives;
2426
use cap_std_ext::cap_std;
27+
use cap_std_ext::cap_std::fs::DirBuilder;
28+
use cap_std_ext::cap_std::io_lifetimes::AsFilelike;
2529
use cap_std_ext::prelude::CapStdExtDirExt;
2630
use chrono::prelude::*;
2731
use clap::ValueEnum;
2832
use ostree_ext::oci_spec;
33+
use rustix::fd::AsRawFd;
2934
use rustix::fs::FileTypeExt;
3035
use rustix::fs::MetadataExt;
3136

@@ -38,6 +43,7 @@ use serde::{Deserialize, Serialize};
3843

3944
use self::baseline::InstallBlockDeviceOpts;
4045
use crate::containerenv::ContainerExecutionInfo;
46+
use crate::lsm::Labeler;
4147
use crate::task::Task;
4248
use crate::utils::sigpolicy_from_opts;
4349

@@ -124,6 +130,27 @@ pub(crate) struct InstallConfigOpts {
124130
#[serde(default)]
125131
pub(crate) disable_selinux: bool,
126132

133+
/// Inject arbitrary files into the target deployment `/etc`. One can use
134+
/// this for example to inject systemd units, or `tmpfiles.d` snippets
135+
/// which set up SSH keys.
136+
///
137+
/// Files injected this way become "unmanaged state"; they will be carried
138+
/// forward across upgrades, but will not otherwise be updated unless
139+
/// a secondary mechanism takes ownership thereafter.
140+
///
141+
/// This option can be specified multiple times; the files will be copied
142+
/// in order.
143+
///
144+
/// Any missing parent directories will be implicitly created with root ownership
145+
/// and mode 0755.
146+
///
147+
/// This option pairs well with additional bind mount
148+
/// volumes set up via the container orchestrator, e.g.:
149+
/// `podman run ... -v /path/to/config:/tmp/etc <image> bootc install to-disk --copy-etc /tmp/etc`
150+
#[clap(long)]
151+
#[serde(default)]
152+
pub(crate) copy_etc: Option<Vec<Utf8PathBuf>>,
153+
127154
// Only occupy at most this much space (if no units are provided, GB is assumed).
128155
// Using this option reserves space for partitions created dynamically on the
129156
// next boot, or by subsequent tools.
@@ -564,11 +591,16 @@ kargs = ["console=ttyS0", "foo=bar"]
564591
}
565592
}
566593

594+
struct DeploymentComplete {
595+
aleph: InstallAleph,
596+
deployment: Dir,
597+
}
598+
567599
#[context("Creating ostree deployment")]
568600
async fn initialize_ostree_root_from_self(
569601
state: &State,
570602
root_setup: &RootSetup,
571-
) -> Result<InstallAleph> {
603+
) -> Result<DeploymentComplete> {
572604
let rootfs_dir = &root_setup.rootfs_fd;
573605
let rootfs = root_setup.rootfs.as_path();
574606
let cancellable = gio::Cancellable::NONE;
@@ -714,7 +746,10 @@ async fn initialize_ostree_root_from_self(
714746
kernel: uname.release().to_str()?.to_string(),
715747
};
716748

717-
Ok(aleph)
749+
Ok(DeploymentComplete {
750+
aleph,
751+
deployment: root,
752+
})
718753
}
719754

720755
#[context("Copying to oci")]
@@ -1058,6 +1093,63 @@ async fn prepare_install(
10581093
Ok(state)
10591094
}
10601095

1096+
// Backing implementation of --copy-etc; just your basic
1097+
// recursive copy algorithm. Parent directories are
1098+
// created as necessary
1099+
fn copy_unmanaged_etc(
1100+
sepolicy: &ostree::SePolicy,
1101+
src: &Dir,
1102+
dest: &Dir,
1103+
path: &mut Utf8PathBuf,
1104+
) -> Result<u64> {
1105+
let mut r = 0u64;
1106+
for ent in src.read_dir(&path)? {
1107+
let ent = ent?;
1108+
let name = ent.file_name();
1109+
let name = if let Some(name) = name.to_str() {
1110+
name
1111+
} else {
1112+
anyhow::bail!("Non-UTF8 name: {name:?}");
1113+
};
1114+
let meta = ent.metadata()?;
1115+
path.push(Utf8Path::new(name));
1116+
r += 1;
1117+
if meta.is_dir() {
1118+
if let Some(parent) = path.parent() {
1119+
dest.create_dir_all(parent)
1120+
.with_context(|| format!("Creating {parent}"))?;
1121+
}
1122+
if !dest.try_exists(&path)? {
1123+
let mut db = DirBuilder::new();
1124+
db.mode(meta.mode());
1125+
let label = Labeler::new(sepolicy, path, meta.mode())?;
1126+
dest.create_dir_with(&path, &db)
1127+
.with_context(|| format!("Creating {path:?}"))?;
1128+
drop(label);
1129+
}
1130+
r += copy_unmanaged_etc(sepolicy, src, dest, path)?;
1131+
} else {
1132+
dest.remove_file_optional(&path)?;
1133+
let label = Labeler::new(sepolicy, path, meta.mode())?;
1134+
if meta.is_symlink() {
1135+
let link_target = cap_primitives::fs::read_link_contents(
1136+
&src.as_filelike_view(),
1137+
path.as_std_path(),
1138+
)
1139+
.context("Reading symlink")?;
1140+
cap_primitives::fs::symlink_contents(link_target, &dest.as_filelike_view(), &path)
1141+
.with_context(|| format!("Writing symlink {path:?}"))?;
1142+
} else {
1143+
src.copy(&path, dest, &path)
1144+
.with_context(|| format!("Copying {path:?}"))?;
1145+
}
1146+
drop(label);
1147+
}
1148+
assert!(path.pop());
1149+
}
1150+
Ok(r)
1151+
}
1152+
10611153
async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Result<()> {
10621154
if state.override_disable_selinux {
10631155
rootfs.kargs.push("selinux=0".to_string());
@@ -1071,16 +1163,41 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re
10711163
tracing::debug!("boot uuid={boot_uuid}");
10721164

10731165
// Write the aleph data that captures the system state at the time of provisioning for aid in future debugging.
1074-
{
1075-
let aleph = initialize_ostree_root_from_self(state, rootfs).await?;
1076-
rootfs
1077-
.rootfs_fd
1078-
.atomic_replace_with(BOOTC_ALEPH_PATH, |f| {
1079-
serde_json::to_writer(f, &aleph)?;
1080-
anyhow::Ok(())
1081-
})
1082-
.context("Writing aleph version")?;
1083-
}
1166+
let deployresult = initialize_ostree_root_from_self(state, rootfs).await?;
1167+
rootfs
1168+
.rootfs_fd
1169+
.atomic_replace_with(BOOTC_ALEPH_PATH, |f| {
1170+
serde_json::to_writer(f, &deployresult.aleph)?;
1171+
anyhow::Ok(())
1172+
})
1173+
.context("Writing aleph version")?;
1174+
let sepolicy =
1175+
ostree::SePolicy::new_at(deployresult.deployment.as_raw_fd(), gio::Cancellable::NONE)?;
1176+
1177+
// Copy unmanaged configuration
1178+
let target_etc = deployresult
1179+
.deployment
1180+
.open_dir("etc")
1181+
.context("Opening deployment /etc")?;
1182+
let copy_etc = state
1183+
.config_opts
1184+
.copy_etc
1185+
.iter()
1186+
.flatten()
1187+
.cloned()
1188+
.collect::<Vec<_>>();
1189+
tokio::task::spawn_blocking(move || {
1190+
for src in copy_etc {
1191+
println!("Injecting configuration from {src}");
1192+
let src = Dir::open_ambient_dir(&src, cap_std::ambient_authority())
1193+
.with_context(|| format!("Opening {src}"))?;
1194+
let mut pb = ".".into();
1195+
let n = copy_unmanaged_etc(&sepolicy, &src, &target_etc, &mut pb)?;
1196+
tracing::debug!("Copied config files: {n}");
1197+
}
1198+
anyhow::Ok(())
1199+
})
1200+
.await??;
10841201

10851202
crate::bootloader::install_via_bootupd(&rootfs.device, &rootfs.rootfs, &state.config_opts)?;
10861203
tracing::debug!("Installed bootloader");
@@ -1092,6 +1209,8 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re
10921209
.args(["+i", "."])
10931210
.run()?;
10941211

1212+
drop(deployresult);
1213+
10951214
// Finalize mounted filesystems
10961215
if !rootfs.is_alongside {
10971216
let bootfs = rootfs.rootfs.join("boot");
@@ -1369,11 +1488,78 @@ pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Resu
13691488
Ok(())
13701489
}
13711490

1372-
#[test]
1373-
fn install_opts_serializable() {
1374-
let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
1375-
"device": "/dev/vda"
1376-
}))
1377-
.unwrap();
1378-
assert_eq!(c.block_opts.device, "/dev/vda");
1491+
#[cfg(test)]
1492+
mod tests {
1493+
use super::*;
1494+
1495+
#[test]
1496+
fn install_opts_serializable() {
1497+
let c: InstallToDiskOpts = serde_json::from_value(serde_json::json!({
1498+
"device": "/dev/vda"
1499+
}))
1500+
.unwrap();
1501+
assert_eq!(c.block_opts.device, "/dev/vda");
1502+
}
1503+
1504+
#[test]
1505+
fn test_copy_etc() -> Result<()> {
1506+
use std::path::PathBuf;
1507+
fn impl_count(d: &Dir, path: &mut PathBuf) -> Result<u64> {
1508+
let mut c = 0u64;
1509+
for ent in d.read_dir(&path)? {
1510+
let ent = ent?;
1511+
path.push(ent.file_name());
1512+
c += 1;
1513+
if ent.file_type()?.is_dir() {
1514+
c += impl_count(d, path)?;
1515+
}
1516+
path.pop();
1517+
}
1518+
return Ok(c);
1519+
}
1520+
fn count(d: &Dir) -> Result<u64> {
1521+
let mut p = PathBuf::from(".");
1522+
impl_count(d, &mut p)
1523+
}
1524+
1525+
use cap_std_ext::cap_tempfile::TempDir;
1526+
let tmproot = TempDir::new(cap_std::ambient_authority())?;
1527+
let src_etc = TempDir::new(cap_std::ambient_authority())?;
1528+
1529+
let init_tmproot = || -> Result<()> {
1530+
tmproot.write("foo.conf", "somefoo")?;
1531+
tmproot.symlink("foo.conf", "foo-link.conf")?;
1532+
tmproot.create_dir_all("systemd/system")?;
1533+
tmproot.write("systemd/system/foo.service", "[fooservice]")?;
1534+
tmproot.write("systemd/system/other.service", "[otherservice]")?;
1535+
Ok(())
1536+
};
1537+
1538+
let mut pb = ".".into();
1539+
let sepolicy = &ostree::SePolicy::new_at(tmproot.as_raw_fd(), gio::Cancellable::NONE)?;
1540+
// First, a no-op
1541+
copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap();
1542+
assert_eq!(count(&tmproot).unwrap(), 0);
1543+
1544+
init_tmproot()?;
1545+
1546+
// Another no-op but with data in dest already
1547+
copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap();
1548+
assert_eq!(count(&tmproot).unwrap(), 6);
1549+
1550+
src_etc.write("injected.conf", "injected")?;
1551+
copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap();
1552+
assert_eq!(count(&tmproot).unwrap(), 7);
1553+
1554+
src_etc.create_dir_all("systemd/system")?;
1555+
src_etc.write("systemd/system/foo.service", "[overwrittenfoo]")?;
1556+
copy_unmanaged_etc(sepolicy, &src_etc, &tmproot, &mut pb).unwrap();
1557+
assert_eq!(count(&tmproot).unwrap(), 7);
1558+
assert_eq!(
1559+
tmproot.read_to_string("systemd/system/foo.service")?,
1560+
"[overwrittenfoo]"
1561+
);
1562+
1563+
Ok(())
1564+
}
13791565
}

lib/src/lsm.rs

+27
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,33 @@ pub(crate) fn lsm_label(target: &Utf8Path, as_path: &Utf8Path, recurse: bool) ->
161161
.run()
162162
}
163163

164+
#[cfg(feature = "install")]
165+
pub(crate) struct Labeler<'a> {
166+
_sepolicy: &'a ostree::SePolicy,
167+
}
168+
169+
#[cfg(feature = "install")]
170+
impl<'a> Labeler<'a> {
171+
pub(crate) fn new(
172+
sepolicy: &'a ostree::SePolicy,
173+
path: &'_ Utf8Path,
174+
mode: u32,
175+
) -> Result<Self> {
176+
sepolicy.setfscreatecon(path.as_str(), mode)?;
177+
Ok(Self {
178+
_sepolicy: sepolicy,
179+
})
180+
}
181+
}
182+
183+
#[cfg(feature = "install")]
184+
impl<'a> Drop for Labeler<'a> {
185+
fn drop(&mut self) {
186+
// TODO: add better bindings for only calling this if we did find a label
187+
ostree::SePolicy::fscreatecon_cleanup()
188+
}
189+
}
190+
164191
#[cfg(feature = "install")]
165192
pub(crate) fn xattrs_have_selinux(xattrs: &ostree::glib::Variant) -> bool {
166193
let v = xattrs.data_as_bytes();

tests/kolainst/install

+10-2
Original file line numberDiff line numberDiff line change
@@ -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/etc/systemd/system/
33+
cat > injected-config/etc/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,9 @@ 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+
diff /var/mnt/ostree/deploy/default/deploy/*.0/etc/systemd/system/injected.service injected-config/etc/systemd/system/injected.service
49+
umount /var/mnt
4250
echo "ok install"
4351

4452
# Now test install to-filesystem

0 commit comments

Comments
 (0)