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    /// The `~/.ssh/config` alias `git push` rides to reach this host. Recorded
39    /// only when it departs from the `git-ark-<name>` convention (a hand-wired
40    /// or adopted host); `None` means "conventional — resolve to `git-ark-<name>`".
41    #[serde(default)]
42    pub push_alias: Option<String>,
43}
44
45impl Host {
46    /// The ssh alias `git push` rides to reach this host: the recorded
47    /// `push_alias` when set, else the conventional `git-ark-<name>`. This is
48    /// the single place the alias is decided — nothing else formats it.
49    pub fn push_alias(&self) -> String {
50        self.push_alias
51            .clone()
52            .unwrap_or_else(|| format!("git-ark-{}", self.name))
53    }
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
57pub struct Registry {
58    #[serde(default)]
59    pub hosts: Vec<Host>,
60}
61
62impl Registry {
63    /// Load the registry from `path`. A missing file is an empty registry,
64    /// not an error — nothing has been wired up yet.
65    pub fn load(path: &Path) -> Result<Registry> {
66        if !path.exists() {
67            return Ok(Registry::default());
68        }
69        let text = std::fs::read_to_string(path)
70            .with_context(|| format!("reading registry {}", path.display()))?;
71        let registry: Registry = toml::from_str(&text).context("parsing hosts.toml")?;
72        Ok(registry)
73    }
74
75    /// Write the registry to `path`, creating parent directories as needed.
76    /// Hosts are written sorted by name for stable diffs.
77    pub fn save(&self, path: &Path) -> Result<()> {
78        if let Some(parent) = path.parent() {
79            std::fs::create_dir_all(parent)
80                .with_context(|| format!("creating registry dir {}", parent.display()))?;
81        }
82        let mut sorted = self.clone();
83        sorted.hosts.sort_by(|a, b| a.name.cmp(&b.name));
84        let text = toml::to_string_pretty(&sorted).context("serializing hosts.toml")?;
85        std::fs::write(path, text)
86            .with_context(|| format!("writing registry {}", path.display()))?;
87        Ok(())
88    }
89
90    /// Insert `host`, replacing any existing entry with the same `name`.
91    pub fn upsert(&mut self, host: Host) {
92        if let Some(existing) = self.hosts.iter_mut().find(|h| h.name == host.name) {
93            *existing = host;
94        } else {
95            self.hosts.push(host);
96        }
97    }
98
99    /// Remove the host named `name`. Returns `true` if a host was removed.
100    pub fn remove(&mut self, name: &str) -> bool {
101        let before = self.hosts.len();
102        self.hosts.retain(|h| h.name != name);
103        self.hosts.len() != before
104    }
105
106    pub fn list(&self) -> &[Host] {
107        &self.hosts
108    }
109
110    /// The single host currently designated as the GitHub mirror, if any.
111    pub fn mirror_host(&self) -> Option<&Host> {
112        self.hosts.iter().find(|h| h.mirror)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn host(name: &str) -> Host {
121        Host {
122            name: name.to_string(),
123            ssh_target: format!("ark@{name}.example.com"),
124            port: 22,
125            identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
126            triple: "aarch64-unknown-linux-musl".to_string(),
127            install_dir: "/home/ark/git-ark".to_string(),
128            recipient: "age1abc".to_string(),
129            bucket: "b".to_string(),
130            region: "us-east-1".to_string(),
131            prefix: "git-ark".to_string(),
132            endpoint: None,
133            mirror: false,
134            push_alias: None,
135        }
136    }
137
138    #[test]
139    fn push_alias_defaults_to_convention_and_honors_override() {
140        let mut h = host("nas");
141        assert_eq!(h.push_alias(), "git-ark-nas"); // None → conventional
142        h.push_alias = Some("git-ark".to_string());
143        assert_eq!(h.push_alias(), "git-ark"); // recorded value wins
144    }
145
146    #[test]
147    fn hosts_toml_without_push_alias_loads_as_none() {
148        // A registry written before push_alias existed must still load.
149        let dir = tempfile::tempdir().unwrap();
150        let path = dir.path().join("hosts.toml");
151        std::fs::write(
152            &path,
153            "[[hosts]]\nname = \"old\"\nssh_target = \"ark@old\"\nport = 22\n\
154             triple = \"x86_64-unknown-linux-musl\"\ninstall_dir = \"/home/ark/git-ark\"\n",
155        )
156        .unwrap();
157        let loaded = Registry::load(&path).unwrap();
158        assert_eq!(loaded.list()[0].push_alias, None);
159        assert_eq!(loaded.list()[0].push_alias(), "git-ark-old");
160    }
161
162    #[test]
163    fn round_trip_through_save_and_load() {
164        let dir = tempfile::tempdir().unwrap();
165        let path = dir.path().join("hosts.toml");
166
167        let mut registry = Registry::default();
168        registry.upsert(host("zeta"));
169        registry.upsert(host("alpha"));
170        registry.save(&path).unwrap();
171
172        let loaded = Registry::load(&path).unwrap();
173        // Order is stable (sorted by name), so comparing the whole struct is fine.
174        let mut expected = Registry::default();
175        expected.upsert(host("alpha"));
176        expected.upsert(host("zeta"));
177        assert_eq!(loaded, expected);
178    }
179
180    #[test]
181    fn upsert_replaces_by_name_latest_wins() {
182        let mut registry = Registry::default();
183        registry.upsert(host("box1"));
184
185        let mut updated = host("box1");
186        updated.port = 2222;
187        updated.triple = "x86_64-unknown-linux-musl".to_string();
188        registry.upsert(updated.clone());
189
190        assert_eq!(registry.list().len(), 1);
191        assert_eq!(registry.list()[0], updated);
192    }
193
194    #[test]
195    fn remove_returns_true_then_false() {
196        let mut registry = Registry::default();
197        registry.upsert(host("box1"));
198
199        assert!(registry.remove("box1"));
200        assert!(!registry.remove("box1"));
201        assert!(registry.list().is_empty());
202    }
203
204    #[test]
205    fn round_trip_preserves_config_fields_and_mirror_flag() {
206        let dir = tempfile::tempdir().unwrap();
207        let path = dir.path().join("hosts.toml");
208
209        let mut h = host("ec2");
210        h.endpoint = Some("http://minio:9000".to_string());
211        h.mirror = true;
212
213        let mut registry = Registry::default();
214        registry.upsert(h.clone());
215        registry.save(&path).unwrap();
216
217        let loaded = Registry::load(&path).unwrap();
218        assert_eq!(loaded.list()[0], h);
219    }
220
221    #[test]
222    fn mirror_host_finds_the_sole_mirror() {
223        let mut registry = Registry::default();
224        registry.upsert(host("nas"));
225        let mut ec2 = host("ec2");
226        ec2.mirror = true;
227        registry.upsert(ec2.clone());
228
229        assert_eq!(registry.mirror_host(), Some(&ec2));
230    }
231
232    #[test]
233    fn mirror_host_is_none_when_no_host_is_designated() {
234        let mut registry = Registry::default();
235        registry.upsert(host("nas"));
236        registry.upsert(host("ec2"));
237
238        assert_eq!(registry.mirror_host(), None);
239    }
240
241    #[test]
242    fn missing_file_loads_empty_registry() {
243        let dir = tempfile::tempdir().unwrap();
244        let path = dir.path().join("does-not-exist").join("hosts.toml");
245
246        let registry = Registry::load(&path).unwrap();
247        assert!(registry.list().is_empty());
248    }
249
250    #[test]
251    fn save_writes_hosts_sorted_by_name() {
252        let dir = tempfile::tempdir().unwrap();
253        let path = dir.path().join("hosts.toml");
254
255        let mut registry = Registry::default();
256        registry.upsert(host("zeta"));
257        registry.upsert(host("alpha"));
258        registry.upsert(host("mid"));
259        registry.save(&path).unwrap();
260
261        let text = std::fs::read_to_string(&path).unwrap();
262        let alpha_pos = text.find("name = \"alpha\"").unwrap();
263        let mid_pos = text.find("name = \"mid\"").unwrap();
264        let zeta_pos = text.find("name = \"zeta\"").unwrap();
265        assert!(alpha_pos < mid_pos, "alpha should precede mid");
266        assert!(mid_pos < zeta_pos, "mid should precede zeta");
267
268        // In-memory field order is unaffected by save().
269        assert_eq!(registry.list()[0].name, "zeta");
270    }
271}