diff --git a/HACKING.md b/HACKING.md index 4345450..47bf4ec 100644 --- a/HACKING.md +++ b/HACKING.md @@ -99,6 +99,21 @@ In case you have an alternate root mounted at `/mnt/squash` from an external sou sudo REPART_COPY_SOURCE=/mnt/rootfs readymade ``` +For playbook-driven installs using the filesystem `Copy` provisioner, `copy_source` may also be +an OCI image reference instead of a path. If the value starts with a Podman transport prefix such +as `containers-storage:`, `docker://`, `oci:`, or `oci-archive:`, Readymade will create a +temporary container with Podman, mount it, copy the mounted root filesystem into the target, then +clean the temporary container up afterwards. + +```json +{ + "filesystem_provisioner": { + "module": "Copy", + "copy_source": "containers-storage:registry.example.org/example/os:latest" + } +} +``` + > [!NOTE] > If Readymade is built as a debug build, it will dump the installation state and the systemd-repart output to `/tmp/` for debugging purposes. diff --git a/crates/libreadymade/backend/provisioners/filesystem/copy.rs b/crates/libreadymade/backend/provisioners/filesystem/copy.rs index 8fe6ef6..f555ce8 100644 --- a/crates/libreadymade/backend/provisioners/filesystem/copy.rs +++ b/crates/libreadymade/backend/provisioners/filesystem/copy.rs @@ -5,7 +5,67 @@ use crate::{ #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct Copy { - pub copy_source: PathBuf, + /// Either a local path, a mountable image file, or an OCI image reference with + /// a Podman-recognized transport prefix such as `containers-storage:`. + pub copy_source: String, +} + +const OCI_COPY_SOURCE_PREFIXES: [&str; 4] = + ["containers-storage:", "docker://", "oci:", "oci-archive:"]; + +fn is_oci_copy_source(copy_source: &str) -> bool { + OCI_COPY_SOURCE_PREFIXES + .iter() + .any(|prefix| copy_source.starts_with(prefix)) +} + +#[tracing::instrument] +fn podman_stdout(args: &[&str], action: &str) -> Result { + tracing::trace!(?args, "running `podman {action}`"); + let mut cmd = Command::new("podman"); + cmd.args(args); + + // hack: do this to allow tracing to see the full command string + tracing::trace!(?cmd, "executing command"); + let output = cmd + .output() + .wrap_err_with(|| format!("failed to run `podman {action}`"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + bail!("`podman {action}` failed: {stderr}"); + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + if stdout.is_empty() { + bail!("`podman {action}` returned empty stdout"); + } + + Ok(stdout) +} + +fn cleanup_podman_container(container_id: &str) { + if let Err(err) = Command::new("podman") + .args(["umount", container_id]) + .status() + { + tracing::warn!( + ?err, + ?container_id, + "podman umount cleanup command failed to execute" + ); + } + + if let Err(err) = Command::new("podman") + .args(["rm", "-f", container_id]) + .status() + { + tracing::warn!( + ?err, + ?container_id, + "podman rm cleanup command failed to execute" + ); + } } impl FileSystemProvisionerModule for Copy { @@ -24,29 +84,72 @@ impl FileSystemProvisionerModule for Copy { } }; - let copy_source = &self.copy_source; + let copy_source = self.copy_source.trim(); + if copy_source.is_empty() { + bail!("copy_source cannot be empty"); + } + tracing::trace!(?copy_source, ?destroot); - if copy_source.is_file() { - // XXX: we should be using consistent paths, maybe a const? -ci - const MOUNT_PATH: &str = "/mnt/rdmsqsh"; - tracing::warn!("Copy source is a file, treating as an image to mount"); + if is_oci_copy_source(copy_source) { + tracing::debug!("Copy source is an OCI reference"); crate::stage!(extracting "Extracting files" { - tracing::trace!(?MOUNT_PATH, "Mounting disk image"); - let return_code = Command::new("mount").arg(copy_source).arg(MOUNT_PATH).status()?.code(); - if return_code.is_none_or(|return_code| return_code != 0) { - bail!("mount command returns rc={return_code:?}"); - } + tracing::info!(copy_source, "Copy source is an OCI image, mounting with podman"); + let container_id = podman_stdout(&["create", copy_source], "create")?; scopeguard::defer! { - _ = Command::new("umount").arg(MOUNT_PATH).status(); + cleanup_podman_container(&container_id); } - copy_dir(MOUNT_PATH, destroot)?; + + let mount_path = podman_stdout(&["mount", &container_id], "mount")?; + copy_dir(&mount_path, destroot)?; }); } else { - crate::stage!(copying "Copying files" { - copy_dir(©_source, destroot)?; - }); + let copy_source = PathBuf::from(copy_source); + if copy_source.is_file() { + // XXX: we should be using consistent paths, maybe a const? -ci + const MOUNT_PATH: &str = "/mnt/rdmsqsh"; + tracing::warn!("Copy source is a file, treating as an image to mount"); + crate::stage!(extracting "Extracting files" { + tracing::trace!(?MOUNT_PATH, "Mounting disk image"); + std::fs::create_dir_all(MOUNT_PATH)?; + let return_code = Command::new("mount") + .arg(©_source) + .arg(MOUNT_PATH) + .status()? + .code(); + if return_code.is_none_or(|return_code| return_code != 0) { + bail!("mount command returns rc={return_code:?}"); + } + scopeguard::defer! { + _ = Command::new("umount").arg(MOUNT_PATH).status(); + } + copy_dir(MOUNT_PATH, destroot)?; + }); + } else { + crate::stage!(copying "Copying files" { + copy_dir(©_source, destroot)?; + }); + } } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::is_oci_copy_source; + + #[test] + fn detects_oci_copy_sources() { + assert!(is_oci_copy_source( + "containers-storage:registry.example.org/example/os:latest" + )); + assert!(is_oci_copy_source("docker://quay.io/fyralabs/os:latest")); + } + + #[test] + fn ignores_plain_paths() { + assert!(!is_oci_copy_source("/mnt/install-root")); + assert!(!is_oci_copy_source("./install.img")); + } +} diff --git a/crates/libreadymade/backend/provisioners/mod.rs b/crates/libreadymade/backend/provisioners/mod.rs index e862d08..c67f020 100644 --- a/crates/libreadymade/backend/provisioners/mod.rs +++ b/crates/libreadymade/backend/provisioners/mod.rs @@ -2,4 +2,4 @@ pub mod disk; pub mod filesystem; pub use disk::DiskProvisioner; -pub use filesystem::FileSystemProvisioner; \ No newline at end of file +pub use filesystem::FileSystemProvisioner; diff --git a/crates/libreadymade/prelude.rs b/crates/libreadymade/prelude.rs index 06dd412..bd97174 100644 --- a/crates/libreadymade/prelude.rs +++ b/crates/libreadymade/prelude.rs @@ -3,7 +3,7 @@ pub use color_eyre::{Result, Section}; pub use itertools::Itertools; pub use serde::{Deserialize, Serialize}; -pub use crate::backend::mounts::{Mount, Mounts, EncryptionOption, CryptData}; +pub use crate::backend::mounts::{CryptData, EncryptionOption, Mount, Mounts}; pub use std::path::Component; pub use std::path::Path; pub use std::path::PathBuf; diff --git a/crates/readymade-playbook/main.rs b/crates/readymade-playbook/main.rs index e1dec26..25922ad 100644 --- a/crates/readymade-playbook/main.rs +++ b/crates/readymade-playbook/main.rs @@ -1,7 +1,7 @@ -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use color_eyre::Result; use libreadymade::playbook::Playbook; use std::fs; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; fn main() -> Result<()> { color_eyre::install()?;