Skip to main content

consortium_cli/
inventory.rs

1//! TOML inventory file format for cascade-copy / cascade deploy bins.
2//!
3//! Schema:
4//!
5//! ```toml
6//! # The seed host — `nix copy` from here runs locally (no SSH wrap).
7//! # Typically the build host.
8//! seed = "olive@seir"
9//!
10//! # All other targets the cascade should distribute to. SSH addresses;
11//! # `user@host` form. The cascade strategy decides which become
12//! # intermediate sources for further fan-out.
13//! nodes = [
14//!     "root@hp01",
15//!     "root@hp02",
16//!     "root@hp03",
17//!     "olive@mm01",
18//!     "olive@mm02",
19//!     "olive@mm03",
20//!     "olive@mm04",
21//!     "olive@mm05",
22//! ]
23//! ```
24//!
25//! Loaded into a `(Vec<CascadeNode>, NodeId)` pair where:
26//! - `nodes[0]` is the seed at `NodeId(0)`
27//! - `nodes[1..]` are the targets at `NodeId(1)`, `NodeId(2)`, etc.
28//! - The returned `NodeId` is the seed (always `NodeId(0)`)
29
30use std::collections::HashMap;
31use std::path::Path;
32
33use anyhow::{Context, Result};
34use serde::Deserialize;
35
36use consortium_nix::cascade::{CascadeNode, NodeId, NodeIdAlloc};
37
38#[derive(Debug, Deserialize)]
39struct InventoryFile {
40    /// SSH address of the seed host (the build machine).
41    seed: String,
42    /// SSH addresses of every other target.
43    nodes: Vec<String>,
44}
45
46/// Load a TOML inventory file. Returns `(nodes, seed_id, addr_map)`:
47///
48/// - `nodes`: the cascade's node list, seed at NodeId(0), targets following
49/// - `seed_id`: always `NodeId(0)`, returned for clarity at call sites
50/// - `addr_map`: NodeId → SSH address, ready to hand to `NixCopyExecutor`
51pub fn load_inventory<P: AsRef<Path>>(
52    path: P,
53) -> Result<(Vec<CascadeNode>, NodeId, HashMap<NodeId, String>)> {
54    let path = path.as_ref();
55    let raw = std::fs::read_to_string(path)
56        .with_context(|| format!("failed to read inventory file: {}", path.display()))?;
57    let inv: InventoryFile = toml::from_str(&raw)
58        .with_context(|| format!("failed to parse inventory file as TOML: {}", path.display()))?;
59
60    if inv.seed.trim().is_empty() {
61        anyhow::bail!("inventory `seed` is empty");
62    }
63
64    let mut alloc = NodeIdAlloc::new();
65    let mut nodes = Vec::with_capacity(1 + inv.nodes.len());
66    let mut addrs = HashMap::with_capacity(1 + inv.nodes.len());
67
68    let seed_id = alloc.alloc();
69    nodes.push(CascadeNode::new(seed_id, inv.seed.clone()));
70    addrs.insert(seed_id, inv.seed);
71
72    for addr in inv.nodes {
73        if addr.trim().is_empty() {
74            continue;
75        }
76        let id = alloc.alloc();
77        nodes.push(CascadeNode::new(id, addr.clone()));
78        addrs.insert(id, addr);
79    }
80
81    Ok((nodes, seed_id, addrs))
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use std::io::Write;
88
89    fn write_temp(content: &str) -> tempfile::NamedTempFile {
90        let mut f = tempfile::NamedTempFile::new().unwrap();
91        f.write_all(content.as_bytes()).unwrap();
92        f.flush().unwrap();
93        f
94    }
95
96    #[test]
97    fn loads_basic_inventory() {
98        let f = write_temp(
99            r#"
100seed = "olive@seir"
101nodes = ["root@hp01", "root@hp02", "olive@mm01"]
102"#,
103        );
104        let (nodes, seed, addrs) = load_inventory(f.path()).unwrap();
105        assert_eq!(seed, NodeId(0));
106        assert_eq!(nodes.len(), 4);
107        assert_eq!(nodes[0].addr, "olive@seir");
108        assert_eq!(nodes[1].addr, "root@hp01");
109        assert_eq!(
110            addrs.get(&NodeId(0)).map(String::as_str),
111            Some("olive@seir")
112        );
113        assert_eq!(addrs.get(&NodeId(2)).map(String::as_str), Some("root@hp02"));
114    }
115
116    #[test]
117    fn rejects_empty_seed() {
118        let f = write_temp(r#"seed = "" \nnodes = []"#);
119        assert!(load_inventory(f.path()).is_err());
120    }
121
122    #[test]
123    fn skips_empty_node_entries() {
124        let f = write_temp(
125            r#"
126seed = "olive@seir"
127nodes = ["root@hp01", "", "root@hp02"]
128"#,
129        );
130        let (nodes, _, _) = load_inventory(f.path()).unwrap();
131        assert_eq!(nodes.len(), 3); // seed + 2 valid (skipped the empty)
132    }
133}