1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum EdgeKind {
67 Hosts,
69 Runs,
71 GatewayOf,
73 Probes,
75 L2Adjacent,
77 #[serde(untagged)]
79 Other(String),
80}
81
82impl EdgeKind {
83 pub fn propagates(&self) -> bool {
87 matches!(
88 self,
89 EdgeKind::Hosts | EdgeKind::Runs | EdgeKind::GatewayOf | EdgeKind::Probes
90 )
91 }
92
93 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
136pub struct EdgeObserver {
137 pub sensor: String,
138 pub origin: String,
139}
140
141#[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 #[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 let back: EdgeDoc = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
185 assert_eq!(back, e);
186
187 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 #[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 #[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}