diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 267f61f3..8ef73783 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -23,6 +23,7 @@ libc = "^0.2" once_cell = "1.9" openssl = "^0.10" nix = ">= 0.24, < 0.26" +regex = "1.7.1" serde = { features = ["derive"], version = "1.0.125" } serde_json = "1.0.64" serde_with = ">= 1.9.4, < 2" diff --git a/lib/src/blockdev.rs b/lib/src/blockdev.rs index 80d99436..7e2b3b81 100644 --- a/lib/src/blockdev.rs +++ b/lib/src/blockdev.rs @@ -4,7 +4,10 @@ use anyhow::{anyhow, Context, Result}; use camino::Utf8Path; use fn_error_context::context; use nix::errno::Errno; +use once_cell::sync::Lazy; +use regex::Regex; use serde::Deserialize; +use std::collections::HashMap; use std::fs::File; use std::os::unix::io::AsRawFd; use std::process::Command; @@ -39,7 +42,7 @@ impl Device { pub(crate) fn wipefs(dev: &Utf8Path) -> Result<()> { Task::new_and_run( - &format!("Wiping device {dev}"), + format!("Wiping device {dev}"), "wipefs", ["-a", dev.as_str()], ) @@ -109,6 +112,67 @@ pub(crate) fn reread_partition_table(file: &mut File, retry: bool) -> Result<()> Ok(()) } +/// Runs the provided Command object, captures its stdout, and swallows its stderr except on +/// failure. Returns a Result describing whether the command failed, and if not, its +/// standard output. Output is assumed to be UTF-8. Errors are adequately prefixed with the full +/// command. +pub(crate) fn cmd_output(cmd: &mut Command) -> Result { + let result = cmd + .output() + .with_context(|| format!("running {:#?}", cmd))?; + if !result.status.success() { + eprint!("{}", String::from_utf8_lossy(&result.stderr)); + anyhow::bail!("{:#?} failed with {}", cmd, result.status); + } + String::from_utf8(result.stdout) + .with_context(|| format!("decoding as UTF-8 output of `{:#?}`", cmd)) +} + +/// Parse key-value pairs from lsblk --pairs. +/// Newer versions of lsblk support JSON but the one in CentOS 7 doesn't. +fn split_lsblk_line(line: &str) -> HashMap { + static REGEX: Lazy = Lazy::new(|| Regex::new(r#"([A-Z-_]+)="([^"]+)""#).unwrap()); + let mut fields: HashMap = HashMap::new(); + for cap in REGEX.captures_iter(line) { + fields.insert(cap[1].to_string(), cap[2].to_string()); + } + fields +} + +/// This is a bit fuzzy, but... this function will return every block device in the parent +/// hierarchy of `device` capable of containing other partitions. So e.g. parent devices of type +/// "part" doesn't match, but "disk" and "mpath" does. +pub(crate) fn find_parent_devices(device: &str) -> Result> { + let mut cmd = Command::new("lsblk"); + // Older lsblk, e.g. in CentOS 7.6, doesn't support PATH, but --paths option + cmd.arg("--pairs") + .arg("--paths") + .arg("--inverse") + .arg("--output") + .arg("NAME,TYPE") + .arg(device); + let output = cmd_output(&mut cmd)?; + let mut parents = Vec::new(); + // skip first line, which is the device itself + for line in output.lines().skip(1) { + let dev = split_lsblk_line(line); + let name = dev + .get("NAME") + .with_context(|| format!("device in hierarchy of {device} missing NAME"))?; + let kind = dev + .get("TYPE") + .with_context(|| format!("device in hierarchy of {device} missing TYPE"))?; + if kind == "disk" { + parents.push(name.clone()); + } else if kind == "mpath" { + parents.push(name.clone()); + // we don't need to know what disks back the multipath + break; + } + } + Ok(parents) +} + // create unsafe ioctl wrappers #[allow(clippy::missing_safety_doc)] mod ioctl { diff --git a/lib/src/bootloader.rs b/lib/src/bootloader.rs index cc84607f..c9e7c16b 100644 --- a/lib/src/bootloader.rs +++ b/lib/src/bootloader.rs @@ -15,6 +15,8 @@ pub(crate) const IGNITION_VARIABLE: &str = "$ignition_firstboot"; const GRUB_BOOT_UUID_FILE: &str = "bootuuid.cfg"; const STATIC_GRUB_CFG: &str = include_str!("grub.cfg"); const STATIC_GRUB_CFG_EFI: &str = include_str!("grub-efi.cfg"); +/// The name of the mountpoint for efi (as a subdirectory of /boot, or at the toplevel) +pub(crate) const EFI_DIR: &str = "efi"; fn install_grub2_efi(efidir: &Dir, uuid: &str) -> Result<()> { let mut vendordir = None; @@ -64,7 +66,7 @@ pub(crate) fn install_via_bootupd( let bootfs = &rootfs.join("boot"); { - let efidir = Dir::open_ambient_dir(&bootfs.join("efi"), cap_std::ambient_authority())?; + let efidir = Dir::open_ambient_dir(bootfs.join("efi"), cap_std::ambient_authority())?; install_grub2_efi(&efidir, &grub2_uuid_contents)?; } diff --git a/lib/src/cli.rs b/lib/src/cli.rs index 89b39234..bd789928 100644 --- a/lib/src/cli.rs +++ b/lib/src/cli.rs @@ -82,6 +82,13 @@ pub(crate) enum TestingOpts { RunPrivilegedIntegration {}, /// Execute integration tests that target a not-privileged ostree container RunContainerIntegration {}, + /// Block device setup for testing + PrepTestInstallFilesystem { blockdev: Utf8PathBuf }, + /// e2e test of install-to-filesystem + TestInstallFilesystem { + image: String, + blockdev: Utf8PathBuf, + }, } /// Deploy and upgrade via bootable container images. @@ -99,6 +106,9 @@ pub(crate) enum Opt { /// Install to the target block device #[cfg(feature = "install")] Install(crate::install::InstallOpts), + /// Install to the target filesystem. + #[cfg(feature = "install")] + InstallToFilesystem(crate::install::InstallToFilesystemOpts), /// Internal integration testing helpers. #[clap(hide(true), subcommand)] #[cfg(feature = "internal-testing-api")] @@ -336,6 +346,8 @@ where Opt::Switch(opts) => switch(opts).await, #[cfg(feature = "install")] Opt::Install(opts) => crate::install::install(opts).await, + #[cfg(feature = "install")] + Opt::InstallToFilesystem(opts) => crate::install::install_to_filesystem(opts).await, Opt::Status(opts) => super::status::status(opts).await, #[cfg(feature = "internal-testing-api")] Opt::InternalTests(opts) => crate::privtests::run(opts).await, diff --git a/lib/src/ignition.rs b/lib/src/ignition.rs index 70937508..f59925a4 100644 --- a/lib/src/ignition.rs +++ b/lib/src/ignition.rs @@ -313,7 +313,7 @@ mod tests { (false, "sha512-cdaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f") ]; for (valid, hash_arg) in &hash_args { - let hasher = IgnitionHash::from_str(&hash_arg).unwrap(); + let hasher = IgnitionHash::from_str(hash_arg).unwrap(); let mut rd = std::io::Cursor::new(&input); assert!(hasher.validate(&mut rd).is_ok() == *valid); } diff --git a/lib/src/install.rs b/lib/src/install.rs index 1638b865..f58faf36 100644 --- a/lib/src/install.rs +++ b/lib/src/install.rs @@ -7,12 +7,14 @@ use std::process::Stdio; use std::str::FromStr; use std::sync::Arc; +use anyhow::Ok; use anyhow::{anyhow, Context, Result}; use camino::Utf8Path; use camino::Utf8PathBuf; use cap_std::fs::Dir; use cap_std_ext::cap_std; use cap_std_ext::prelude::CapStdExtDirExt; +use cap_std_ext::rustix::fs::MetadataExt; use clap::ArgEnum; use fn_error_context::context; use ostree::gio; @@ -29,9 +31,12 @@ use crate::utils::run_in_host_mountns; /// The default "stateroot" or "osname"; see https://github.com/ostreedev/ostree/issues/2794 const STATEROOT_DEFAULT: &str = "default"; - +/// The toplevel boot directory +const BOOT: &str = "boot"; /// Directory for transient runtime state const RUN_BOOTC: &str = "/run/bootc"; +/// This is an ext4 special directory we need to ignore. +const LOST_AND_FOUND: &str = "lost+found"; #[derive(clap::ValueEnum, Debug, Copy, Clone, PartialEq, Eq)] pub(crate) enum BlockSetup { @@ -174,6 +179,48 @@ pub(crate) struct InstallOpts { pub(crate) config_opts: InstallConfigOpts, } +/// Options for installing to a filesystem +#[derive(Debug, Clone, clap::Args)] +pub(crate) struct InstallTargetFilesystemOpts { + /// Path to the mounted root filesystem. + /// + /// By default, the filesystem UUID will be discovered and used for mounting. + /// To override this, use `--root-mount-spec`. + pub(crate) root_path: Utf8PathBuf, + + /// Source device specification for the root filesystem. For example, UUID=2e9f4241-229b-4202-8429-62d2302382e1 + #[clap(long)] + pub(crate) root_mount_spec: Option, + + /// Comma-separated mount options for the root filesystem. For example: rw,prjquota + #[clap(long)] + pub(crate) root_options: Option, + + /// Mount specification for the /boot filesystem. + /// + /// At the current time, a separate /boot is required. This restriction will be lifted in + /// future versions. If not specified, the filesystem UUID will be used. + #[clap(long)] + pub(crate) boot_mount_spec: Option, + + /// Automatically wipe existing data on the filesystems. + #[clap(long)] + pub(crate) wipe: bool, +} + +/// Perform an installation to a mounted filesystem. +#[derive(Debug, Clone, clap::Parser)] +pub(crate) struct InstallToFilesystemOpts { + #[clap(flatten)] + pub(crate) filesystem_opts: InstallTargetFilesystemOpts, + + #[clap(flatten)] + pub(crate) target_opts: InstallTargetOpts, + + #[clap(flatten)] + pub(crate) config_opts: InstallConfigOpts, +} + // Shared read-only global state struct State { container_info: ContainerExecutionInfo, @@ -230,6 +277,20 @@ impl MountSpec { } } + /// Construct a new mount that uses the provided uuid as a source. + pub(crate) fn new_uuid_src(uuid: &str, target: &str) -> Self { + Self::new(&format!("UUID={uuid}"), target) + } + + pub(crate) fn get_source_uuid(&self) -> Option<&str> { + if let Some((t, rest)) = self.source.split_once('=') { + if t.eq_ignore_ascii_case("uuid") { + return Some(rest); + } + } + None + } + pub(crate) fn to_fstab(&self) -> String { let options = self.options.as_deref().unwrap_or("defaults"); format!( @@ -286,7 +347,7 @@ fn mkfs<'a>( opts: impl IntoIterator, ) -> Result { let u = uuid::Uuid::new_v4(); - let mut t = Task::new("Creating filesystem", &format!("mkfs.{fs}")); + let mut t = Task::new("Creating filesystem", format!("mkfs.{fs}")); match fs { Filesystem::Xfs => { t.cmd.arg("-m"); @@ -311,7 +372,7 @@ fn mkfs<'a>( fn mount(dev: &str, target: &Utf8Path) -> Result<()> { Task::new_and_run( - &format!("Mounting {target}"), + format!("Mounting {target}"), "mount", [dev, target.as_str()], ) @@ -328,7 +389,7 @@ fn bind_mount_from_host(src: impl AsRef, dest: impl AsRef) - // the host's mount namespace, then give `mount` our own pid (from which it finds the mount namespace). let desc = format!("Bind mounting {src} from host"); let target = format!("{}", nix::unistd::getpid()); - Task::new_cmd(&desc, run_in_host_mountns("mount")) + Task::new_cmd(desc, run_in_host_mountns("mount")) .quiet() .args(["--bind", "-N", target.as_str(), src.as_str(), dest.as_str()]) .run() @@ -546,17 +607,16 @@ struct RootSetup { kargs: Vec, } +fn require_boot_uuid(spec: &MountSpec) -> Result<&str> { + spec.get_source_uuid() + .ok_or_else(|| anyhow!("/boot is not specified via UUID= (this is currently required)")) +} + impl RootSetup { /// Get the UUID= mount specifier for the /boot filesystem. At the current time this is /// required. fn get_boot_uuid(&self) -> Result<&str> { - let bootsrc = &self.boot.source; - if let Some((t, rest)) = bootsrc.split_once('=') { - if t.eq_ignore_ascii_case("uuid") { - return Ok(rest); - } - } - anyhow::bail!("/boot is not specified via UUID= (this is currently required): {bootsrc}") + require_boot_uuid(&self.boot) } } @@ -603,7 +663,7 @@ fn install_create_rootfs(state: &State, opts: InstallBlockDeviceOpts) -> Result< let rootfs = state.mntdir.join("rootfs"); std::fs::create_dir_all(&rootfs)?; let bootfs = state.mntdir.join("boot"); - std::fs::create_dir_all(&bootfs)?; + std::fs::create_dir_all(bootfs)?; // Run sgdisk to create partitions. let mut sgdisk = Task::new("Initializing partitions", "sgdisk"); @@ -718,7 +778,7 @@ fn install_create_rootfs(state: &State, opts: InstallBlockDeviceOpts) -> Result< .args([espdev.as_str(), "-n", "EFI-SYSTEM"]) .quiet_output() .run()?; - let efifs_path = bootfs.join("efi"); + let efifs_path = bootfs.join(crate::bootloader::EFI_DIR); std::fs::create_dir(&efifs_path).context("Creating efi dir")?; mount(&espdev, &efifs_path)?; } @@ -767,7 +827,7 @@ pub(crate) fn finalize_filesystem(fs: &Utf8Path) -> Result<()> { Task::new_and_run(format!("Trimming {fsname}"), "fstrim", ["-v", fs.as_str()])?; // Remounting readonly will flush outstanding writes and ensure we error out if there were background // writeback problems. - Task::new(&format!("Finalizing filesystem {fsname}"), "mount") + Task::new(format!("Finalizing filesystem {fsname}"), "mount") .args(["-o", "remount,ro", fs.as_str()]) .run()?; // Finally, freezing (and thawing) the filesystem will flush the journal, which means the next boot is clean. @@ -869,16 +929,7 @@ async fn prepare_install( Ok(state) } -/// Implementation of the `bootc install` CLI command. -pub(crate) async fn install(opts: InstallOpts) -> Result<()> { - let block_opts = opts.block_opts; - let state = prepare_install(opts.config_opts, opts.target_opts).await?; - - // This is all blocking stuff - let mut rootfs = { - let state = state.clone(); - tokio::task::spawn_blocking(move || install_create_rootfs(&state, block_opts)).await?? - }; +async fn install_to_filesystem_impl(state: &State, rootfs: &mut RootSetup) -> Result<()> { if state.override_disable_selinux { rootfs.kargs.push("selinux=0".to_string()); } @@ -894,7 +945,7 @@ pub(crate) async fn install(opts: InstallOpts) -> Result<()> { // 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?; + let aleph = initialize_ostree_root_from_self(state, rootfs).await?; rootfs .rootfs_fd .atomic_replace_with(BOOTC_ALEPH_PATH, |f| { @@ -906,6 +957,7 @@ pub(crate) async fn install(opts: InstallOpts) -> Result<()> { let boot_uuid = rootfs.get_boot_uuid()?; crate::bootloader::install_via_bootupd(&rootfs.device, &rootfs.rootfs, boot_uuid)?; + tracing::debug!("Installed bootloader"); // If Ignition is specified, enable it if let Some(ignition_file) = state.config_opts.ignition_file.as_deref() { @@ -930,6 +982,26 @@ pub(crate) async fn install(opts: InstallOpts) -> Result<()> { finalize_filesystem(fs)?; } + Ok(()) +} + +fn installation_complete() { + println!("Installation complete!"); +} + +/// Implementation of the `bootc install` CLI command. +pub(crate) async fn install(opts: InstallOpts) -> Result<()> { + let block_opts = opts.block_opts; + let state = prepare_install(opts.config_opts, opts.target_opts).await?; + + // This is all blocking stuff + let mut rootfs = { + let state = state.clone(); + tokio::task::spawn_blocking(move || install_create_rootfs(&state, block_opts)).await?? + }; + + install_to_filesystem_impl(&state, &mut rootfs).await?; + // Drop all data about the root except the path to ensure any file descriptors etc. are closed. let rootfs_path = rootfs.rootfs.clone(); drop(rootfs); @@ -940,7 +1012,154 @@ pub(crate) async fn install(opts: InstallOpts) -> Result<()> { ["-R", rootfs_path.as_str()], )?; - println!("Installation complete!"); + installation_complete(); + + Ok(()) +} + +#[context("Verifying empty rootfs")] +fn require_empty_rootdir(rootfs_fd: &Dir) -> Result<()> { + for e in rootfs_fd.entries()? { + let e = e?; + let name = e.file_name(); + let name = name + .to_str() + .ok_or_else(|| anyhow!("Invalid non-UTF8 filename: {name:?}"))?; + if name == LOST_AND_FOUND { + continue; + } + // There must be a boot directory (that is empty) + if name == BOOT { + let mut entries = rootfs_fd.read_dir(BOOT)?; + if let Some(e) = entries.next() { + let e = e?; + let name = e.file_name(); + let name = name + .to_str() + .ok_or_else(|| anyhow!("Invalid non-UTF8 filename: {name:?}"))?; + if matches!(name, LOST_AND_FOUND | crate::bootloader::EFI_DIR) { + continue; + } + anyhow::bail!("Non-empty boot directory, found {name:?}"); + } + } else { + anyhow::bail!("Non-empty root filesystem; found {name:?}"); + } + } + Ok(()) +} + +/// Implementation of the `bootc install-to-filsystem` CLI command. +pub(crate) async fn install_to_filesystem(opts: InstallToFilesystemOpts) -> Result<()> { + // Gather global state, destructuring the provided options + let state = prepare_install(opts.config_opts, opts.target_opts).await?; + let fsopts = opts.filesystem_opts; + + let root_path = &fsopts.root_path; + let rootfs_fd = Dir::open_ambient_dir(root_path, cap_std::ambient_authority()) + .with_context(|| format!("Opening target root directory {root_path}"))?; + if fsopts.wipe { + let rootfs_fd = rootfs_fd.try_clone()?; + println!("Wiping contents of root"); + tokio::task::spawn_blocking(move || { + for e in rootfs_fd.entries()? { + let e = e?; + rootfs_fd.remove_all_optional(e.file_name())?; + } + anyhow::Ok(()) + }) + .await??; + } else { + require_empty_rootdir(&rootfs_fd)?; + } + + // Gather data about the root filesystem + let inspect = crate::mount::inspect_filesystem(&fsopts.root_path)?; + + // We support overriding the mount specification for root (i.e. LABEL vs UUID versus + // raw paths). + let root_mount_spec = if let Some(s) = fsopts.root_mount_spec { + s + } else { + let mut uuid = inspect + .uuid + .ok_or_else(|| anyhow!("No filesystem uuid found in target root"))?; + uuid.insert_str(0, "UUID="); + tracing::debug!("root {uuid}"); + uuid + }; + tracing::debug!("Root mount spec: {root_mount_spec}"); + + // Verify /boot is a separate mount + { + let root_dev = rootfs_fd.dir_metadata()?.dev(); + let boot_dev = rootfs_fd + .symlink_metadata_optional(BOOT)? + .ok_or_else(|| { + anyhow!("No /{BOOT} directory found in root; this is is currently required") + })? + .dev(); + tracing::debug!("root_dev={root_dev} boot_dev={boot_dev}"); + if root_dev == boot_dev { + anyhow::bail!("/{BOOT} must currently be a separate mounted filesystem"); + } + } + // Find the UUID of /boot because we need it for GRUB. + let boot_path = fsopts.root_path.join(BOOT); + let boot_uuid = crate::mount::inspect_filesystem(&boot_path) + .context("Inspecting /{BOOT}")? + .uuid + .ok_or_else(|| anyhow!("No UUID found for /{BOOT}"))?; + tracing::debug!("boot UUID: {boot_uuid}"); + + // Find the real underlying backing device for the root. This is currently just required + // for GRUB (BIOS) and in the future zipl (I think). + let backing_device = { + let mut dev = inspect.source; + loop { + tracing::debug!("Finding parents for {dev}"); + let mut parents = crate::blockdev::find_parent_devices(&dev)?.into_iter(); + let parent = if let Some(f) = parents.next() { + f + } else { + break; + }; + if let Some(next) = parents.next() { + anyhow::bail!( + "Found multiple parent devices {parent} and {next}; not currently supported" + ); + } + dev = parent; + } + dev + }; + tracing::debug!("Backing device: {backing_device}"); + + let rootarg = format!("root={root_mount_spec}"); + let boot = if let Some(spec) = fsopts.boot_mount_spec { + MountSpec::new(&spec, "/boot") + } else { + MountSpec::new_uuid_src(&boot_uuid, "/boot") + }; + // By default, we inject a boot= karg because things like FIPS compliance currently + // require checking in the initramfs. + let bootarg = format!("boot={}", &boot.source); + let kargs = vec![rootarg, RW_KARG.to_string(), bootarg]; + + let mut rootfs = RootSetup { + device: backing_device.into(), + rootfs: fsopts.root_path, + rootfs_fd, + boot, + kargs, + }; + + install_to_filesystem_impl(&state, &mut rootfs).await?; + + // Drop all data about the root except the path to ensure any file descriptors etc. are closed. + drop(rootfs); + + installation_complete(); Ok(()) } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 015745b0..d1f03152 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -33,6 +33,8 @@ pub(crate) mod ignition; #[cfg(feature = "install")] mod install; #[cfg(feature = "install")] +pub(crate) mod mount; +#[cfg(feature = "install")] mod podman; #[cfg(feature = "install")] mod task; diff --git a/lib/src/mount.rs b/lib/src/mount.rs new file mode 100644 index 00000000..859e0526 --- /dev/null +++ b/lib/src/mount.rs @@ -0,0 +1,38 @@ +//! Helpers for interacting with mountpoints + +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; +use camino::Utf8Path; +use fn_error_context::context; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct Filesystem { + pub(crate) source: String, + pub(crate) uuid: Option, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct Findmnt { + pub(crate) filesystems: Vec, +} + +#[context("Inspecting filesystem {path}")] +pub(crate) fn inspect_filesystem(path: &Utf8Path) -> Result { + tracing::debug!("Inspecting {path}"); + let o = Command::new("findmnt") + .args(["-J", "--output-all", path.as_str()]) + .output()?; + let st = o.status; + if !st.success() { + anyhow::bail!("findmnt {path} failed: {st:?}"); + } + let o: Findmnt = serde_json::from_reader(std::io::Cursor::new(&o.stdout)) + .context("Parsing findmnt output")?; + o.filesystems + .into_iter() + .next() + .ok_or_else(|| anyhow!("findmnt returned no data for {path}")) +} diff --git a/lib/src/privtests.rs b/lib/src/privtests.rs index 6aa40cc2..1a0cb08a 100644 --- a/lib/src/privtests.rs +++ b/lib/src/privtests.rs @@ -114,6 +114,52 @@ pub(crate) fn impl_run_container() -> Result<()> { Ok(()) } +#[context("Container tests")] +fn prep_test_install_filesystem(blockdev: &Utf8Path) -> Result { + let sh = Shell::new()?; + // Arbitrarily larger partition offsets + let efipn = "5"; + let bootpn = "6"; + let rootpn = "7"; + let mountpoint_dir = tempfile::tempdir()?; + let mountpoint: &Utf8Path = mountpoint_dir.path().try_into().unwrap(); + // Create the partition setup; we add some random empty partitions for 2,3,4 just to exercise things + cmd!( + sh, + "sgdisk -Z {blockdev} -n 1:0:+1M -c 1:BIOS-BOOT -t 1:21686148-6449-6E6F-744E-656564454649 -n 2:0:+3M -n 3:0:+2M -n 4:0:+5M -n {efipn}:0:+127M -c {efipn}:EFI-SYSTEM -t ${efipn}:C12A7328-F81F-11D2-BA4B-00A0C93EC93B -n {bootpn}:0:+510M -c {bootpn}:boot -n {rootpn}:0:0 -c {rootpn}:root -t {rootpn}:0FC63DAF-8483-4772-8E79-3D69D8477DE4" + ) + .run()?; + // Create filesystems and mount + cmd!(sh, "mkfs.ext4 {blockdev}{bootpn}").run()?; + cmd!(sh, "mkfs.ext4 {blockdev}{rootpn}").run()?; + cmd!(sh, "mkfs.fat {blockdev}{efipn}").run()?; + cmd!(sh, "mount {blockdev}{rootpn} {mountpoint}").run()?; + cmd!(sh, "mkdir {mountpoint}/boot").run()?; + cmd!(sh, "mount {blockdev}{bootpn} {mountpoint}/boot").run()?; + let efidir = crate::bootloader::EFI_DIR; + cmd!(sh, "mkdir {mountpoint}/boot/{efidir}").run()?; + cmd!(sh, "mount {blockdev}{efipn} {mountpoint}/boot/{efidir}").run()?; + + Ok(mountpoint_dir) +} + +#[context("Container tests")] +fn test_install_filesystem(image: &str, blockdev: &Utf8Path) -> Result<()> { + let sh = Shell::new()?; + + let mountpoint_dir = prep_test_install_filesystem(blockdev)?; + let mountpoint: &Utf8Path = mountpoint_dir.path().try_into().unwrap(); + + // And run the install + cmd!(sh, "podman run --rm --privileged --pid=host --net=none --env=RUST_LOG -v /usr/bin/bootc:/usr/bin/bootc -v {mountpoint}:/target-root {image} bootc install-to-filesystem /target-root").run()?; + + cmd!(sh, "umount -R {mountpoint}").run()?; + + drop(mountpoint); + + Ok(()) +} + pub(crate) async fn run(opts: TestingOpts) -> Result<()> { match opts { TestingOpts::RunPrivilegedIntegration {} => { @@ -123,5 +169,13 @@ pub(crate) async fn run(opts: TestingOpts) -> Result<()> { TestingOpts::RunContainerIntegration {} => { tokio::task::spawn_blocking(impl_run_container).await? } + TestingOpts::PrepTestInstallFilesystem { blockdev } => { + tokio::task::spawn_blocking(move || prep_test_install_filesystem(&blockdev).map(|_| ())) + .await? + } + TestingOpts::TestInstallFilesystem { image, blockdev } => { + crate::cli::ensure_self_unshared_mount_namespace().await?; + tokio::task::spawn_blocking(move || test_install_filesystem(&image, &blockdev)).await? + } } } diff --git a/tests/kolainst/install b/tests/kolainst/install index 62ba8624..7af7e6fb 100755 --- a/tests/kolainst/install +++ b/tests/kolainst/install @@ -25,6 +25,12 @@ case "${AUTOPKGTEST_REBOOT_MARK:-}" in # but for now let's just sanity test that the install command executes. lsblk ${DEV} echo "ok install" + + # Now test install-to-filesystem + # Wipe the device + ls ${DEV}* | tac | xargs wipefs -af + # This prepares the device and also runs podman directliy + bootc internal-tests test-install-filesystem ${IMAGE} ${DEV} ;; *) echo "unexpected mark: ${AUTOPKGTEST_REBOOT_MARK}"; exit 1;; esac