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