1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use anyhow::{Context, Result, anyhow, bail};
6use clap::ValueEnum;
7use nix::unistd::Pid;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use tokio::process::Command;
11
12const DEFAULT_RELEASE: &str = "24.04";
13const DEFAULT_CPU_COUNT: u8 = 4;
14const DEFAULT_MEMORY_MIB: u32 = 4096;
15const DEFAULT_DISK_GIB: u32 = 24;
16const DEFAULT_TIMEOUT_SECS: u64 = 180;
17const DEFAULT_SSH_USER: &str = "ubuntu";
18const DEFAULT_SSH_HOST: &str = "127.0.0.1";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
21#[serde(rename_all = "snake_case")]
22pub enum GuestArch {
23 Amd64,
24 Arm64,
25}
26
27impl GuestArch {
28 pub fn host_native() -> Result<Self> {
29 match std::env::consts::ARCH {
30 "x86_64" => Ok(Self::Amd64),
31 "aarch64" | "arm64" => Ok(Self::Arm64),
32 other => bail!("unsupported host architecture: {other}"),
33 }
34 }
35
36 pub fn ubuntu_arch(self) -> &'static str {
37 match self {
38 Self::Amd64 => "amd64",
39 Self::Arm64 => "arm64",
40 }
41 }
42
43 pub fn qemu_binary(self) -> &'static str {
44 match self {
45 Self::Amd64 => "qemu-system-x86_64",
46 Self::Arm64 => "qemu-system-aarch64",
47 }
48 }
49}
50
51impl Display for GuestArch {
52 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
53 f.write_str(match self {
54 Self::Amd64 => "amd64",
55 Self::Arm64 => "arm64",
56 })
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
61#[serde(rename_all = "snake_case")]
62pub enum AccelMode {
63 Auto,
64 Hvf,
65 Kvm,
66 Tcg,
67}
68
69impl Display for AccelMode {
70 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
71 f.write_str(match self {
72 Self::Auto => "auto",
73 Self::Hvf => "hvf",
74 Self::Kvm => "kvm",
75 Self::Tcg => "tcg",
76 })
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct PortForward {
82 pub host: u16,
83 pub guest: u16,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
87pub struct SshConfig {
88 pub user: String,
89 pub host: String,
90 pub port: u16,
91 pub identity_file: PathBuf,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct ImageConfig {
96 pub release: String,
97 pub arch: GuestArch,
98 pub url: String,
99 pub sha256_url: String,
100 pub filename: String,
101 pub sha256: String,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct CloudInitConfig {
106 pub user_data_sha256: String,
107 pub network_config_sha256: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
111pub struct InstanceConfig {
112 pub name: String,
113 pub release: String,
114 pub arch: GuestArch,
115 pub accel: AccelMode,
116 pub cpus: u8,
117 pub memory_mib: u32,
118 pub disk_gib: u32,
119 pub timeout_secs: u64,
120 pub ssh: SshConfig,
121 pub forwards: Vec<PortForward>,
122 pub image: ImageConfig,
123 pub cloud_init: CloudInitConfig,
124}
125
126impl InstanceConfig {
127 pub fn default_release() -> &'static str {
128 DEFAULT_RELEASE
129 }
130
131 pub fn default_cpus() -> u8 {
132 DEFAULT_CPU_COUNT
133 }
134
135 pub fn default_memory_mib() -> u32 {
136 DEFAULT_MEMORY_MIB
137 }
138
139 pub fn default_disk_gib() -> u32 {
140 DEFAULT_DISK_GIB
141 }
142
143 pub fn default_timeout_secs() -> u64 {
144 DEFAULT_TIMEOUT_SECS
145 }
146
147 pub fn default_ssh_user() -> &'static str {
148 DEFAULT_SSH_USER
149 }
150
151 pub fn default_ssh_host() -> &'static str {
152 DEFAULT_SSH_HOST
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum InstanceStatus {
159 Missing,
160 Stopped,
161 Running,
162}
163
164impl Display for InstanceStatus {
165 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
166 f.write_str(match self {
167 Self::Missing => "missing",
168 Self::Stopped => "stopped",
169 Self::Running => "running",
170 })
171 }
172}
173
174#[derive(Debug, Clone)]
175pub struct HardpassState {
176 root: PathBuf,
177 manages_ssh_config: bool,
178 auto_sync_ssh_config: bool,
179}
180
181impl HardpassState {
182 pub async fn load() -> Result<Self> {
183 let default_root = default_root_path()?;
184 let (root, auto_sync_ssh_config) = if let Some(explicit) = std::env::var_os("HARDPASS_HOME")
185 {
186 (PathBuf::from(explicit), false)
187 } else {
188 (default_root.clone(), true)
189 };
190 let manages_ssh_config = paths_match(&root, &default_root)?;
191 Self::load_with_flags(root, manages_ssh_config, auto_sync_ssh_config).await
192 }
193
194 pub(crate) async fn load_with_root(root: PathBuf) -> Result<Self> {
195 let manages_ssh_config = paths_match(&root, &default_root_path()?)?;
196 Self::load_with_flags(root, manages_ssh_config, false).await
197 }
198
199 async fn load_with_flags(
200 root: PathBuf,
201 manages_ssh_config: bool,
202 auto_sync_ssh_config: bool,
203 ) -> Result<Self> {
204 tokio::fs::create_dir_all(root.join("images")).await?;
205 tokio::fs::create_dir_all(root.join("instances")).await?;
206 tokio::fs::create_dir_all(root.join("keys")).await?;
207 tokio::fs::create_dir_all(root.join("locks")).await?;
208 Ok(Self {
209 root,
210 manages_ssh_config,
211 auto_sync_ssh_config,
212 })
213 }
214
215 #[cfg(test)]
216 pub fn root(&self) -> &Path {
217 &self.root
218 }
219
220 pub fn images_dir(&self) -> PathBuf {
221 self.root.join("images")
222 }
223
224 pub fn locks_dir(&self) -> PathBuf {
225 self.root.join("locks")
226 }
227
228 pub fn instances_dir(&self) -> PathBuf {
229 self.root.join("instances")
230 }
231
232 pub fn keys_dir(&self) -> PathBuf {
233 self.root.join("keys")
234 }
235
236 pub fn default_ssh_key_path(&self) -> PathBuf {
237 self.keys_dir().join("id_ed25519")
238 }
239
240 pub fn ports_lock_path(&self) -> PathBuf {
241 self.locks_dir().join("ports.lock")
242 }
243
244 pub fn ssh_config_lock_path(&self) -> PathBuf {
245 self.locks_dir().join("ssh-config.lock")
246 }
247
248 pub fn manages_ssh_config(&self) -> bool {
249 self.manages_ssh_config
250 }
251
252 pub fn should_auto_sync_ssh_config(&self) -> bool {
253 self.manages_ssh_config && self.auto_sync_ssh_config
254 }
255
256 pub fn instance_paths(&self, name: &str) -> Result<InstancePaths> {
257 validate_name(name)?;
258 Ok(InstancePaths::new(self.instances_dir().join(name)))
259 }
260
261 pub async fn instance_names(&self) -> Result<Vec<String>> {
262 let mut names = Vec::new();
263 let mut dir = tokio::fs::read_dir(self.instances_dir()).await?;
264 while let Some(entry) = dir.next_entry().await? {
265 if entry.file_type().await?.is_dir() {
266 names.push(entry.file_name().to_string_lossy().into_owned());
267 }
268 }
269 names.sort();
270 Ok(names)
271 }
272}
273
274#[derive(Debug, Clone)]
275pub struct InstancePaths {
276 pub dir: PathBuf,
277 pub config: PathBuf,
278 pub disk: PathBuf,
279 pub seed: PathBuf,
280 pub pid: PathBuf,
281 pub qmp: PathBuf,
282 pub serial: PathBuf,
283 pub firmware_vars: PathBuf,
284}
285
286impl InstancePaths {
287 pub fn new(dir: PathBuf) -> Self {
288 Self {
289 config: dir.join("config.json"),
290 disk: dir.join("disk.qcow2"),
291 seed: dir.join("seed.img"),
292 pid: dir.join("pid"),
293 qmp: dir.join("qmp.sock"),
294 serial: dir.join("serial.log"),
295 firmware_vars: dir.join("firmware.vars.fd"),
296 dir,
297 }
298 }
299
300 pub fn lock_path(&self) -> PathBuf {
301 self.dir.with_extension("lock")
302 }
303
304 pub async fn ensure_dir(&self) -> Result<()> {
305 tokio::fs::create_dir_all(&self.dir).await?;
306 Ok(())
307 }
308
309 pub async fn read_config(&self) -> Result<InstanceConfig> {
310 let content = tokio::fs::read_to_string(&self.config)
311 .await
312 .with_context(|| format!("read {}", self.config.display()))?;
313 serde_json::from_str(&content).with_context(|| format!("parse {}", self.config.display()))
314 }
315
316 pub async fn write_config(&self, config: &InstanceConfig) -> Result<()> {
317 self.ensure_dir().await?;
318 let payload = serde_json::to_vec_pretty(config)?;
319 atomic_write(&self.config, &payload).await
320 }
321
322 pub async fn read_pid(&self) -> Result<Option<u32>> {
323 match tokio::fs::read_to_string(&self.pid).await {
324 Ok(raw) => {
325 let pid = raw
326 .trim()
327 .parse::<u32>()
328 .with_context(|| format!("parse pid file {}", self.pid.display()))?;
329 Ok(Some(pid))
330 }
331 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
332 Err(err) => Err(err.into()),
333 }
334 }
335
336 pub async fn status(&self) -> Result<InstanceStatus> {
337 if tokio::fs::metadata(&self.config).await.is_err() {
338 return Ok(InstanceStatus::Missing);
339 }
340 let Some(pid) = self.read_pid().await? else {
341 return Ok(InstanceStatus::Stopped);
342 };
343 if process_is_alive(pid) && process_matches_instance(pid, self).await {
344 Ok(InstanceStatus::Running)
345 } else {
346 Ok(InstanceStatus::Stopped)
347 }
348 }
349
350 pub async fn clear_runtime_artifacts(&self) -> Result<()> {
351 remove_if_exists(&self.pid).await?;
352 remove_if_exists(&self.qmp).await?;
353 Ok(())
354 }
355
356 pub async fn remove_all(&self) -> Result<()> {
357 match tokio::fs::remove_dir_all(&self.dir).await {
358 Ok(()) => Ok(()),
359 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
360 Err(err) => Err(err.into()),
361 }
362 }
363}
364
365pub async fn atomic_write(path: &Path, payload: &[u8]) -> Result<()> {
366 if let Some(parent) = path.parent() {
367 tokio::fs::create_dir_all(parent).await?;
368 }
369 let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
370 tokio::fs::write(&tmp, payload).await?;
371 tokio::fs::rename(&tmp, path).await?;
372 Ok(())
373}
374
375pub async fn remove_if_exists(path: &Path) -> Result<()> {
376 match tokio::fs::remove_file(path).await {
377 Ok(()) => Ok(()),
378 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
379 Err(err) => Err(err.into()),
380 }
381}
382
383pub fn sha256_hex(bytes: &[u8]) -> String {
384 format!("{:x}", Sha256::digest(bytes))
385}
386
387pub async fn sha256_file(path: &Path) -> Result<String> {
388 let path = path.to_path_buf();
389 tokio::task::spawn_blocking(move || -> Result<String> {
390 let file = std::fs::File::open(&path)?;
391 let mut reader = std::io::BufReader::new(file);
392 let mut hasher = Sha256::new();
393 let mut buf = [0u8; 1024 * 1024];
394 loop {
395 let read = std::io::Read::read(&mut reader, &mut buf)?;
396 if read == 0 {
397 break;
398 }
399 hasher.update(&buf[..read]);
400 }
401 Ok(format!("{:x}", hasher.finalize()))
402 })
403 .await?
404}
405
406pub fn process_is_alive(pid: u32) -> bool {
407 nix::sys::signal::kill(Pid::from_raw(pid as i32), None).is_ok()
408}
409
410async fn process_matches_instance(pid: u32, paths: &InstancePaths) -> bool {
411 let output = match Command::new("ps")
412 .arg("-ww")
413 .arg("-o")
414 .arg("command=")
415 .arg("-p")
416 .arg(pid.to_string())
417 .stdin(Stdio::null())
418 .stderr(Stdio::null())
419 .output()
420 .await
421 {
422 Ok(output) if output.status.success() => output,
423 _ => return false,
424 };
425
426 let command = String::from_utf8_lossy(&output.stdout);
427 let pid_path = paths.pid.to_string_lossy().into_owned();
428 let qmp_path = paths.qmp.to_string_lossy().into_owned();
429 let serial_path = paths.serial.to_string_lossy().into_owned();
430 let expected = ["qemu-system-".to_string(), pid_path, qmp_path, serial_path];
431 expected
432 .into_iter()
433 .all(|needle| command.contains(needle.as_str()))
434}
435
436pub async fn command_exists(name: &str) -> bool {
437 Command::new("sh")
438 .arg("-c")
439 .arg(format!("command -v {name} >/dev/null 2>&1"))
440 .stdin(Stdio::null())
441 .stdout(Stdio::null())
442 .stderr(Stdio::null())
443 .status()
444 .await
445 .map(|status| status.success())
446 .unwrap_or(false)
447}
448
449pub fn validate_name(name: &str) -> Result<()> {
450 if name.is_empty() {
451 bail!("instance name must not be empty");
452 }
453 if name
454 .chars()
455 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
456 {
457 Ok(())
458 } else {
459 bail!("instance name may only contain ASCII letters, digits, '-' and '_'");
460 }
461}
462
463fn default_root_path() -> Result<PathBuf> {
464 dirs::home_dir()
465 .ok_or_else(|| anyhow!("unable to determine home directory"))
466 .map(|home| home.join(".hardpass"))
467}
468
469fn paths_match(left: &Path, right: &Path) -> Result<bool> {
470 Ok(normalize_path_for_compare(left)? == normalize_path_for_compare(right)?)
471}
472
473fn normalize_path_for_compare(path: &Path) -> Result<PathBuf> {
474 if path.is_absolute() {
475 Ok(path.to_path_buf())
476 } else {
477 Ok(std::env::current_dir()?.join(path))
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use std::process::Stdio;
484
485 use tempfile::tempdir;
486 use tokio::process::Command;
487
488 use super::{
489 CloudInitConfig, HardpassState, ImageConfig, InstanceConfig, InstancePaths, InstanceStatus,
490 PortForward, SshConfig, atomic_write,
491 };
492 use crate::state::{AccelMode, GuestArch};
493
494 fn test_config(dir: &std::path::Path) -> InstanceConfig {
495 InstanceConfig {
496 name: "vm".into(),
497 release: "24.04".into(),
498 arch: GuestArch::Arm64,
499 accel: AccelMode::Auto,
500 cpus: 2,
501 memory_mib: 2048,
502 disk_gib: 12,
503 timeout_secs: 30,
504 ssh: SshConfig {
505 user: "ubuntu".into(),
506 host: "127.0.0.1".into(),
507 port: 2222,
508 identity_file: dir.join("id_ed25519"),
509 },
510 forwards: vec![PortForward {
511 host: 8080,
512 guest: 8080,
513 }],
514 image: ImageConfig {
515 release: "24.04".into(),
516 arch: GuestArch::Arm64,
517 url: "https://example.invalid".into(),
518 sha256_url: "https://example.invalid/SHA256SUMS".into(),
519 filename: "ubuntu.img".into(),
520 sha256: "abc".into(),
521 },
522 cloud_init: CloudInitConfig {
523 user_data_sha256: "abc".into(),
524 network_config_sha256: None,
525 },
526 }
527 }
528
529 #[tokio::test]
530 async fn state_uses_env_override() {
531 let dir = tempdir().expect("tempdir");
532 let state = HardpassState::load_with_root(dir.path().to_path_buf())
533 .await
534 .expect("load");
535 assert_eq!(state.root(), dir.path());
536 }
537
538 #[tokio::test]
539 async fn status_missing_without_config() {
540 let dir = tempdir().expect("tempdir");
541 let paths = InstancePaths::new(dir.path().join("vm"));
542 assert_eq!(
543 paths.status().await.expect("status"),
544 InstanceStatus::Missing
545 );
546 }
547
548 #[tokio::test]
549 async fn status_stopped_with_config_only() {
550 let dir = tempdir().expect("tempdir");
551 let paths = InstancePaths::new(dir.path().join("vm"));
552 let config = test_config(dir.path());
553 paths.write_config(&config).await.expect("write config");
554 assert_eq!(
555 paths.status().await.expect("status"),
556 InstanceStatus::Stopped
557 );
558 }
559
560 #[tokio::test]
561 async fn status_ignores_alive_process_with_unrelated_pid() {
562 let dir = tempdir().expect("tempdir");
563 let paths = InstancePaths::new(dir.path().join("vm"));
564 paths
565 .write_config(&test_config(dir.path()))
566 .await
567 .expect("write config");
568
569 let mut child = Command::new("sleep")
570 .arg("30")
571 .stdin(Stdio::null())
572 .stdout(Stdio::null())
573 .stderr(Stdio::null())
574 .spawn()
575 .expect("spawn sleep");
576 let pid = child.id().expect("sleep pid");
577
578 atomic_write(&paths.pid, pid.to_string().as_bytes())
579 .await
580 .expect("write pid");
581
582 assert_eq!(
583 paths.status().await.expect("status"),
584 InstanceStatus::Stopped
585 );
586
587 let _ = child.kill().await;
588 }
589
590 #[tokio::test]
591 async fn status_accepts_matching_qemu_process_identity() {
592 let dir = tempdir().expect("tempdir");
593 let paths = InstancePaths::new(dir.path().join("vm"));
594 paths
595 .write_config(&test_config(dir.path()))
596 .await
597 .expect("write config");
598
599 let qmp_arg = format!("unix:{},server=on,wait=off", paths.qmp.display());
600 let serial_arg = format!("file:{}", paths.serial.display());
601 let mut child = Command::new("python3")
602 .arg("-c")
603 .arg("import time; time.sleep(30)")
604 .arg("qemu-system-aarch64")
605 .arg("-pidfile")
606 .arg(&paths.pid)
607 .arg("-qmp")
608 .arg(&qmp_arg)
609 .arg("-serial")
610 .arg(&serial_arg)
611 .stdin(Stdio::null())
612 .stdout(Stdio::null())
613 .stderr(Stdio::null())
614 .spawn()
615 .expect("spawn python");
616 let pid = child.id().expect("python pid");
617
618 atomic_write(&paths.pid, pid.to_string().as_bytes())
619 .await
620 .expect("write pid");
621
622 assert_eq!(
623 paths.status().await.expect("status"),
624 InstanceStatus::Running
625 );
626
627 let _ = child.kill().await;
628 }
629}