Skip to content

Commit

Permalink
install: Add hidden ensure-completion verb
Browse files Browse the repository at this point in the history
This will be runnable via
```
%post --erroronfail
bootc install ensure-completion
%end
```

in Anaconda to work around the fact that it's not today
using `bootc install to-filesystem`.

Signed-off-by: Colin Walters <[email protected]>
  • Loading branch information
cgwalters committed Nov 22, 2024
1 parent 7b50408 commit 0425c61
Show file tree
Hide file tree
Showing 5 changed files with 288 additions and 20 deletions.
13 changes: 13 additions & 0 deletions lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ pub(crate) enum InstallOpts {
/// will be wiped, but the content of the existing root will otherwise be retained, and will
/// need to be cleaned up if desired when rebooted into the new root.
ToExistingRoot(crate::install::InstallToExistingRootOpts),
/// Intended for use in environments that are performing an ostree-based installation, not bootc.
///
/// In this scenario the installation may be missing bootc specific features such as
/// kernel arguments, logically bound images and more. This command can be used to attempt
/// to reconcile. At the current time, the only tested environment is Anaconda using `ostreecontainer`
/// and it is recommended to avoid usage outside of that environment. Instead, ensure your
/// code is using `bootc install to-filesystem` from the start.
#[clap(hide = true)]
EnsureCompletion,
/// Output JSON to stdout that contains the merged installation configuration
/// as it may be relevant to calling processes using `install to-filesystem`
/// that in particular want to discover the desired root filesystem type from the container image.
Expand Down Expand Up @@ -971,6 +980,10 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
crate::install::install_to_existing_root(opts).await
}
InstallOpts::PrintConfiguration => crate::install::print_configuration(),
InstallOpts::EnsureCompletion => {
let rootfs = &Dir::open_ambient_dir("/", cap_std::ambient_authority())?;
crate::install::completion::run(rootfs).await
}
},
#[cfg(feature = "install")]
Opt::ExecInHostMountNamespace { args } => {
Expand Down
46 changes: 29 additions & 17 deletions lib/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
// This sub-module is the "basic" installer that handles creating basic block device
// and filesystem setup.
pub(crate) mod baseline;
pub(crate) mod completion;
pub(crate) mod config;
mod osbuild;
pub(crate) mod osconfig;
Expand Down Expand Up @@ -495,6 +496,32 @@ impl FromStr for MountSpec {
}
}

impl InstallAleph {
#[context("Creating aleph data")]
pub(crate) fn new(
src_imageref: &ostree_container::OstreeImageReference,
imgstate: &ostree_container::store::LayeredImageState,
selinux_state: &SELinuxFinalState,
) -> Result<Self> {
let uname = rustix::system::uname();
let labels = crate::status::labels_of_config(&imgstate.configuration);
let timestamp = labels
.and_then(|l| {
l.get(oci_spec::image::ANNOTATION_CREATED)
.map(|s| s.as_str())
})
.and_then(crate::status::try_deserialize_timestamp);
let r = InstallAleph {
image: src_imageref.imgref.name.clone(),
version: imgstate.version().as_ref().map(|s| s.to_string()),
timestamp,
kernel: uname.release().to_str()?.to_string(),
selinux: selinux_state.to_aleph().to_string(),
};
Ok(r)
}
}

impl SourceInfo {
// Inspect container information and convert it to an ostree image reference
// that pulls from containers-storage.
Expand Down Expand Up @@ -762,6 +789,7 @@ async fn install_container(
)?;
let kargsd = kargsd.iter().map(|s| s.as_str());

// Keep this in sync with install/completion.rs for the Anaconda fixups
let install_config_kargs = state
.install_config
.as_ref()
Expand Down Expand Up @@ -842,23 +870,7 @@ async fn install_container(
osconfig::inject_root_ssh_authorized_keys(&root, sepolicy, contents)?;
}

let uname = rustix::system::uname();

let labels = crate::status::labels_of_config(&imgstate.configuration);
let timestamp = labels
.and_then(|l| {
l.get(oci_spec::image::ANNOTATION_CREATED)
.map(|s| s.as_str())
})
.and_then(crate::status::try_deserialize_timestamp);
let aleph = InstallAleph {
image: src_imageref.imgref.name.clone(),
version: imgstate.version().as_ref().map(|s| s.to_string()),
timestamp,
kernel: uname.release().to_str()?.to_string(),
selinux: state.selinux_state.to_aleph().to_string(),
};

let aleph = InstallAleph::new(&src_imageref, &imgstate, &state.selinux_state)?;
Ok((deployment, aleph))
}

Expand Down
199 changes: 199 additions & 0 deletions lib/src/install/completion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
//! This module handles finishing/completion after an ostree-based
//! install from e.g. Anaconda.
use std::{io::BufReader, process::Command};

use anyhow::{Context, Result};
use bootc_utils::CommandRunExt;
use camino::Utf8Path;
use cap_std_ext::{cap_std::fs::Dir, cmdext::CapStdExtCommandExt, dirext::CapStdExtDirExt};
use fn_error_context::context;
use ostree_ext::{gio, ostree};

use super::{config, BOOTC_ALEPH_PATH};
use crate::{mount::TemporaryMount, status::get_image_origin, utils::medium_visibility_warning};

/// An environment variable set by anaconda that hints
/// we are running as part of that environment.
const ANACONDA_ENV_HINT: &str = "ANA_INSTALL_PATH";
const OSTREE_BOOTED: &str = "run/ostree-booted";
const RESOLVCONF: &str = "etc/resolv.conf";
const RUNHOST: &str = "run/host";
const CGROUPFS: &str = "sys/fs/cgroup";
const RUN_OSTREE_AUTH: &str = "run/ostree/auth.json";

fn reconcile_kargs(
rootfs: &Dir,
sysroot: &ostree::Sysroot,
deployment: &ostree::Deployment,
) -> Result<()> {
let cancellable = gio::Cancellable::NONE;

let current_kargs = deployment
.bootconfig()
.expect("bootconfig for deployment")
.get("options");
let current_kargs = current_kargs
.as_ref()
.map(|s| s.as_str())
.unwrap_or_default();
tracing::debug!("current_kargs={current_kargs}");
let current_kargs = ostree::KernelArgs::from_string(&current_kargs);

// Keep this in sync with install_container
let install_config = config::load_config()?;
let install_config_kargs = install_config
.as_ref()
.and_then(|c| c.kargs.as_ref())
.into_iter()
.flatten()
.map(|s| s.as_str())
.collect::<Vec<_>>();
let kargsd = crate::kargs::get_kargs_in_root(rootfs, std::env::consts::ARCH)?;
let kargsd = kargsd.iter().map(|s| s.as_str()).collect::<Vec<_>>();

current_kargs.append_argv(&install_config_kargs);
current_kargs.append_argv(&kargsd);
let new_kargs = current_kargs.to_string();
tracing::debug!("new_kargs={new_kargs}");

sysroot.deployment_set_kargs_in_place(deployment, Some(&new_kargs), cancellable)?;
Ok(())
}

/// Work around https://github.com/containers/buildah/issues/4242#issuecomment-2492480586
/// among other things. We unconditionally replace the contents of `/etc/resolv.conf`
/// in the target root with whatever the host uses (in Fedora 41+, that's systemd-resolved for Anaconda).
#[context("Copying host resolv.conf")]
fn ensure_resolvconf(rootfs: &Dir) -> Result<()> {
rootfs.remove_file_optional(RESOLVCONF)?;
let host_resolvconf = format!("/{RUNHOST}/{RESOLVCONF}");
Command::new("cp")
.args(["-a", host_resolvconf.as_str(), RESOLVCONF])
.cwd_dir(rootfs.try_clone()?)
.run()?;
Ok(())
}

/// Bind a mount point from the host namespace into our root
fn bind_from_host(
rootfs: &Dir,
src: impl AsRef<Utf8Path>,
target: impl AsRef<Utf8Path>,
) -> Result<Option<TemporaryMount>> {
fn bind_from_host_impl(
rootfs: &Dir,
src: &Utf8Path,
target: &Utf8Path,
) -> Result<Option<TemporaryMount>> {
rootfs.create_dir_all(target)?;
if rootfs.is_mountpoint(target)?.unwrap_or_default() {
return Ok(None);
}
let target = format!("/mnt/sysroot/{target}");
tracing::debug!("Binding {src} to {target}");
Command::new("nsenter")
.args(["-m", "-t", "1", "--", "mount", "--bind"])
.arg(src)
.arg(&target)
.run()?;
Ok(Some(TemporaryMount::new_from_path(target)))
}

bind_from_host_impl(rootfs, src.as_ref(), target.as_ref())
}

/// Anaconda doesn't mount /sys/fs/cgroup, do it
fn ensure_cgroupfs(rootfs: &Dir) -> Result<Option<TemporaryMount>> {
bind_from_host(rootfs, CGROUPFS, CGROUPFS)
}

/// If we have /etc/ostree/auth.json in the Anaconda environment then propagate
/// it into /run/ostree/auth.json
fn ensure_ostree_auth(rootfs: &Dir) -> Result<()> {
let runhost = &rootfs.open_dir(RUNHOST)?;
let Some((authpath, authfd)) = ostree_ext::globals::get_global_authfile(runhost)? else {
tracing::debug!("No auth found in {RUNHOST}");
return Ok(());
};
tracing::debug!("Discovered auth in host: {authpath}");
let mut authfd = BufReader::new(authfd);
let run_ostree_auth = Utf8Path::new(RUN_OSTREE_AUTH);
rootfs.create_dir_all(run_ostree_auth.parent().unwrap())?;
rootfs.atomic_replace_with(run_ostree_auth, |w| std::io::copy(&mut authfd, w))?;
Ok(())
}

/// Core entrypoint.
pub(crate) async fn run(rootfs: &Dir) -> Result<()> {
if std::env::var_os(ANACONDA_ENV_HINT).is_none() {
// Be loud if a user is invoking this outside of the expected setup.
medium_visibility_warning(&format!("Missing environment variable {ANACONDA_ENV_HINT}"));
} else {
// In the way Anaconda sets up the bind mounts today, this doesn't exist. Later
// code expects it to exist, so do so.
if !rootfs.try_exists(OSTREE_BOOTED)? {
tracing::debug!("Writing {OSTREE_BOOTED}");
rootfs.atomic_write(OSTREE_BOOTED, b"")?;
}
}

// If we've already deployed via bootc, then we're done - we want
// to be i
let aleph_path = &format!("sysroot/{BOOTC_ALEPH_PATH}");
if rootfs.try_exists(aleph_path)? {
println!("Detected existing aleph {BOOTC_ALEPH_PATH}");
return Ok(());
}

// Get access to the Anaconda real rootfs at /run/host
let runhost = bind_from_host(rootfs, "/", RUNHOST)?;
let _cgroupfs = ensure_cgroupfs(rootfs)?;
// Sometimes Anaconda may not initialize networking in the target root?
ensure_resolvconf(rootfs)?;
// Propagate an injected authfile for pulling logically bound images
ensure_ostree_auth(rootfs)?;

// Initialize our storage.
let sysroot = crate::cli::get_storage().await?;

let deployments = sysroot.deployments();
let deployment = match deployments.as_slice() {
[d] => d,
o => anyhow::bail!("Expected exactly 1 deployment, not {}", o.len()),
};
let origin = deployment.origin();
let Some(origin) = origin.as_ref() else {
anyhow::bail!("Missing origin for deployment")
};
let Some(src_imgref) = get_image_origin(origin)? else {
anyhow::bail!("Not a bootc deployment");
};
let csum = deployment.csum();
let imgstate = ostree_ext::container::store::query_image_commit(&sysroot.repo(), &csum)?;

// ostree-ext doesn't do kargs, so handle that now
reconcile_kargs(rootfs, &sysroot, deployment)?;

// ostree-ext doesn't do logically bound images
let bound_images = crate::boundimage::query_bound_images(&rootfs)?;
crate::boundimage::pull_images(&sysroot, bound_images)
.await
.context("pulling bound images")?;

// TODO: This isn't fully accurate, but oh well
let selinux_state = super::SELinuxFinalState::Enabled(None);
// Synthesize the alpeh data
let aleph = super::InstallAleph::new(&src_imgref, &imgstate, &selinux_state)?;
let aleph_path = format!("sysroot/{BOOTC_ALEPH_PATH}");
rootfs
.atomic_replace_with(&aleph_path, |f| {
serde_json::to_writer(f, &aleph)?;
anyhow::Ok(())
})
.context("Writing aleph version")?;

drop(_cgroupfs);
drop(runhost);
Ok(())
}
46 changes: 45 additions & 1 deletion lib/src/mount.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::process::Command;

use anyhow::{anyhow, Result};
use bootc_utils::CommandRunExt;
use camino::Utf8Path;
use camino::{Utf8Path, Utf8PathBuf};
use fn_error_context::context;
use serde::Deserialize;

Expand Down Expand Up @@ -103,6 +103,50 @@ pub(crate) fn mount(dev: &str, target: &Utf8Path) -> Result<()> {
)
}

/// A mount that when dropped is also unmounted
pub(crate) struct TemporaryMount {
path: Utf8PathBuf,
}

impl Drop for TemporaryMount {
fn drop(&mut self) {
let path = &self.path;
let e = rustix::mount::unmount(path.as_std_path(), rustix::mount::UnmountFlags::empty());
if let Err(e) = e {
tracing::warn!("failed to unmount {path}: {e}");
}
}
}

impl TemporaryMount {
/// Ensure that the target path (which is already mounted) will be unmounted when
/// this object is dropped.
pub(crate) fn new_from_path(path: impl AsRef<Utf8Path>) -> Self {
Self {
path: path.as_ref().into(),
}
}

/// Create a bind mount
#[allow(dead_code)]
pub(crate) fn new_bind(
src: impl AsRef<Utf8Path>,
target: impl AsRef<Utf8Path>,
) -> Result<Self> {
Self::new_mount_cmd(&["--bind", src.as_ref().as_str()], target)
}

/// Run a mount command
#[allow(dead_code)]
pub(crate) fn new_mount_cmd(args: &[&str], target: impl AsRef<Utf8Path>) -> Result<Self> {
let target = target.as_ref();
Command::new("mount").args(args).arg(target).run()?;
Ok(Self {
path: target.into(),
})
}
}

/// If the fsid of the passed path matches the fsid of the same path rooted
/// at /proc/1/root, it is assumed that these are indeed the same mounted
/// filesystem between container and host.
Expand Down
4 changes: 2 additions & 2 deletions lib/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ impl From<ImageReference> for OstreeImageReference {

/// Parse an ostree origin file (a keyfile) and extract the targeted
/// container image reference.
fn get_image_origin(origin: &glib::KeyFile) -> Result<Option<OstreeImageReference>> {
pub(crate) fn get_image_origin(origin: &glib::KeyFile) -> Result<Option<OstreeImageReference>> {
origin
.optional_string("origin", ostree_container::deploy::ORIGIN_CONTAINER)
.context("Failed to load container image from origin")?
Expand Down Expand Up @@ -121,7 +121,7 @@ pub(crate) fn labels_of_config(

/// Given an OSTree deployment, parse out metadata into our spec.
#[context("Reading deployment metadata")]
fn boot_entry_from_deployment(
pub(crate) fn boot_entry_from_deployment(
sysroot: &Storage,
deployment: &ostree::Deployment,
) -> Result<BootEntry> {
Expand Down

0 comments on commit 0425c61

Please sign in to comment.