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}