diff --git a/Cargo.lock b/Cargo.lock index b39a279c..a7557b4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,7 @@ dependencies = [ "openssl", "ostree-ext", "regex", + "rust-ini", "rustix", "schemars", "serde", @@ -398,6 +399,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "containers-image-proxy" version = "0.5.8" @@ -443,6 +464,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -581,6 +608,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dyn-clone" version = "1.0.16" @@ -1373,6 +1409,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.3", +] + [[package]] name = "ostree" version = "0.19.1" @@ -1677,6 +1723,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b833d8d034ea094b1ea68aa6d5c740e0d04bad9d16568d08ba6f76823a114316" +[[package]] +name = "rust-ini" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d625ed57d8f49af6cfa514c42e1a71fadcff60eb0b1c517ff82fe41aa025b41" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -2083,6 +2140,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2257,6 +2323,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + [[package]] name = "typenum" version = "1.17.0" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 31c97489..710d7f83 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -47,6 +47,7 @@ tempfile = "3.10.1" toml = "0.8.12" xshell = { version = "0.2.6", optional = true } uuid = { version = "1.8.0", features = ["v4"] } +rust-ini = "0.21.0" [features] default = ["install"] diff --git a/lib/src/deploy.rs b/lib/src/deploy.rs index 900ca839..7458354a 100644 --- a/lib/src/deploy.rs +++ b/lib/src/deploy.rs @@ -2,7 +2,9 @@ //! //! Create a merged filesystem tree with the image and mounted configmaps. +use std::collections::HashSet; use std::io::{BufRead, Write}; +use std::process::Command; use anyhow::Ok; use anyhow::{anyhow, Context, Result}; @@ -18,7 +20,9 @@ use ostree_ext::container::store::PrepareResult; use ostree_ext::ostree; use ostree_ext::ostree::Deployment; use ostree_ext::sysroot::SysrootLock; +use rustix::fd::BorrowedFd; +use crate::podman; use crate::spec::ImageReference; use crate::spec::{BootOrder, HostSpec}; use crate::status::labels_of_config; @@ -113,6 +117,53 @@ pub(crate) fn check_bootc_label(config: &ostree_ext::oci_spec::image::ImageConfi } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BoundState { + pub(crate) total_images: usize, + pub(crate) bound_images: HashSet, +} + +impl BoundState { + pub(crate) fn is_empty(&self) -> bool { + self.bound_images.is_empty() + } + + pub(crate) fn print(&self) { + if self.total_images == 0 { + println!("No podman .image definitions found"); + } else { + println!("podman systemd .image entries: {}", self.total_images); + println!("Bound images: {}", self.bound_images.len()); + } + } +} + +pub(crate) fn query_bound_state(root: &Dir) -> Result { + let (total_images, bound_images) = podman::list_container_images(root)?; + tracing::debug!("images={total_images} bound={}", bound_images.len()); + Ok(BoundState { + total_images, + bound_images, + }) +} + +/// Pre-fetch e.g. podman `.image` files which reference external images. This +/// expects that podman sees e.g. `/var` set up as the deployment root. +#[context("Fetching bound state")] +pub(crate) async fn fetch_bound_state(state: &BoundState) -> Result<()> { + for image in state.bound_images.iter() { + let mut cmd = Command::new("podman"); + cmd.args(["pull", image.as_str()]); + let mut cmd = tokio::process::Command::from(cmd); + cmd.kill_on_drop(true); + let status = cmd.status().await.context("bound podman pull")?; + if !status.success() { + anyhow::bail!("Failed to pull {image}"); + } + } + Ok(()) +} + /// Write container fetch progress to standard output. async fn handle_layer_progress_print( mut layers: tokio::sync::mpsc::Receiver, @@ -278,19 +329,20 @@ async fn deploy( stateroot: &str, image: &ImageState, origin: &glib::KeyFile, -) -> Result<()> { +) -> Result { let stateroot = Some(stateroot); // Copy to move into thread let cancellable = gio::Cancellable::NONE; - let _new_deployment = sysroot.stage_tree_with_options( - stateroot, - image.ostree_commit.as_str(), - Some(origin), - merge_deployment, - &Default::default(), - cancellable, - )?; - Ok(()) + sysroot + .stage_tree_with_options( + stateroot, + image.ostree_commit.as_str(), + Some(origin), + merge_deployment, + &Default::default(), + cancellable, + ) + .map_err(Into::into) } #[context("Generating origin")] @@ -307,6 +359,7 @@ fn origin_from_imageref(imgref: &ImageReference) -> Result { /// Stage (queue deployment of) a fetched container image. #[context("Staging")] +#[allow(unsafe_code)] pub(crate) async fn stage( sysroot: &SysrootLock, stateroot: &str, @@ -315,7 +368,7 @@ pub(crate) async fn stage( ) -> Result<()> { let merge_deployment = sysroot.merge_deployment(Some(stateroot)); let origin = origin_from_imageref(spec.image)?; - crate::deploy::deploy( + let deployment = crate::deploy::deploy( sysroot, merge_deployment.as_ref(), stateroot, @@ -323,6 +376,12 @@ pub(crate) async fn stage( &origin, ) .await?; + let sysroot_fd = Dir::reopen_dir(unsafe { &BorrowedFd::borrow_raw(sysroot.fd()) })?; + let deployment_dir = sysroot_fd.open_dir(sysroot.deployment_dirpath(&deployment))?; + // TODO: Make things atomic here by not completing the staging unless we can fetch + // the new images. + let bound = query_bound_state(&deployment_dir)?; + fetch_bound_state(&bound).await?; crate::deploy::cleanup(sysroot).await?; println!("Queued for next boot: {:#}", spec.image); if let Some(version) = image.version.as_deref() { diff --git a/lib/src/install.rs b/lib/src/install.rs index b3ed0d37..67db1e0f 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -40,6 +40,7 @@ use serde::{Deserialize, Serialize}; use self::baseline::InstallBlockDeviceOpts; use crate::containerenv::ContainerExecutionInfo; +use crate::deploy::query_bound_state; use crate::mount::Filesystem; use crate::task::Task; use crate::utils::sigpolicy_from_opts; @@ -530,11 +531,17 @@ pub(crate) fn print_configuration() -> Result<()> { serde_json::to_writer(stdout, &install_config).map_err(Into::into) } +pub(crate) struct InitializedRoot { + aleph: InstallAleph, + deployment: Dir, + var: Dir, +} + #[context("Creating ostree deployment")] async fn initialize_ostree_root_from_self( state: &State, root_setup: &RootSetup, -) -> Result { +) -> Result { let sepolicy = state.load_policy()?; let sepolicy = sepolicy.as_ref(); @@ -667,6 +674,10 @@ async fn initialize_ostree_root_from_self( let root = rootfs_dir .open_dir(path.as_str()) .context("Opening deployment dir")?; + let varpath = format!("ostree/deploy/{stateroot}/var"); + let var = rootfs_dir + .open_dir(&varpath) + .with_context(|| format!("Opening {varpath}"))?; // And do another recursive relabeling pass over the ostree-owned directories // but avoid recursing into the deployment root (because that's a *distinct* @@ -715,7 +726,11 @@ async fn initialize_ostree_root_from_self( selinux: state.selinux_state.to_aleph().to_string(), }; - Ok(aleph) + Ok(InitializedRoot { + aleph, + deployment: root, + var: var, + }) } /// Run a command in the host mount namespace @@ -1180,15 +1195,29 @@ async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Re tracing::debug!("boot uuid={boot_uuid}"); // Write the aleph data that captures the system state at the time of provisioning for aid in future debugging. - { - let aleph = initialize_ostree_root_from_self(state, rootfs).await?; - rootfs - .rootfs_fd - .atomic_replace_with(BOOTC_ALEPH_PATH, |f| { - serde_json::to_writer(f, &aleph)?; - anyhow::Ok(()) - }) - .context("Writing aleph version")?; + let inst = initialize_ostree_root_from_self(state, rootfs).await?; + rootfs + .rootfs_fd + .atomic_replace_with(BOOTC_ALEPH_PATH, |f| { + serde_json::to_writer(f, &inst.aleph)?; + anyhow::Ok(()) + }) + .context("Writing aleph version")?; + + let bound = query_bound_state(&inst.deployment)?; + bound.print(); + if !bound.is_empty() { + println!(); + Task::new("Mounting deployment /var", "mount") + .args(["--bind", ".", "/var"]) + .cwd(&inst.var)? + .run()?; + // podman needs this + Task::new("Initializing /var/tmp", "systemd-tmpfiles") + .args(["--create", "--boot", "--prefix=/var/tmp"]) + .verbose() + .run()?; + crate::deploy::fetch_bound_state(&bound).await?; } crate::bootloader::install_via_bootupd(&rootfs.device, &rootfs.rootfs, &state.config_opts)?; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index b04d3364..467f648e 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -45,7 +45,6 @@ mod k8sapitypes; mod kernel; #[cfg(feature = "install")] pub(crate) mod mount; -#[cfg(feature = "install")] mod podman; pub mod spec; diff --git a/lib/src/podman.rs b/lib/src/podman.rs index f5f7fd96..2022b7a7 100644 --- a/lib/src/podman.rs +++ b/lib/src/podman.rs @@ -1,4 +1,11 @@ -use anyhow::{anyhow, Result}; +use std::collections::HashSet; +use std::io::{BufReader, Read}; + +use anyhow::{anyhow, Context, Result}; +use camino::Utf8Path; +use cap_std_ext::cap_std::fs::Dir; +use cap_std_ext::dirext::CapStdExtDirExt; +use fn_error_context::context; use serde::Deserialize; use crate::install::run_in_host_mountns; @@ -7,6 +14,9 @@ use crate::task::Task; /// Where we look inside our container to find our own image /// for use with `bootc install`. pub(crate) const CONTAINER_STORAGE: &str = "/var/lib/containers"; +/// Currently a magic comment which instructs bootc it should pull these +/// images. +pub(crate) const BOOTC_BOUND_FLAG: &str = "# bootc: bound"; #[derive(Deserialize)] #[serde(rename_all = "PascalCase")] @@ -27,3 +37,60 @@ pub(crate) fn imageid_to_digest(imgid: &str) -> Result { .ok_or_else(|| anyhow!("No images returned for inspect"))?; Ok(i.digest) } + +/// List all container `.image` files described in the target root +#[context("Listing .image files")] +pub(crate) fn list_container_images(root: &Dir) -> Result<(usize, HashSet)> { + const ETC_ROOT: &str = "etc/containers/systemd"; + const USR_ROOT: &str = "usr/share/containers/systemd"; + + let mut found_image_files = 0; + let mut r = HashSet::new(); + for d in [ETC_ROOT, USR_ROOT] { + let imagedir = if let Some(d) = root.open_dir_optional(d)? { + d + } else { + tracing::debug!("No {d} found"); + continue; + }; + for entry in imagedir.entries()? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name(); + let name = if let Some(n) = name.to_str() { + n + } else { + anyhow::bail!("Invalid non-UTF8 filename: {name:?} in {d}"); + }; + if !matches!(Utf8Path::new(name).extension(), Some("image")) { + continue; + } + found_image_files += 1; + let mut buf = String::new(); + entry.open().map(BufReader::new)?.read_to_string(&mut buf)?; + let mut is_bound = false; + for line in buf.lines() { + if line.starts_with(BOOTC_BOUND_FLAG) { + is_bound = true; + break; + } + } + if !is_bound { + tracing::trace!("{name}: Did not find {BOOTC_BOUND_FLAG}"); + continue; + } + let config = ini::Ini::load_from_str(&buf).with_context(|| format!("{name}:"))?; + let image = if let Some(img) = config.get_from(Some("Container"), "Image") { + img + } else { + tracing::debug!("{name}: Missing Container/Image key"); + continue; + }; + tracing::trace!("{name}: Bound {image}"); + r.insert(image.to_string()); + } + } + Ok((found_image_files, r)) +}