Skip to main content

hardpass/
instance.rs

1use std::collections::BTreeSet;
2use std::io::{IsTerminal, Write};
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::time::Duration;
6
7use anyhow::{Context, Result, bail};
8use nix::sys::signal::Signal;
9use nix::unistd::Pid;
10use serde::Serialize;
11use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
12use tokio::process::Command;
13use tokio::sync::watch;
14use tokio::time::sleep;
15
16use crate::cli::{CreateArgs, PrefetchImageArgs};
17use crate::cloud_init::{create_seed_image, render_cloud_init};
18use crate::images::ensure_image;
19use crate::lock::lock_file;
20use crate::ports::{reserve_ports, validate_forwards};
21use crate::qemu::{discover_aarch64_firmware, launch_vm, system_powerdown};
22use crate::ssh::{
23    ExecOutput as SshExecOutput, ensure_ssh_key, exec as ssh_exec,
24    exec_capture as ssh_exec_capture, exec_checked as ssh_exec_checked, open_session, wait_for_ssh,
25};
26use crate::ssh_config::{SshAliasEntry, SshConfigManager};
27use crate::state::{
28    AccelMode, CloudInitConfig, GuestArch, HardpassState, ImageConfig, InstanceConfig,
29    InstancePaths, InstanceStatus, PortForward, SshConfig, command_exists, process_is_alive,
30    validate_name,
31};
32
33#[derive(Clone)]
34pub struct InstanceManager {
35    state: HardpassState,
36    client: reqwest::Client,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum HostDependency {
41    QemuImg,
42    QemuSystem,
43    Ssh,
44    SshKeygen,
45    Aarch64Firmware,
46}
47
48impl HostDependency {
49    fn label(self, guest_arch: GuestArch) -> String {
50        match self {
51            Self::QemuImg => "qemu-img".to_string(),
52            Self::QemuSystem => guest_arch.qemu_binary().to_string(),
53            Self::Ssh => "ssh".to_string(),
54            Self::SshKeygen => "ssh-keygen".to_string(),
55            Self::Aarch64Firmware => "aarch64-firmware".to_string(),
56        }
57    }
58
59    fn is_qemu_related(self) -> bool {
60        matches!(
61            self,
62            Self::QemuImg | Self::QemuSystem | Self::Aarch64Firmware
63        )
64    }
65}
66
67impl InstanceManager {
68    pub fn new(state: HardpassState) -> Self {
69        Self {
70            state,
71            client: reqwest::Client::builder()
72                .user_agent(concat!("hardpass/", env!("CARGO_PKG_VERSION")))
73                .build()
74                .expect("reqwest client"),
75        }
76    }
77
78    pub async fn doctor(&self) -> Result<()> {
79        let host_arch = GuestArch::host_native()?;
80        let required_tools = [
81            "qemu-img".to_string(),
82            host_arch.qemu_binary().to_string(),
83            "ssh".to_string(),
84            "ssh-keygen".to_string(),
85        ];
86        let mut missing = false;
87        println!("Host architecture: {host_arch}");
88        for tool in required_tools {
89            if let Some(path) = resolve_command_path(&tool).await? {
90                println!("ok    {tool:<20} {path}");
91            } else {
92                println!("fail  {tool:<20} not found");
93                missing = true;
94            }
95        }
96
97        if host_arch == GuestArch::Arm64 {
98            match discover_aarch64_firmware() {
99                Ok(firmware) => {
100                    println!(
101                        "ok    {:<20} code={} vars={}",
102                        "aarch64-firmware",
103                        firmware.code.display(),
104                        firmware.vars_template.display()
105                    );
106                }
107                Err(err) => {
108                    println!("fail  {:<20} {err}", "aarch64-firmware");
109                    missing = true;
110                }
111            }
112        }
113
114        if cfg!(target_os = "linux") && !Path::new("/dev/kvm").exists() {
115            println!(
116                "warn  {:<20} /dev/kvm unavailable; `hp start` with auto/kvm will fail, but `--accel tcg` remains available for slower emulated guests",
117                "kvm"
118            );
119        }
120
121        if missing {
122            bail!("doctor found missing requirements");
123        }
124        Ok(())
125    }
126
127    pub async fn prefetch_image(&self, args: PrefetchImageArgs) -> Result<()> {
128        let release = args
129            .release
130            .unwrap_or_else(|| InstanceConfig::default_release().to_string());
131        let arch = args.arch.unwrap_or(GuestArch::host_native()?);
132        let image = ensure_image(&self.client, &self.state.images_dir(), &release, arch).await?;
133        let size_bytes = tokio::fs::metadata(&image.local_path).await?.len();
134        println!("Prefetched Ubuntu {release} {arch} image");
135        println!("path: {}", image.local_path.display());
136        println!("sha256: {}", image.config.sha256);
137        println!("size_bytes: {size_bytes}");
138        Ok(())
139    }
140
141    pub async fn create(&self, args: CreateArgs) -> Result<()> {
142        let info = self.create_with_output(args).await?;
143        self.auto_configure_ssh_if_enabled().await;
144        self.print_created(&info);
145        Ok(())
146    }
147
148    pub async fn start(&self, name: &str) -> Result<()> {
149        let info = self.start_inner(name, true).await?;
150        self.print_ready(&info);
151        Ok(())
152    }
153
154    pub async fn stop(&self, name: &str) -> Result<()> {
155        let paths = self.state.instance_paths(name)?;
156        let _lock = lock_file(paths.lock_path()).await?;
157        self.stop_inner(name, true).await
158    }
159
160    pub async fn delete(&self, name: &str) -> Result<()> {
161        let paths = self.state.instance_paths(name)?;
162        let _lock = lock_file(paths.lock_path()).await?;
163        self.delete_inner(name, true).await?;
164        drop(_lock);
165        self.auto_configure_ssh_if_enabled().await;
166        Ok(())
167    }
168
169    pub async fn list(&self) -> Result<()> {
170        let names = self.state.instance_names().await?;
171        if names.is_empty() {
172            println!("No Hardpass instances found");
173            return Ok(());
174        }
175        let mut rows = Vec::new();
176        for name in names {
177            let paths = self.state.instance_paths(&name)?;
178            if !paths.config.is_file() {
179                continue;
180            }
181            let config = paths.read_config().await?;
182            let status = paths.status().await?;
183            rows.push(ListRow {
184                name: config.name,
185                status: status.to_string(),
186                arch: config.arch.to_string(),
187                release: config.release,
188                ssh: format!("{}:{}", config.ssh.host, config.ssh.port),
189            });
190        }
191        if rows.is_empty() {
192            println!("No Hardpass instances found");
193            return Ok(());
194        }
195        print!("{}", render_list_table(&rows));
196        Ok(())
197    }
198
199    pub async fn info(&self, name: &str, json: bool) -> Result<()> {
200        let output = self.vm_info(name).await?;
201        if json {
202            println!("{}", serde_json::to_string_pretty(&output)?);
203        } else {
204            println!("name: {}", output.name);
205            println!("status: {}", output.status);
206            println!("release: {}", output.release);
207            println!("arch: {}", output.arch);
208            println!(
209                "ssh: {}@{}:{}",
210                output.ssh.user, output.ssh.host, output.ssh.port
211            );
212            println!("ssh alias: {}", output.ssh.alias);
213            println!("instance_dir: {}", output.instance_dir.display());
214            println!("serial_log: {}", output.serial_log.display());
215            if output.forwards.is_empty() {
216                println!("forwards: none");
217            } else {
218                let forwards = output
219                    .forwards
220                    .iter()
221                    .map(|forward| format!("{}:{}", forward.host, forward.guest))
222                    .collect::<Vec<_>>()
223                    .join(", ");
224                println!("forwards: {forwards}");
225            }
226        }
227        Ok(())
228    }
229
230    pub async fn ssh(&self, name: &str, ssh_args: &[String]) -> Result<()> {
231        let (_, config) = self.running_instance(name).await?;
232        open_session(&config.ssh, ssh_args).await
233    }
234
235    pub async fn exec(&self, name: &str, command: &[String]) -> Result<()> {
236        let (_, config) = self.running_instance(name).await?;
237        ssh_exec(&config.ssh, command).await
238    }
239
240    pub(crate) async fn create_silent(&self, args: CreateArgs) -> Result<VmInfo> {
241        self.create_inner(args, false).await
242    }
243
244    async fn create_with_output(&self, args: CreateArgs) -> Result<VmInfo> {
245        self.create_inner(args, true).await
246    }
247
248    async fn create_inner(&self, args: CreateArgs, allow_prompt: bool) -> Result<VmInfo> {
249        validate_name(&args.name)?;
250        let paths = self.state.instance_paths(&args.name)?;
251        let _lock = lock_file(paths.lock_path()).await?;
252        let host_arch = GuestArch::host_native()?;
253        let guest_arch = args.arch.unwrap_or(host_arch);
254        let accel = args.accel.unwrap_or(AccelMode::Auto);
255        validate_arch_accel_combo(host_arch, guest_arch, accel)?;
256        match paths.status().await? {
257            InstanceStatus::Missing => {
258                self.ensure_create_dependencies(guest_arch, allow_prompt)
259                    .await?;
260                let config = self.create_instance(&paths, &args).await?;
261                Ok(VmInfo::from_config(
262                    &config,
263                    &paths,
264                    InstanceStatus::Stopped,
265                ))
266            }
267            InstanceStatus::Stopped | InstanceStatus::Running => bail!(
268                "instance {} already exists; use `hp start {}` or `hp delete {}`",
269                args.name,
270                args.name,
271                args.name
272            ),
273        }
274    }
275
276    pub(crate) async fn start_silent(&self, name: &str) -> Result<VmInfo> {
277        self.start_inner(name, false).await
278    }
279
280    pub(crate) async fn stop_silent(&self, name: &str) -> Result<()> {
281        let paths = self.state.instance_paths(name)?;
282        let _lock = lock_file(paths.lock_path()).await?;
283        self.stop_inner(name, false).await
284    }
285
286    pub(crate) async fn delete_silent(&self, name: &str) -> Result<()> {
287        let paths = self.state.instance_paths(name)?;
288        let _lock = lock_file(paths.lock_path()).await?;
289        self.delete_inner(name, false).await
290    }
291
292    pub(crate) async fn wait_for_ssh_ready(&self, name: &str) -> Result<VmInfo> {
293        let (paths, config) = self.running_instance(name).await?;
294        self.ensure_start_dependencies(config.arch, false, false)
295            .await?;
296        wait_for_ssh(&config.ssh, config.timeout_secs).await?;
297        Ok(VmInfo::from_config(&config, &paths, paths.status().await?))
298    }
299
300    pub(crate) async fn vm_info(&self, name: &str) -> Result<VmInfo> {
301        let (paths, config) = self.instance(name).await?;
302        Ok(VmInfo::from_config(&config, &paths, paths.status().await?))
303    }
304
305    pub(crate) async fn status(&self, name: &str) -> Result<InstanceStatus> {
306        let paths = self.state.instance_paths(name)?;
307        paths.status().await
308    }
309
310    pub(crate) async fn exec_capture(
311        &self,
312        name: &str,
313        command: &[String],
314    ) -> Result<SshExecOutput> {
315        let (_, config) = self.running_instance(name).await?;
316        self.ensure_start_dependencies(config.arch, false, false)
317            .await?;
318        ssh_exec_capture(&config.ssh, command).await
319    }
320
321    pub(crate) async fn exec_checked(
322        &self,
323        name: &str,
324        command: &[String],
325    ) -> Result<SshExecOutput> {
326        let (_, config) = self.running_instance(name).await?;
327        self.ensure_start_dependencies(config.arch, false, false)
328            .await?;
329        ssh_exec_checked(&config.ssh, command).await
330    }
331
332    async fn create_instance(
333        &self,
334        paths: &InstancePaths,
335        args: &CreateArgs,
336    ) -> Result<InstanceConfig> {
337        let host_arch = GuestArch::host_native()?;
338        let arch = args.arch.unwrap_or(host_arch);
339        let accel = args.accel.unwrap_or(AccelMode::Auto);
340        validate_arch_accel_combo(host_arch, arch, accel)?;
341        let ssh_key_path = self.resolve_ssh_key_path(args.ssh_key.as_deref())?;
342        let public_key = ensure_ssh_key(&ssh_key_path).await?;
343        let user_data_path = args
344            .cloud_init_user_data
345            .as_deref()
346            .map(expand_path)
347            .transpose()?;
348        let network_config_path = args
349            .cloud_init_network_config
350            .as_deref()
351            .map(expand_path)
352            .transpose()?;
353        let render = render_cloud_init(
354            &args.name,
355            &public_key,
356            user_data_path.as_deref(),
357            network_config_path.as_deref(),
358        )
359        .await?;
360
361        let forwards = args
362            .forwards
363            .iter()
364            .copied()
365            .map(|(host, guest)| PortForward { host, guest })
366            .collect::<Vec<_>>();
367
368        let release = args
369            .release
370            .clone()
371            .unwrap_or_else(|| InstanceConfig::default_release().to_string());
372        let image = ensure_image(&self.client, &self.state.images_dir(), &release, arch).await?;
373
374        let port_reservation = self.reserve_host_ports(&forwards).await?;
375        let ssh_port = port_reservation.ssh_port;
376        validate_forwards(&forwards, ssh_port)?;
377        let config = InstanceConfig {
378            name: args.name.clone(),
379            release,
380            arch,
381            accel,
382            cpus: args.cpus.unwrap_or_else(InstanceConfig::default_cpus),
383            memory_mib: args
384                .memory_mib
385                .unwrap_or_else(InstanceConfig::default_memory_mib),
386            disk_gib: args
387                .disk_gib
388                .unwrap_or_else(InstanceConfig::default_disk_gib),
389            timeout_secs: args
390                .timeout_secs
391                .unwrap_or_else(InstanceConfig::default_timeout_secs),
392            ssh: SshConfig {
393                user: InstanceConfig::default_ssh_user().to_string(),
394                host: InstanceConfig::default_ssh_host().to_string(),
395                port: ssh_port,
396                identity_file: ssh_key_path,
397            },
398            forwards,
399            image: ImageConfig {
400                sha256: image.config.sha256.clone(),
401                ..image.config
402            },
403            cloud_init: CloudInitConfig {
404                user_data_sha256: render.user_data_sha256.clone(),
405                network_config_sha256: render.network_config_sha256.clone(),
406            },
407        };
408
409        paths.ensure_dir().await?;
410        crate::qemu::create_overlay_disk(&image.local_path, &paths.disk, config.disk_gib).await?;
411        create_seed_image(&paths.seed, &render).await?;
412        paths.write_config(&config).await?;
413        Ok(config)
414    }
415
416    async fn start_inner(&self, name: &str, show_serial: bool) -> Result<VmInfo> {
417        let paths = self.state.instance_paths(name)?;
418        let _lock = lock_file(paths.lock_path()).await?;
419        self.start_locked(&paths, name, show_serial).await
420    }
421
422    async fn start_locked(
423        &self,
424        paths: &InstancePaths,
425        name: &str,
426        show_serial: bool,
427    ) -> Result<VmInfo> {
428        match paths.status().await? {
429            InstanceStatus::Missing => {
430                bail!("instance {name} does not exist; use `hp create {name}` first")
431            }
432            InstanceStatus::Stopped => {
433                let config = paths.read_config().await?;
434                self.ensure_start_dependencies(config.arch, true, show_serial)
435                    .await?;
436                self.ensure_existing_artifacts(paths).await?;
437                paths.clear_runtime_artifacts().await?;
438                launch_vm(&config, paths).await?;
439                let _ = self.wait_for_pid(paths).await?;
440                if show_serial {
441                    self.wait_for_ssh_with_serial(&config, paths).await?;
442                } else {
443                    wait_for_ssh(&config.ssh, config.timeout_secs).await?;
444                }
445                Ok(VmInfo::from_config(&config, paths, paths.status().await?))
446            }
447            InstanceStatus::Running => {
448                let config = paths.read_config().await?;
449                self.ensure_start_dependencies(config.arch, false, show_serial)
450                    .await?;
451                wait_for_ssh(&config.ssh, config.timeout_secs).await?;
452                Ok(VmInfo::from_config(&config, paths, paths.status().await?))
453            }
454        }
455    }
456
457    async fn stop_inner(&self, name: &str, report: bool) -> Result<()> {
458        let paths = self.state.instance_paths(name)?;
459        match paths.status().await? {
460            InstanceStatus::Missing => bail!("instance {name} does not exist"),
461            InstanceStatus::Stopped => {
462                paths.clear_runtime_artifacts().await?;
463                if report {
464                    println!("{name} is already stopped");
465                }
466                Ok(())
467            }
468            InstanceStatus::Running => {
469                let pid = paths
470                    .read_pid()
471                    .await?
472                    .ok_or_else(|| anyhow::anyhow!("missing pid file"))?;
473                if paths.qmp.is_file() {
474                    let _ = system_powerdown(&paths.qmp).await;
475                } else {
476                    send_signal(pid, Signal::SIGTERM)?;
477                }
478                if !wait_for_process_exit(pid, Duration::from_secs(20)).await {
479                    let _ = send_signal(pid, Signal::SIGTERM);
480                    if !wait_for_process_exit(pid, Duration::from_secs(5)).await {
481                        send_signal(pid, Signal::SIGKILL)?;
482                        let _ = wait_for_process_exit(pid, Duration::from_secs(2)).await;
483                    }
484                }
485                paths.clear_runtime_artifacts().await?;
486                if report {
487                    println!("Stopped {name}");
488                }
489                Ok(())
490            }
491        }
492    }
493
494    async fn delete_inner(&self, name: &str, report: bool) -> Result<()> {
495        let paths = self.state.instance_paths(name)?;
496        if matches!(paths.status().await?, InstanceStatus::Running) {
497            self.stop_inner(name, report).await?;
498        }
499        if !paths.dir.exists() {
500            if report {
501                println!("Instance {name} does not exist");
502            }
503            return Ok(());
504        }
505        paths.remove_all().await?;
506        if report {
507            println!("Deleted {name}");
508        }
509        Ok(())
510    }
511
512    async fn ensure_existing_artifacts(&self, paths: &InstancePaths) -> Result<()> {
513        if !paths.disk.is_file() {
514            bail!(
515                "missing VM disk at {}; delete and recreate",
516                paths.disk.display()
517            );
518        }
519        if !paths.seed.is_file() {
520            bail!(
521                "missing cloud-init seed image at {}; delete and recreate",
522                paths.seed.display()
523            );
524        }
525        Ok(())
526    }
527
528    async fn ensure_create_dependencies(
529        &self,
530        guest_arch: GuestArch,
531        allow_prompt: bool,
532    ) -> Result<()> {
533        let mut missing = self.collect_create_missing_dependencies(guest_arch).await;
534        if self
535            .maybe_offer_brew_install(guest_arch, &missing, allow_prompt)
536            .await?
537        {
538            missing = self.collect_create_missing_dependencies(guest_arch).await;
539        }
540        ensure_host_dependencies(guest_arch, &missing)
541    }
542
543    async fn ensure_start_dependencies(
544        &self,
545        guest_arch: GuestArch,
546        needs_launch: bool,
547        allow_prompt: bool,
548    ) -> Result<()> {
549        let mut missing = self
550            .collect_start_missing_dependencies(guest_arch, needs_launch)
551            .await;
552        if self
553            .maybe_offer_brew_install(guest_arch, &missing, allow_prompt)
554            .await?
555        {
556            missing = self
557                .collect_start_missing_dependencies(guest_arch, needs_launch)
558                .await;
559        }
560        ensure_host_dependencies(guest_arch, &missing)
561    }
562
563    async fn collect_create_missing_dependencies(
564        &self,
565        guest_arch: GuestArch,
566    ) -> Vec<HostDependency> {
567        let mut missing = Vec::new();
568        if !command_exists("qemu-img").await {
569            missing.push(HostDependency::QemuImg);
570        }
571        if !command_exists(guest_arch.qemu_binary()).await {
572            missing.push(HostDependency::QemuSystem);
573        }
574        if !command_exists("ssh-keygen").await {
575            missing.push(HostDependency::SshKeygen);
576        }
577        if guest_arch == GuestArch::Arm64 && discover_aarch64_firmware().is_err() {
578            missing.push(HostDependency::Aarch64Firmware);
579        }
580        missing
581    }
582
583    async fn collect_start_missing_dependencies(
584        &self,
585        guest_arch: GuestArch,
586        needs_launch: bool,
587    ) -> Vec<HostDependency> {
588        let mut missing = Vec::new();
589        if needs_launch && !command_exists(guest_arch.qemu_binary()).await {
590            missing.push(HostDependency::QemuSystem);
591        }
592        if !command_exists("ssh").await {
593            missing.push(HostDependency::Ssh);
594        }
595        if needs_launch && guest_arch == GuestArch::Arm64 && discover_aarch64_firmware().is_err() {
596            missing.push(HostDependency::Aarch64Firmware);
597        }
598        missing
599    }
600
601    async fn maybe_offer_brew_install(
602        &self,
603        guest_arch: GuestArch,
604        missing: &[HostDependency],
605        allow_prompt: bool,
606    ) -> Result<bool> {
607        if !allow_prompt {
608            return Ok(false);
609        }
610        if !should_offer_brew_install(
611            std::env::consts::OS,
612            missing,
613            std::io::stdin().is_terminal(),
614            std::io::stdout().is_terminal(),
615            command_exists("brew").await,
616        ) {
617            return Ok(false);
618        }
619
620        let prompt = brew_install_prompt(guest_arch, missing);
621        if !prompt_yes_no(&prompt).await? {
622            return Ok(false);
623        }
624
625        let status = Command::new("brew")
626            .arg("install")
627            .arg("qemu")
628            .stdin(Stdio::inherit())
629            .stdout(Stdio::inherit())
630            .stderr(Stdio::inherit())
631            .status()
632            .await
633            .context("run brew install qemu")?;
634        if status.success() {
635            Ok(true)
636        } else {
637            bail!("`brew install qemu` failed with status {status}")
638        }
639    }
640
641    async fn instance(&self, name: &str) -> Result<(InstancePaths, InstanceConfig)> {
642        let paths = self.state.instance_paths(name)?;
643        if !paths.config.is_file() {
644            bail!("instance {name} does not exist");
645        }
646        let config = paths.read_config().await?;
647        Ok((paths, config))
648    }
649
650    async fn running_instance(&self, name: &str) -> Result<(InstancePaths, InstanceConfig)> {
651        let (paths, config) = self.instance(name).await?;
652        if !matches!(paths.status().await?, InstanceStatus::Running) {
653            bail!("instance {name} is not running; use `hp start {name}` first");
654        }
655        Ok((paths, config))
656    }
657
658    async fn reserve_host_ports(
659        &self,
660        forwards: &[PortForward],
661    ) -> Result<crate::ports::PortReservation> {
662        let _lock = lock_file(self.state.ports_lock_path()).await?;
663        let occupied = self.collect_reserved_host_ports().await?;
664        reserve_ports(forwards, &occupied).await
665    }
666
667    async fn collect_reserved_host_ports(&self) -> Result<BTreeSet<u16>> {
668        let mut occupied = BTreeSet::new();
669        for name in self.state.instance_names().await? {
670            let paths = self.state.instance_paths(&name)?;
671            if !paths.config.is_file() {
672                continue;
673            }
674            let Ok(config) = paths.read_config().await else {
675                continue;
676            };
677            occupied.insert(config.ssh.port);
678            occupied.extend(config.forwards.into_iter().map(|forward| forward.host));
679        }
680        Ok(occupied)
681    }
682
683    fn resolve_ssh_key_path(&self, path: Option<&str>) -> Result<PathBuf> {
684        match path {
685            Some(path) => expand_path(path),
686            None => Ok(self.state.default_ssh_key_path()),
687        }
688    }
689
690    async fn wait_for_pid(&self, paths: &InstancePaths) -> Result<u32> {
691        for _ in 0..50 {
692            if let Some(pid) = paths.read_pid().await? {
693                return Ok(pid);
694            }
695            sleep(Duration::from_millis(100)).await;
696        }
697        bail!("QEMU did not write a pid file")
698    }
699
700    fn print_created(&self, info: &VmInfo) {
701        for line in created_lines(info) {
702            println!("{line}");
703        }
704    }
705
706    fn print_ready(&self, info: &VmInfo) {
707        for line in ready_lines(info) {
708            println!("{line}");
709        }
710    }
711
712    async fn wait_for_ssh_with_serial(
713        &self,
714        config: &InstanceConfig,
715        paths: &InstancePaths,
716    ) -> Result<()> {
717        println!("{}", booting_message(&config.name));
718        let (stop_tx, stop_rx) = watch::channel(false);
719        let serial_path = paths.serial.clone();
720        let tail_task = tokio::spawn(async move { tail_serial_log(serial_path, stop_rx).await });
721        let wait_result = wait_for_ssh(&config.ssh, config.timeout_secs).await;
722        let _ = stop_tx.send(true);
723        let tail_state = tail_task.await.unwrap_or_default();
724        if tail_state.printed_any && !tail_state.ended_with_newline {
725            println!();
726        }
727        wait_result
728    }
729
730    async fn collect_ssh_alias_entries(&self) -> Result<Vec<SshAliasEntry>> {
731        let mut entries = Vec::new();
732        for name in self.state.instance_names().await? {
733            let paths = self.state.instance_paths(&name)?;
734            if !paths.config.is_file() {
735                continue;
736            }
737            let config = paths.read_config().await?;
738            entries.push(SshAliasEntry {
739                alias: config.name.clone(),
740                host: config.ssh.host.clone(),
741                port: config.ssh.port,
742                user: config.ssh.user.clone(),
743                identity_file: config.ssh.identity_file.clone(),
744            });
745        }
746        Ok(entries)
747    }
748
749    pub(crate) async fn auto_configure_ssh_if_enabled(&self) {
750        if !self.state.should_auto_sync_ssh_config() {
751            return;
752        }
753        if let Err(err) = self.configure_ssh_if_enabled().await {
754            eprintln!("warning: failed to update Hardpass SSH config: {err:#}");
755        }
756    }
757
758    async fn configure_ssh_if_enabled(&self) -> Result<()> {
759        let _lock = lock_file(self.state.ssh_config_lock_path()).await?;
760        let manager = SshConfigManager::from_home_dir()?;
761        let entries = self.collect_ssh_alias_entries().await?;
762        manager.install().await?;
763        manager.sync(&entries).await?;
764        Ok(())
765    }
766}
767
768#[derive(Debug, Clone, Serialize)]
769pub struct VmInfo {
770    pub name: String,
771    pub status: InstanceStatus,
772    pub release: String,
773    pub arch: GuestArch,
774    pub accel: AccelMode,
775    pub cpus: u8,
776    pub memory_mib: u32,
777    pub disk_gib: u32,
778    pub instance_dir: PathBuf,
779    pub serial_log: PathBuf,
780    pub ssh: VmSshInfo,
781    pub forwards: Vec<PortForward>,
782}
783
784#[derive(Debug, Clone, Serialize)]
785pub struct VmSshInfo {
786    pub alias: String,
787    pub user: String,
788    pub host: String,
789    pub port: u16,
790    pub identity_file: PathBuf,
791}
792
793impl VmInfo {
794    fn from_config(config: &InstanceConfig, paths: &InstancePaths, status: InstanceStatus) -> Self {
795        Self {
796            name: config.name.clone(),
797            status,
798            release: config.release.clone(),
799            arch: config.arch,
800            accel: config.accel,
801            cpus: config.cpus,
802            memory_mib: config.memory_mib,
803            disk_gib: config.disk_gib,
804            instance_dir: paths.dir.clone(),
805            serial_log: paths.serial.clone(),
806            ssh: VmSshInfo {
807                alias: config.name.clone(),
808                user: config.ssh.user.clone(),
809                host: config.ssh.host.clone(),
810                port: config.ssh.port,
811                identity_file: config.ssh.identity_file.clone(),
812            },
813            forwards: config.forwards.clone(),
814        }
815    }
816}
817
818#[derive(Debug, Default)]
819struct SerialTailState {
820    printed_any: bool,
821    ended_with_newline: bool,
822}
823
824#[derive(Debug, Clone, PartialEq, Eq)]
825struct ListRow {
826    name: String,
827    status: String,
828    arch: String,
829    release: String,
830    ssh: String,
831}
832
833fn booting_message(name: &str) -> String {
834    format!("Booting {name}; waiting for SSH...")
835}
836
837fn created_lines(info: &VmInfo) -> [String; 3] {
838    [
839        format!("Created {}", info.name),
840        format!("start: hp start {}", info.name),
841        format!("serial log: {}", info.serial_log.display()),
842    ]
843}
844
845fn ready_lines(info: &VmInfo) -> [String; 3] {
846    [
847        format!("{} is ready", info.name),
848        format!("ssh: hp ssh {}", info.name),
849        format!("serial log: {}", info.serial_log.display()),
850    ]
851}
852
853fn render_list_table(rows: &[ListRow]) -> String {
854    let name_width = "NAME"
855        .len()
856        .max(rows.iter().map(|row| row.name.len()).max().unwrap_or(0));
857    let status_width = "STATUS"
858        .len()
859        .max(rows.iter().map(|row| row.status.len()).max().unwrap_or(0));
860    let arch_width = "ARCH"
861        .len()
862        .max(rows.iter().map(|row| row.arch.len()).max().unwrap_or(0));
863    let release_width = "RELEASE"
864        .len()
865        .max(rows.iter().map(|row| row.release.len()).max().unwrap_or(0));
866
867    let mut output = String::new();
868    output.push_str(&format!(
869        "{:<name_width$}  {:<status_width$}  {:<arch_width$}  {:<release_width$}  SSH\n",
870        "NAME", "STATUS", "ARCH", "RELEASE",
871    ));
872    for row in rows {
873        output.push_str(&format!(
874            "{:<name_width$}  {:<status_width$}  {:<arch_width$}  {:<release_width$}  {}\n",
875            row.name, row.status, row.arch, row.release, row.ssh,
876        ));
877    }
878    output
879}
880
881fn validate_arch_accel_combo(
882    host_arch: GuestArch,
883    guest_arch: GuestArch,
884    accel: AccelMode,
885) -> Result<()> {
886    if guest_arch == host_arch || accel == AccelMode::Tcg {
887        return Ok(());
888    }
889
890    if accel == AccelMode::Auto {
891        bail!(
892            "guest architecture {guest_arch} does not match host architecture {host_arch}; use `--accel tcg` for cross-architecture emulation"
893        );
894    }
895
896    bail!(
897        "acceleration mode {accel} only supports host-native guests ({host_arch}); use `--accel tcg` for {guest_arch} emulation"
898    )
899}
900
901fn ensure_host_dependencies(guest_arch: GuestArch, missing: &[HostDependency]) -> Result<()> {
902    if missing.is_empty() {
903        return Ok(());
904    }
905    bail!("{}", missing_dependency_message(guest_arch, missing))
906}
907
908fn missing_dependency_message(guest_arch: GuestArch, missing: &[HostDependency]) -> String {
909    missing_dependency_message_for_os(guest_arch, missing, std::env::consts::OS)
910}
911
912fn missing_dependency_message_for_os(
913    guest_arch: GuestArch,
914    missing: &[HostDependency],
915    os: &str,
916) -> String {
917    let labels = missing
918        .iter()
919        .map(|dependency| dependency.label(guest_arch))
920        .collect::<Vec<_>>()
921        .join(", ");
922    if missing
923        .iter()
924        .all(|dependency| dependency.is_qemu_related())
925    {
926        let install_hint = if os == "macos" {
927            "install QEMU with `brew install qemu`"
928        } else {
929            "install QEMU"
930        };
931        format!(
932            "QEMU is not installed or incomplete (missing {labels}); {install_hint} and run `hp doctor` for details"
933        )
934    } else {
935        format!("missing required host dependencies: {labels}; run `hp doctor` for details")
936    }
937}
938
939fn should_offer_brew_install(
940    os: &str,
941    missing: &[HostDependency],
942    stdin_is_terminal: bool,
943    stdout_is_terminal: bool,
944    brew_available: bool,
945) -> bool {
946    os == "macos"
947        && stdin_is_terminal
948        && stdout_is_terminal
949        && brew_available
950        && missing
951            .iter()
952            .any(|dependency| dependency.is_qemu_related())
953}
954
955fn brew_install_prompt(guest_arch: GuestArch, missing: &[HostDependency]) -> String {
956    let labels = missing
957        .iter()
958        .filter(|dependency| dependency.is_qemu_related())
959        .map(|dependency| dependency.label(guest_arch))
960        .collect::<Vec<_>>()
961        .join(", ");
962    format!("QEMU is missing ({labels}). Run `brew install qemu` now? [y/N]: ")
963}
964
965#[cfg(test)]
966fn ensure_match<T>(field: &str, expected: &T, actual: &T) -> Result<()>
967where
968    T: std::fmt::Debug + PartialEq,
969{
970    if expected == actual {
971        Ok(())
972    } else {
973        bail!(
974            "instance configuration mismatch for {field}: existing={expected:?}, requested={actual:?}. Delete and recreate the VM to change it."
975        )
976    }
977}
978
979async fn resolve_command_path(name: &str) -> Result<Option<String>> {
980    if !command_exists(name).await {
981        return Ok(None);
982    }
983    let output = Command::new("sh")
984        .arg("-c")
985        .arg(format!("command -v {name}"))
986        .output()
987        .await
988        .with_context(|| format!("resolve {name}"))?;
989    if output.status.success() {
990        Ok(Some(
991            String::from_utf8_lossy(&output.stdout).trim().to_string(),
992        ))
993    } else {
994        Ok(None)
995    }
996}
997
998async fn prompt_yes_no(prompt: &str) -> Result<bool> {
999    let prompt = prompt.to_string();
1000    tokio::task::spawn_blocking(move || -> Result<bool> {
1001        let mut stdout = std::io::stdout();
1002        stdout.write_all(prompt.as_bytes())?;
1003        stdout.flush()?;
1004
1005        let mut response = String::new();
1006        std::io::stdin().read_line(&mut response)?;
1007        let trimmed = response.trim();
1008        Ok(trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes"))
1009    })
1010    .await?
1011}
1012
1013fn expand_path(path: &str) -> Result<PathBuf> {
1014    let expanded = if path == "~" {
1015        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("unable to determine home directory"))?
1016    } else if let Some(rest) = path.strip_prefix("~/") {
1017        dirs::home_dir()
1018            .ok_or_else(|| anyhow::anyhow!("unable to determine home directory"))?
1019            .join(rest)
1020    } else {
1021        PathBuf::from(path)
1022    };
1023    if expanded.is_absolute() {
1024        Ok(expanded)
1025    } else {
1026        Ok(std::env::current_dir()?.join(expanded))
1027    }
1028}
1029
1030fn send_signal(pid: u32, signal: Signal) -> Result<()> {
1031    nix::sys::signal::kill(Pid::from_raw(pid as i32), Some(signal))
1032        .with_context(|| format!("send {signal:?} to pid {pid}"))?;
1033    Ok(())
1034}
1035
1036async fn wait_for_process_exit(pid: u32, timeout: Duration) -> bool {
1037    let deadline = tokio::time::Instant::now() + timeout;
1038    while tokio::time::Instant::now() < deadline {
1039        if !process_is_alive(pid) {
1040            return true;
1041        }
1042        sleep(Duration::from_millis(250)).await;
1043    }
1044    !process_is_alive(pid)
1045}
1046
1047async fn tail_serial_log(path: PathBuf, mut stop_rx: watch::Receiver<bool>) -> SerialTailState {
1048    let mut offset = 0u64;
1049    let mut state = SerialTailState::default();
1050    let mut stdout = tokio::io::stdout();
1051    loop {
1052        read_serial_delta(&path, &mut offset, &mut stdout, &mut state).await;
1053        if *stop_rx.borrow() {
1054            break;
1055        }
1056        tokio::select! {
1057            _ = stop_rx.changed() => {}
1058            _ = sleep(Duration::from_millis(200)) => {}
1059        }
1060    }
1061    read_serial_delta(&path, &mut offset, &mut stdout, &mut state).await;
1062    state
1063}
1064
1065async fn read_serial_delta(
1066    path: &Path,
1067    offset: &mut u64,
1068    stdout: &mut tokio::io::Stdout,
1069    state: &mut SerialTailState,
1070) {
1071    let Ok(mut file) = tokio::fs::File::open(path).await else {
1072        return;
1073    };
1074    if file.seek(std::io::SeekFrom::Start(*offset)).await.is_err() {
1075        return;
1076    }
1077    let mut buf = Vec::new();
1078    if file.read_to_end(&mut buf).await.is_err() || buf.is_empty() {
1079        return;
1080    }
1081    let _ = stdout.write_all(&buf).await;
1082    let _ = stdout.flush().await;
1083    *offset += buf.len() as u64;
1084    state.printed_any = true;
1085    state.ended_with_newline = buf.last() == Some(&b'\n');
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use tempfile::tempdir;
1091
1092    use super::{
1093        HostDependency, ListRow, VmInfo, booting_message, brew_install_prompt, created_lines,
1094        ensure_match, expand_path, missing_dependency_message, missing_dependency_message_for_os,
1095        ready_lines, render_list_table, should_offer_brew_install, validate_arch_accel_combo,
1096    };
1097    use crate::state::{
1098        AccelMode, CloudInitConfig, GuestArch, ImageConfig, InstanceConfig, InstancePaths,
1099        InstanceStatus, PortForward, SshConfig,
1100    };
1101
1102    #[test]
1103    fn ensure_match_reports_differences() {
1104        let err = ensure_match("cpus", &2u8, &4u8).expect_err("should fail");
1105        assert!(err.to_string().contains("configuration mismatch"));
1106    }
1107
1108    #[test]
1109    fn booting_message_is_concise() {
1110        assert_eq!(
1111            booting_message("neuromancer"),
1112            "Booting neuromancer; waiting for SSH..."
1113        );
1114    }
1115
1116    #[test]
1117    fn expand_relative_paths() {
1118        let current = std::env::current_dir().expect("cwd");
1119        let path = expand_path("relative/file").expect("expand");
1120        assert!(path.starts_with(current));
1121    }
1122
1123    #[test]
1124    fn info_output_captures_paths() {
1125        let dir = tempdir().expect("tempdir");
1126        let paths = InstancePaths::new(dir.path().join("vm"));
1127        let config = InstanceConfig {
1128            name: "vm".into(),
1129            release: "24.04".into(),
1130            arch: GuestArch::Arm64,
1131            accel: AccelMode::Auto,
1132            cpus: 4,
1133            memory_mib: 4096,
1134            disk_gib: 24,
1135            timeout_secs: 180,
1136            ssh: SshConfig {
1137                user: "ubuntu".into(),
1138                host: "127.0.0.1".into(),
1139                port: 2222,
1140                identity_file: dir.path().join("id_ed25519"),
1141            },
1142            forwards: vec![PortForward {
1143                host: 8080,
1144                guest: 8080,
1145            }],
1146            image: ImageConfig {
1147                release: "24.04".into(),
1148                arch: GuestArch::Arm64,
1149                url: "https://example.invalid".into(),
1150                sha256_url: "https://example.invalid/SHA256SUMS".into(),
1151                filename: "ubuntu.img".into(),
1152                sha256: "abc".into(),
1153            },
1154            cloud_init: CloudInitConfig {
1155                user_data_sha256: "abc".into(),
1156                network_config_sha256: Some("def".into()),
1157            },
1158        };
1159        let output = VmInfo::from_config(&config, &paths, InstanceStatus::Stopped);
1160        assert_eq!(output.name, "vm");
1161        assert_eq!(output.status, InstanceStatus::Stopped);
1162        assert_eq!(output.ssh.port, 2222);
1163    }
1164
1165    #[test]
1166    fn created_lines_use_start_hint() {
1167        let dir = tempdir().expect("tempdir");
1168        let paths = InstancePaths::new(dir.path().join("neuromancer"));
1169        let config = InstanceConfig {
1170            name: "neuromancer".into(),
1171            release: "24.04".into(),
1172            arch: GuestArch::Arm64,
1173            accel: AccelMode::Auto,
1174            cpus: 4,
1175            memory_mib: 4096,
1176            disk_gib: 24,
1177            timeout_secs: 180,
1178            ssh: SshConfig {
1179                user: "ubuntu".into(),
1180                host: "127.0.0.1".into(),
1181                port: 49702,
1182                identity_file: dir.path().join("id_ed25519"),
1183            },
1184            forwards: vec![],
1185            image: ImageConfig {
1186                release: "24.04".into(),
1187                arch: GuestArch::Arm64,
1188                url: "https://example.invalid".into(),
1189                sha256_url: "https://example.invalid/SHA256SUMS".into(),
1190                filename: "ubuntu.img".into(),
1191                sha256: "abc".into(),
1192            },
1193            cloud_init: CloudInitConfig {
1194                user_data_sha256: "abc".into(),
1195                network_config_sha256: None,
1196            },
1197        };
1198        let info = VmInfo::from_config(&config, &paths, InstanceStatus::Stopped);
1199        let lines = created_lines(&info);
1200        assert_eq!(lines[0], "Created neuromancer");
1201        assert_eq!(lines[1], "start: hp start neuromancer");
1202        assert!(lines[2].contains("serial log:"));
1203    }
1204
1205    #[test]
1206    fn ready_lines_use_hardpass_ssh_hint() {
1207        let dir = tempdir().expect("tempdir");
1208        let paths = InstancePaths::new(dir.path().join("neuromancer"));
1209        let config = InstanceConfig {
1210            name: "neuromancer".into(),
1211            release: "24.04".into(),
1212            arch: GuestArch::Arm64,
1213            accel: AccelMode::Auto,
1214            cpus: 4,
1215            memory_mib: 4096,
1216            disk_gib: 24,
1217            timeout_secs: 180,
1218            ssh: SshConfig {
1219                user: "ubuntu".into(),
1220                host: "127.0.0.1".into(),
1221                port: 49702,
1222                identity_file: dir.path().join("id_ed25519"),
1223            },
1224            forwards: vec![],
1225            image: ImageConfig {
1226                release: "24.04".into(),
1227                arch: GuestArch::Arm64,
1228                url: "https://example.invalid".into(),
1229                sha256_url: "https://example.invalid/SHA256SUMS".into(),
1230                filename: "ubuntu.img".into(),
1231                sha256: "abc".into(),
1232            },
1233            cloud_init: CloudInitConfig {
1234                user_data_sha256: "abc".into(),
1235                network_config_sha256: None,
1236            },
1237        };
1238        let info = VmInfo::from_config(&config, &paths, InstanceStatus::Running);
1239        let lines = ready_lines(&info);
1240        assert_eq!(lines[0], "neuromancer is ready");
1241        assert_eq!(lines[1], "ssh: hp ssh neuromancer");
1242        assert!(lines[2].contains("serial log:"));
1243    }
1244
1245    #[test]
1246    fn qemu_only_missing_dependencies_have_install_hint() {
1247        let message = missing_dependency_message_for_os(
1248            GuestArch::Arm64,
1249            &[HostDependency::QemuSystem, HostDependency::Aarch64Firmware],
1250            "macos",
1251        );
1252        assert!(message.contains("QEMU is not installed or incomplete"));
1253        assert!(message.contains("qemu-system-aarch64"));
1254        assert!(message.contains("aarch64-firmware"));
1255        assert!(message.contains("brew install qemu"));
1256        assert!(message.contains("hp doctor"));
1257    }
1258
1259    #[test]
1260    fn mixed_missing_dependencies_use_generic_message() {
1261        let message = missing_dependency_message(
1262            GuestArch::Amd64,
1263            &[HostDependency::QemuImg, HostDependency::SshKeygen],
1264        );
1265        assert!(message.contains("missing required host dependencies"));
1266        assert!(message.contains("qemu-img"));
1267        assert!(message.contains("ssh-keygen"));
1268        assert!(message.contains("hp doctor"));
1269    }
1270
1271    #[test]
1272    fn linux_qemu_hint_stays_generic() {
1273        let message = missing_dependency_message_for_os(
1274            GuestArch::Amd64,
1275            &[HostDependency::QemuImg, HostDependency::QemuSystem],
1276            "linux",
1277        );
1278        assert!(message.contains("install QEMU"));
1279        assert!(!message.contains("brew install qemu"));
1280    }
1281
1282    #[test]
1283    fn cross_arch_requires_tcg_for_auto() {
1284        let err = validate_arch_accel_combo(GuestArch::Arm64, GuestArch::Amd64, AccelMode::Auto)
1285            .expect_err("should fail");
1286        assert!(err.to_string().contains("--accel tcg"));
1287        assert!(err.to_string().contains("cross-architecture emulation"));
1288    }
1289
1290    #[test]
1291    fn cross_arch_requires_tcg_for_non_tcg_explicit_accel() {
1292        let err = validate_arch_accel_combo(GuestArch::Arm64, GuestArch::Amd64, AccelMode::Hvf)
1293            .expect_err("should fail");
1294        assert!(err.to_string().contains("host-native guests"));
1295        assert!(err.to_string().contains("--accel tcg"));
1296    }
1297
1298    #[test]
1299    fn cross_arch_is_allowed_with_tcg() {
1300        validate_arch_accel_combo(GuestArch::Arm64, GuestArch::Amd64, AccelMode::Tcg)
1301            .expect("tcg should allow cross-arch guests");
1302    }
1303
1304    #[test]
1305    fn brew_offer_only_happens_on_interactive_macos_with_brew() {
1306        assert!(should_offer_brew_install(
1307            "macos",
1308            &[HostDependency::QemuImg],
1309            true,
1310            true,
1311            true,
1312        ));
1313        assert!(!should_offer_brew_install(
1314            "linux",
1315            &[HostDependency::QemuImg],
1316            true,
1317            true,
1318            true,
1319        ));
1320        assert!(!should_offer_brew_install(
1321            "macos",
1322            &[HostDependency::QemuImg],
1323            false,
1324            true,
1325            true,
1326        ));
1327        assert!(!should_offer_brew_install(
1328            "macos",
1329            &[HostDependency::QemuImg],
1330            true,
1331            true,
1332            false,
1333        ));
1334        assert!(!should_offer_brew_install(
1335            "macos",
1336            &[HostDependency::Ssh],
1337            true,
1338            true,
1339            true,
1340        ));
1341    }
1342
1343    #[test]
1344    fn brew_prompt_lists_qemu_missing_bits_only() {
1345        let prompt = brew_install_prompt(
1346            GuestArch::Arm64,
1347            &[
1348                HostDependency::QemuImg,
1349                HostDependency::Ssh,
1350                HostDependency::Aarch64Firmware,
1351            ],
1352        );
1353        assert!(prompt.contains("qemu-img"));
1354        assert!(prompt.contains("aarch64-firmware"));
1355        assert!(!prompt.contains("ssh"));
1356        assert!(prompt.contains("brew install qemu"));
1357    }
1358
1359    #[test]
1360    fn list_table_aligns_columns_with_spaces() {
1361        let output = render_list_table(&[
1362            ListRow {
1363                name: "neuromancer".into(),
1364                status: "running".into(),
1365                arch: "arm64".into(),
1366                release: "24.04".into(),
1367                ssh: "127.0.0.1:63320".into(),
1368            },
1369            ListRow {
1370                name: "vm".into(),
1371                status: "stopped".into(),
1372                arch: "amd64".into(),
1373                release: "24.04".into(),
1374                ssh: "127.0.0.1:40222".into(),
1375            },
1376        ]);
1377        let lines = output.lines().collect::<Vec<_>>();
1378        assert_eq!(lines[0], "NAME         STATUS   ARCH   RELEASE  SSH");
1379        assert_eq!(
1380            lines[1],
1381            "neuromancer  running  arm64  24.04    127.0.0.1:63320"
1382        );
1383        assert_eq!(
1384            lines[2],
1385            "vm           stopped  amd64  24.04    127.0.0.1:40222"
1386        );
1387    }
1388}