Skip to main content

zenkey_fleet/report/
catalog.rs

1//! The `@catalog` documents as a **consumer** reads them (#389): entity,
2//! alias and edge — other people's wire shapes, `Deserialize`d here so a
3//! key-agnostic tool (a notifier, an exporter, a second UI) can run the
4//! RFC 06 §5.1 join and the §5.6 impact walk from the documents alone.
5//!
6//! These are deliberately *readers*, not the catalog's own types: every
7//! field the walk does not need is optional or ignored, an unknown edge
8//! kind is carried rather than refused (the vocabulary is closed by
9//! amendment, RFC 06 §5.6 rule 3, and a consumer built before the amendment
10//! must not fall over on it), and the spelling is the reference profile's
11//! (RFC 11 §3.3) — pinned by the tests below against verbatim documents.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17/// One `@catalog/state/entity/{entity_id}` document (RFC 06 §5.1, §6.4).
18///
19/// `origins[]` is the normative bridge (self-reported origins only);
20/// `host_id` is the older single-origin form a consumer falls back to when
21/// a catalog predates the field. Everything else rides in `rest`.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct EntityDoc {
24    pub entity_id: String,
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub origins: Vec<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub host_id: Option<String>,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub hostname: Option<String>,
31    #[serde(flatten)]
32    pub rest: BTreeMap<String, serde_json::Value>,
33}
34
35impl EntityDoc {
36    /// Every origin this entity merges: `origins[]` first, then the legacy
37    /// `host_id` when it is not already among them (RFC 06 §5.1 step 3).
38    pub fn member_origins(&self) -> Vec<&str> {
39        let mut out: Vec<&str> = self.origins.iter().map(String::as_str).collect();
40        if let Some(h) = &self.host_id
41            && !out.contains(&h.as_str())
42        {
43            out.push(h);
44        }
45        out
46    }
47}
48
49/// One `@catalog/state/alias/{old_id}` document: a merged entity's old id
50/// re-pointed at the survivor (RFC 06 §5.1 step 1).
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct AliasDoc {
53    pub old_id: String,
54    pub entity_id: String,
55    #[serde(flatten)]
56    pub rest: BTreeMap<String, serde_json::Value>,
57}
58
59/// The closed edge-kind vocabulary (RFC 06 §5.6 rule 3, spelled by RFC 11
60/// §3.3) — plus [`EdgeKind::Other`], which carries a token this build does
61/// not know so the document is still readable. An unknown kind never
62/// propagates: a consumer that guessed a causal direction for a kind it
63/// cannot name would be inventing structure.
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum EdgeKind {
67    /// A hypervisor node hosts a guest (containment).
68    Hosts,
69    /// A host runs a container (containment).
70    Runs,
71    /// `from` is the gateway for `to` (containment).
72    GatewayOf,
73    /// `from` is a vantage point checking `to` (containment).
74    Probes,
75    /// The two share a link-layer segment — symmetric, **inert**.
76    L2Adjacent,
77    /// A token outside this build's vocabulary, carried verbatim.
78    #[serde(untagged)]
79    Other(String),
80}
81
82impl EdgeKind {
83    /// Whether failure propagates `from → to` over this kind: true for the
84    /// four containment kinds and nothing else (RFC 11 §3.3's containment
85    /// column; `l2_adjacent` is the reason the column exists).
86    pub fn propagates(&self) -> bool {
87        matches!(
88            self,
89            EdgeKind::Hosts | EdgeKind::Runs | EdgeKind::GatewayOf | EdgeKind::Probes
90        )
91    }
92
93    /// The wire token.
94    pub fn as_str(&self) -> &str {
95        match self {
96            EdgeKind::Hosts => "hosts",
97            EdgeKind::Runs => "runs",
98            EdgeKind::GatewayOf => "gateway_of",
99            EdgeKind::Probes => "probes",
100            EdgeKind::L2Adjacent => "l2_adjacent",
101            EdgeKind::Other(s) => s,
102        }
103    }
104}
105
106/// One resolved end of an edge (RFC 06 §5.6): an entity, or an honest
107/// `External` for something the fleet observed but runs no sensor on.
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum EdgeEnd {
111    Entity {
112        entity_id: String,
113    },
114    External {
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        ip: Option<String>,
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        mac: Option<String>,
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        name: Option<String>,
121    },
122}
123
124impl EdgeEnd {
125    /// The entity id, when this end resolved to one.
126    pub fn entity_id(&self) -> Option<&str> {
127        match self {
128            EdgeEnd::Entity { entity_id } => Some(entity_id),
129            EdgeEnd::External { .. } => None,
130        }
131    }
132}
133
134/// Who claimed an edge: one sensor on one origin.
135#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
136pub struct EdgeObserver {
137    pub sensor: String,
138    pub origin: String,
139}
140
141/// One `@catalog/state/edge/{edge_id}` document (RFC 06 §5.6). The id is
142/// opaque (rule 2): the ends are read from here, never from the key.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct EdgeDoc {
145    pub edge_id: String,
146    pub kind: EdgeKind,
147    pub from: EdgeEnd,
148    pub to: EdgeEnd,
149    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
150    pub attrs: BTreeMap<String, String>,
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub observers: Vec<EdgeObserver>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub last_updated: Option<i64>,
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    /// The RFC 11 §3.3 test vector, verbatim: the pve node hosts the guest.
162    /// Externally tagged ends, snake_case kinds, optional fields absent.
163    #[test]
164    fn an_edge_document_reads_with_the_profiles_spelling() {
165        let doc = r#"{
166            "edge_id": "e-2879d4667f9d946d",
167            "kind": "hosts",
168            "from": {"entity": {"entity_id": "h-3fa9c2d41b7e"}},
169            "to": {"entity": {"entity_id": "h-9d02aa17c44f"}},
170            "attrs": {"vmid": "101"},
171            "observers": [{"sensor": "pve", "origin": "h-3fa9c2d41b7e"}],
172            "last_updated": 1700000000
173        }"#;
174        let e: EdgeDoc = serde_json::from_str(doc).unwrap();
175        assert_eq!(e.edge_id, "e-2879d4667f9d946d");
176        assert_eq!(e.kind, EdgeKind::Hosts);
177        assert!(e.kind.propagates());
178        assert_eq!(e.from.entity_id(), Some("h-3fa9c2d41b7e"));
179        assert_eq!(e.to.entity_id(), Some("h-9d02aa17c44f"));
180        assert_eq!(e.attrs.get("vmid").map(String::as_str), Some("101"));
181        assert_eq!(e.observers[0].sensor, "pve");
182        assert_eq!(e.last_updated, Some(1_700_000_000));
183        // Round-trip: what it reads is what it writes.
184        let back: EdgeDoc = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
185        assert_eq!(back, e);
186
187        // An external end and a probe: the minimal document.
188        let probe: EdgeDoc = serde_json::from_str(
189            r#"{"edge_id":"e-1","kind":"probes","from":{"entity":{"entity_id":"h-1"}},
190                "to":{"external":{"ip":"1.1.1.1"}}}"#,
191        )
192        .unwrap();
193        assert_eq!(probe.to.entity_id(), None);
194        assert!(probe.attrs.is_empty() && probe.observers.is_empty());
195        assert_eq!(probe.last_updated, None);
196    }
197
198    /// Every token of the closed vocabulary, its containment column, and an
199    /// unknown token carried rather than refused — and never propagating.
200    #[test]
201    fn edge_kinds_spell_the_vocabulary_and_only_containment_propagates() {
202        for (token, propagates) in [
203            ("hosts", true),
204            ("runs", true),
205            ("gateway_of", true),
206            ("probes", true),
207            ("l2_adjacent", false),
208            ("teleports", false),
209        ] {
210            let k: EdgeKind = serde_json::from_value(serde_json::json!(token)).unwrap();
211            assert_eq!(k.as_str(), token);
212            assert_eq!(k.propagates(), propagates, "{token}");
213            assert_eq!(serde_json::to_value(&k).unwrap(), serde_json::json!(token));
214        }
215        assert_eq!(
216            serde_json::from_value::<EdgeKind>(serde_json::json!("teleports")).unwrap(),
217            EdgeKind::Other("teleports".into())
218        );
219    }
220
221    /// An entity with `origins[]`, one without (an older catalog: `host_id`
222    /// is the bridge), and an alias — the fields the join needs, the rest
223    /// kept.
224    #[test]
225    fn entity_and_alias_documents_read_the_join_fields() {
226        let e: EntityDoc = serde_json::from_str(
227            r#"{"entity_id":"h-9d02aa17c44f","host_id":"h-9d02aa17c44f",
228                "origins":["h-9d02aa17c44f","h-0000aa17c44f"],"hostname":"db01",
229                "ips":["10.0.0.5"],"last_seen":1700000000}"#,
230        )
231        .unwrap();
232        assert_eq!(
233            e.member_origins(),
234            vec!["h-9d02aa17c44f", "h-0000aa17c44f"],
235            "host_id already among the origins is not repeated"
236        );
237        assert_eq!(e.hostname.as_deref(), Some("db01"));
238        assert_eq!(e.rest["ips"], serde_json::json!(["10.0.0.5"]));
239
240        let old: EntityDoc =
241            serde_json::from_str(r#"{"entity_id":"ent-1","host_id":"h-3fa9c2d41b7e"}"#).unwrap();
242        assert_eq!(old.member_origins(), vec!["h-3fa9c2d41b7e"]);
243        assert!(old.origins.is_empty());
244
245        let a: AliasDoc = serde_json::from_str(
246            r#"{"old_id":"ent-1","entity_id":"ent-2","last_updated":1700000000}"#,
247        )
248        .unwrap();
249        assert_eq!(
250            (a.old_id.as_str(), a.entity_id.as_str()),
251            ("ent-1", "ent-2")
252        );
253    }
254}