diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 8425714d5..06075c36e 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -182,6 +182,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. @@ -989,6 +998,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 } => { diff --git a/lib/src/install.rs b/lib/src/install.rs index 00a07c21e..36c4ab124 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -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; @@ -788,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() diff --git a/lib/src/install/completion.rs b/lib/src/install/completion.rs new file mode 100644 index 000000000..cd2a20c98 --- /dev/null +++ b/lib/src/install/completion.rs @@ -0,0 +1,201 @@ +//! 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::{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(¤t_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::>(); + let kargsd = crate::kargs::get_kargs_in_root(rootfs, std::env::consts::ARCH)?; + let kargsd = kargsd.iter().map(|s| s.as_str()).collect::>(); + + 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, + target: impl AsRef, +) -> Result<()> { + fn bind_from_host_impl(rootfs: &Dir, src: &Utf8Path, target: &Utf8Path) -> Result<()> { + rootfs.create_dir_all(target)?; + if rootfs.is_mountpoint(target)?.unwrap_or_default() { + return Ok(()); + } + 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(()) + } + + 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<()> { + 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. This is ideally + // something anaconda itself would set up. This targets `/run` in the + // target root which will always be cleaned up. + bind_from_host(rootfs, "/", RUNHOST)?; + + // Now unshare our mount namespace, so any *further* mounts aren't leaked. + // Note that because this does a re-exec, anything *before* this point + // should be idempotent. + crate::cli::ensure_self_unshared_mount_namespace()?; + + 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")?; + + Ok(()) +} diff --git a/lib/src/status.rs b/lib/src/status.rs index 0dc800708..b66c637a4 100644 --- a/lib/src/status.rs +++ b/lib/src/status.rs @@ -87,7 +87,7 @@ impl From for OstreeImageReference { /// Parse an ostree origin file (a keyfile) and extract the targeted /// container image reference. -fn get_image_origin(origin: &glib::KeyFile) -> Result> { +pub(crate) fn get_image_origin(origin: &glib::KeyFile) -> Result> { origin .optional_string("origin", ostree_container::deploy::ORIGIN_CONTAINER) .context("Failed to load container image from origin")? @@ -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 {