Skip to main content

git_ark/
registry.rs

1//! Client-side host registry (`hosts.toml`).
2//!
3//! Tracks the git-ark hosts this client has wired up: enough to re-run
4//! `host add` idempotently and to drive `host list`/`host remove`. Lives on
5//! the operator's machine only — never shipped to a host.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct Host {
13    pub name: String,
14    pub ssh_target: String,
15    pub port: u16,
16    #[serde(default)]
17    pub identity: Option<PathBuf>,
18    pub triple: String,
19    pub install_dir: String,
20    /// The host's config values, carried here so the client can re-render
21    /// and re-apply `config.toml`/`secrets.toml` at will (mirror
22    /// designation, credential rotation) without re-probing the host.
23    /// `#[serde(default)]` on every field below so an existing `hosts.toml`
24    /// from before this slice still loads.
25    #[serde(default)]
26    pub recipient: String,
27    #[serde(default)]
28    pub bucket: String,
29    #[serde(default)]
30    pub region: String,
31    #[serde(default)]
32    pub prefix: String,
33    #[serde(default)]
34    pub endpoint: Option<String>,
35    /// Whether this host is the client-enforced singleton GitHub mirror.
36    #[serde(default)]
37    pub mirror: bool,
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
41pub struct Registry {
42    #[serde(default)]
43    pub hosts: Vec<Host>,
44}
45
46impl Registry {
47    /// Load the registry from `path`. A missing file is an empty registry,
48    /// not an error — nothing has been wired up yet.
49    pub fn load(path: &Path) -> Result<Registry> {
50        if !path.exists() {
51            return Ok(Registry::default());
52        }
53        let text = std::fs::read_to_string(path)
54            .with_context(|| format!("reading registry {}", path.display()))?;
55        let registry: Registry = toml::from_str(&text).context("parsing hosts.toml")?;
56        Ok(registry)
57    }
58
59    /// Write the registry to `path`, creating parent directories as needed.
60    /// Hosts are written sorted by name for stable diffs.
61    pub fn save(&self, path: &Path) -> Result<()> {
62        if let Some(parent) = path.parent() {
63            std::fs::create_dir_all(parent)
64                .with_context(|| format!("creating registry dir {}", parent.display()))?;
65        }
66        let mut sorted = self.clone();
67        sorted.hosts.sort_by(|a, b| a.name.cmp(&b.name));
68        let text = toml::to_string_pretty(&sorted).context("serializing hosts.toml")?;
69        std::fs::write(path, text)
70            .with_context(|| format!("writing registry {}", path.display()))?;
71        Ok(())
72    }
73
74    /// Insert `host`, replacing any existing entry with the same `name`.
75    pub fn upsert(&mut self, host: Host) {
76        if let Some(existing) = self.hosts.iter_mut().find(|h| h.name == host.name) {
77            *existing = host;
78        } else {
79            self.hosts.push(host);
80        }
81    }
82
83    /// Remove the host named `name`. Returns `true` if a host was removed.
84    pub fn remove(&mut self, name: &str) -> bool {
85        let before = self.hosts.len();
86        self.hosts.retain(|h| h.name != name);
87        self.hosts.len() != before
88    }
89
90    pub fn list(&self) -> &[Host] {
91        &self.hosts
92    }
93
94    /// The single host currently designated as the GitHub mirror, if any.
95    pub fn mirror_host(&self) -> Option<&Host> {
96        self.hosts.iter().find(|h| h.mirror)
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    fn host(name: &str) -> Host {
105        Host {
106            name: name.to_string(),
107            ssh_target: format!("ark@{name}.example.com"),
108            port: 22,
109            identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
110            triple: "aarch64-unknown-linux-musl".to_string(),
111            install_dir: "/home/ark/git-ark".to_string(),
112            recipient: "age1abc".to_string(),
113            bucket: "b".to_string(),
114            region: "us-east-1".to_string(),
115            prefix: "git-ark".to_string(),
116            endpoint: None,
117            mirror: false,
118        }
119    }
120
121    #[test]
122    fn round_trip_through_save_and_load() {
123        let dir = tempfile::tempdir().unwrap();
124        let path = dir.path().join("hosts.toml");
125
126        let mut registry = Registry::default();
127        registry.upsert(host("zeta"));
128        registry.upsert(host("alpha"));
129        registry.save(&path).unwrap();
130
131        let loaded = Registry::load(&path).unwrap();
132        // Order is stable (sorted by name), so comparing the whole struct is fine.
133        let mut expected = Registry::default();
134        expected.upsert(host("alpha"));
135        expected.upsert(host("zeta"));
136        assert_eq!(loaded, expected);
137    }
138
139    #[test]
140    fn upsert_replaces_by_name_latest_wins() {
141        let mut registry = Registry::default();
142        registry.upsert(host("box1"));
143
144        let mut updated = host("box1");
145        updated.port = 2222;
146        updated.triple = "x86_64-unknown-linux-musl".to_string();
147        registry.upsert(updated.clone());
148
149        assert_eq!(registry.list().len(), 1);
150        assert_eq!(registry.list()[0], updated);
151    }
152
153    #[test]
154    fn remove_returns_true_then_false() {
155        let mut registry = Registry::default();
156        registry.upsert(host("box1"));
157
158        assert!(registry.remove("box1"));
159        assert!(!registry.remove("box1"));
160        assert!(registry.list().is_empty());
161    }
162
163    #[test]
164    fn round_trip_preserves_config_fields_and_mirror_flag() {
165        let dir = tempfile::tempdir().unwrap();
166        let path = dir.path().join("hosts.toml");
167
168        let mut h = host("ec2");
169        h.endpoint = Some("http://minio:9000".to_string());
170        h.mirror = true;
171
172        let mut registry = Registry::default();
173        registry.upsert(h.clone());
174        registry.save(&path).unwrap();
175
176        let loaded = Registry::load(&path).unwrap();
177        assert_eq!(loaded.list()[0], h);
178    }
179
180    #[test]
181    fn mirror_host_finds_the_sole_mirror() {
182        let mut registry = Registry::default();
183        registry.upsert(host("nas"));
184        let mut ec2 = host("ec2");
185        ec2.mirror = true;
186        registry.upsert(ec2.clone());
187
188        assert_eq!(registry.mirror_host(), Some(&ec2));
189    }
190
191    #[test]
192    fn mirror_host_is_none_when_no_host_is_designated() {
193        let mut registry = Registry::default();
194        registry.upsert(host("nas"));
195        registry.upsert(host("ec2"));
196
197        assert_eq!(registry.mirror_host(), None);
198    }
199
200    #[test]
201    fn missing_file_loads_empty_registry() {
202        let dir = tempfile::tempdir().unwrap();
203        let path = dir.path().join("does-not-exist").join("hosts.toml");
204
205        let registry = Registry::load(&path).unwrap();
206        assert!(registry.list().is_empty());
207    }
208
209    #[test]
210    fn save_writes_hosts_sorted_by_name() {
211        let dir = tempfile::tempdir().unwrap();
212        let path = dir.path().join("hosts.toml");
213
214        let mut registry = Registry::default();
215        registry.upsert(host("zeta"));
216        registry.upsert(host("alpha"));
217        registry.upsert(host("mid"));
218        registry.save(&path).unwrap();
219
220        let text = std::fs::read_to_string(&path).unwrap();
221        let alpha_pos = text.find("name = \"alpha\"").unwrap();
222        let mid_pos = text.find("name = \"mid\"").unwrap();
223        let zeta_pos = text.find("name = \"zeta\"").unwrap();
224        assert!(alpha_pos < mid_pos, "alpha should precede mid");
225        assert!(mid_pos < zeta_pos, "mid should precede zeta");
226
227        // In-memory field order is unaffected by save().
228        assert_eq!(registry.list()[0].name, "zeta");
229    }
230}