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 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}