1use std::path::{Path, PathBuf};
2use std::process::Stdio;
3use std::sync::Mutex;
4
5use anyhow::{Context, Result};
6use tokio::process::Command;
7
8use crate::image::Image;
9
10static ACTIVE_VMS: Mutex<Vec<ActiveVm>> = Mutex::new(Vec::new());
12
13struct ActiveVm {
14 pid: u32,
15 work_dir: PathBuf,
16}
17
18pub fn cleanup_all_vms() {
21 let vms = {
22 let mut guard = ACTIVE_VMS.lock().unwrap_or_else(|e| e.into_inner());
23 std::mem::take(&mut *guard)
24 };
25 for vm in &vms {
26 unsafe {
28 libc::kill(vm.pid as i32, libc::SIGKILL);
29 }
30 let _ = std::fs::remove_dir_all(&vm.work_dir);
31 }
32 if !vms.is_empty() {
33 eprintln!("\nCleaned up {} VM(s)", vms.len());
34 }
35}
36
37fn register_vm(pid: u32, work_dir: &Path) {
38 let mut guard = ACTIVE_VMS.lock().unwrap_or_else(|e| e.into_inner());
39 guard.push(ActiveVm {
40 pid,
41 work_dir: work_dir.to_path_buf(),
42 });
43}
44
45fn deregister_vm(pid: u32) {
46 let mut guard = ACTIVE_VMS.lock().unwrap_or_else(|e| e.into_inner());
47 guard.retain(|vm| vm.pid != pid);
48}
49
50pub struct Machine {
52 pub name: String,
53 pub ssh_host: String,
54 pub ssh_port: u16,
55 pub work_dir: PathBuf,
56 process: tokio::process::Child,
57}
58
59impl Drop for Machine {
60 fn drop(&mut self) {
67 if let Some(pid) = self.process.id() {
68 unsafe {
71 libc::kill(pid as i32, libc::SIGKILL);
72 }
73 deregister_vm(pid);
74 }
75 }
76}
77
78pub struct SpawnOpts {
80 pub use_kvm: bool,
81 pub memory_mb: u32,
82 pub cpus: u32,
83 pub disk_gb: u32,
86}
87
88impl SpawnOpts {
89 pub fn boot_timeout(&self) -> std::time::Duration {
91 if self.use_kvm {
92 std::time::Duration::from_secs(300)
93 } else {
94 std::time::Duration::from_secs(900) }
96 }
97}
98
99impl Default for SpawnOpts {
100 fn default() -> Self {
101 Self {
102 use_kvm: true,
103 memory_mb: 2048,
104 cpus: 2,
105 disk_gb: 20,
106 }
107 }
108}
109
110pub fn random_id() -> String {
111 use rand::Rng;
112 let mut rng = rand::rng();
113 format!("{:06x}", rng.random::<u32>() & 0xffffff)
114}
115
116impl Machine {
117 pub async fn spawn(
118 image: &Image,
119 test_id: &str,
120 ssh_port: u16,
121 opts: &SpawnOpts,
122 ) -> Result<Self> {
123 if let Some(ref snapshot) = image.snapshot {
124 return Self::spawn_from_snapshot(image, snapshot, test_id, ssh_port, opts).await;
125 }
126 Self::spawn_cold(image, test_id, ssh_port, opts).await
127 }
128
129 async fn spawn_from_snapshot(
131 image: &Image,
132 snapshot: &crate::image::SnapshotFiles,
133 test_id: &str,
134 ssh_port: u16,
135 opts: &SpawnOpts,
136 ) -> Result<Self> {
137 let name = format!("ryra-test-{test_id}");
138 let work_dir = vm_work_base_dir()?.join(&name);
139 tokio::fs::create_dir_all(&work_dir)
140 .await
141 .context("failed to create VM work directory")?;
142
143 let disk = work_dir.join("disk.qcow2");
145 let efi_vars = work_dir.join("efivars.qcow2");
146 let seed_iso = work_dir.join("seed.iso");
147
148 run_cmd(
149 "cp",
150 &[
151 "--reflink=auto",
152 &snapshot.disk.to_string_lossy(),
153 &disk.to_string_lossy(),
154 ],
155 )
156 .await
157 .context("failed to copy snapshot disk")?;
158 run_cmd(
159 "cp",
160 &[
161 "--reflink=auto",
162 &snapshot.efivars.to_string_lossy(),
163 &efi_vars.to_string_lossy(),
164 ],
165 )
166 .await
167 .context("failed to copy snapshot efivars")?;
168 run_cmd(
169 "cp",
170 &[
171 "--reflink=auto",
172 &snapshot.seed_iso.to_string_lossy(),
173 &seed_iso.to_string_lossy(),
174 ],
175 )
176 .await
177 .context("failed to copy snapshot seed ISO")?;
178
179 let key_path = work_dir.join("id_ed25519");
181 tokio::fs::copy(&snapshot.ssh_key, &key_path)
182 .await
183 .context("failed to copy SSH key")?;
184
185 let memory = snapshot.memory_mb.to_string();
187 let cpus = opts.cpus.to_string();
188 let efi_code_arg = format!(
189 "if=pflash,format=raw,file={},readonly=on",
190 image.efi_code.display()
191 );
192 let efi_vars_arg = format!("if=pflash,format=qcow2,file={}", efi_vars.display());
193 let disk_arg = format!("if=virtio,file={},format=qcow2", disk.display());
194 let seed_arg = format!(
195 "if=virtio,file={},format=raw,readonly=on",
196 seed_iso.display()
197 );
198 let nic_arg = format!("user,hostfwd=tcp::{ssh_port}-:22");
199 let serial_log = work_dir.join("serial.log");
200 let serial_arg = format!("file:{}", serial_log.display());
201 let shared_store = image_shared_store_dir()?;
202 tokio::fs::create_dir_all(&shared_store).await.ok();
203 let virtfs_arg = format!(
204 "local,path={},mount_tag=images,security_model=none,readonly=on",
205 shared_store.display()
206 );
207
208 let mut args: Vec<&str> = vec![
209 "-machine",
210 "virt",
211 "-cpu",
212 if opts.use_kvm { "host" } else { "max" },
213 "-m",
214 &memory,
215 "-smp",
216 &cpus,
217 "-drive",
218 &efi_code_arg,
219 "-drive",
220 &efi_vars_arg,
221 "-drive",
222 &disk_arg,
223 "-drive",
224 &seed_arg,
225 "-nic",
226 &nic_arg,
227 "-nographic",
228 "-serial",
229 &serial_arg,
230 "-monitor",
231 "none",
232 "-virtfs",
233 &virtfs_arg,
234 "-loadvm",
235 "ready",
236 ];
237
238 if opts.use_kvm {
239 args.extend(crate::accel_args());
240 }
241
242 let process = Command::new("qemu-system-aarch64")
243 .args(&args)
244 .stdout(Stdio::null())
245 .stderr(Stdio::null())
246 .spawn()
247 .context("failed to start QEMU — is qemu-system-aarch64 installed?")?;
248
249 let machine = Machine {
250 name,
251 ssh_host: "127.0.0.1".to_string(),
252 ssh_port,
253 work_dir,
254 process,
255 };
256
257 if let Some(pid) = machine.process.id() {
258 register_vm(pid, &machine.work_dir);
259 }
260
261 machine
265 .wait_for_ssh(std::time::Duration::from_secs(60))
266 .await?;
267
268 let host_epoch = std::time::SystemTime::now()
279 .duration_since(std::time::UNIX_EPOCH)
280 .map(|d| d.as_secs())
281 .unwrap_or(0);
282 if host_epoch > 0
283 && let Err(e) = machine
284 .exec(&format!(
285 "sudo date -s @{host_epoch} >/dev/null 2>&1 && sudo hwclock --systohc >/dev/null 2>&1 || true"
286 ))
287 .await
288 {
289 eprintln!(" warning: failed to sync clock in snapshot-booted VM: {e:#}");
290 }
291
292 Ok(machine)
293 }
294
295 async fn spawn_cold(
297 image: &Image,
298 test_id: &str,
299 ssh_port: u16,
300 opts: &SpawnOpts,
301 ) -> Result<Self> {
302 let name = format!("ryra-test-{test_id}");
303 let work_dir = vm_work_base_dir()?.join(&name);
304 tokio::fs::create_dir_all(&work_dir)
305 .await
306 .context("failed to create VM work directory")?;
307
308 let disk = work_dir.join("disk.qcow2");
310 let disk_size = format!("{}G", opts.disk_gb);
311 run_cmd(
312 "qemu-img",
313 &[
314 "create",
315 "-f",
316 "qcow2",
317 "-b",
318 &image.path.to_string_lossy(),
319 "-F",
320 "qcow2",
321 &disk.to_string_lossy(),
322 &disk_size,
323 ],
324 )
325 .await
326 .context("qemu-img create failed")?;
327
328 let efi_vars = work_dir.join("efivars.fd");
330 tokio::fs::copy(&image.efi_vars_template, &efi_vars)
331 .await
332 .context("failed to copy EFI vars template")?;
333
334 let key_path = work_dir.join("id_ed25519");
336 let _ = tokio::fs::remove_file(&key_path).await;
337 run_cmd(
338 "ssh-keygen",
339 &[
340 "-t",
341 "ed25519",
342 "-f",
343 &key_path.to_string_lossy(),
344 "-N",
345 "",
346 "-q",
347 ],
348 )
349 .await
350 .context("ssh-keygen failed")?;
351
352 let pub_key = tokio::fs::read_to_string(format!("{}.pub", key_path.display()))
353 .await
354 .context("failed to read SSH public key")?;
355
356 let seed_iso = work_dir.join("seed.iso");
358 build_seed_iso(&work_dir, &seed_iso, &name, pub_key.trim()).await?;
359
360 let memory = opts.memory_mb.to_string();
362 let cpus = opts.cpus.to_string();
363 let efi_code_arg = format!(
364 "if=pflash,format=raw,file={},readonly=on",
365 image.efi_code.display()
366 );
367 let efi_vars_arg = format!("if=pflash,format=raw,file={}", efi_vars.display());
368 let disk_arg = format!("if=virtio,file={},format=qcow2", disk.display());
369 let seed_arg = format!("if=virtio,file={},format=raw", seed_iso.display());
370 let nic_arg = format!("user,hostfwd=tcp::{ssh_port}-:22");
371 let serial_log = work_dir.join("serial.log");
372 let serial_arg = format!("file:{}", serial_log.display());
373
374 let shared_store = image_shared_store_dir()?;
375 tokio::fs::create_dir_all(&shared_store).await.ok();
376 let virtfs_arg = format!(
377 "local,path={},mount_tag=images,security_model=none,readonly=on",
378 shared_store.display()
379 );
380
381 let mut args: Vec<&str> = vec![
382 "-machine",
383 "virt",
384 "-cpu",
385 if opts.use_kvm { "host" } else { "max" },
386 "-m",
387 &memory,
388 "-smp",
389 &cpus,
390 "-drive",
391 &efi_code_arg,
392 "-drive",
393 &efi_vars_arg,
394 "-drive",
395 &disk_arg,
396 "-drive",
397 &seed_arg,
398 "-nic",
399 &nic_arg,
400 "-nographic",
401 "-serial",
402 &serial_arg,
403 "-monitor",
404 "none",
405 "-virtfs",
406 &virtfs_arg,
407 ];
408
409 if opts.use_kvm {
410 args.extend(crate::accel_args());
411 }
412
413 let process = Command::new("qemu-system-aarch64")
414 .args(&args)
415 .stdout(Stdio::null())
416 .stderr(Stdio::null())
417 .spawn()
418 .context("failed to start QEMU — is qemu-system-aarch64 installed?")?;
419
420 let machine = Machine {
421 name,
422 ssh_host: "127.0.0.1".to_string(),
423 ssh_port,
424 work_dir,
425 process,
426 };
427
428 if let Some(pid) = machine.process.id() {
429 register_vm(pid, &machine.work_dir);
430 }
431
432 let boot_timeout = opts.boot_timeout();
433 machine.wait_for_ssh(boot_timeout).await?;
434
435 machine
436 .exec("cloud-init status --wait")
437 .await
438 .context("cloud-init did not complete")?;
439
440 Ok(machine)
441 }
442
443 fn ssh_args(&self) -> Vec<String> {
445 let key = self.ssh_key_path();
446 vec![
447 "-o".into(),
448 "StrictHostKeyChecking=no".into(),
449 "-o".into(),
450 "UserKnownHostsFile=/dev/null".into(),
451 "-o".into(),
452 "LogLevel=ERROR".into(),
453 "-o".into(),
454 "ConnectTimeout=10".into(),
455 "-o".into(),
456 "BatchMode=yes".into(),
457 "-i".into(),
458 key.to_string_lossy().into_owned(),
459 "-p".into(),
460 self.ssh_port.to_string(),
461 format!("ryra@{}", self.ssh_host),
462 ]
463 }
464
465 pub async fn exec(&self, cmd: &str) -> Result<ExecOutput> {
467 let mut args = self.ssh_args();
468 args.push(cmd.to_string());
469 let output = Command::new("ssh")
470 .args(&args)
471 .output()
472 .await
473 .with_context(|| format!("failed to SSH exec in {}: {cmd}", self.name))?;
474
475 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
476 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
477
478 if !output.status.success() {
479 anyhow::bail!(
480 "command failed in VM {} (exit {}): {cmd}\nstdout: {stdout}\nstderr: {stderr}",
481 self.name,
482 output.status,
483 );
484 }
485
486 Ok(ExecOutput { stdout, stderr })
487 }
488
489 pub async fn exec_streaming(&self, cmd: &str, prefix: &str) -> Result<ExecOutput> {
492 use tokio::io::{AsyncBufReadExt, BufReader};
493 use tokio::process::Command as TokioCommand;
494
495 let mut args = self.ssh_args();
496 args.push(cmd.to_string());
497 let mut child = TokioCommand::new("ssh")
498 .args(&args)
499 .stdout(std::process::Stdio::piped())
500 .stderr(std::process::Stdio::piped())
501 .spawn()
502 .with_context(|| format!("failed to SSH exec in {}: {cmd}", self.name))?;
503
504 let stdout_pipe = child.stdout.take();
505 let stderr_pipe = child.stderr.take();
506
507 let prefix_out = prefix.to_string();
508 let prefix_err = prefix.to_string();
509
510 let stdout_handle = tokio::spawn(async move {
511 let mut lines = String::new();
512 if let Some(pipe) = stdout_pipe {
513 let mut reader = BufReader::new(pipe).lines();
514 while let Ok(Some(line)) = reader.next_line().await {
515 if prefix_out.is_empty() {
516 println!(" {line}");
517 } else {
518 println!("[{prefix_out}] {line}");
519 }
520 lines.push_str(&line);
521 lines.push('\n');
522 }
523 }
524 lines
525 });
526
527 let stderr_handle = tokio::spawn(async move {
528 let mut lines = String::new();
529 if let Some(pipe) = stderr_pipe {
530 let mut reader = BufReader::new(pipe).lines();
531 while let Ok(Some(line)) = reader.next_line().await {
532 if prefix_err.is_empty() {
533 eprintln!(" {line}");
534 } else {
535 eprintln!("[{prefix_err}] {line}");
536 }
537 lines.push_str(&line);
538 lines.push('\n');
539 }
540 }
541 lines
542 });
543
544 let status = child.wait().await?;
545 let stdout_buf = stdout_handle.await.unwrap_or_default();
546 let stderr_buf = stderr_handle.await.unwrap_or_default();
547
548 if !status.success() {
549 anyhow::bail!(
550 "command failed in VM {} (exit {}): {cmd}\nstdout: {stdout_buf}\nstderr: {stderr_buf}",
551 self.name,
552 status,
553 );
554 }
555
556 Ok(ExecOutput {
557 stdout: stdout_buf,
558 stderr: stderr_buf,
559 })
560 }
561
562 pub async fn wait_for_exit(&mut self, timeout: std::time::Duration) {
566 let start = std::time::Instant::now();
567 while start.elapsed() < timeout {
568 if matches!(self.process.try_wait(), Ok(Some(_))) {
569 return;
570 }
571 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
572 }
573 }
574
575 pub async fn destroy(mut self) -> Result<()> {
577 if let Some(pid) = self.process.id() {
578 deregister_vm(pid);
579 }
580 let _ = self.exec("sudo poweroff").await;
581 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
582 let _ = self.process.kill().await;
583 let _ = self.process.wait().await;
584 let _ = tokio::fs::remove_dir_all(&self.work_dir).await;
585 Ok(())
586 }
587
588 pub fn keep_alive(self) {
591 if let Some(pid) = self.process.id() {
592 deregister_vm(pid);
593 }
594 println!(
595 " VM still running. Connect with:\n \
596 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
597 -i {}/id_ed25519 -p {} ryra@{}\n \
598 Serial log: {}/serial.log\n \
599 Kill with: kill {}",
600 self.work_dir.display(),
601 self.ssh_port,
602 self.ssh_host,
603 self.work_dir.display(),
604 self.process
605 .id()
606 .map(|id| id.to_string())
607 .unwrap_or_else(|| "?".to_string()),
608 );
609 std::mem::forget(self);
611 }
612
613 fn ssh_key_path(&self) -> PathBuf {
614 self.work_dir.join("id_ed25519")
615 }
616
617 async fn wait_for_ssh(&self, timeout: std::time::Duration) -> Result<()> {
618 let mut ssh_args = self.ssh_args();
619 if let Some(pos) = ssh_args.iter().position(|a| a == "ConnectTimeout=10") {
621 ssh_args[pos] = "ConnectTimeout=3".into();
622 }
623 ssh_args.push("true".into());
624 let mut progress = crate::progress::WaitProgress::new("SSH", "ssh", timeout)
625 .with_prefix(format!(" [{}] ", self.name))
626 .with_heartbeat(std::time::Duration::from_secs(30));
627
628 loop {
629 let result = Command::new("ssh")
631 .args(&ssh_args)
632 .stdout(Stdio::null())
633 .stderr(Stdio::null())
634 .status()
635 .await;
636
637 if let Ok(status) = result
638 && status.success()
639 {
640 return Ok(());
641 }
642
643 if progress.timed_out() {
644 anyhow::bail!(
645 "timed out waiting for SSH on {}:{} after {}s\n \
646 Check serial log: {}/serial.log",
647 self.ssh_host,
648 self.ssh_port,
649 timeout.as_secs(),
650 self.work_dir.display(),
651 );
652 }
653
654 progress.tick();
655 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
656 }
657 }
658}
659
660#[allow(dead_code)]
661pub struct ExecOutput {
662 pub stdout: String,
663 pub stderr: String,
664}
665
666impl ExecOutput {
667 pub fn stdout_trimmed(&self) -> &str {
668 self.stdout.trim()
669 }
670}
671
672async fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
674 let status = Command::new(program)
675 .args(args)
676 .stdout(Stdio::null())
677 .stderr(Stdio::null())
678 .status()
679 .await
680 .with_context(|| format!("failed to run {program}"))?;
681 if !status.success() {
682 anyhow::bail!("{program} failed with exit status {status}");
683 }
684 Ok(())
685}
686
687fn host_subid_mapping() -> Result<(u32, u32)> {
691 let user = std::env::var("USER").context("USER env var not set")?;
692 let subuid = std::fs::read_to_string("/etc/subuid").context("failed to read /etc/subuid")?;
693 for line in subuid.lines() {
694 let parts: Vec<&str> = line.split(':').collect();
695 if parts.len() == 3 && parts[0] == user {
696 let start: u32 = parts[1]
697 .parse()
698 .with_context(|| format!("invalid subuid start for {user}: {}", parts[1]))?;
699 let size: u32 = parts[2]
700 .parse()
701 .with_context(|| format!("invalid subuid size for {user}: {}", parts[2]))?;
702 return Ok((start, size));
703 }
704 }
705 anyhow::bail!("no subuid entry found for user {user} in /etc/subuid")
706}
707
708pub(crate) async fn build_seed_iso(
711 work_dir: &Path,
712 output: &Path,
713 hostname: &str,
714 pub_key: &str,
715) -> Result<()> {
716 let (subid_start, subid_size) = host_subid_mapping()?;
723 let user_data = format!(
724 r#"#cloud-config
725ssh_pwauth: false
726
727users:
728 - name: ryra
729 uid: 1000
730 shell: /bin/bash
731 lock_passwd: true
732 sudo: ALL=(ALL) NOPASSWD:ALL
733 ssh_authorized_keys:
734 - {pub_key}
735
736write_files:
737 - path: /etc/subuid
738 content: "ryra:{subid_start}:{subid_size}\n"
739 permissions: '0644'
740 - path: /etc/subgid
741 content: "ryra:{subid_start}:{subid_size}\n"
742 permissions: '0644'
743
744runcmd:
745 - loginctl enable-linger ryra
746"#
747 );
748 write_seed_iso(work_dir, output, hostname, &user_data).await
749}
750
751pub async fn build_seed_iso_full(
754 work_dir: &Path,
755 output: &Path,
756 hostname: &str,
757 pub_key: &str,
758 packages: &[&str],
759) -> Result<()> {
760 let package_list = packages
761 .iter()
762 .map(|p| format!(" - {p}"))
763 .collect::<Vec<_>>()
764 .join("\n");
765 let (subid_start, subid_size) = host_subid_mapping()?;
766 let user_data = format!(
767 r#"#cloud-config
768ssh_pwauth: false
769
770users:
771 - name: ryra
772 uid: 1000
773 shell: /bin/bash
774 lock_passwd: true
775 sudo: ALL=(ALL) NOPASSWD:ALL
776 ssh_authorized_keys:
777 - {pub_key}
778
779packages:
780{package_list}
781
782write_files:
783 - path: /etc/subuid
784 content: "ryra:{subid_start}:{subid_size}\n"
785 permissions: '0644'
786 - path: /etc/subgid
787 content: "ryra:{subid_start}:{subid_size}\n"
788 permissions: '0644'
789
790runcmd:
791 - loginctl enable-linger ryra
792"#
793 );
794 write_seed_iso(work_dir, output, hostname, &user_data).await
795}
796
797async fn write_seed_iso(
799 work_dir: &Path,
800 output: &Path,
801 hostname: &str,
802 user_data: &str,
803) -> Result<()> {
804 let seed_dir = work_dir.join("seed");
805 tokio::fs::create_dir_all(&seed_dir)
806 .await
807 .context("failed to create seed dir")?;
808
809 let meta_data = format!("instance-id: {hostname}\nlocal-hostname: {hostname}\n");
810 tokio::fs::write(seed_dir.join("meta-data"), &meta_data)
811 .await
812 .context("failed to write meta-data")?;
813
814 tokio::fs::write(seed_dir.join("user-data"), user_data)
815 .await
816 .context("failed to write user-data")?;
817
818 let iso_tools = ["genisoimage", "mkisofs"];
820 let mut created = false;
821 for tool in &iso_tools {
822 let result = Command::new(tool)
823 .args([
824 "-output",
825 &output.to_string_lossy(),
826 "-volid",
827 "cidata",
828 "-joliet",
829 "-rock",
830 &seed_dir.to_string_lossy(),
831 ])
832 .stdout(Stdio::null())
833 .stderr(Stdio::null())
834 .status()
835 .await;
836
837 if let Ok(status) = result
838 && status.success()
839 {
840 created = true;
841 break;
842 }
843 }
844
845 if !created {
846 anyhow::bail!(
847 "failed to create seed ISO — install genisoimage or mkisofs:\n \
848 sudo apt install genisoimage # Debian/Ubuntu\n \
849 sudo dnf install genisoimage # Fedora\n \
850 sudo pacman -S cdrtools # Arch"
851 );
852 }
853
854 let _ = tokio::fs::remove_dir_all(&seed_dir).await;
856
857 Ok(())
858}
859
860fn image_tar_cache_dir() -> Result<PathBuf> {
866 Ok(cache_base_dir()?.join("image-tars"))
867}
868
869pub fn image_shared_store_dir() -> Result<PathBuf> {
878 Ok(cache_base_dir()?.join("image-store"))
879}
880
881fn vm_work_base_dir() -> Result<PathBuf> {
885 Ok(cache_base_dir()?.join("vms"))
886}
887
888fn cache_base_dir() -> Result<PathBuf> {
890 let base = dirs::cache_dir().context("could not determine cache directory (is $HOME set?)")?;
891 Ok(base.join("ryra-vm"))
892}
893
894pub async fn ensure_image_cached(image: &str) -> Result<()> {
904 let store_dir = image_shared_store_dir()?;
905 tokio::fs::create_dir_all(&store_dir).await.ok();
906
907 if image_exists_in_store(&store_dir, image).await {
909 return Ok(());
910 }
911
912 let tar_dir = image_tar_cache_dir()?;
914 tokio::fs::create_dir_all(&tar_dir).await.ok();
915 let tar_name = sanitize_image_name(image);
916 let tar_path = tar_dir.join(&tar_name);
917
918 if !tar_path.exists() {
919 let short_name = strip_docker_io(image);
923 let local_name = if Command::new("podman")
924 .args(["image", "exists", image])
925 .stdout(Stdio::null())
926 .stderr(Stdio::null())
927 .status()
928 .await
929 .map(|s| s.success())
930 .unwrap_or(false)
931 {
932 image
933 } else if short_name != image
934 && Command::new("podman")
935 .args(["image", "exists", short_name])
936 .stdout(Stdio::null())
937 .stderr(Stdio::null())
938 .status()
939 .await
940 .map(|s| s.success())
941 .unwrap_or(false)
942 {
943 short_name
944 } else {
945 println!(" pulling {image}...");
947 let status = Command::new("podman")
948 .args(["pull", image])
949 .stdout(Stdio::null())
950 .stderr(Stdio::null())
951 .status()
952 .await
953 .context("failed to run podman pull")?;
954 if !status.success() {
955 anyhow::bail!("podman pull {image} failed");
956 }
957 image
958 };
959
960 println!(" saving {image}...");
961 let status = Command::new("podman")
962 .args(["save", "-o", &tar_path.display().to_string(), local_name])
963 .stdout(Stdio::null())
964 .stderr(Stdio::null())
965 .status()
966 .await
967 .context("failed to run podman save")?;
968 if !status.success() {
969 let _ = tokio::fs::remove_file(&tar_path).await;
970 anyhow::bail!("podman save {image} failed");
971 }
972 }
973
974 println!(" loading {image} into shared store...");
976 let status = Command::new("podman")
977 .args([
978 "--root",
979 &store_dir.display().to_string(),
980 "--storage-driver",
981 "overlay",
982 "load",
983 "-i",
984 &tar_path.display().to_string(),
985 ])
986 .stdout(Stdio::null())
987 .stderr(Stdio::null())
988 .status()
989 .await
990 .context("failed to load image into shared store")?;
991 if !status.success() {
992 anyhow::bail!("podman load into shared store failed for {image}");
993 }
994
995 let _ = tokio::fs::remove_file(&tar_path).await;
998
999 Ok(())
1000}
1001
1002async fn image_exists_in_store(store_dir: &Path, image: &str) -> bool {
1008 let short = strip_docker_io(image);
1009 let expanded_library = format!("docker.io/library/{short}");
1010 let expanded_org = format!("docker.io/{short}");
1011 for name in [image, short, &expanded_library, &expanded_org] {
1012 let ok = Command::new("podman")
1013 .args([
1014 "--root",
1015 &store_dir.display().to_string(),
1016 "--storage-driver",
1017 "overlay",
1018 "image",
1019 "exists",
1020 name,
1021 ])
1022 .stdout(Stdio::null())
1023 .stderr(Stdio::null())
1024 .status()
1025 .await
1026 .map(|s| s.success())
1027 .unwrap_or(false);
1028 if ok {
1029 return true;
1030 }
1031 }
1032 false
1033}
1034
1035fn strip_docker_io(image: &str) -> &str {
1039 image
1040 .strip_prefix("docker.io/library/")
1041 .or_else(|| image.strip_prefix("docker.io/"))
1042 .unwrap_or(image)
1043}
1044
1045fn sanitize_image_name(image: &str) -> String {
1046 image.replace(['/', ':'], "_") + ".tar"
1047}
1048
1049pub async fn load_images_into_vm(machine: &Machine, _images: &[String]) -> Result<()> {
1054 machine
1057 .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)")
1058 .await
1059 .context("failed to mount 9p image store in VM")?;
1060
1061 machine
1067 .exec(
1068 "mkdir -p ~/.config/containers && \
1069 (grep -q '/mnt/images' ~/.config/containers/storage.conf 2>/dev/null || \
1070 printf '[storage]\\ndriver = \"overlay\"\\n[storage.options]\\nadditionalimagestores = [\"/mnt/images\"]\\n' > ~/.config/containers/storage.conf) && \
1071 (grep -q 'docker.io' ~/.config/containers/registries.conf 2>/dev/null || \
1072 printf 'unqualified-search-registries = [\"docker.io\"]\\n' > ~/.config/containers/registries.conf)",
1073 )
1074 .await
1075 .context("failed to configure podman config in VM")?;
1076
1077 Ok(())
1078}
1079
1080fn scp_base_args(machine: &Machine) -> Vec<String> {
1082 let key = machine.ssh_key_path();
1083 vec![
1084 "-o".into(),
1085 "StrictHostKeyChecking=no".into(),
1086 "-o".into(),
1087 "UserKnownHostsFile=/dev/null".into(),
1088 "-o".into(),
1089 "LogLevel=ERROR".into(),
1090 "-i".into(),
1091 key.to_string_lossy().into_owned(),
1092 "-P".into(),
1093 machine.ssh_port.to_string(),
1094 ]
1095}
1096
1097async fn scp_to_vm(machine: &Machine, local_path: &Path, remote_path: &str) -> Result<()> {
1099 let dest = format!("ryra@{}:{remote_path}", machine.ssh_host);
1100 let mut args = scp_base_args(machine);
1101 args.push(local_path.to_string_lossy().into_owned());
1102 args.push(dest);
1103
1104 let status = Command::new("scp")
1105 .args(&args)
1106 .status()
1107 .await
1108 .with_context(|| format!("failed to SCP {} to VM", local_path.display()))?;
1109 if !status.success() {
1110 anyhow::bail!("SCP of {} failed", local_path.display());
1111 }
1112 Ok(())
1113}
1114
1115pub async fn scp_dir_from_vm(
1119 machine: &Machine,
1120 remote_path: &str,
1121 local_path: &Path,
1122) -> Result<()> {
1123 let source = format!("ryra@{}:{remote_path}", machine.ssh_host);
1124 let mut args = scp_base_args(machine);
1125 args.push("-r".into());
1126 args.push(source);
1127 args.push(local_path.to_string_lossy().into_owned());
1128
1129 let status = Command::new("scp")
1130 .args(&args)
1131 .status()
1132 .await
1133 .with_context(|| format!("failed to SCP {remote_path} from VM"))?;
1134 if !status.success() {
1135 anyhow::bail!("SCP of {remote_path} from VM failed");
1136 }
1137 Ok(())
1138}
1139
1140pub async fn copy_ryra_to_vm(machine: &Machine, ryra_bin: &Path) -> Result<()> {
1142 scp_to_vm(machine, ryra_bin, "/tmp/ryra").await?;
1144 machine
1145 .exec("sudo mv /tmp/ryra /usr/local/bin/ryra && sudo chmod +x /usr/local/bin/ryra")
1146 .await?;
1147 Ok(())
1148}
1149
1150pub async fn copy_fixtures_to_vm(machine: &Machine, fixtures_dir: &Path) -> Result<()> {
1167 if !fixtures_dir.exists() {
1168 return Ok(());
1169 }
1170
1171 let tar_path = std::env::temp_dir().join(format!(
1172 "ryra-test-registry-{}-{}.tar",
1173 std::process::id(),
1174 machine.ssh_port
1175 ));
1176 let status = Command::new("tar")
1177 .args([
1178 "--exclude=node_modules",
1179 "--exclude=test-results",
1180 "--exclude=playwright-report",
1181 "-cf",
1182 ])
1183 .arg(&tar_path)
1184 .arg("-C")
1185 .arg(fixtures_dir)
1186 .arg(".")
1187 .status()
1188 .await
1189 .context("failed to run tar for the test registry")?;
1190 if !status.success() {
1191 let _ = tokio::fs::remove_file(&tar_path).await;
1192 anyhow::bail!("tar of {} failed", fixtures_dir.display());
1193 }
1194
1195 let copy_result = async {
1196 scp_to_vm(machine, &tar_path, "/tmp/ryra-test-registry.tar").await?;
1197 machine
1199 .exec(
1200 "sudo mkdir -p /opt/ryra-test-registry && \
1201 sudo chown ryra:ryra /opt/ryra-test-registry && \
1202 tar -xf /tmp/ryra-test-registry.tar -C /opt/ryra-test-registry && \
1203 rm /tmp/ryra-test-registry.tar",
1204 )
1205 .await
1206 .map(|_| ())
1207 }
1208 .await;
1209 let _ = tokio::fs::remove_file(&tar_path).await;
1211 copy_result
1212}
1213
1214pub async fn copy_project_to_vm(machine: &Machine, project_dir: &Path) -> Result<()> {
1219 if !project_dir.exists() {
1220 anyhow::bail!("project directory not found: {}", project_dir.display());
1221 }
1222
1223 machine
1224 .exec("sudo mkdir -p /opt/ryra-test-project && sudo chown ryra:ryra /opt/ryra-test-project")
1225 .await?;
1226
1227 let quadlet_extensions = ["container", "volume", "network", "pod", "kube", "toml"];
1229 let mut entries = tokio::fs::read_dir(project_dir)
1230 .await
1231 .with_context(|| format!("failed to read project directory {}", project_dir.display()))?;
1232
1233 while let Some(entry) = entries.next_entry().await? {
1234 let path = entry.path();
1235 if !path.is_file() {
1236 continue;
1237 }
1238 if let Some(ext) = path.extension().and_then(|e| e.to_str())
1239 && quadlet_extensions.contains(&ext)
1240 {
1241 scp_to_vm(machine, &path, "/opt/ryra-test-project/").await?;
1242 }
1243 }
1244
1245 Ok(())
1246}