1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use super::asked::Asked;
18use super::judgement::Judgement;
19
20#[derive(Debug, Clone, Default, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct Deployment {
59 pub base: Option<String>,
62 #[serde(default)]
63 pub volumes: BTreeMap<String, VolumeSpec>,
64 #[serde(default)]
65 pub storages: BTreeMap<String, StorageSpec>,
66}
67
68#[derive(Debug, Clone, Deserialize)]
74pub struct VolumeSpec {
75 pub plugin: String,
78 pub history: Option<HistoryMode>,
82 #[serde(flatten)]
84 pub params: BTreeMap<String, serde_json::Value>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum HistoryMode {
92 Latest,
93 All,
94}
95
96impl HistoryMode {
97 pub fn as_str(self) -> &'static str {
98 match self {
99 HistoryMode::Latest => "latest",
100 HistoryMode::All => "all",
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum Persistence {
109 Volatile,
110 Durable,
111}
112
113impl Persistence {
114 pub fn as_str(self) -> &'static str {
115 match self {
116 Persistence::Volatile => "volatile",
117 Persistence::Durable => "durable",
118 }
119 }
120}
121
122#[derive(Debug, Clone, Default, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct StorageSpec {
128 pub class: Option<StorageClass>,
131 pub selector: Option<String>,
134 pub volume: String,
136 #[serde(default)]
139 pub params: BTreeMap<String, serde_json::Value>,
140 #[serde(default)]
142 pub replication: Replication,
143 #[serde(default)]
146 pub complete: bool,
147 pub retention: Option<serde_json::Value>,
149 pub gc_period_s: Option<u64>,
151 pub gc_margin: Option<f64>,
153 pub gc_lifespan_s: Option<i64>,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "kebab-case")]
162pub enum StorageClass {
163 State,
165 Telemetry,
167 Events,
169 Catalog,
172 CatalogPdns,
175}
176
177impl StorageClass {
178 pub fn as_str(self) -> &'static str {
180 match self {
181 StorageClass::State => "state",
182 StorageClass::Telemetry => "telemetry",
183 StorageClass::Events => "events",
184 StorageClass::Catalog => "catalog",
185 StorageClass::CatalogPdns => "catalog-pdns",
186 }
187 }
188
189 pub fn selector(self) -> &'static str {
191 match self {
192 StorageClass::State => "v1/*/state/**",
193 StorageClass::Telemetry => "v1/*/telemetry/**",
194 StorageClass::Events => "v1/*/events/**",
195 StorageClass::Catalog => "v1/@catalog/state/**",
196 StorageClass::CatalogPdns => "v1/@catalog/state/pdns/**",
197 }
198 }
199
200 pub fn seeds(self) -> bool {
203 matches!(
204 self,
205 StorageClass::State | StorageClass::Catalog | StorageClass::CatalogPdns
206 )
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Deserialize)]
212#[serde(untagged)]
213pub enum Replication {
214 Enabled(bool),
215 Params(BTreeMap<String, serde_json::Value>),
216}
217
218impl Default for Replication {
219 fn default() -> Self {
220 Replication::Enabled(false)
221 }
222}
223
224#[derive(Debug, Clone, Serialize)]
228pub struct StoragePlan {
229 pub base: String,
231 #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
235 pub registry: Asked<RegistryFacts>,
236 pub volumes: Vec<PlannedVolume>,
237 pub storages: Vec<PlannedStorage>,
238 pub refusals: Vec<Refusal>,
241}
242
243impl StoragePlan {
244 pub fn warnings(&self) -> impl Iterator<Item = (&str, &PlanWarning)> {
246 self.volumes
247 .iter()
248 .flat_map(|v| v.warnings.iter().map(move |w| (v.id.as_str(), w)))
249 .chain(
250 self.storages
251 .iter()
252 .flat_map(|s| s.warnings.iter().map(move |w| (s.name.as_str(), w))),
253 )
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
259pub struct RegistryFacts {
260 pub slices: usize,
261 #[serde(skip_serializing_if = "Option::is_none")]
265 pub max_ttl_s: Option<i64>,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 pub ttl_source: Option<String>,
269}
270
271#[derive(Debug, Clone, Serialize)]
273pub struct PlannedVolume {
274 pub id: String,
275 pub plugin: String,
276 pub history: HistoryMode,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub persistence: Option<Persistence>,
280 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
281 pub params: BTreeMap<String, serde_json::Value>,
282 #[serde(skip_serializing_if = "Vec::is_empty")]
283 pub warnings: Vec<PlanWarning>,
284}
285
286#[derive(Debug, Clone, Serialize)]
288pub struct PlannedStorage {
289 pub name: String,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub class: Option<StorageClass>,
293 pub key_expr: String,
295 pub strip_prefix: String,
297 pub volume: String,
298 pub history: HistoryMode,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub replication: Option<BTreeMap<String, serde_json::Value>>,
303 pub complete: bool,
305 pub garbage_collection: GarbageCollection,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 pub retention: Option<serde_json::Value>,
308 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
309 pub params: BTreeMap<String, serde_json::Value>,
310 #[serde(skip_serializing_if = "Asked::is_not_asked", default)]
313 pub covers: Asked<usize>,
314 #[serde(skip_serializing_if = "Vec::is_empty")]
315 pub warnings: Vec<PlanWarning>,
316}
317
318#[derive(Debug, Clone, PartialEq, Serialize)]
321pub struct GarbageCollection {
322 pub period_s: u64,
323 pub lifespan_s: i64,
324 pub derivation: String,
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize)]
330pub struct PlanWarning {
331 pub kind: WarningKind,
332 pub text: String,
333 pub cite: String,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
340#[serde(rename_all = "snake_case")]
341pub enum WarningKind {
342 Overlap,
345 CompleteRefused,
348 RetentionIsTheDatabases,
351 RetentionRequired,
354 RetentionPointless,
357 VolatileSeed,
360 LifespanBelowTtl,
362 ReplicationParams,
364 UnknownPlugin,
367}
368
369#[derive(Debug, Clone, PartialEq, Serialize)]
371pub struct Refusal {
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub storage: Option<String>,
374 #[serde(skip_serializing_if = "Option::is_none")]
375 pub volume: Option<String>,
376 #[serde(skip_serializing_if = "Option::is_none")]
379 pub key_expr: Option<String>,
380 pub reason: String,
381 pub cite: String,
382}
383
384#[derive(Debug, Clone, Serialize)]
388pub struct StorageCheck {
389 pub base: String,
390 pub asked: String,
392 pub planned: usize,
393 pub observed: usize,
394 pub findings: Vec<CheckFinding>,
395 #[serde(skip_serializing_if = "Vec::is_empty")]
398 pub unjudged: Vec<String>,
399 pub judgement: Judgement,
400}
401
402#[derive(Debug, Clone, PartialEq, Serialize)]
404pub struct CheckFinding {
405 pub kind: CheckKind,
406 pub storage: String,
407 #[serde(skip_serializing_if = "Option::is_none")]
408 pub zid: Option<String>,
409 #[serde(skip_serializing_if = "Option::is_none")]
410 pub planned: Option<String>,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 pub observed: Option<String>,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
416#[serde(rename_all = "snake_case")]
417pub enum CheckKind {
418 Missing,
420 Extra,
422 KeyExprDiffers,
423 StripPrefixDiffers,
424 VolumeDiffers,
425 LifespanBelowMinimum,
428}
429
430#[derive(Debug, Clone, Serialize)]
434pub struct StorageExplain {
435 pub key: String,
436 pub base: String,
437 pub takers: Vec<Taker>,
438 #[serde(skip_serializing_if = "Vec::is_empty")]
440 pub refused_takers: Vec<String>,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 pub none_reason: Option<String>,
444}
445
446#[derive(Debug, Clone, PartialEq, Serialize)]
448pub struct Taker {
449 pub storage: String,
450 pub key_expr: String,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub class: Option<StorageClass>,
453 pub relation: TakerRelation,
456 pub why: String,
457}
458
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
460#[serde(rename_all = "snake_case")]
461pub enum TakerRelation {
462 Includes,
463 Intersects,
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use serde_json::json;
470
471 fn gc() -> GarbageCollection {
472 GarbageCollection {
473 period_s: 30,
474 lifespan_s: 1800,
475 derivation: "max ttl_s 900 (netring/alert/{alert_key}) × 2.0 = 1800 s".into(),
476 }
477 }
478
479 #[test]
484 fn the_deployment_file_parses_as_documented() {
485 let d: Deployment = serde_json::from_value(json!({
486 "base": "zensight",
487 "volumes": {
488 "fs": {"plugin": "fs", "dir": "/var/lib/zenoh"},
489 "redb-history": {"plugin": "redb", "history": "all"}
490 },
491 "storages": {
492 "latest": {"class": "state", "volume": "fs", "replication": true, "complete": true},
493 "pdns": {"class": "catalog-pdns", "volume": "redb-history",
494 "replication": {"interval": 10.0}, "retention": {"max_age_s": 86400}}
495 }
496 }))
497 .unwrap();
498 assert_eq!(d.base.as_deref(), Some("zensight"));
499 assert_eq!(d.volumes["fs"].params["dir"], json!("/var/lib/zenoh"));
500 assert_eq!(d.volumes["redb-history"].history, Some(HistoryMode::All));
501 assert_eq!(d.storages["latest"].class, Some(StorageClass::State));
502 assert_eq!(d.storages["latest"].replication, Replication::Enabled(true));
503 assert_eq!(d.storages["pdns"].class, Some(StorageClass::CatalogPdns));
504 assert!(matches!(
505 d.storages["pdns"].replication,
506 Replication::Params(ref p) if p["interval"] == json!(10.0)
507 ));
508
509 let typo: Result<Deployment, _> = serde_json::from_value(json!({
510 "storages": {"latest": {"class": "state", "volume": "fs", "replicaton": true}}
511 }));
512 assert!(
513 typo.is_err(),
514 "a storage key this tool does not know is refused"
515 );
516 }
517
518 #[test]
521 fn the_plan_pins_its_shape() {
522 let plan = StoragePlan {
523 base: "zensight".into(),
524 registry: Asked::NotAsked,
525 volumes: vec![PlannedVolume {
526 id: "fs".into(),
527 plugin: "fs".into(),
528 history: HistoryMode::Latest,
529 persistence: Some(Persistence::Durable),
530 params: BTreeMap::new(),
531 warnings: vec![],
532 }],
533 storages: vec![PlannedStorage {
534 name: "latest".into(),
535 class: Some(StorageClass::State),
536 key_expr: "zensight/v1/*/state/**".into(),
537 strip_prefix: "zensight/v1".into(),
538 volume: "fs".into(),
539 history: HistoryMode::Latest,
540 replication: None,
541 complete: false,
542 garbage_collection: gc(),
543 retention: None,
544 params: BTreeMap::new(),
545 covers: Asked::NotAsked,
546 warnings: vec![PlanWarning {
547 kind: WarningKind::CompleteRefused,
548 text: "t".into(),
549 cite: "RFC 09 §2.2".into(),
550 }],
551 }],
552 refusals: vec![Refusal {
553 storage: Some("events".into()),
554 volume: None,
555 key_expr: Some("zensight/v1/*/events/**".into()),
556 reason: "r".into(),
557 cite: "RFC 09 §2".into(),
558 }],
559 };
560 let v = serde_json::to_value(&plan).unwrap();
561 assert!(v.get("registry").is_none(), "not asked is absence");
562 assert_eq!(v["volumes"][0]["persistence"], json!("durable"));
563 assert_eq!(v["volumes"][0]["history"], json!("latest"));
564 assert!(v["volumes"][0].get("params").is_none());
565 let s = &v["storages"][0];
566 assert_eq!(s["class"], json!("state"));
567 assert_eq!(s["complete"], json!(false));
568 assert!(s.get("replication").is_none());
569 assert!(s.get("covers").is_none());
570 assert_eq!(s["garbage_collection"]["lifespan_s"], json!(1800));
571 assert_eq!(s["warnings"][0]["kind"], json!("complete_refused"));
572 assert_eq!(v["refusals"][0]["storage"], json!("events"));
573 assert!(v["refusals"][0].get("volume").is_none());
574
575 let asked = StoragePlan {
576 registry: Asked::Asked(RegistryFacts {
577 slices: 3,
578 max_ttl_s: Some(900),
579 ttl_source: Some("netring/alert/{alert_key}".into()),
580 }),
581 ..plan
582 };
583 let v = serde_json::to_value(&asked).unwrap();
584 assert_eq!(v["registry"]["max_ttl_s"], json!(900));
585 assert_eq!(
586 serde_json::to_value(StorageClass::CatalogPdns).unwrap(),
587 json!("catalog-pdns")
588 );
589 }
590
591 #[test]
594 fn the_check_pins_its_shape() {
595 let check = StorageCheck {
596 base: "".into(),
597 asked: "@/*/router/**/storage_manager/storages/**".into(),
598 planned: 1,
599 observed: 1,
600 findings: vec![CheckFinding {
601 kind: CheckKind::LifespanBelowMinimum,
602 storage: "latest".into(),
603 zid: Some("aabb".into()),
604 planned: Some("1800".into()),
605 observed: Some("600".into()),
606 }],
607 unjudged: vec![],
608 judgement: Judgement::Established,
609 };
610 let v = serde_json::to_value(&check).unwrap();
611 assert_eq!(v["findings"][0]["kind"], json!("lifespan_below_minimum"));
612 assert_eq!(v["judgement"], json!({"answer": "established"}));
613 assert!(v.get("unjudged").is_none(), "empty unjudged is absence");
614 }
615
616 #[test]
619 fn the_explain_pins_its_shape() {
620 let e = StorageExplain {
621 key: "zensight/v1/@catalog/state/entity/x".into(),
622 base: "zensight".into(),
623 takers: vec![Taker {
624 storage: "catalog".into(),
625 key_expr: "zensight/v1/@catalog/state/**".into(),
626 class: Some(StorageClass::Catalog),
627 relation: TakerRelation::Includes,
628 why: "w".into(),
629 }],
630 refused_takers: vec![],
631 none_reason: None,
632 };
633 let v = serde_json::to_value(&e).unwrap();
634 assert_eq!(v["takers"][0]["relation"], json!("includes"));
635 assert!(v.get("none_reason").is_none());
636 assert!(v.get("refused_takers").is_none());
637 }
638}