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