use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Mutex;
use anyhow::{Context, Result};
use tokio::process::Command;
use crate::image::Image;
static ACTIVE_VMS: Mutex<Vec<ActiveVm>> = Mutex::new(Vec::new());
struct ActiveVm {
pid: u32,
work_dir: PathBuf,
}
pub fn cleanup_all_vms() {
let vms = {
let mut guard = ACTIVE_VMS.lock().unwrap_or_else(|e| e.into_inner());
std::mem::take(&mut *guard)
};
for vm in &vms {
unsafe {
libc::kill(vm.pid as i32, libc::SIGKILL);
}
let _ = std::fs::remove_dir_all(&vm.work_dir);
}
if !vms.is_empty() {
eprintln!("\nCleaned up {} VM(s)", vms.len());
}
}
fn register_vm(pid: u32, work_dir: &Path) {
let mut guard = ACTIVE_VMS.lock().unwrap_or_else(|e| e.into_inner());
guard.push(ActiveVm {
pid,
work_dir: work_dir.to_path_buf(),
});
}
fn deregister_vm(pid: u32) {
let mut guard = ACTIVE_VMS.lock().unwrap_or_else(|e| e.into_inner());
guard.retain(|vm| vm.pid != pid);
}
pub struct Machine {
pub name: String,
pub ssh_host: String,
pub ssh_port: u16,
pub work_dir: PathBuf,
process: tokio::process::Child,
}
impl Drop for Machine {
fn drop(&mut self) {
if let Some(pid) = self.process.id() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
deregister_vm(pid);
}
}
}
pub struct SpawnOpts {
pub use_kvm: bool,
pub memory_mb: u32,
pub cpus: u32,
pub disk_gb: u32,
}
impl SpawnOpts {
pub fn boot_timeout(&self) -> std::time::Duration {
if self.use_kvm {
std::time::Duration::from_secs(300)
} else {
std::time::Duration::from_secs(900) }
}
}
impl Default for SpawnOpts {
fn default() -> Self {
Self {
use_kvm: true,
memory_mb: 2048,
cpus: 2,
disk_gb: 20,
}
}
}
pub fn random_id() -> String {
use rand::Rng;
let mut rng = rand::rng();
format!("{:06x}", rng.random::<u32>() & 0xffffff)
}
impl Machine {
pub async fn spawn(
image: &Image,
test_id: &str,
ssh_port: u16,
opts: &SpawnOpts,
) -> Result<Self> {
if let Some(ref snapshot) = image.snapshot {
return Self::spawn_from_snapshot(image, snapshot, test_id, ssh_port, opts).await;
}
Self::spawn_cold(image, test_id, ssh_port, opts).await
}
async fn spawn_from_snapshot(
image: &Image,
snapshot: &crate::image::SnapshotFiles,
test_id: &str,
ssh_port: u16,
opts: &SpawnOpts,
) -> Result<Self> {
let name = format!("ryra-test-{test_id}");
let work_dir = vm_work_base_dir()?.join(&name);
tokio::fs::create_dir_all(&work_dir)
.await
.context("failed to create VM work directory")?;
let disk = work_dir.join("disk.qcow2");
let efi_vars = work_dir.join("efivars.qcow2");
let seed_iso = work_dir.join("seed.iso");
run_cmd(
"cp",
&[
"--reflink=auto",
&snapshot.disk.to_string_lossy(),
&disk.to_string_lossy(),
],
)
.await
.context("failed to copy snapshot disk")?;
run_cmd(
"cp",
&[
"--reflink=auto",
&snapshot.efivars.to_string_lossy(),
&efi_vars.to_string_lossy(),
],
)
.await
.context("failed to copy snapshot efivars")?;
run_cmd(
"cp",
&[
"--reflink=auto",
&snapshot.seed_iso.to_string_lossy(),
&seed_iso.to_string_lossy(),
],
)
.await
.context("failed to copy snapshot seed ISO")?;
let key_path = work_dir.join("id_ed25519");
tokio::fs::copy(&snapshot.ssh_key, &key_path)
.await
.context("failed to copy SSH key")?;
let memory = snapshot.memory_mb.to_string();
let cpus = opts.cpus.to_string();
let efi_code_arg = format!(
"if=pflash,format=raw,file={},readonly=on",
image.efi_code.display()
);
let efi_vars_arg = format!("if=pflash,format=qcow2,file={}", efi_vars.display());
let disk_arg = format!("if=virtio,file={},format=qcow2", disk.display());
let seed_arg = format!(
"if=virtio,file={},format=raw,readonly=on",
seed_iso.display()
);
let nic_arg = format!("user,hostfwd=tcp::{ssh_port}-:22");
let serial_log = work_dir.join("serial.log");
let serial_arg = format!("file:{}", serial_log.display());
let shared_store = image_shared_store_dir()?;
tokio::fs::create_dir_all(&shared_store).await.ok();
let virtfs_arg = format!(
"local,path={},mount_tag=images,security_model=none,readonly=on",
shared_store.display()
);
let mut args: Vec<&str> = vec![
"-machine",
"virt",
"-cpu",
if opts.use_kvm { "host" } else { "max" },
"-m",
&memory,
"-smp",
&cpus,
"-drive",
&efi_code_arg,
"-drive",
&efi_vars_arg,
"-drive",
&disk_arg,
"-drive",
&seed_arg,
"-nic",
&nic_arg,
"-nographic",
"-serial",
&serial_arg,
"-monitor",
"none",
"-virtfs",
&virtfs_arg,
"-loadvm",
"ready",
];
if opts.use_kvm {
args.extend(crate::accel_args());
}
let process = Command::new("qemu-system-aarch64")
.args(&args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("failed to start QEMU — is qemu-system-aarch64 installed?")?;
let machine = Machine {
name,
ssh_host: "127.0.0.1".to_string(),
ssh_port,
work_dir,
process,
};
if let Some(pid) = machine.process.id() {
register_vm(pid, &machine.work_dir);
}
machine
.wait_for_ssh(std::time::Duration::from_secs(60))
.await?;
let host_epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if host_epoch > 0
&& let Err(e) = machine
.exec(&format!(
"sudo date -s @{host_epoch} >/dev/null 2>&1 && sudo hwclock --systohc >/dev/null 2>&1 || true"
))
.await
{
eprintln!(" warning: failed to sync clock in snapshot-booted VM: {e:#}");
}
Ok(machine)
}
async fn spawn_cold(
image: &Image,
test_id: &str,
ssh_port: u16,
opts: &SpawnOpts,
) -> Result<Self> {
let name = format!("ryra-test-{test_id}");
let work_dir = vm_work_base_dir()?.join(&name);
tokio::fs::create_dir_all(&work_dir)
.await
.context("failed to create VM work directory")?;
let disk = work_dir.join("disk.qcow2");
let disk_size = format!("{}G", opts.disk_gb);
run_cmd(
"qemu-img",
&[
"create",
"-f",
"qcow2",
"-b",
&image.path.to_string_lossy(),
"-F",
"qcow2",
&disk.to_string_lossy(),
&disk_size,
],
)
.await
.context("qemu-img create failed")?;
let efi_vars = work_dir.join("efivars.fd");
tokio::fs::copy(&image.efi_vars_template, &efi_vars)
.await
.context("failed to copy EFI vars template")?;
let key_path = work_dir.join("id_ed25519");
let _ = tokio::fs::remove_file(&key_path).await;
run_cmd(
"ssh-keygen",
&[
"-t",
"ed25519",
"-f",
&key_path.to_string_lossy(),
"-N",
"",
"-q",
],
)
.await
.context("ssh-keygen failed")?;
let pub_key = tokio::fs::read_to_string(format!("{}.pub", key_path.display()))
.await
.context("failed to read SSH public key")?;
let seed_iso = work_dir.join("seed.iso");
build_seed_iso(&work_dir, &seed_iso, &name, pub_key.trim()).await?;
let memory = opts.memory_mb.to_string();
let cpus = opts.cpus.to_string();
let efi_code_arg = format!(
"if=pflash,format=raw,file={},readonly=on",
image.efi_code.display()
);
let efi_vars_arg = format!("if=pflash,format=raw,file={}", efi_vars.display());
let disk_arg = format!("if=virtio,file={},format=qcow2", disk.display());
let seed_arg = format!("if=virtio,file={},format=raw", seed_iso.display());
let nic_arg = format!("user,hostfwd=tcp::{ssh_port}-:22");
let serial_log = work_dir.join("serial.log");
let serial_arg = format!("file:{}", serial_log.display());
let shared_store = image_shared_store_dir()?;
tokio::fs::create_dir_all(&shared_store).await.ok();
let virtfs_arg = format!(
"local,path={},mount_tag=images,security_model=none,readonly=on",
shared_store.display()
);
let mut args: Vec<&str> = vec![
"-machine",
"virt",
"-cpu",
if opts.use_kvm { "host" } else { "max" },
"-m",
&memory,
"-smp",
&cpus,
"-drive",
&efi_code_arg,
"-drive",
&efi_vars_arg,
"-drive",
&disk_arg,
"-drive",
&seed_arg,
"-nic",
&nic_arg,
"-nographic",
"-serial",
&serial_arg,
"-monitor",
"none",
"-virtfs",
&virtfs_arg,
];
if opts.use_kvm {
args.extend(crate::accel_args());
}
let process = Command::new("qemu-system-aarch64")
.args(&args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("failed to start QEMU — is qemu-system-aarch64 installed?")?;
let machine = Machine {
name,
ssh_host: "127.0.0.1".to_string(),
ssh_port,
work_dir,
process,
};
if let Some(pid) = machine.process.id() {
register_vm(pid, &machine.work_dir);
}
let boot_timeout = opts.boot_timeout();
machine.wait_for_ssh(boot_timeout).await?;
machine
.exec("cloud-init status --wait")
.await
.context("cloud-init did not complete")?;
Ok(machine)
}
fn ssh_args(&self) -> Vec<String> {
let key = self.ssh_key_path();
vec![
"-o".into(),
"StrictHostKeyChecking=no".into(),
"-o".into(),
"UserKnownHostsFile=/dev/null".into(),
"-o".into(),
"LogLevel=ERROR".into(),
"-o".into(),
"ConnectTimeout=10".into(),
"-o".into(),
"BatchMode=yes".into(),
"-i".into(),
key.to_string_lossy().into_owned(),
"-p".into(),
self.ssh_port.to_string(),
format!("ryra@{}", self.ssh_host),
]
}
pub async fn exec(&self, cmd: &str) -> Result<ExecOutput> {
let mut args = self.ssh_args();
args.push(cmd.to_string());
let output = Command::new("ssh")
.args(&args)
.output()
.await
.with_context(|| format!("failed to SSH exec in {}: {cmd}", self.name))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
anyhow::bail!(
"command failed in VM {} (exit {}): {cmd}\nstdout: {stdout}\nstderr: {stderr}",
self.name,
output.status,
);
}
Ok(ExecOutput { stdout, stderr })
}
pub async fn exec_streaming(&self, cmd: &str, prefix: &str) -> Result<ExecOutput> {
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command as TokioCommand;
let mut args = self.ssh_args();
args.push(cmd.to_string());
let mut child = TokioCommand::new("ssh")
.args(&args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.with_context(|| format!("failed to SSH exec in {}: {cmd}", self.name))?;
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let prefix_out = prefix.to_string();
let prefix_err = prefix.to_string();
let stdout_handle = tokio::spawn(async move {
let mut lines = String::new();
if let Some(pipe) = stdout_pipe {
let mut reader = BufReader::new(pipe).lines();
while let Ok(Some(line)) = reader.next_line().await {
if prefix_out.is_empty() {
println!(" {line}");
} else {
println!("[{prefix_out}] {line}");
}
lines.push_str(&line);
lines.push('\n');
}
}
lines
});
let stderr_handle = tokio::spawn(async move {
let mut lines = String::new();
if let Some(pipe) = stderr_pipe {
let mut reader = BufReader::new(pipe).lines();
while let Ok(Some(line)) = reader.next_line().await {
if prefix_err.is_empty() {
eprintln!(" {line}");
} else {
eprintln!("[{prefix_err}] {line}");
}
lines.push_str(&line);
lines.push('\n');
}
}
lines
});
let status = child.wait().await?;
let stdout_buf = stdout_handle.await.unwrap_or_default();
let stderr_buf = stderr_handle.await.unwrap_or_default();
if !status.success() {
anyhow::bail!(
"command failed in VM {} (exit {}): {cmd}\nstdout: {stdout_buf}\nstderr: {stderr_buf}",
self.name,
status,
);
}
Ok(ExecOutput {
stdout: stdout_buf,
stderr: stderr_buf,
})
}
pub async fn wait_for_exit(&mut self, timeout: std::time::Duration) {
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if matches!(self.process.try_wait(), Ok(Some(_))) {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
pub async fn destroy(mut self) -> Result<()> {
if let Some(pid) = self.process.id() {
deregister_vm(pid);
}
let _ = self.exec("sudo poweroff").await;
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let _ = self.process.kill().await;
let _ = self.process.wait().await;
let _ = tokio::fs::remove_dir_all(&self.work_dir).await;
Ok(())
}
pub fn keep_alive(self) {
if let Some(pid) = self.process.id() {
deregister_vm(pid);
}
println!(
" VM still running. Connect with:\n \
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-i {}/id_ed25519 -p {} ryra@{}\n \
Serial log: {}/serial.log\n \
Kill with: kill {}",
self.work_dir.display(),
self.ssh_port,
self.ssh_host,
self.work_dir.display(),
self.process
.id()
.map(|id| id.to_string())
.unwrap_or_else(|| "?".to_string()),
);
std::mem::forget(self);
}
fn ssh_key_path(&self) -> PathBuf {
self.work_dir.join("id_ed25519")
}
async fn wait_for_ssh(&self, timeout: std::time::Duration) -> Result<()> {
let mut ssh_args = self.ssh_args();
if let Some(pos) = ssh_args.iter().position(|a| a == "ConnectTimeout=10") {
ssh_args[pos] = "ConnectTimeout=3".into();
}
ssh_args.push("true".into());
let mut progress = crate::progress::WaitProgress::new("SSH", "ssh", timeout)
.with_prefix(format!(" [{}] ", self.name))
.with_heartbeat(std::time::Duration::from_secs(30));
loop {
let result = Command::new("ssh")
.args(&ssh_args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
if let Ok(status) = result
&& status.success()
{
return Ok(());
}
if progress.timed_out() {
anyhow::bail!(
"timed out waiting for SSH on {}:{} after {}s\n \
Check serial log: {}/serial.log",
self.ssh_host,
self.ssh_port,
timeout.as_secs(),
self.work_dir.display(),
);
}
progress.tick();
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
}
}
#[allow(dead_code)]
pub struct ExecOutput {
pub stdout: String,
pub stderr: String,
}
impl ExecOutput {
pub fn stdout_trimmed(&self) -> &str {
self.stdout.trim()
}
}
async fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
let status = Command::new(program)
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.with_context(|| format!("failed to run {program}"))?;
if !status.success() {
anyhow::bail!("{program} failed with exit status {status}");
}
Ok(())
}
fn host_subid_mapping() -> Result<(u32, u32)> {
let user = std::env::var("USER").context("USER env var not set")?;
let subuid = std::fs::read_to_string("/etc/subuid").context("failed to read /etc/subuid")?;
for line in subuid.lines() {
let parts: Vec<&str> = line.split(':').collect();
if parts.len() == 3 && parts[0] == user {
let start: u32 = parts[1]
.parse()
.with_context(|| format!("invalid subuid start for {user}: {}", parts[1]))?;
let size: u32 = parts[2]
.parse()
.with_context(|| format!("invalid subuid size for {user}: {}", parts[2]))?;
return Ok((start, size));
}
}
anyhow::bail!("no subuid entry found for user {user} in /etc/subuid")
}
pub(crate) async fn build_seed_iso(
work_dir: &Path,
output: &Path,
hostname: &str,
pub_key: &str,
) -> Result<()> {
let (subid_start, subid_size) = host_subid_mapping()?;
let user_data = format!(
r#"#cloud-config
ssh_pwauth: false
users:
- name: ryra
uid: 1000
shell: /bin/bash
lock_passwd: true
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- {pub_key}
write_files:
- path: /etc/subuid
content: "ryra:{subid_start}:{subid_size}\n"
permissions: '0644'
- path: /etc/subgid
content: "ryra:{subid_start}:{subid_size}\n"
permissions: '0644'
runcmd:
- loginctl enable-linger ryra
"#
);
write_seed_iso(work_dir, output, hostname, &user_data).await
}
pub async fn build_seed_iso_full(
work_dir: &Path,
output: &Path,
hostname: &str,
pub_key: &str,
packages: &[&str],
) -> Result<()> {
let package_list = packages
.iter()
.map(|p| format!(" - {p}"))
.collect::<Vec<_>>()
.join("\n");
let (subid_start, subid_size) = host_subid_mapping()?;
let user_data = format!(
r#"#cloud-config
ssh_pwauth: false
users:
- name: ryra
uid: 1000
shell: /bin/bash
lock_passwd: true
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- {pub_key}
packages:
{package_list}
write_files:
- path: /etc/subuid
content: "ryra:{subid_start}:{subid_size}\n"
permissions: '0644'
- path: /etc/subgid
content: "ryra:{subid_start}:{subid_size}\n"
permissions: '0644'
runcmd:
- loginctl enable-linger ryra
"#
);
write_seed_iso(work_dir, output, hostname, &user_data).await
}
async fn write_seed_iso(
work_dir: &Path,
output: &Path,
hostname: &str,
user_data: &str,
) -> Result<()> {
let seed_dir = work_dir.join("seed");
tokio::fs::create_dir_all(&seed_dir)
.await
.context("failed to create seed dir")?;
let meta_data = format!("instance-id: {hostname}\nlocal-hostname: {hostname}\n");
tokio::fs::write(seed_dir.join("meta-data"), &meta_data)
.await
.context("failed to write meta-data")?;
tokio::fs::write(seed_dir.join("user-data"), user_data)
.await
.context("failed to write user-data")?;
let iso_tools = ["genisoimage", "mkisofs"];
let mut created = false;
for tool in &iso_tools {
let result = Command::new(tool)
.args([
"-output",
&output.to_string_lossy(),
"-volid",
"cidata",
"-joliet",
"-rock",
&seed_dir.to_string_lossy(),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
if let Ok(status) = result
&& status.success()
{
created = true;
break;
}
}
if !created {
anyhow::bail!(
"failed to create seed ISO — install genisoimage or mkisofs:\n \
sudo apt install genisoimage # Debian/Ubuntu\n \
sudo dnf install genisoimage # Fedora\n \
sudo pacman -S cdrtools # Arch"
);
}
let _ = tokio::fs::remove_dir_all(&seed_dir).await;
Ok(())
}
fn image_tar_cache_dir() -> Result<PathBuf> {
Ok(cache_base_dir()?.join("image-tars"))
}
pub fn image_shared_store_dir() -> Result<PathBuf> {
Ok(cache_base_dir()?.join("image-store"))
}
fn vm_work_base_dir() -> Result<PathBuf> {
Ok(cache_base_dir()?.join("vms"))
}
fn cache_base_dir() -> Result<PathBuf> {
let base = dirs::cache_dir().context("could not determine cache directory (is $HOME set?)")?;
Ok(base.join("ryra-vm"))
}
pub async fn ensure_image_cached(image: &str) -> Result<()> {
let store_dir = image_shared_store_dir()?;
tokio::fs::create_dir_all(&store_dir).await.ok();
if image_exists_in_store(&store_dir, image).await {
return Ok(());
}
let tar_dir = image_tar_cache_dir()?;
tokio::fs::create_dir_all(&tar_dir).await.ok();
let tar_name = sanitize_image_name(image);
let tar_path = tar_dir.join(&tar_name);
if !tar_path.exists() {
let short_name = strip_docker_io(image);
let local_name = if Command::new("podman")
.args(["image", "exists", image])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
{
image
} else if short_name != image
&& Command::new("podman")
.args(["image", "exists", short_name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false)
{
short_name
} else {
println!(" pulling {image}...");
let status = Command::new("podman")
.args(["pull", image])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.context("failed to run podman pull")?;
if !status.success() {
anyhow::bail!("podman pull {image} failed");
}
image
};
println!(" saving {image}...");
let status = Command::new("podman")
.args(["save", "-o", &tar_path.display().to_string(), local_name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.context("failed to run podman save")?;
if !status.success() {
let _ = tokio::fs::remove_file(&tar_path).await;
anyhow::bail!("podman save {image} failed");
}
}
println!(" loading {image} into shared store...");
let status = Command::new("podman")
.args([
"--root",
&store_dir.display().to_string(),
"--storage-driver",
"overlay",
"load",
"-i",
&tar_path.display().to_string(),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.context("failed to load image into shared store")?;
if !status.success() {
anyhow::bail!("podman load into shared store failed for {image}");
}
let _ = tokio::fs::remove_file(&tar_path).await;
Ok(())
}
async fn image_exists_in_store(store_dir: &Path, image: &str) -> bool {
let short = strip_docker_io(image);
let expanded_library = format!("docker.io/library/{short}");
let expanded_org = format!("docker.io/{short}");
for name in [image, short, &expanded_library, &expanded_org] {
let ok = Command::new("podman")
.args([
"--root",
&store_dir.display().to_string(),
"--storage-driver",
"overlay",
"image",
"exists",
name,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if ok {
return true;
}
}
false
}
fn strip_docker_io(image: &str) -> &str {
image
.strip_prefix("docker.io/library/")
.or_else(|| image.strip_prefix("docker.io/"))
.unwrap_or(image)
}
fn sanitize_image_name(image: &str) -> String {
image.replace(['/', ':'], "_") + ".tar"
}
pub async fn load_images_into_vm(machine: &Machine, _images: &[String]) -> Result<()> {
machine
.exec("sudo mkdir -p /mnt/images && (mountpoint -q /mnt/images 2>/dev/null || sudo mount -t 9p -o trans=virtio,version=9p2000.L,ro images /mnt/images)")
.await
.context("failed to mount 9p image store in VM")?;
machine
.exec(
"mkdir -p ~/.config/containers && \
(grep -q '/mnt/images' ~/.config/containers/storage.conf 2>/dev/null || \
printf '[storage]\\ndriver = \"overlay\"\\n[storage.options]\\nadditionalimagestores = [\"/mnt/images\"]\\n' > ~/.config/containers/storage.conf) && \
(grep -q 'docker.io' ~/.config/containers/registries.conf 2>/dev/null || \
printf 'unqualified-search-registries = [\"docker.io\"]\\n' > ~/.config/containers/registries.conf)",
)
.await
.context("failed to configure podman config in VM")?;
Ok(())
}
fn scp_base_args(machine: &Machine) -> Vec<String> {
let key = machine.ssh_key_path();
vec![
"-o".into(),
"StrictHostKeyChecking=no".into(),
"-o".into(),
"UserKnownHostsFile=/dev/null".into(),
"-o".into(),
"LogLevel=ERROR".into(),
"-i".into(),
key.to_string_lossy().into_owned(),
"-P".into(),
machine.ssh_port.to_string(),
]
}
async fn scp_to_vm(machine: &Machine, local_path: &Path, remote_path: &str) -> Result<()> {
let dest = format!("ryra@{}:{remote_path}", machine.ssh_host);
let mut args = scp_base_args(machine);
args.push(local_path.to_string_lossy().into_owned());
args.push(dest);
let status = Command::new("scp")
.args(&args)
.status()
.await
.with_context(|| format!("failed to SCP {} to VM", local_path.display()))?;
if !status.success() {
anyhow::bail!("SCP of {} failed", local_path.display());
}
Ok(())
}
pub async fn scp_dir_from_vm(
machine: &Machine,
remote_path: &str,
local_path: &Path,
) -> Result<()> {
let source = format!("ryra@{}:{remote_path}", machine.ssh_host);
let mut args = scp_base_args(machine);
args.push("-r".into());
args.push(source);
args.push(local_path.to_string_lossy().into_owned());
let status = Command::new("scp")
.args(&args)
.status()
.await
.with_context(|| format!("failed to SCP {remote_path} from VM"))?;
if !status.success() {
anyhow::bail!("SCP of {remote_path} from VM failed");
}
Ok(())
}
pub async fn copy_ryra_to_vm(machine: &Machine, ryra_bin: &Path) -> Result<()> {
scp_to_vm(machine, ryra_bin, "/tmp/ryra").await?;
machine
.exec("sudo mv /tmp/ryra /usr/local/bin/ryra && sudo chmod +x /usr/local/bin/ryra")
.await?;
Ok(())
}
pub async fn copy_fixtures_to_vm(machine: &Machine, fixtures_dir: &Path) -> Result<()> {
if !fixtures_dir.exists() {
return Ok(());
}
let tar_path = std::env::temp_dir().join(format!(
"ryra-test-registry-{}-{}.tar",
std::process::id(),
machine.ssh_port
));
let status = Command::new("tar")
.args([
"--exclude=node_modules",
"--exclude=test-results",
"--exclude=playwright-report",
"-cf",
])
.arg(&tar_path)
.arg("-C")
.arg(fixtures_dir)
.arg(".")
.status()
.await
.context("failed to run tar for the test registry")?;
if !status.success() {
let _ = tokio::fs::remove_file(&tar_path).await;
anyhow::bail!("tar of {} failed", fixtures_dir.display());
}
let copy_result = async {
scp_to_vm(machine, &tar_path, "/tmp/ryra-test-registry.tar").await?;
machine
.exec(
"sudo mkdir -p /opt/ryra-test-registry && \
sudo chown ryra:ryra /opt/ryra-test-registry && \
tar -xf /tmp/ryra-test-registry.tar -C /opt/ryra-test-registry && \
rm /tmp/ryra-test-registry.tar",
)
.await
.map(|_| ())
}
.await;
let _ = tokio::fs::remove_file(&tar_path).await;
copy_result
}
pub async fn copy_project_to_vm(machine: &Machine, project_dir: &Path) -> Result<()> {
if !project_dir.exists() {
anyhow::bail!("project directory not found: {}", project_dir.display());
}
machine
.exec("sudo mkdir -p /opt/ryra-test-project && sudo chown ryra:ryra /opt/ryra-test-project")
.await?;
let quadlet_extensions = ["container", "volume", "network", "pod", "kube", "toml"];
let mut entries = tokio::fs::read_dir(project_dir)
.await
.with_context(|| format!("failed to read project directory {}", project_dir.display()))?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str())
&& quadlet_extensions.contains(&ext)
{
scp_to_vm(machine, &path, "/opt/ryra-test-project/").await?;
}
}
Ok(())
}