Skip to main content

hardpass/
state.rs

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}