Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

install: Add --copy-etc #267

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,22 @@ jobs:
run: |
set -xeuo pipefail
image=quay.io/centos-bootc/centos-bootc-dev:stream9
tmpd=$(mktemp -d)
# Create local /etc content
echo foohost > ${tmpd}/hostname
mkdir -p ${tmpd}/systemd/system
echo -e '[Service]\nExecStart=true' > ${tmpd}/systemd/system/foo-local.service
echo 'ssh-ed25519 ABC0123 testcase@example.com' > test_authorized_keys
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 \
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 \
-v ${tmpd}:/config \
--pid=host --security-opt label=disable \
${image} bootc install to-filesystem --acknowledge-destructive \
--copy-etc /config \
--karg=foo=bar --disable-selinux --replace=alongside --root-ssh-authorized-keys=/test_authorized_keys /target
ls -al /boot/loader/
sudo grep foo=bar /boot/loader/entries/*.conf
grep authorized_keys /ostree/deploy/default/deploy/*/etc/tmpfiles.d/bootc-root-ssh.conf
grep ExecStart=true /ostree/deploy/default/deploy/*/etc/systemd/system/foo-local.service
# TODO fix https://github.com/containers/bootc/pull/137
sudo chattr -i /ostree/deploy/default/deploy/*
sudo rm /ostree/deploy/default -rf
Expand Down
185 changes: 178 additions & 7 deletions lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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 @@ -139,6 +141,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:/config <image> bootc install to-disk --copy-etc /config`
#[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 @@ -697,6 +720,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 @@ -1166,6 +1207,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)?;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or, like Ignition should we default to failing when there is an extant file there? That gets annoying though because suddenly one needs an extra override flag for when it's truly necessary.

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 matches!(state.selinux_state, SELinuxFinalState::ForceTargetDisabled) {
rootfs.kargs.push("selinux=0".to_string());
Expand Down Expand Up @@ -1606,13 +1711,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
12 changes: 7 additions & 5 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 @@ -246,12 +247,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 @@ -280,7 +284,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 @@ -306,7 +310,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 @@ -332,8 +336,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 --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 --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
Loading