Skip to main content

iac_forge/
fleet.rs

1//! Fleet-level IR aggregation and hashing.
2//!
3//! A `Fleet` is a named collection of `IacResource` values — an entire
4//! cluster's worth of declarations in one value. Like individual
5//! resources, it emits a canonical sexpr, content-hashes over that
6//! emission, and round-trips losslessly through `ToSExpr`/`FromSExpr`.
7//!
8//! # Why it matters
9//!
10//! Individual resources attest themselves. A Fleet attests the *whole
11//! set* — sekiban can gate admission on fleet membership + hash match,
12//! so a single deploy carries a single verifiable identity for an
13//! entire environment. Drift at any resource changes the fleet hash;
14//! drift nowhere leaves it stable.
15//!
16//! # Design
17//!
18//! - Storage: `BTreeMap<String, IacResource>` — deterministic order by
19//!   member name, which is what canonical emission needs.
20//! - Sexpr form: `(fleet (:name "…") (:members (list (member (:name "a") (:resource <r>)) …)))`.
21//! - Content hash: BLAKE3 over the canonical emission, same pattern as
22//!   every other ToSExpr type.
23//! - `member_hash(name)` exposes the per-resource hash so callers can
24//!   attest specific members without re-hashing the fleet.
25
26use std::collections::BTreeMap;
27
28use crate::ir::IacResource;
29use crate::sexpr::{FromSExpr, SExpr, SExprError, ToSExpr, parse_struct, struct_expr, take_field};
30
31/// A named collection of `IacResource` values with a stable canonical
32/// order (by member name).
33#[derive(Debug, Clone, Default)]
34pub struct Fleet {
35    /// Human-readable fleet name (often an environment or cluster id).
36    pub name: String,
37    /// Members keyed by member name — `BTreeMap` so iteration is sorted.
38    pub members: BTreeMap<String, IacResource>,
39}
40
41impl Fleet {
42    /// New empty fleet with the given name.
43    #[must_use]
44    pub fn new(name: impl Into<String>) -> Self {
45        Self {
46            name: name.into(),
47            members: BTreeMap::new(),
48        }
49    }
50
51    /// Insert or replace a member by name. Returns the previous value.
52    pub fn insert(
53        &mut self,
54        name: impl Into<String>,
55        resource: IacResource,
56    ) -> Option<IacResource> {
57        self.members.insert(name.into(), resource)
58    }
59
60    /// Remove a member. Returns the removed value.
61    pub fn remove(&mut self, name: &str) -> Option<IacResource> {
62        self.members.remove(name)
63    }
64
65    /// Number of members.
66    #[must_use]
67    pub fn len(&self) -> usize {
68        self.members.len()
69    }
70
71    /// Whether the fleet is empty.
72    #[must_use]
73    pub fn is_empty(&self) -> bool {
74        self.members.is_empty()
75    }
76
77    /// Look up a member by name.
78    #[must_use]
79    pub fn get(&self, name: &str) -> Option<&IacResource> {
80        self.members.get(name)
81    }
82
83    /// Per-member content hash in hex (the same hash the blanket
84    /// Backend→Morphism impl records as `source_hash`).
85    #[must_use]
86    pub fn member_hash(&self, name: &str) -> Option<String> {
87        self.members.get(name).map(|r| r.content_hash().to_hex())
88    }
89
90    /// Member names in canonical (sorted) order.
91    #[must_use]
92    pub fn member_names(&self) -> Vec<&str> {
93        self.members.keys().map(String::as_str).collect()
94    }
95}
96
97// ── Sexpr serialization ─────────────────────────────────────────────
98
99impl ToSExpr for Fleet {
100    fn to_sexpr(&self) -> SExpr {
101        let members: Vec<SExpr> = self
102            .members
103            .iter()
104            .map(|(name, resource)| {
105                struct_expr(
106                    "member",
107                    vec![("name", name.to_sexpr()), ("resource", resource.to_sexpr())],
108                )
109            })
110            .collect();
111        let mut member_list = Vec::with_capacity(members.len() + 1);
112        member_list.push(SExpr::Symbol("list".into()));
113        member_list.extend(members);
114        struct_expr(
115            "fleet",
116            vec![
117                ("name", self.name.to_sexpr()),
118                ("members", SExpr::List(member_list)),
119            ],
120        )
121    }
122}
123
124impl FromSExpr for Fleet {
125    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
126        let f = parse_struct(s, "fleet")?;
127        let name = String::from_sexpr(take_field(&f, "name")?)?;
128        let members_sexpr = take_field(&f, "members")?;
129        let items = members_sexpr.as_list()?;
130        let (head, rest) = items
131            .split_first()
132            .ok_or_else(|| SExprError::Shape("fleet members must be a (list …) form".into()))?;
133        let tag = head.as_symbol()?;
134        if tag != "list" {
135            return Err(SExprError::Shape(format!(
136                "expected 'list' for fleet members, got '{tag}'"
137            )));
138        }
139        let mut members = BTreeMap::new();
140        for item in rest {
141            let mf = parse_struct(item, "member")?;
142            let member_name = String::from_sexpr(take_field(&mf, "name")?)?;
143            let resource = IacResource::from_sexpr(take_field(&mf, "resource")?)?;
144            members.insert(member_name, resource);
145        }
146        Ok(Self { name, members })
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::sexpr::SExpr;
154    use crate::testing::test_resource;
155
156    fn two_member_fleet() -> Fleet {
157        let mut fleet = Fleet::new("prod");
158        fleet.insert("api", test_resource("api"));
159        fleet.insert("worker", test_resource("worker"));
160        fleet
161    }
162
163    // ── Basic construction ────────────────────────────────────
164
165    #[test]
166    fn new_fleet_is_empty() {
167        let fleet = Fleet::new("x");
168        assert!(fleet.is_empty());
169        assert_eq!(fleet.len(), 0);
170        assert_eq!(fleet.name, "x");
171    }
172
173    #[test]
174    fn insert_and_get() {
175        let mut fleet = Fleet::new("x");
176        fleet.insert("a", test_resource("a"));
177        assert_eq!(fleet.len(), 1);
178        assert_eq!(fleet.get("a").unwrap().name, "a");
179    }
180
181    #[test]
182    fn remove_returns_value() {
183        let mut fleet = two_member_fleet();
184        let removed = fleet.remove("api");
185        assert!(removed.is_some());
186        assert_eq!(fleet.len(), 1);
187    }
188
189    #[test]
190    fn insert_replaces_returns_previous() {
191        let mut fleet = Fleet::new("x");
192        let a1 = test_resource("a");
193        let mut a2 = test_resource("a");
194        a2.description = "different".into();
195
196        assert!(fleet.insert("a", a1).is_none());
197        let prev = fleet.insert("a", a2);
198        assert!(prev.is_some());
199        assert_eq!(fleet.len(), 1);
200    }
201
202    #[test]
203    fn member_names_are_sorted() {
204        let mut fleet = Fleet::new("x");
205        fleet.insert("z", test_resource("z"));
206        fleet.insert("a", test_resource("a"));
207        fleet.insert("m", test_resource("m"));
208        assert_eq!(fleet.member_names(), vec!["a", "m", "z"]);
209    }
210
211    // ── Sexpr round-trip ──────────────────────────────────────
212
213    #[test]
214    fn round_trip_empty() {
215        let fleet = Fleet::new("empty");
216        let parsed = Fleet::from_sexpr(&fleet.to_sexpr()).unwrap();
217        assert_eq!(parsed.content_hash(), fleet.content_hash());
218        assert_eq!(parsed.name, fleet.name);
219        assert_eq!(parsed.member_names(), fleet.member_names());
220    }
221
222    #[test]
223    fn round_trip_single_member() {
224        let mut fleet = Fleet::new("one");
225        fleet.insert("only", test_resource("only"));
226        let parsed = Fleet::from_sexpr(&fleet.to_sexpr()).unwrap();
227        assert_eq!(parsed.content_hash(), fleet.content_hash());
228        assert_eq!(parsed.name, fleet.name);
229        assert_eq!(parsed.member_names(), fleet.member_names());
230    }
231
232    #[test]
233    fn round_trip_multiple_members() {
234        let fleet = two_member_fleet();
235        let parsed = Fleet::from_sexpr(&fleet.to_sexpr()).unwrap();
236        assert_eq!(parsed.content_hash(), fleet.content_hash());
237        assert_eq!(parsed.name, fleet.name);
238        assert_eq!(parsed.member_names(), fleet.member_names());
239    }
240
241    #[test]
242    fn round_trip_through_text_boundary() {
243        let fleet = two_member_fleet();
244        let emitted = fleet.to_sexpr().emit();
245        let parsed = Fleet::from_sexpr(&SExpr::parse(&emitted).unwrap()).unwrap();
246        assert_eq!(parsed.content_hash(), fleet.content_hash());
247        assert_eq!(parsed.member_names(), fleet.member_names());
248    }
249
250    // ── Hashing ───────────────────────────────────────────────
251
252    #[test]
253    fn fleet_content_hash_is_deterministic() {
254        let a = two_member_fleet();
255        let b = two_member_fleet();
256        assert_eq!(a.content_hash(), b.content_hash());
257    }
258
259    #[test]
260    fn different_fleets_differ_in_hash() {
261        let a = two_member_fleet();
262        let b = Fleet::new("prod");
263        assert_ne!(a.content_hash(), b.content_hash());
264    }
265
266    #[test]
267    fn adding_a_member_changes_hash() {
268        let a = two_member_fleet();
269        let mut b = two_member_fleet();
270        b.insert("extra", test_resource("extra"));
271        assert_ne!(a.content_hash(), b.content_hash());
272    }
273
274    #[test]
275    fn insertion_order_does_not_affect_hash() {
276        // BTreeMap canonicalizes order, so insertion order is irrelevant.
277        let mut a = Fleet::new("prod");
278        a.insert("z", test_resource("z"));
279        a.insert("a", test_resource("a"));
280
281        let mut b = Fleet::new("prod");
282        b.insert("a", test_resource("a"));
283        b.insert("z", test_resource("z"));
284
285        assert_eq!(a.content_hash(), b.content_hash());
286    }
287
288    #[test]
289    fn renaming_fleet_changes_hash() {
290        let mut a = Fleet::new("prod");
291        a.insert("api", test_resource("api"));
292        let mut b = Fleet::new("staging");
293        b.insert("api", test_resource("api"));
294        assert_ne!(a.content_hash(), b.content_hash());
295    }
296
297    // ── Member-level attestation ──────────────────────────────
298
299    #[test]
300    fn member_hash_matches_resource_content_hash() {
301        let fleet = two_member_fleet();
302        let expected = test_resource("api").content_hash().to_hex();
303        assert_eq!(fleet.member_hash("api").unwrap(), expected);
304    }
305
306    #[test]
307    fn member_hash_is_none_for_missing() {
308        let fleet = two_member_fleet();
309        assert!(fleet.member_hash("nope").is_none());
310    }
311
312    #[test]
313    fn mutating_one_member_changes_fleet_hash() {
314        let mut a = two_member_fleet();
315        let mut b = two_member_fleet();
316        assert_eq!(a.content_hash(), b.content_hash());
317
318        // Mutate one member in b.
319        if let Some(r) = b.members.get_mut("api") {
320            r.description = "mutated".into();
321        }
322        assert_ne!(a.content_hash(), b.content_hash());
323
324        // And the member hash of that member also differs.
325        assert_ne!(a.member_hash("api"), b.member_hash("api"));
326        // Unchanged member's hash is still equal.
327        assert_eq!(a.member_hash("worker"), b.member_hash("worker"));
328    }
329
330    // ── Error paths ───────────────────────────────────────────
331
332    #[test]
333    fn from_sexpr_rejects_wrong_top_level() {
334        let err =
335            Fleet::from_sexpr(&SExpr::parse("(resource (:name \"x\"))").unwrap()).unwrap_err();
336        assert!(matches!(err, SExprError::Shape(_)));
337    }
338
339    #[test]
340    fn from_sexpr_rejects_non_list_members() {
341        let s = SExpr::parse("(fleet (:name \"x\") (:members 42))").unwrap();
342        let err = Fleet::from_sexpr(&s).unwrap_err();
343        assert!(matches!(err, SExprError::Shape(_)));
344    }
345}