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 should_auto_sync_ssh_config(&self) -> bool {
249 self.manages_ssh_config && self.auto_sync_ssh_config
250 }
251
252 pub fn instance_paths(&self, name: &str) -> Result<InstancePaths> {
253 validate_name(name)?;
254 Ok(InstancePaths::new(self.instances_dir().join(name)))
255 }
256
257 pub async fn instance_names(&self) -> Result<Vec<String>> {
258 let mut names = Vec::new();
259 let mut dir = tokio::fs::read_dir(self.instances_dir()).await?;
260 while let Some(entry) = dir.next_entry().await? {
261 if entry.file_type().await?.is_dir() {
262 names.push(entry.file_name().to_string_lossy().into_owned());
263 }
264 }
265 names.sort();
266 Ok(names)
267 }
268}
269
270#[derive(Debug, Clone)]
271pub struct InstancePaths {
272 pub dir: PathBuf,
273 pub config: PathBuf,
274 pub disk: PathBuf,
275 pub seed: PathBuf,
276 pub pid: PathBuf,
277 pub qmp: PathBuf,
278 pub serial: PathBuf,
279 pub firmware_vars: PathBuf,
280}
281
282impl InstancePaths {
283 pub fn new(dir: PathBuf) -> Self {
284 Self {
285 config: dir.join("config.json"),
286 disk: dir.join("disk.qcow2"),
287 seed: dir.join("seed.img"),
288 pid: dir.join("pid"),
289 qmp: dir.join("qmp.sock"),
290 serial: dir.join("serial.log"),
291 firmware_vars: dir.join("firmware.vars.fd"),
292 dir,
293 }
294 }
295
296 pub fn lock_path(&self) -> PathBuf {
297 self.dir.with_extension("lock")
298 }
299
300 pub async fn ensure_dir(&self) -> Result<()> {
301 tokio::fs::create_dir_all(&self.dir).await?;
302 Ok(())
303 }
304
305 pub async fn read_config(&self) -> Result<InstanceConfig> {
306 let content = tokio::fs::read_to_string(&self.config)
307 .await
308 .with_context(|| format!("read {}", self.config.display()))?;
309 serde_json::from_str(&content).with_context(|| format!("parse {}", self.config.display()))
310 }
311
312 pub async fn write_config(&self, config: &InstanceConfig) -> Result<()> {
313 self.ensure_dir().await?;
314 let payload = serde_json::to_vec_pretty(config)?;
315 atomic_write(&self.config, &payload).await
316 }
317
318 pub async fn read_pid(&self) -> Result<Option<u32>> {
319 match tokio::fs::read_to_string(&self.pid).await {
320 Ok(raw) => {
321 let pid = raw
322 .trim()
323 .parse::<u32>()
324 .with_context(|| format!("parse pid file {}", self.pid.display()))?;
325 Ok(Some(pid))
326 }
327 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
328 Err(err) => Err(err.into()),
329 }
330 }
331
332 pub async fn status(&self) -> Result<InstanceStatus> {
333 if tokio::fs::metadata(&self.config).await.is_err() {
334 return Ok(InstanceStatus::Missing);
335 }
336 let Some(pid) = self.read_pid().await? else {
337 return Ok(InstanceStatus::Stopped);
338 };
339 if process_is_alive(pid) && process_matches_instance(pid, self).await {
340 Ok(InstanceStatus::Running)
341 } else {
342 Ok(InstanceStatus::Stopped)
343 }
344 }
345
346 pub async fn clear_runtime_artifacts(&self) -> Result<()> {
347 remove_if_exists(&self.pid).await?;
348 remove_if_exists(&self.qmp).await?;
349 Ok(())
350 }
351
352 pub async fn remove_all(&self) -> Result<()> {
353 match tokio::fs::remove_dir_all(&self.dir).await {
354 Ok(()) => Ok(()),
355 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
356 Err(err) => Err(err.into()),
357 }
358 }
359}
360
361pub async fn atomic_write(path: &Path, payload: &[u8]) -> Result<()> {
362 if let Some(parent) = path.parent() {
363 tokio::fs::create_dir_all(parent).await?;
364 }
365 let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
366 tokio::fs::write(&tmp, payload).await?;
367 tokio::fs::rename(&tmp, path).await?;
368 Ok(())
369}
370
371pub async fn remove_if_exists(path: &Path) -> Result<()> {
372 match tokio::fs::remove_file(path).await {
373 Ok(()) => Ok(()),
374 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
375 Err(err) => Err(err.into()),
376 }
377}
378
379pub fn sha256_hex(bytes: &[u8]) -> String {
380 format!("{:x}", Sha256::digest(bytes))
381}
382
383pub async fn sha256_file(path: &Path) -> Result<String> {
384 let path = path.to_path_buf();
385 tokio::task::spawn_blocking(move || -> Result<String> {
386 let file = std::fs::File::open(&path)?;
387 let mut reader = std::io::BufReader::new(file);
388 let mut hasher = Sha256::new();
389 let mut buf = [0u8; 1024 * 1024];
390 loop {
391 let read = std::io::Read::read(&mut reader, &mut buf)?;
392 if read == 0 {
393 break;
394 }
395 hasher.update(&buf[..read]);
396 }
397 Ok(format!("{:x}", hasher.finalize()))
398 })
399 .await?
400}
401
402pub fn process_is_alive(pid: u32) -> bool {
403 nix::sys::signal::kill(Pid::from_raw(pid as i32), None).is_ok()
404}
405
406async fn process_matches_instance(pid: u32, paths: &InstancePaths) -> bool {
407 let output = match Command::new("ps")
408 .arg("-ww")
409 .arg("-o")
410 .arg("command=")
411 .arg("-p")
412 .arg(pid.to_string())
413 .stdin(Stdio::null())
414 .stderr(Stdio::null())
415 .output()
416 .await
417 {
418 Ok(output) if output.status.success() => output,
419 _ => return false,
420 };
421
422 let command = String::from_utf8_lossy(&output.stdout);
423 let pid_path = paths.pid.to_string_lossy().into_owned();
424 let qmp_path = paths.qmp.to_string_lossy().into_owned();
425 let serial_path = paths.serial.to_string_lossy().into_owned();
426 let expected = ["qemu-system-".to_string(), pid_path, qmp_path, serial_path];
427 expected
428 .into_iter()
429 .all(|needle| command.contains(needle.as_str()))
430}
431
432pub async fn command_exists(name: &str) -> bool {
433 Command::new("sh")
434 .arg("-c")
435 .arg(format!("command -v {name} >/dev/null 2>&1"))
436 .stdin(Stdio::null())
437 .stdout(Stdio::null())
438 .stderr(Stdio::null())
439 .status()
440 .await
441 .map(|status| status.success())
442 .unwrap_or(false)
443}
444
445pub fn validate_name(name: &str) -> Result<()> {
446 if name.is_empty() {
447 bail!("instance name must not be empty");
448 }
449 if name
450 .chars()
451 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
452 {
453 Ok(())
454 } else {
455 bail!("instance name may only contain ASCII letters, digits, '-' and '_'");
456 }
457}
458
459fn default_root_path() -> Result<PathBuf> {
460 dirs::home_dir()
461 .ok_or_else(|| anyhow!("unable to determine home directory"))
462 .map(|home| home.join(".hardpass"))
463}
464
465fn paths_match(left: &Path, right: &Path) -> Result<bool> {
466 Ok(normalize_path_for_compare(left)? == normalize_path_for_compare(right)?)
467}
468
469fn normalize_path_for_compare(path: &Path) -> Result<PathBuf> {
470 if path.is_absolute() {
471 Ok(path.to_path_buf())
472 } else {
473 Ok(std::env::current_dir()?.join(path))
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use std::process::Stdio;
480
481 use tempfile::tempdir;
482 use tokio::process::Command;
483
484 use super::{
485 CloudInitConfig, HardpassState, ImageConfig, InstanceConfig, InstancePaths, InstanceStatus,
486 PortForward, SshConfig, atomic_write,
487 };
488 use crate::state::{AccelMode, GuestArch};
489
490 fn test_config(dir: &std::path::Path) -> InstanceConfig {
491 InstanceConfig {
492 name: "vm".into(),
493 release: "24.04".into(),
494 arch: GuestArch::Arm64,
495 accel: AccelMode::Auto,
496 cpus: 2,
497 memory_mib: 2048,
498 disk_gib: 12,
499 timeout_secs: 30,
500 ssh: SshConfig {
501 user: "ubuntu".into(),
502 host: "127.0.0.1".into(),
503 port: 2222,
504 identity_file: dir.join("id_ed25519"),
505 },
506 forwards: vec![PortForward {
507 host: 8080,
508 guest: 8080,
509 }],
510 image: ImageConfig {
511 release: "24.04".into(),
512 arch: GuestArch::Arm64,
513 url: "https://example.invalid".into(),
514 sha256_url: "https://example.invalid/SHA256SUMS".into(),
515 filename: "ubuntu.img".into(),
516 sha256: "abc".into(),
517 },
518 cloud_init: CloudInitConfig {
519 user_data_sha256: "abc".into(),
520 network_config_sha256: None,
521 },
522 }
523 }
524
525 #[tokio::test]
526 async fn state_uses_env_override() {
527 let dir = tempdir().expect("tempdir");
528 let state = HardpassState::load_with_root(dir.path().to_path_buf())
529 .await
530 .expect("load");
531 assert_eq!(state.root(), dir.path());
532 }
533
534 #[tokio::test]
535 async fn status_missing_without_config() {
536 let dir = tempdir().expect("tempdir");
537 let paths = InstancePaths::new(dir.path().join("vm"));
538 assert_eq!(
539 paths.status().await.expect("status"),
540 InstanceStatus::Missing
541 );
542 }
543
544 #[tokio::test]
545 async fn status_stopped_with_config_only() {
546 let dir = tempdir().expect("tempdir");
547 let paths = InstancePaths::new(dir.path().join("vm"));
548 let config = test_config(dir.path());
549 paths.write_config(&config).await.expect("write config");
550 assert_eq!(
551 paths.status().await.expect("status"),
552 InstanceStatus::Stopped
553 );
554 }
555
556 #[tokio::test]
557 async fn status_ignores_alive_process_with_unrelated_pid() {
558 let dir = tempdir().expect("tempdir");
559 let paths = InstancePaths::new(dir.path().join("vm"));
560 paths
561 .write_config(&test_config(dir.path()))
562 .await
563 .expect("write config");
564
565 let mut child = Command::new("sleep")
566 .arg("30")
567 .stdin(Stdio::null())
568 .stdout(Stdio::null())
569 .stderr(Stdio::null())
570 .spawn()
571 .expect("spawn sleep");
572 let pid = child.id().expect("sleep pid");
573
574 atomic_write(&paths.pid, pid.to_string().as_bytes())
575 .await
576 .expect("write pid");
577
578 assert_eq!(
579 paths.status().await.expect("status"),
580 InstanceStatus::Stopped
581 );
582
583 let _ = child.kill().await;
584 }
585
586 #[tokio::test]
587 async fn status_accepts_matching_qemu_process_identity() {
588 let dir = tempdir().expect("tempdir");
589 let paths = InstancePaths::new(dir.path().join("vm"));
590 paths
591 .write_config(&test_config(dir.path()))
592 .await
593 .expect("write config");
594
595 let qmp_arg = format!("unix:{},server=on,wait=off", paths.qmp.display());
596 let serial_arg = format!("file:{}", paths.serial.display());
597 let mut child = Command::new("python3")
598 .arg("-c")
599 .arg("import time; time.sleep(30)")
600 .arg("qemu-system-aarch64")
601 .arg("-pidfile")
602 .arg(&paths.pid)
603 .arg("-qmp")
604 .arg(&qmp_arg)
605 .arg("-serial")
606 .arg(&serial_arg)
607 .stdin(Stdio::null())
608 .stdout(Stdio::null())
609 .stderr(Stdio::null())
610 .spawn()
611 .expect("spawn python");
612 let pid = child.id().expect("python pid");
613
614 atomic_write(&paths.pid, pid.to_string().as_bytes())
615 .await
616 .expect("write pid");
617
618 assert_eq!(
619 paths.status().await.expect("status"),
620 InstanceStatus::Running
621 );
622
623 let _ = child.kill().await;
624 }
625}