use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
use anyhow::{Context, Result, anyhow};
use imago::{
FormatAccess, FormatDriverBuilder, PermissiveImplicitOpenGate, file::File as ImagoFile,
qcow2::Qcow2,
};
use libtest_mimic::{Arguments, Completion, Failed, Trial};
use vmrunner::{RootDevice, RootFsType, RootMount, RootMountOptions, TestCase};
const SECTOR_SIZE: u64 = 512;
const GUEST_SMOKE_MARKER: &str = "vmrunner-smoke-ok";
const FEDORA_AARCH64_GUEST_TARGET: &str = "aarch64-unknown-linux-gnu";
const AARCH64_ELF_MACHINE: u16 = 183;
type ImageSource = vmrunner::ExternalQCowFilesystem;
fn main() {
let args = Arguments::from_args();
let trials = vec![
Trial::ignorable_test(
"ubuntu_vm_smoke",
|| result_to_completion(ubuntu_vm_smoke()),
)
.with_kind("vm"),
Trial::ignorable_test(
"fedora_vm_smoke",
|| result_to_completion(fedora_vm_smoke()),
)
.with_kind("vm"),
Trial::ignorable_test("fedora_aarch64_sysroot_compile_smoke", || {
result_to_completion(fedora_aarch64_sysroot_compile_smoke())
})
.with_kind("sysroot"),
];
libtest_mimic::run(&args, trials).exit();
}
fn result_to_completion(result: Result<()>) -> Result<Completion, Failed> {
result
.map(|()| Completion::Completed)
.map_err(|error| Failed::from(format!("{error:#}")))
}
fn ubuntu_vm_smoke() -> Result<()> {
run_linux_vm_smoke("ubuntu_vm_smoke", "ubuntu", host_ubuntu_qcow2()?, |_| {
Ok(RootMount::new(RootDevice::virtio_first_partition()))
})
}
fn fedora_vm_smoke() -> Result<()> {
run_linux_vm_smoke(
"fedora_vm_smoke",
"fedora",
host_fedora_qcow2()?,
fedora_root_mount,
)
}
fn fedora_aarch64_sysroot_compile_smoke() -> Result<()> {
let image = fedora_aarch64_qcow2()?;
let root_qcow2 = vmrunner::ensure_qcow2_image_cached_with_sha256(image.url, image.digest)?;
let sysroot_parent = tempfile::Builder::new()
.prefix("vmrunner-fedora-aarch64-sysroot-")
.tempdir()
.context("create temporary Fedora aarch64 sysroot output parent")?;
let build_dir = tempfile::Builder::new()
.prefix("vmrunner-fedora-aarch64-sysroot-build-")
.tempdir()
.context("create temporary Fedora aarch64 sysroot compile directory")?;
let sysroot =
vmrunner_sysroot::linux::extract_linux_sysroot(vmrunner_sysroot::SysrootOptions::new(
&root_qcow2,
FEDORA_AARCH64_GUEST_TARGET,
sysroot_parent.path(),
))
.with_context(|| {
format!(
"extract Fedora aarch64 sysroot from '{}'",
root_qcow2.display()
)
})?;
compile_aarch64_fedora_libc_smoke(&sysroot, build_dir.path())
}
fn run_linux_vm_smoke(
test_name: &str,
guest_name: &str,
root_qcow2: ImageSource,
root_mount: impl FnOnce(&Path) -> Result<RootMount>,
) -> Result<()> {
let root_qcow2 =
vmrunner::ensure_qcow2_image_cached_with_sha256(root_qcow2.url, root_qcow2.digest)?;
let root_mount = root_mount(&root_qcow2)?;
let guest_target = host_linux_guest_target()?;
vmrunner::ensure_guest_init(&root_qcow2, Some(guest_target), None)?;
if vmrunner::run_current_test_in_platform_child(test_name)? {
return Ok(());
}
run_guest_true(guest_name, &root_qcow2, root_mount)
}
fn run_guest_true(name: &str, root_qcow2: &Path, root_mount: RootMount) -> Result<()> {
let node = vmrunner::Node::new(&[], "/bin/sh")
.name(name)
.args(["-c".to_owned(), format!("echo {GUEST_SMOKE_MARKER}")]);
let mut test_case = TestCase::new(&[&node])
.root_qcow2(root_qcow2)
.guest_init(vmrunner::guest_init_path_for_root_qcow2(root_qcow2))
.root_device(root_mount.device().clone());
if let Some(root_fstype) = root_mount.root_fstype() {
test_case = test_case.root_fstype(root_fstype.clone());
}
if let Some(root_options) = root_mount.root_options() {
test_case = test_case.root_options(root_options.clone());
}
let running = futures::executor::block_on(test_case.launch())?;
let output = futures::executor::block_on(running.wait_with_output())?;
if output.len() == 1 && output[0].stdout.contains(GUEST_SMOKE_MARKER) {
Ok(())
} else {
Err(anyhow!(
"{name} guest did not print {GUEST_SMOKE_MARKER:?}: {output:?}"
))
}
}
fn host_linux_guest_target() -> Result<&'static str> {
match std::env::consts::ARCH {
"aarch64" => Ok("aarch64-unknown-linux-gnu"),
"x86_64" => Ok("x86_64-unknown-linux-gnu"),
arch => Err(anyhow!(
"unsupported host arch for Linux VM image test: {arch}"
)),
}
}
fn host_ubuntu_qcow2() -> Result<ImageSource> {
host_qcow2("ubuntu")
}
fn host_fedora_qcow2() -> Result<ImageSource> {
host_qcow2("fedora")
}
fn fedora_aarch64_qcow2() -> Result<ImageSource> {
qcow2("fedora", "aarch64")
}
fn host_qcow2(name: &str) -> Result<ImageSource> {
qcow2(name, std::env::consts::ARCH)
}
fn qcow2(name: &str, arch: &str) -> Result<ImageSource> {
vmrunner::EXTERNAL_QCOW_FILESYSTEMS
.iter()
.copied()
.find(|image| image.name == name && image.arch == arch)
.ok_or_else(|| anyhow!("unsupported arch for {name} VM image test: {arch}"))
}
fn compile_aarch64_fedora_libc_smoke(sysroot: &Path, build_dir: &Path) -> Result<()> {
let libc = find_sysroot_file(sysroot, "libc.so.6")
.with_context(|| format!("find libc.so.6 under '{}'", sysroot.display()))?;
let dynamic_linker = find_sysroot_file(sysroot, "ld-linux-aarch64.so.1")
.with_context(|| format!("find aarch64 dynamic linker under '{}'", sysroot.display()))?;
let dynamic_linker_guest_path = sysroot_guest_path(sysroot, &dynamic_linker)?;
let lib_dir = libc
.parent()
.ok_or_else(|| anyhow!("libc path '{}' has no parent", libc.display()))?;
let source = build_dir.join("hello.c");
let object = build_dir.join("hello.o");
let binary = build_dir.join("hello");
fs::write(
&source,
r#"extern int puts(const char *);
extern void _exit(int);
void _start(void) {
puts("hello from Fedora aarch64 sysroot");
_exit(0);
}
"#,
)
.with_context(|| format!("write sysroot compile smoke source '{}'", source.display()))?;
run_checked_command(
Command::new("clang")
.arg("--target")
.arg(FEDORA_AARCH64_GUEST_TARGET)
.arg("--sysroot")
.arg(sysroot)
.arg("-c")
.arg(&source)
.arg("-o")
.arg(&object),
"compile Fedora aarch64 sysroot smoke object",
)?;
run_checked_command(
Command::new("clang")
.arg("--target")
.arg(FEDORA_AARCH64_GUEST_TARGET)
.arg("--sysroot")
.arg(sysroot)
.arg("-fuse-ld=lld")
.arg("-nostdlib")
.arg(format!("-Wl,--dynamic-linker,{dynamic_linker_guest_path}"))
.arg(format!("-Wl,-rpath-link,{}", lib_dir.display()))
.arg("-L")
.arg(lib_dir)
.arg(&object)
.arg("-l:libc.so.6")
.arg("-o")
.arg(&binary),
"link Fedora aarch64 sysroot smoke binary",
)?;
validate_elf_machine(&binary, AARCH64_ELF_MACHINE)?;
Ok(())
}
fn find_sysroot_file(sysroot: &Path, file_name: &str) -> Result<PathBuf> {
let mut stack = vec![sysroot.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in fs::read_dir(&dir).with_context(|| format!("read '{}'", dir.display()))? {
let entry = entry?;
let path = entry.path();
if entry.file_name() == file_name && path.is_file() {
return Ok(path);
}
if entry.file_type()?.is_dir() {
stack.push(path);
}
}
}
Err(anyhow!(
"file '{file_name}' not found under sysroot '{}'",
sysroot.display()
))
}
fn sysroot_guest_path(sysroot: &Path, path: &Path) -> Result<String> {
let relative = path.strip_prefix(sysroot).with_context(|| {
format!(
"path '{}' is not under sysroot '{}'",
path.display(),
sysroot.display()
)
})?;
Ok(format!("/{}", relative.display()))
}
fn run_checked_command(command: &mut Command, description: &str) -> Result<()> {
let command_debug = format!("{command:?}");
let output = command
.output()
.with_context(|| format!("spawn command to {description}: {command_debug}"))?;
if output.status.success() {
return Ok(());
}
Err(anyhow!(
"command failed while trying to {description}: {command_debug}\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
))
}
fn validate_elf_machine(path: &Path, expected_machine: u16) -> Result<()> {
let header = fs::read(path).with_context(|| format!("read ELF binary '{}'", path.display()))?;
if header.len() < 20 || &header[0..4] != b"\x7fELF" {
return Err(anyhow!("'{}' is not an ELF binary", path.display()));
}
if header[4] != 2 || header[5] != 1 {
return Err(anyhow!(
"'{}' is not a 64-bit little-endian ELF binary",
path.display()
));
}
let machine = u16::from_le_bytes(header[18..20].try_into()?);
if machine == expected_machine {
Ok(())
} else {
Err(anyhow!(
"ELF binary '{}' has machine {machine}, expected {expected_machine}",
path.display()
))
}
}
fn fedora_root_mount(root_qcow2: &Path) -> Result<RootMount> {
Ok(
RootMount::new(root_device_from_last_gpt_partition(root_qcow2)?)
.fstype(RootFsType::Btrfs)
.options(RootMountOptions::new("subvol=root")),
)
}
fn root_device_from_last_gpt_partition(root_qcow2: &Path) -> Result<RootDevice> {
let partition_number = futures::executor::block_on(last_gpt_partition_number(root_qcow2))?;
Ok(RootDevice::new(format!("/dev/vda{partition_number}")))
}
async fn last_gpt_partition_number(root_qcow2: &Path) -> Result<usize> {
let qcow2 = Qcow2::<ImagoFile>::builder_path(root_qcow2)
.open(PermissiveImplicitOpenGate::default())
.await
.with_context(|| format!("open qcow2 image '{}'", root_qcow2.display()))?;
let image = FormatAccess::new(qcow2);
let mut header = vec![0; SECTOR_SIZE as usize];
image
.read(&mut header, SECTOR_SIZE)
.await
.with_context(|| {
format!(
"read GPT header from qcow2 image '{}'",
root_qcow2.display()
)
})?;
if &header[0..8] != b"EFI PART" {
return Err(anyhow!(
"qcow2 image '{}' has no GPT header",
root_qcow2.display()
));
}
let partition_entry_lba = le_u64(&header, 72)?;
let partition_count = le_u32(&header, 80)? as usize;
let partition_entry_size = le_u32(&header, 84)? as usize;
if partition_entry_size < 128 {
return Err(anyhow!(
"GPT partition entry size is too small in '{}': {partition_entry_size}",
root_qcow2.display()
));
}
if partition_count > 4096 || partition_entry_size > 4096 {
return Err(anyhow!(
"GPT partition table is too large in '{}': {partition_count} entries of {partition_entry_size} bytes",
root_qcow2.display()
));
}
let table_len = partition_count
.checked_mul(partition_entry_size)
.ok_or_else(|| {
anyhow!(
"GPT partition table size overflow in '{}'",
root_qcow2.display()
)
})?;
let mut table = vec![0; table_len];
image
.read(&mut table, partition_entry_lba * SECTOR_SIZE)
.await
.with_context(|| {
format!(
"read GPT partition table from qcow2 image '{}'",
root_qcow2.display()
)
})?;
table
.chunks_exact(partition_entry_size)
.enumerate()
.filter_map(|(index, entry)| {
let type_guid = &entry[0..16];
let first_lba = le_u64(entry, 32).ok()?;
let last_lba = le_u64(entry, 40).ok()?;
if type_guid != [0; 16] && first_lba != 0 && last_lba >= first_lba {
Some((index + 1, first_lba))
} else {
None
}
})
.max_by_key(|(_, first_lba)| *first_lba)
.map(|(partition_number, _)| partition_number)
.ok_or_else(|| anyhow!("no GPT partitions found in '{}'", root_qcow2.display()))
}
fn le_u32(bytes: &[u8], offset: usize) -> Result<u32> {
let bytes = bytes
.get(offset..offset + 4)
.ok_or_else(|| anyhow!("short little-endian u32 read at offset {offset}"))?;
Ok(u32::from_le_bytes(bytes.try_into()?))
}
fn le_u64(bytes: &[u8], offset: usize) -> Result<u64> {
let bytes = bytes
.get(offset..offset + 8)
.ok_or_else(|| anyhow!("short little-endian u64 read at offset {offset}"))?;
Ok(u64::from_le_bytes(bytes.try_into()?))
}