1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use anyhow::{Context, Result};
6use tokio::process::Command;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Distro {
11 Debian13,
12 Fedora43,
13}
14
15impl Distro {
16 fn cloud_image_url(&self) -> &str {
17 match self {
18 Distro::Debian13 => {
19 "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-arm64.qcow2"
20 }
21 Distro::Fedora43 => {
22 "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-43-1.1.aarch64.qcow2"
23 }
24 }
25 }
26
27 fn image_filename(&self) -> &str {
28 match self {
29 Distro::Debian13 => "debian-13-generic-arm64.qcow2",
30 Distro::Fedora43 => "fedora-43-cloud-arm64.qcow2",
31 }
32 }
33
34 fn prepared_filename(&self) -> &str {
35 match self {
36 Distro::Debian13 => "debian-13-prepared-arm64.qcow2",
37 Distro::Fedora43 => "fedora-43-prepared-arm64.qcow2",
38 }
39 }
40
41 fn browser_prepared_filename(&self) -> &str {
42 match self {
43 Distro::Debian13 => "debian-13-browser-arm64.qcow2",
44 Distro::Fedora43 => "fedora-43-browser-arm64.qcow2",
45 }
46 }
47
48 fn snapshot_base(&self) -> &str {
49 match self {
50 Distro::Debian13 => "debian-13-arm64",
51 Distro::Fedora43 => "fedora-43-arm64",
52 }
53 }
54
55 fn browser_snapshot_base(&self) -> &str {
56 match self {
57 Distro::Debian13 => "debian-13-browser-arm64",
58 Distro::Fedora43 => "fedora-43-browser-arm64",
59 }
60 }
61
62 pub fn cloud_init_packages(&self) -> &[&str] {
64 match self {
65 Distro::Debian13 => &[
70 "podman",
71 "podman-compose",
72 "uidmap",
73 "git",
74 "systemd-container",
75 "curl",
76 "postgresql-client",
77 "restic",
78 ],
79 Distro::Fedora43 => &[
81 "podman",
82 "podman-compose",
83 "git",
84 "systemd-container",
85 "curl",
86 "restic",
87 ],
88 }
89 }
90}
91
92impl fmt::Display for Distro {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 match self {
95 Distro::Debian13 => write!(f, "debian-13"),
96 Distro::Fedora43 => write!(f, "fedora-43"),
97 }
98 }
99}
100
101impl std::str::FromStr for Distro {
102 type Err = String;
103
104 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
105 match s {
106 "debian-13" => Ok(Distro::Debian13),
107 "fedora-43" => Ok(Distro::Fedora43),
108 other => Err(format!(
109 "unknown distro: {other} (available: debian-13, fedora-43)"
110 )),
111 }
112 }
113}
114
115pub struct Image {
117 pub path: PathBuf,
119 pub efi_code: PathBuf,
120 pub efi_vars_template: PathBuf,
121 pub prepared: bool,
123 pub snapshot: Option<SnapshotFiles>,
126}
127
128pub struct SnapshotFiles {
130 pub disk: PathBuf,
132 pub efivars: PathBuf,
134 pub seed_iso: PathBuf,
137 pub ssh_key: PathBuf,
139 pub memory_mb: u32,
141}
142
143fn cache_dir() -> Result<PathBuf> {
145 let base = dirs::cache_dir().context("could not determine cache directory (is $HOME set?)")?;
146 Ok(base.join("ryra-vm"))
147}
148
149pub async fn ensure_image(
155 distro: &Distro,
156 redownload: bool,
157 use_kvm: bool,
158 max_memory_mb: u32,
159) -> Result<Image> {
160 let cache = cache_dir()?;
161 tokio::fs::create_dir_all(&cache)
162 .await
163 .context("failed to create image cache directory")?;
164
165 let raw_path = cache.join(distro.image_filename());
166 let prepared_path = cache.join(distro.prepared_filename());
167
168 if redownload || !raw_path.exists() {
170 download_image(distro, &raw_path).await?;
171 let _ = tokio::fs::remove_file(&prepared_path).await;
173 }
174
175 let efi = find_efi_firmware().await?;
177
178 let vars_template = cache.join("efivars.fd");
180 if !vars_template.exists() {
181 tokio::fs::copy(&efi.vars, &vars_template)
182 .await
183 .context("failed to copy EFI vars template")?;
184 }
185
186 if !prepared_path.exists() {
188 println!("Preparing base image (installing packages — this is a one-time operation)...");
189 let serial_log = cache_dir()?.join("prepare-base").join("serial.log");
190 println!(" Serial log: {}", serial_log.display());
191 prepare_image(
192 distro,
193 &raw_path,
194 &prepared_path,
195 &efi.code,
196 &vars_template,
197 use_kvm,
198 )
199 .await?;
200 println!("Prepared image cached at: {}", prepared_path.display());
201 } else {
202 println!("Using prepared image: {}", prepared_path.display());
203 }
204
205 let snapshot_prefix = format!("{}-snapshot-{max_memory_mb}", distro.snapshot_base());
209 let snapshot_disk = cache.join(format!("{snapshot_prefix}.qcow2"));
210 let snapshot_efivars = cache.join(format!("{snapshot_prefix}-efivars.qcow2"));
211 let snapshot_seed = cache.join(format!("{snapshot_prefix}-seed.iso"));
212 let snapshot_key = cache.join("test-ssh-key");
213
214 let snapshot = if snapshot_disk.exists() && snapshot_key.exists() {
215 Some(SnapshotFiles {
216 disk: snapshot_disk,
217 efivars: snapshot_efivars,
218 seed_iso: snapshot_seed,
219 ssh_key: snapshot_key,
220 memory_mb: max_memory_mb,
221 })
222 } else {
223 match create_snapshot(
224 &prepared_path,
225 &efi.code,
226 &vars_template,
227 &snapshot_disk,
228 &snapshot_efivars,
229 &snapshot_seed,
230 &snapshot_key,
231 max_memory_mb,
232 use_kvm,
233 )
234 .await
235 {
236 Ok(()) => {
237 println!(" VM snapshot created for instant boot ({max_memory_mb}MB)");
238 Some(SnapshotFiles {
239 disk: snapshot_disk,
240 efivars: snapshot_efivars,
241 seed_iso: snapshot_seed,
242 ssh_key: snapshot_key,
243 memory_mb: max_memory_mb,
244 })
245 }
246 Err(e) => {
247 eprintln!(
248 " Warning: failed to create VM snapshot (falling back to cold boot): {e:#}"
249 );
250 None
251 }
252 }
253 };
254
255 Ok(Image {
256 path: prepared_path,
257 efi_code: efi.code,
258 efi_vars_template: vars_template,
259 prepared: true,
260 snapshot,
261 })
262}
263
264pub async fn ensure_browser_image(
267 base: &Image,
268 distro: &Distro,
269 redownload: bool,
270 use_kvm: bool,
271 max_memory_mb: u32,
272) -> Result<Image> {
273 let cache = cache_dir()?;
274 let browser_path = cache.join(distro.browser_prepared_filename());
275
276 if redownload {
277 let _ = tokio::fs::remove_file(&browser_path).await;
278 }
279
280 if !browser_path.exists() {
281 println!("Preparing browser image (installing bun + playwright + chromium)...");
282 println!(" This is a one-time operation.");
283 prepare_browser_image(base, &browser_path, use_kvm).await?;
284 println!("Browser image cached at: {}", browser_path.display());
285 } else {
286 println!("Using browser image: {}", browser_path.display());
287 }
288
289 let cache = cache_dir()?;
291 let snap_prefix = format!(
292 "{}-snapshot-{max_memory_mb}",
293 distro.browser_snapshot_base()
294 );
295 let snap_disk = cache.join(format!("{snap_prefix}.qcow2"));
296 let snap_efivars = cache.join(format!("{snap_prefix}-efivars.qcow2"));
297 let snap_seed = cache.join(format!("{snap_prefix}-seed.iso"));
298 let snap_key = cache.join("test-ssh-key");
299
300 let snapshot = if snap_disk.exists() && snap_key.exists() {
301 Some(SnapshotFiles {
302 disk: snap_disk,
303 efivars: snap_efivars,
304 seed_iso: snap_seed,
305 ssh_key: snap_key,
306 memory_mb: max_memory_mb,
307 })
308 } else {
309 match create_snapshot(
310 &browser_path,
311 &base.efi_code,
312 &base.efi_vars_template,
313 &snap_disk,
314 &snap_efivars,
315 &snap_seed,
316 &snap_key,
317 max_memory_mb,
318 use_kvm,
319 )
320 .await
321 {
322 Ok(()) => Some(SnapshotFiles {
323 disk: snap_disk,
324 efivars: snap_efivars,
325 seed_iso: snap_seed,
326 ssh_key: snap_key,
327 memory_mb: max_memory_mb,
328 }),
329 Err(e) => {
330 eprintln!(" Warning: failed to create browser VM snapshot: {e:#}");
331 None
332 }
333 }
334 };
335
336 Ok(Image {
337 path: browser_path,
338 efi_code: base.efi_code.clone(),
339 efi_vars_template: base.efi_vars_template.clone(),
340 prepared: true,
341 snapshot,
342 })
343}
344
345async fn prepare_browser_image(base: &Image, browser_path: &Path, use_kvm: bool) -> Result<()> {
347 use crate::machine::{Machine, SpawnOpts};
348 use crate::ports;
349
350 let id = crate::machine::random_id();
351 let ssh_port = ports::allocate_ssh_port();
352 let opts = SpawnOpts {
353 use_kvm,
354 memory_mb: 4096, cpus: 2,
356 disk_gb: 20,
357 };
358
359 let mut vm = Machine::spawn(base, &id, ssh_port, &opts).await?;
360
361 let install_script = r#"
364set -e
365sudo apt-get update -qq && sudo apt-get install -y -qq unzip >/dev/null 2>&1
366curl -fsSL https://bun.sh/install | bash
367export BUN_INSTALL="$HOME/.bun"
368export PATH="$BUN_INSTALL/bin:$PATH"
369
370# Create a global playwright project so chromium is cached system-wide
371sudo mkdir -p /opt/playwright && sudo chown $USER:$USER /opt/playwright
372cd /opt/playwright
373bun init -y >/dev/null 2>&1
374bun add playwright @playwright/test
375bunx playwright install chromium --with-deps
376
377# Add bun to PATH for future SSH sessions
378echo 'export BUN_INSTALL="$HOME/.bun"' >> $HOME/.bashrc
379echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> $HOME/.bashrc
380"#;
381
382 println!(" Installing bun + playwright + chromium in VM...");
383 let result = vm.exec(install_script).await;
384 if let Err(e) = &result {
385 let _ = vm.destroy().await;
386 anyhow::bail!("failed to install browser tools: {e:#}");
387 }
388
389 let disk = vm.work_dir.join("disk.qcow2");
391 let _ = vm.exec("sudo sync && sudo poweroff").await;
392 vm.wait_for_exit(std::time::Duration::from_secs(30)).await;
393
394 let status = Command::new("qemu-img")
395 .args([
396 "convert",
397 "-f",
398 "qcow2",
399 "-O",
400 "qcow2",
401 &disk.to_string_lossy(),
402 &browser_path.to_string_lossy(),
403 ])
404 .stdout(Stdio::null())
405 .stderr(Stdio::null())
406 .status()
407 .await
408 .context("qemu-img convert failed")?;
409 if !status.success() {
410 anyhow::bail!("qemu-img convert failed for browser image");
411 }
412
413 let _ = vm.destroy().await;
414 Ok(())
415}
416
417struct EfiFirmware {
418 code: PathBuf,
419 vars: PathBuf,
420}
421
422async fn find_efi_firmware() -> Result<EfiFirmware> {
423 let candidates = [
424 (
426 "/usr/share/AAVMF/AAVMF_CODE.fd",
427 "/usr/share/AAVMF/AAVMF_VARS.fd",
428 ),
429 (
430 "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd",
431 "/usr/share/qemu-efi-aarch64/vars-template-pflash.raw",
432 ),
433 (
435 "/usr/share/edk2/aarch64/QEMU_EFI-pflash.raw",
436 "/usr/share/edk2/aarch64/vars-template-pflash.raw",
437 ),
438 ];
439
440 for (code, vars) in &candidates {
441 let code_path = PathBuf::from(code);
442 let vars_path = PathBuf::from(vars);
443 if code_path.exists() && vars_path.exists() {
444 return Ok(EfiFirmware {
445 code: code_path,
446 vars: vars_path,
447 });
448 }
449 }
450
451 anyhow::bail!(
452 "EFI firmware not found. Install it with:\n \
453 sudo apt install qemu-efi-aarch64 # Debian/Ubuntu\n \
454 sudo dnf install edk2-aarch64 # Fedora\n \
455 sudo pacman -S edk2-aarch64 # Arch"
456 )
457}
458
459async fn download_image(distro: &Distro, dest: &PathBuf) -> Result<()> {
460 let url = distro.cloud_image_url();
461 println!("Downloading {distro} cloud image...");
462 println!(" {url}");
463
464 let partial = dest.with_extension("qcow2.partial");
465
466 let status = Command::new("curl")
467 .args([
468 "-L",
469 "--progress-bar",
470 "-o",
471 &partial.to_string_lossy(),
472 url,
473 ])
474 .stdout(Stdio::inherit())
475 .stderr(Stdio::inherit())
476 .status()
477 .await
478 .context("failed to run curl — is it installed?")?;
479
480 if !status.success() {
481 let _ = tokio::fs::remove_file(&partial).await;
482 anyhow::bail!("failed to download cloud image from {url}");
483 }
484
485 tokio::fs::rename(&partial, dest)
486 .await
487 .context("failed to move downloaded image into place")?;
488
489 println!("Image cached at: {}", dest.display());
490 Ok(())
491}
492
493async fn prepare_image(
498 distro: &Distro,
499 raw_image: &Path,
500 prepared_path: &Path,
501 efi_code: &Path,
502 efi_vars_template: &Path,
503 use_kvm: bool,
504) -> Result<()> {
505 let work_dir = cache_dir()?.join("prepare-base");
506 let _ = tokio::fs::remove_dir_all(&work_dir).await;
507 tokio::fs::create_dir_all(&work_dir)
508 .await
509 .context("failed to create prepare work dir")?;
510
511 let disk = work_dir.join("disk.qcow2");
513 let status = Command::new("qemu-img")
514 .args([
515 "create",
516 "-f",
517 "qcow2",
518 "-b",
519 &raw_image.to_string_lossy(),
520 "-F",
521 "qcow2",
522 &disk.to_string_lossy(),
523 "20G",
524 ])
525 .stdout(Stdio::null())
526 .stderr(Stdio::null())
527 .status()
528 .await
529 .context("qemu-img create failed")?;
530 if !status.success() {
531 anyhow::bail!("qemu-img create failed for prepare step");
532 }
533
534 let efi_vars = work_dir.join("efivars.fd");
536 tokio::fs::copy(efi_vars_template, &efi_vars)
537 .await
538 .context("failed to copy EFI vars")?;
539
540 let key_path = work_dir.join("id_ed25519");
542 let status = Command::new("ssh-keygen")
543 .args([
544 "-t",
545 "ed25519",
546 "-f",
547 &key_path.to_string_lossy(),
548 "-N",
549 "",
550 "-q",
551 ])
552 .stdout(Stdio::null())
553 .stderr(Stdio::null())
554 .status()
555 .await
556 .context("ssh-keygen failed")?;
557 if !status.success() {
558 anyhow::bail!("ssh-keygen failed");
559 }
560 let pub_key = tokio::fs::read_to_string(format!("{}.pub", key_path.display()))
561 .await
562 .context("failed to read public key")?;
563
564 let seed_iso = work_dir.join("seed.iso");
566 crate::machine::build_seed_iso_full(
567 &work_dir,
568 &seed_iso,
569 "ryra-prepare",
570 pub_key.trim(),
571 distro.cloud_init_packages(),
572 )
573 .await?;
574
575 let ssh_port = crate::ports::allocate_ssh_port();
577 let serial_log = work_dir.join("serial.log");
578 let memory = "2048";
579 let cpus = "2";
580 let efi_code_arg = format!(
581 "if=pflash,format=raw,file={},readonly=on",
582 efi_code.display()
583 );
584 let efi_vars_arg = format!("if=pflash,format=raw,file={}", efi_vars.display());
585 let disk_arg = format!("if=virtio,file={},format=qcow2", disk.display());
586 let seed_arg = format!("if=virtio,file={},format=raw", seed_iso.display());
587 let nic_arg = format!("user,hostfwd=tcp::{ssh_port}-:22");
588 let serial_arg = format!("file:{}", serial_log.display());
589
590 let mut args: Vec<&str> = vec![
591 "-machine",
592 "virt",
593 "-cpu",
594 if use_kvm { "host" } else { "max" },
595 "-m",
596 memory,
597 "-smp",
598 cpus,
599 "-drive",
600 &efi_code_arg,
601 "-drive",
602 &efi_vars_arg,
603 "-drive",
604 &disk_arg,
605 "-drive",
606 &seed_arg,
607 "-nic",
608 &nic_arg,
609 "-nographic",
610 "-serial",
611 &serial_arg,
612 "-monitor",
613 "none",
614 ];
615 if use_kvm {
616 args.extend(crate::accel_args().iter().copied());
617 }
618
619 let mut qemu = Command::new("qemu-system-aarch64")
620 .args(&args)
621 .stdout(Stdio::null())
622 .stderr(Stdio::null())
623 .spawn()
624 .context("failed to start QEMU for image preparation")?;
625
626 let timeout = if use_kvm {
628 std::time::Duration::from_secs(300)
629 } else {
630 std::time::Duration::from_secs(900)
631 };
632 let start = std::time::Instant::now();
633 let port_str = ssh_port.to_string();
634 loop {
635 let result = Command::new("ssh")
636 .args([
637 "-o",
638 "StrictHostKeyChecking=no",
639 "-o",
640 "UserKnownHostsFile=/dev/null",
641 "-o",
642 "LogLevel=ERROR",
643 "-o",
644 "ConnectTimeout=3",
645 "-o",
646 "BatchMode=yes",
647 "-i",
648 &key_path.to_string_lossy(),
649 "-p",
650 &port_str,
651 "ryra@127.0.0.1",
652 "true",
653 ])
654 .stdout(Stdio::null())
655 .stderr(Stdio::null())
656 .status()
657 .await;
658
659 if let Ok(s) = result
660 && s.success()
661 {
662 break;
663 }
664
665 if start.elapsed() > timeout {
666 let _ = qemu.kill().await;
667 anyhow::bail!(
668 "timed out waiting for SSH during image preparation after {}s\n \
669 Serial log: {}",
670 timeout.as_secs(),
671 serial_log.display()
672 );
673 }
674
675 if start.elapsed().as_secs().is_multiple_of(30) && start.elapsed().as_secs() > 0 {
676 println!(
677 " preparing image... ({:.0}s elapsed)",
678 start.elapsed().as_secs_f64()
679 );
680 }
681
682 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
683 }
684
685 println!(" SSH ready, waiting for cloud-init to finish installing packages...");
687 let ci_result = Command::new("ssh")
688 .args([
689 "-o",
690 "StrictHostKeyChecking=no",
691 "-o",
692 "UserKnownHostsFile=/dev/null",
693 "-o",
694 "LogLevel=ERROR",
695 "-o",
696 "ConnectTimeout=10",
697 "-o",
698 "BatchMode=yes",
699 "-i",
700 &key_path.to_string_lossy(),
701 "-p",
702 &port_str,
703 "ryra@127.0.0.1",
704 "cloud-init status --wait",
705 ])
706 .stdout(Stdio::null())
707 .stderr(Stdio::null())
708 .status()
709 .await
710 .context("cloud-init wait failed")?;
711
712 if !ci_result.success() {
713 let _ = qemu.kill().await;
714 anyhow::bail!("cloud-init failed during image preparation");
715 }
716
717 let _ = Command::new("ssh")
719 .args([
720 "-o",
721 "StrictHostKeyChecking=no",
722 "-o",
723 "UserKnownHostsFile=/dev/null",
724 "-o",
725 "LogLevel=ERROR",
726 "-o",
727 "BatchMode=yes",
728 "-i",
729 &key_path.to_string_lossy(),
730 "-p",
731 &port_str,
732 "ryra@127.0.0.1",
733 "cloud-init clean --logs && rm -f /etc/ssh/ssh_host_*_key*",
734 ])
735 .stdout(Stdio::null())
736 .stderr(Stdio::null())
737 .status()
738 .await;
739
740 let _ = Command::new("ssh")
742 .args([
743 "-o",
744 "StrictHostKeyChecking=no",
745 "-o",
746 "UserKnownHostsFile=/dev/null",
747 "-o",
748 "LogLevel=ERROR",
749 "-o",
750 "BatchMode=yes",
751 "-i",
752 &key_path.to_string_lossy(),
753 "-p",
754 &port_str,
755 "ryra@127.0.0.1",
756 "sudo poweroff",
757 ])
758 .stdout(Stdio::null())
759 .stderr(Stdio::null())
760 .status()
761 .await;
762
763 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
764 let _ = qemu.kill().await;
765 let _ = qemu.wait().await;
766
767 let status = Command::new("qemu-img")
769 .args([
770 "convert",
771 "-O",
772 "qcow2",
773 "-c",
774 &disk.to_string_lossy(),
775 &prepared_path.to_string_lossy(),
776 ])
777 .stdout(Stdio::null())
778 .stderr(Stdio::null())
779 .status()
780 .await
781 .context("qemu-img convert failed")?;
782 if !status.success() {
783 anyhow::bail!("failed to compact prepared image");
784 }
785
786 let _ = tokio::fs::remove_dir_all(&work_dir).await;
788
789 Ok(())
790}
791
792#[allow(clippy::too_many_arguments)]
797async fn create_snapshot(
798 prepared_path: &Path,
799 efi_code: &Path,
800 efi_vars_template: &Path,
801 snapshot_disk: &Path,
802 snapshot_efivars: &Path,
803 snapshot_seed: &Path,
804 ssh_key_path: &Path,
805 memory_mb: u32,
806 use_kvm: bool,
807) -> Result<()> {
808 let work_dir = cache_dir()?.join("prepare-snapshot");
809 let _ = tokio::fs::remove_dir_all(&work_dir).await;
810 tokio::fs::create_dir_all(&work_dir)
811 .await
812 .context("failed to create snapshot work dir")?;
813
814 if !ssh_key_path.exists() {
816 let status = Command::new("ssh-keygen")
817 .args([
818 "-t",
819 "ed25519",
820 "-f",
821 &ssh_key_path.to_string_lossy(),
822 "-N",
823 "",
824 "-q",
825 ])
826 .stdout(Stdio::null())
827 .stderr(Stdio::null())
828 .status()
829 .await
830 .context("ssh-keygen failed")?;
831 if !status.success() {
832 anyhow::bail!("ssh-keygen failed for test SSH key");
833 }
834 }
835
836 let pub_key = tokio::fs::read_to_string(format!("{}.pub", ssh_key_path.display()))
837 .await
838 .context("failed to read test SSH public key")?;
839
840 let disk = work_dir.join("disk.qcow2");
842 let status = Command::new("qemu-img")
843 .args([
844 "create",
845 "-f",
846 "qcow2",
847 "-b",
848 &prepared_path.to_string_lossy(),
849 "-F",
850 "qcow2",
851 &disk.to_string_lossy(),
852 "20G",
853 ])
854 .stdout(Stdio::null())
855 .stderr(Stdio::null())
856 .status()
857 .await
858 .context("qemu-img create failed")?;
859 if !status.success() {
860 anyhow::bail!("qemu-img create failed for snapshot disk");
861 }
862
863 let efivars = work_dir.join("efivars.qcow2");
865 let status = Command::new("qemu-img")
866 .args([
867 "convert",
868 "-f",
869 "raw",
870 "-O",
871 "qcow2",
872 &efi_vars_template.to_string_lossy(),
873 &efivars.to_string_lossy(),
874 ])
875 .stdout(Stdio::null())
876 .stderr(Stdio::null())
877 .status()
878 .await
879 .context("qemu-img convert failed for efivars")?;
880 if !status.success() {
881 anyhow::bail!("failed to convert EFI vars to qcow2");
882 }
883
884 let seed_iso = work_dir.join("seed.iso");
886 crate::machine::build_seed_iso(&work_dir, &seed_iso, "snapshot-prep", pub_key.trim()).await?;
887
888 let ssh_port = crate::ports::allocate_ssh_port();
890 let serial_log = work_dir.join("serial.log");
891 let port_str = ssh_port.to_string();
892
893 let shared_store = crate::machine::image_shared_store_dir()?;
895 tokio::fs::create_dir_all(&shared_store).await.ok();
896
897 let efi_code_arg = format!(
898 "if=pflash,format=raw,file={},readonly=on",
899 efi_code.display()
900 );
901 let efi_vars_arg = format!("if=pflash,format=qcow2,file={}", efivars.display());
902 let disk_arg = format!("if=virtio,file={},format=qcow2", disk.display());
903 let seed_arg = format!(
904 "if=virtio,file={},format=raw,readonly=on",
905 seed_iso.display()
906 );
907 let nic_arg = format!("user,hostfwd=tcp::{ssh_port}-:22");
908 let serial_arg = format!("file:{}", serial_log.display());
909 let mon_sock = work_dir.join("mon.sock");
910 let mon_arg = format!("unix:{},server,nowait", mon_sock.display());
911 let virtfs_arg = format!(
912 "local,path={},mount_tag=images,security_model=none,readonly=on",
913 shared_store.display()
914 );
915
916 let memory_str = memory_mb.to_string();
917 let mut args: Vec<&str> = vec![
918 "-machine",
919 "virt",
920 "-cpu",
921 if use_kvm { "host" } else { "max" },
922 "-m",
923 &memory_str,
924 "-smp",
925 "2",
926 "-drive",
927 &efi_code_arg,
928 "-drive",
929 &efi_vars_arg,
930 "-drive",
931 &disk_arg,
932 "-drive",
933 &seed_arg,
934 "-nic",
935 &nic_arg,
936 "-nographic",
937 "-serial",
938 &serial_arg,
939 "-monitor",
940 &mon_arg,
941 "-virtfs",
942 &virtfs_arg,
943 ];
944 if use_kvm {
945 args.extend(crate::accel_args().iter().copied());
946 }
947
948 let mut qemu = Command::new("qemu-system-aarch64")
949 .args(&args)
950 .stdout(Stdio::null())
951 .stderr(Stdio::null())
952 .spawn()
953 .context("failed to start QEMU for snapshot creation")?;
954
955 let timeout = std::time::Duration::from_secs(if use_kvm { 120 } else { 600 });
957 let start = std::time::Instant::now();
958 loop {
959 let result = Command::new("ssh")
960 .args([
961 "-o",
962 "StrictHostKeyChecking=no",
963 "-o",
964 "UserKnownHostsFile=/dev/null",
965 "-o",
966 "LogLevel=ERROR",
967 "-o",
968 "ConnectTimeout=2",
969 "-o",
970 "BatchMode=yes",
971 "-i",
972 &ssh_key_path.to_string_lossy(),
973 "-p",
974 &port_str,
975 "ryra@127.0.0.1",
976 "true",
977 ])
978 .stdout(Stdio::null())
979 .stderr(Stdio::null())
980 .status()
981 .await;
982
983 if let Ok(s) = result
984 && s.success()
985 {
986 break;
987 }
988 if start.elapsed() > timeout {
989 let _ = qemu.kill().await;
990 anyhow::bail!("timed out waiting for SSH during snapshot creation");
991 }
992 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
993 }
994
995 let _ = Command::new("ssh")
997 .args([
998 "-o",
999 "StrictHostKeyChecking=no",
1000 "-o",
1001 "UserKnownHostsFile=/dev/null",
1002 "-o",
1003 "LogLevel=ERROR",
1004 "-o",
1005 "BatchMode=yes",
1006 "-i",
1007 &ssh_key_path.to_string_lossy(),
1008 "-p",
1009 &port_str,
1010 "ryra@127.0.0.1",
1011 "cloud-init status --wait",
1012 ])
1013 .stdout(Stdio::null())
1014 .stderr(Stdio::null())
1015 .status()
1016 .await;
1017
1018 let setup_cmd = "\
1031 sudo mkdir -p /mnt/images; \
1032 mkdir -p ~/.config/containers && \
1033 printf '[storage]\\ndriver = \"overlay\"\\n[storage.options]\\nadditionalimagestores = [\"/mnt/images\"]\\n' > ~/.config/containers/storage.conf && \
1034 printf 'unqualified-search-registries = [\"docker.io\"]\\n' > ~/.config/containers/registries.conf; \
1035 systemctl --user daemon-reload";
1036 let setup_status = Command::new("ssh")
1037 .args([
1038 "-o",
1039 "StrictHostKeyChecking=no",
1040 "-o",
1041 "UserKnownHostsFile=/dev/null",
1042 "-o",
1043 "LogLevel=ERROR",
1044 "-o",
1045 "BatchMode=yes",
1046 "-i",
1047 &ssh_key_path.to_string_lossy(),
1048 "-p",
1049 &port_str,
1050 "ryra@127.0.0.1",
1051 setup_cmd,
1052 ])
1053 .output()
1054 .await
1055 .context("failed to SSH for snapshot setup")?;
1056 if !setup_status.status.success() {
1057 let stderr = String::from_utf8_lossy(&setup_status.stderr);
1058 anyhow::bail!("snapshot setup failed: {stderr}");
1059 }
1060
1061 let socat_result = std::process::Command::new("socat")
1063 .args(["-", &format!("UNIX-CONNECT:{}", mon_sock.display())])
1064 .stdin(std::process::Stdio::piped())
1065 .stdout(std::process::Stdio::null())
1066 .stderr(std::process::Stdio::null())
1067 .spawn()
1068 .and_then(|mut child| {
1069 use std::io::Write;
1070 if let Some(ref mut stdin) = child.stdin {
1071 stdin.write_all(b"savevm ready\n")?;
1072 stdin.flush()?;
1073 }
1074 child.stdin.take();
1075 Ok(child)
1076 });
1077
1078 match socat_result {
1079 Ok(mut child) => {
1080 let start = std::time::Instant::now();
1092 let max_wait =
1093 std::time::Duration::from_secs(std::cmp::max(300, (memory_mb as u64) * 2));
1094 let poll_interval = std::time::Duration::from_secs(2);
1095 let stable_polls_needed = 3;
1100
1101 let mut last_size: u64 = 0;
1102 let mut stable_polls: u32 = 0;
1103 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1107
1108 loop {
1109 let size = tokio::fs::metadata(&disk)
1110 .await
1111 .map(|m| m.len())
1112 .unwrap_or(0);
1113 if size == last_size && size > 0 {
1114 stable_polls += 1;
1115 if stable_polls >= stable_polls_needed {
1116 break;
1117 }
1118 } else {
1119 stable_polls = 0;
1120 last_size = size;
1121 }
1122 if start.elapsed() > max_wait {
1123 eprintln!(
1124 " warning: savevm hit max wait ({}s) — qcow2 size {}MB, proceeding anyway",
1125 max_wait.as_secs(),
1126 size / (1024 * 1024),
1127 );
1128 break;
1129 }
1130 tokio::time::sleep(poll_interval).await;
1131 }
1132 let _ = child.kill();
1133 let _ = child.wait();
1134 }
1135 Err(e) => {
1136 let _ = qemu.kill().await;
1137 anyhow::bail!("failed to save VM snapshot via socat: {e}. Is socat installed?");
1138 }
1139 }
1140
1141 let _ = qemu.kill().await;
1142 let _ = qemu.wait().await;
1143
1144 let check = Command::new("qemu-img")
1146 .args(["snapshot", "-l", &disk.to_string_lossy()])
1147 .output()
1148 .await
1149 .context("failed to run qemu-img snapshot -l")?;
1150 let snapshot_list = String::from_utf8_lossy(&check.stdout);
1151 if !snapshot_list.contains("ready") {
1152 anyhow::bail!(
1153 "savevm failed — snapshot 'ready' not found in {}. \
1154 This can happen if the VM needed more time to save {}MB of RAM.",
1155 disk.display(),
1156 memory_mb
1157 );
1158 }
1159
1160 tokio::fs::rename(&disk, snapshot_disk)
1162 .await
1163 .context("failed to move snapshot disk")?;
1164 tokio::fs::rename(&efivars, snapshot_efivars)
1165 .await
1166 .context("failed to move snapshot efivars")?;
1167 tokio::fs::rename(&seed_iso, snapshot_seed)
1168 .await
1169 .context("failed to move snapshot seed ISO")?;
1170
1171 let _ = tokio::fs::remove_dir_all(&work_dir).await;
1172 Ok(())
1173}