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}