Skip to main content

cloud/
state.rs

1//! Reconciler-written sidecar state for declared machines.
2//!
3//! R330-F15 split: `.yah/infra/machines/<name>.toml` is the declared identity
4//! (operator-edited, version-controlled, names the logical slot + shape).
5//! `.yah/infra/state/machines/<name>.json` is the observable state captured
6//! when the reconciler adopts a live server into that slot. State carries
7//! provider-side derivatives (Hetzner server id, current public IPv4, last
8//! adoption timestamp, hostkey fingerprint observed at attach time).
9//!
10//! Why split: name is the binding identity (Hetzner enforces unique server
11//! names per project; names survive provider migration). Provider ids and
12//! observables are derived state — pinning them in `machine.toml` couples the
13//! declared shape to a particular provider instantiation, blocking the
14//! us-west-001-moves-Hetzner→DigitalOcean story by editing one TOML field.
15//!
16//! Adoption-shape verification compares `machine.toml` against the live API,
17//! NOT against the state file. State is for humans/audit/idempotency. The
18//! reconciler reads it only to skip redundant API calls on re-run.
19
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24
25/// On-disk JSON shape for `.yah/infra/state/machines/<name>.json`.
26///
27/// Forward-compatible: every field is optional so a partial adopt (server
28/// found, hostkey poll timed out) still produces a useful record. The file is
29/// re-written wholesale on each adopt + each `attach` — no incremental patching.
30#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
31pub struct MachineState {
32    /// Provider-native server id (Hetzner: `server.id` as decimal string).
33    /// Captured once on first adopt; refreshed only if the box is rebuilt and
34    /// the name re-bound to a new server id.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub provider_id: Option<String>,
37    /// Primary public IPv4 observed at last adopt/attach. Refreshed every
38    /// adopt; for audit, not for binding (mesh IP is what callers use).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub public_ipv4: Option<String>,
41    /// SSH hostkey fingerprint observed at attach time. Refreshed on every
42    /// `attach` — when it changes between runs, the box was rebuilt.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub hostkey_fingerprint: Option<String>,
45    /// RFC3339 timestamp of the most recent successful adopt.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub last_adopted_at: Option<String>,
48}
49
50impl MachineState {
51    /// `<workspace_root>/.yah/infra/state/machines/<name>.json`.
52    pub fn path(workspace_root: &Path, name: &str) -> PathBuf {
53        workspace_root
54            .join(".yah")
55            .join("infra")
56            .join("state")
57            .join("machines")
58            .join(format!("{name}.json"))
59    }
60
61    /// Load state for `name`. Missing file returns `Ok(MachineState::default())`
62    /// so callers can treat the no-state-yet case identically to the partial-
63    /// state case (initialize on adopt).
64    pub fn load(workspace_root: &Path, name: &str) -> Result<Self> {
65        let p = Self::path(workspace_root, name);
66        match std::fs::read(&p) {
67            Ok(bytes) => {
68                serde_json::from_slice(&bytes).with_context(|| format!("parsing {}", p.display()))
69            }
70            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
71            Err(e) => Err(e).with_context(|| format!("reading {}", p.display())),
72        }
73    }
74
75    /// Write state for `name`, creating parent dirs as needed.
76    pub fn save(&self, workspace_root: &Path, name: &str) -> Result<()> {
77        let p = Self::path(workspace_root, name);
78        if let Some(parent) = p.parent() {
79            std::fs::create_dir_all(parent)
80                .with_context(|| format!("creating {}", parent.display()))?;
81        }
82        let json = serde_json::to_vec_pretty(self)
83            .with_context(|| format!("serializing state for {name}"))?;
84        std::fs::write(&p, json).with_context(|| format!("writing {}", p.display()))
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use tempfile::TempDir;
92
93    #[test]
94    fn load_missing_returns_default() {
95        let tmp = TempDir::new().unwrap();
96        let s = MachineState::load(tmp.path(), "nonesuch").unwrap();
97        assert_eq!(s, MachineState::default());
98    }
99
100    #[test]
101    fn round_trip_through_disk() {
102        let tmp = TempDir::new().unwrap();
103        let s = MachineState {
104            provider_id: Some("134855726".into()),
105            public_ipv4: Some("5.78.210.121".into()),
106            hostkey_fingerprint: Some("SHA256:abc".into()),
107            last_adopted_at: Some("2026-06-09T02:30:00Z".into()),
108        };
109        s.save(tmp.path(), "us-west-001").unwrap();
110        let loaded = MachineState::load(tmp.path(), "us-west-001").unwrap();
111        assert_eq!(loaded, s);
112    }
113
114    #[test]
115    fn partial_state_skips_none_fields_in_json() {
116        let tmp = TempDir::new().unwrap();
117        let s = MachineState {
118            provider_id: Some("9".into()),
119            ..Default::default()
120        };
121        s.save(tmp.path(), "m").unwrap();
122        let bytes = std::fs::read(MachineState::path(tmp.path(), "m")).unwrap();
123        let json = String::from_utf8(bytes).unwrap();
124        assert!(json.contains("provider_id"));
125        assert!(!json.contains("public_ipv4"));
126        assert!(!json.contains("hostkey_fingerprint"));
127    }
128}