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