use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::asked::Asked;
use super::judgement::Judgement;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Deployment {
pub base: Option<String>,
#[serde(default)]
pub volumes: BTreeMap<String, VolumeSpec>,
#[serde(default)]
pub storages: BTreeMap<String, StorageSpec>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VolumeSpec {
pub plugin: String,
pub history: Option<HistoryMode>,
#[serde(flatten)]
pub params: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HistoryMode {
Latest,
All,
}
impl HistoryMode {
pub fn as_str(self) -> &'static str {
match self {
HistoryMode::Latest => "latest",
HistoryMode::All => "all",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Persistence {
Volatile,
Durable,
}
impl Persistence {
pub fn as_str(self) -> &'static str {
match self {
Persistence::Volatile => "volatile",
Persistence::Durable => "durable",
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StorageSpec {
pub class: Option<StorageClass>,
pub selector: Option<String>,
pub volume: String,
#[serde(default)]
pub params: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub replication: Replication,
#[serde(default)]
pub complete: bool,
pub retention: Option<serde_json::Value>,
pub gc_period_s: Option<u64>,
pub gc_margin: Option<f64>,
pub gc_lifespan_s: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StorageClass {
State,
Telemetry,
Events,
Catalog,
CatalogPdns,
}
impl StorageClass {
pub fn as_str(self) -> &'static str {
match self {
StorageClass::State => "state",
StorageClass::Telemetry => "telemetry",
StorageClass::Events => "events",
StorageClass::Catalog => "catalog",
StorageClass::CatalogPdns => "catalog-pdns",
}
}
pub fn selector(self) -> &'static str {
match self {
StorageClass::State => "v1/*/state/**",
StorageClass::Telemetry => "v1/*/telemetry/**",
StorageClass::Events => "v1/*/events/**",
StorageClass::Catalog => "v1/@catalog/state/**",
StorageClass::CatalogPdns => "v1/@catalog/state/pdns/**",
}
}
pub fn seeds(self) -> bool {
matches!(
self,
StorageClass::State | StorageClass::Catalog | StorageClass::CatalogPdns
)
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum Replication {
Enabled(bool),
Params(BTreeMap<String, serde_json::Value>),
}
impl Default for Replication {
fn default() -> Self {
Replication::Enabled(false)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct StoragePlan {
pub base: String,
#[serde(skip_serializing_if = "Asked::is_not_asked", default)]
pub registry: Asked<RegistryFacts>,
pub volumes: Vec<PlannedVolume>,
pub storages: Vec<PlannedStorage>,
pub refusals: Vec<Refusal>,
}
impl StoragePlan {
pub fn warnings(&self) -> impl Iterator<Item = (&str, &PlanWarning)> {
self.volumes
.iter()
.flat_map(|v| v.warnings.iter().map(move |w| (v.id.as_str(), w)))
.chain(
self.storages
.iter()
.flat_map(|s| s.warnings.iter().map(move |w| (s.name.as_str(), w))),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RegistryFacts {
pub slices: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_ttl_s: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_source: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlannedVolume {
pub id: String,
pub plugin: String,
pub history: HistoryMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub persistence: Option<Persistence>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<PlanWarning>,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlannedStorage {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<StorageClass>,
pub key_expr: String,
pub strip_prefix: String,
pub volume: String,
pub history: HistoryMode,
#[serde(skip_serializing_if = "Option::is_none")]
pub replication: Option<BTreeMap<String, serde_json::Value>>,
pub complete: bool,
pub garbage_collection: GarbageCollection,
#[serde(skip_serializing_if = "Option::is_none")]
pub retention: Option<serde_json::Value>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Asked::is_not_asked", default)]
pub covers: Asked<usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<PlanWarning>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct GarbageCollection {
pub period_s: u64,
pub lifespan_s: i64,
pub derivation: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct PlanWarning {
pub kind: WarningKind,
pub text: String,
pub cite: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WarningKind {
Overlap,
CompleteRefused,
RetentionIsTheDatabases,
RetentionRequired,
RetentionPointless,
VolatileSeed,
LifespanBelowTtl,
ReplicationParams,
UnknownPlugin,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Refusal {
#[serde(skip_serializing_if = "Option::is_none")]
pub storage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_expr: Option<String>,
pub reason: String,
pub cite: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct StorageCheck {
pub base: String,
pub asked: String,
pub planned: usize,
pub observed: usize,
pub findings: Vec<CheckFinding>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unjudged: Vec<String>,
pub judgement: Judgement,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CheckFinding {
pub kind: CheckKind,
pub storage: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub zid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckKind {
Missing,
Extra,
KeyExprDiffers,
StripPrefixDiffers,
VolumeDiffers,
LifespanBelowMinimum,
}
#[derive(Debug, Clone, Serialize)]
pub struct StorageExplain {
pub key: String,
pub base: String,
pub takers: Vec<Taker>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub refused_takers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub none_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Taker {
pub storage: String,
pub key_expr: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<StorageClass>,
pub relation: TakerRelation,
pub why: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TakerRelation {
Includes,
Intersects,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn gc() -> GarbageCollection {
GarbageCollection {
period_s: 30,
lifespan_s: 1800,
derivation: "max ttl_s 900 (netring/alert/{alert_key}) × 2.0 = 1800 s".into(),
}
}
#[test]
fn the_deployment_file_parses_as_documented() {
let d: Deployment = serde_json::from_value(json!({
"base": "zensight",
"volumes": {
"fs": {"plugin": "fs", "dir": "/var/lib/zenoh"},
"redb-history": {"plugin": "redb", "history": "all"}
},
"storages": {
"latest": {"class": "state", "volume": "fs", "replication": true, "complete": true},
"pdns": {"class": "catalog-pdns", "volume": "redb-history",
"replication": {"interval": 10.0}, "retention": {"max_age_s": 86400}}
}
}))
.unwrap();
assert_eq!(d.base.as_deref(), Some("zensight"));
assert_eq!(d.volumes["fs"].params["dir"], json!("/var/lib/zenoh"));
assert_eq!(d.volumes["redb-history"].history, Some(HistoryMode::All));
assert_eq!(d.storages["latest"].class, Some(StorageClass::State));
assert_eq!(d.storages["latest"].replication, Replication::Enabled(true));
assert_eq!(d.storages["pdns"].class, Some(StorageClass::CatalogPdns));
assert!(matches!(
d.storages["pdns"].replication,
Replication::Params(ref p) if p["interval"] == json!(10.0)
));
let typo: Result<Deployment, _> = serde_json::from_value(json!({
"storages": {"latest": {"class": "state", "volume": "fs", "replicaton": true}}
}));
assert!(
typo.is_err(),
"a storage key this tool does not know is refused"
);
}
#[test]
fn the_plan_pins_its_shape() {
let plan = StoragePlan {
base: "zensight".into(),
registry: Asked::NotAsked,
volumes: vec![PlannedVolume {
id: "fs".into(),
plugin: "fs".into(),
history: HistoryMode::Latest,
persistence: Some(Persistence::Durable),
params: BTreeMap::new(),
warnings: vec![],
}],
storages: vec![PlannedStorage {
name: "latest".into(),
class: Some(StorageClass::State),
key_expr: "zensight/v1/*/state/**".into(),
strip_prefix: "zensight/v1".into(),
volume: "fs".into(),
history: HistoryMode::Latest,
replication: None,
complete: false,
garbage_collection: gc(),
retention: None,
params: BTreeMap::new(),
covers: Asked::NotAsked,
warnings: vec![PlanWarning {
kind: WarningKind::CompleteRefused,
text: "t".into(),
cite: "RFC 09 §2.2".into(),
}],
}],
refusals: vec![Refusal {
storage: Some("events".into()),
volume: None,
key_expr: Some("zensight/v1/*/events/**".into()),
reason: "r".into(),
cite: "RFC 09 §2".into(),
}],
};
let v = serde_json::to_value(&plan).unwrap();
assert!(v.get("registry").is_none(), "not asked is absence");
assert_eq!(v["volumes"][0]["persistence"], json!("durable"));
assert_eq!(v["volumes"][0]["history"], json!("latest"));
assert!(v["volumes"][0].get("params").is_none());
let s = &v["storages"][0];
assert_eq!(s["class"], json!("state"));
assert_eq!(s["complete"], json!(false));
assert!(s.get("replication").is_none());
assert!(s.get("covers").is_none());
assert_eq!(s["garbage_collection"]["lifespan_s"], json!(1800));
assert_eq!(s["warnings"][0]["kind"], json!("complete_refused"));
assert_eq!(v["refusals"][0]["storage"], json!("events"));
assert!(v["refusals"][0].get("volume").is_none());
let asked = StoragePlan {
registry: Asked::Asked(RegistryFacts {
slices: 3,
max_ttl_s: Some(900),
ttl_source: Some("netring/alert/{alert_key}".into()),
}),
..plan
};
let v = serde_json::to_value(&asked).unwrap();
assert_eq!(v["registry"]["max_ttl_s"], json!(900));
assert_eq!(
serde_json::to_value(StorageClass::CatalogPdns).unwrap(),
json!("catalog-pdns")
);
}
#[test]
fn the_check_pins_its_shape() {
let check = StorageCheck {
base: "".into(),
asked: "@/*/router/**/storage_manager/storages/**".into(),
planned: 1,
observed: 1,
findings: vec![CheckFinding {
kind: CheckKind::LifespanBelowMinimum,
storage: "latest".into(),
zid: Some("aabb".into()),
planned: Some("1800".into()),
observed: Some("600".into()),
}],
unjudged: vec![],
judgement: Judgement::Established,
};
let v = serde_json::to_value(&check).unwrap();
assert_eq!(v["findings"][0]["kind"], json!("lifespan_below_minimum"));
assert_eq!(v["judgement"], json!({"answer": "established"}));
assert!(v.get("unjudged").is_none(), "empty unjudged is absence");
}
#[test]
fn the_explain_pins_its_shape() {
let e = StorageExplain {
key: "zensight/v1/@catalog/state/entity/x".into(),
base: "zensight".into(),
takers: vec![Taker {
storage: "catalog".into(),
key_expr: "zensight/v1/@catalog/state/**".into(),
class: Some(StorageClass::Catalog),
relation: TakerRelation::Includes,
why: "w".into(),
}],
refused_takers: vec![],
none_reason: None,
};
let v = serde_json::to_value(&e).unwrap();
assert_eq!(v["takers"][0]["relation"], json!("includes"));
assert!(v.get("none_reason").is_none());
assert!(v.get("refused_takers").is_none());
}
}