Skip to main content

zenkey_fleet/
admin.rs

1//! Zenoh admin-space access (issue #14): browse `@/**` — the middleware's
2//! own introspection — from the same un-namespaced session the convention
3//! tooling already holds (a namespaced session's admin selector would be
4//! rewritten and match nothing, RFC 09 §5).
5//!
6//! Admin key layouts vary between zenoh versions (report §3.1's caveat), so
7//! this module stays a thin, honest transport: keys + JSON values, no
8//! hardcoded schema. `routers` extracts the few fields every 1.x layout
9//! carries, and leaves the rest visible in `raw`.
10
11use std::time::Duration;
12
13use anyhow::{Result, anyhow};
14use zenoh::Session;
15use zenoh::query::{ConsolidationMode, QueryTarget};
16
17/// One admin-space entry.
18#[derive(Debug, Clone)]
19pub struct AdminEntry {
20    pub key: String,
21    pub value: serde_json::Value,
22}
23
24/// GET an admin selector (default `@/**`). Fans to every node (target All,
25/// consolidation None — several routers may answer).
26pub async fn admin_get(
27    session: &Session,
28    selector: &str,
29    timeout: Duration,
30) -> Result<Vec<AdminEntry>> {
31    let replies = session
32        .get(selector)
33        .target(QueryTarget::All)
34        .consolidation(ConsolidationMode::None)
35        .timeout(timeout)
36        .await
37        .map_err(|e| anyhow!("admin get {selector}: {e}"))?;
38    let mut out = Vec::new();
39    while let Ok(reply) = replies.recv_async().await {
40        let Ok(sample) = reply.result() else { continue };
41        let bytes = sample.payload().to_bytes();
42        let value = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
43            serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
44        });
45        out.push(AdminEntry {
46            key: sample.key_expr().as_str().to_string(),
47            value,
48        });
49    }
50    out.sort_by(|a, b| a.key.cmp(&b.key));
51    Ok(out)
52}
53
54/// A router (or peer) as the admin space reports it.
55#[derive(Debug, Clone, serde::Serialize)]
56pub struct RouterInfo {
57    pub zid: String,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub version: Option<String>,
60    #[serde(skip_serializing_if = "Vec::is_empty")]
61    pub locators: Vec<String>,
62    /// The full admin document, untrimmed — layouts vary by version.
63    pub raw: serde_json::Value,
64}
65
66/// Enumerate routers/peers from `@/*/router` (and the fields every layout
67/// carries).
68pub async fn routers(session: &Session, timeout: Duration) -> Result<Vec<RouterInfo>> {
69    let entries = admin_get(session, "@/*/router", timeout).await?;
70    Ok(entries
71        .into_iter()
72        .map(|e| {
73            let zid = e
74                .value
75                .get("zid")
76                .and_then(|v| v.as_str())
77                .map(str::to_string)
78                .unwrap_or_else(|| {
79                    // Fall back to the key's zid chunk: @/<zid>/router.
80                    e.key.split('/').nth(1).unwrap_or("?").to_string()
81                });
82            let version = e
83                .value
84                .get("version")
85                .and_then(|v| v.as_str())
86                .map(str::to_string);
87            let locators = e
88                .value
89                .get("locators")
90                .and_then(|v| v.as_array())
91                .map(|a| {
92                    a.iter()
93                        .filter_map(|l| l.as_str().map(str::to_string))
94                        .collect()
95                })
96                .unwrap_or_default();
97            RouterInfo {
98                zid,
99                version,
100                locators,
101                raw: e.value,
102            }
103        })
104        .collect())
105}
106
107/// One configured storage, as the admin space reports it.
108#[derive(Debug, Clone, serde::Serialize)]
109pub struct StorageInfo {
110    pub zid: String,
111    pub name: String,
112    /// The key expression the storage captures, when the layout exposes it.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub key_expr: Option<String>,
115    /// The full admin document, untrimmed — layouts vary by version.
116    pub raw: serde_json::Value,
117}
118
119/// Extract a storage from one admin entry, tolerantly: the key shape is
120/// `@/<zid>/router/…/storage_manager/storages/<name>[…]`, the value a config
121/// document whose `key_expr` field names what it captures. Pure — the
122/// version-variance lives here, unit-tested.
123pub fn storage_from_admin_entry(key: &str, value: &serde_json::Value) -> Option<StorageInfo> {
124    let chunks: Vec<&str> = key.split('/').collect();
125    let storages_pos = chunks.iter().position(|c| *c == "storages")?;
126    // Only storage_manager subtrees qualify (volumes etc. share the plugin).
127    if chunks.get(storages_pos.checked_sub(1)?) != Some(&"storage_manager") {
128        return None;
129    }
130    let name = chunks.get(storages_pos + 1)?;
131    let zid = chunks.get(1).unwrap_or(&"?");
132    let key_expr = value
133        .get("key_expr")
134        .and_then(|v| v.as_str())
135        .map(str::to_string);
136    Some(StorageInfo {
137        zid: (*zid).to_string(),
138        name: (*name).to_string(),
139        key_expr,
140        raw: value.clone(),
141    })
142}
143
144/// Enumerate configured storages across the mesh (issue #14). Zero routers
145/// (peer mesh, admin disabled) is an empty vec, never an error.
146pub async fn storages(session: &Session, timeout: Duration) -> Result<Vec<StorageInfo>> {
147    let entries = admin_get(
148        session,
149        "@/*/router/**/storage_manager/storages/**",
150        timeout,
151    )
152    .await?;
153    let mut out: Vec<StorageInfo> = entries
154        .iter()
155        .filter_map(|e| storage_from_admin_entry(&e.key, &e.value))
156        .collect();
157    // One row per (zid, name): config and status subtrees can both answer.
158    out.sort_by(|a, b| (&a.zid, &a.name).cmp(&(&b.zid, &b.name)));
159    out.dedup_by(|a, b| {
160        if a.zid == b.zid && a.name == b.name {
161            // Keep the richer entry (the one that names a key_expr).
162            if b.key_expr.is_none() {
163                b.key_expr = a.key_expr.take();
164            }
165            true
166        } else {
167            false
168        }
169    });
170    Ok(out)
171}
172
173/// How a declared state family relates to the configured storages.
174#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
175#[serde(tag = "coverage", content = "storage")]
176pub enum Coverage {
177    /// Some storage's key expression includes every key of the family.
178    Covered(String),
179    /// A storage overlaps the family but does not include all of it.
180    Partial(String),
181    /// No storage touches the family. For volatile (ttl'd) state this can be
182    /// legitimate — advanced-pub/sub cache seeding (RFC 04 §3.5); storage is
183    /// authoritative for durable data.
184    Uncovered,
185}
186
187#[derive(Debug, Clone, serde::Serialize)]
188pub struct CoverageRow {
189    pub producer: String,
190    pub path: String,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub ttl_s: Option<i64>,
193    #[serde(flatten)]
194    pub coverage: Coverage,
195}
196
197/// Judge every declared **state** family against the configured storages
198/// (issue #14): the family's wire selector vs each storage's key expression,
199/// by key algebra (`includes` ⇒ covered, `intersects` ⇒ partial). Pure.
200pub fn state_coverage(
201    slices: &crate::registry::SliceSet,
202    base: &str,
203    storages: &[StorageInfo],
204) -> Vec<CoverageRow> {
205    use zenoh::key_expr::keyexpr;
206    let storage_kes: Vec<(&StorageInfo, &keyexpr)> = storages
207        .iter()
208        .filter_map(|s| {
209            let ke = s.key_expr.as_deref()?;
210            keyexpr::new(ke).ok().map(|ke| (s, ke))
211        })
212        .collect();
213    let mut rows = Vec::new();
214    for slice in slices.slices() {
215        for subject in &slice.subjects {
216            if subject.class != "state" {
217                continue;
218            }
219            let Ok(pattern) = zenkey::pattern::SubjectPattern::parse(&subject.path) else {
220                continue;
221            };
222            // Composed via `with_base` so the empty base stays a valid
223            // keyexpr (`format!("{base}/…")` would grow a leading slash and
224            // silently drop every family below).
225            let selector = match &slice.service_origin {
226                Some(origin) => zenkey::grammar::with_base(
227                    base,
228                    format!("v1/{origin}/state/{}", pattern.selector_tail()),
229                ),
230                None => zenkey::grammar::with_base(
231                    base,
232                    format!("v1/*/state/{}/{}", slice.name, pattern.selector_tail()),
233                ),
234            };
235            let Ok(family) = keyexpr::new(selector.as_str()) else {
236                continue;
237            };
238            let mut coverage = Coverage::Uncovered;
239            for (info, ke) in &storage_kes {
240                if ke.includes(family) {
241                    coverage = Coverage::Covered(format!("{}@{}", info.name, info.zid));
242                    break;
243                }
244                if ke.intersects(family) && coverage == Coverage::Uncovered {
245                    coverage = Coverage::Partial(format!("{}@{}", info.name, info.zid));
246                }
247            }
248            rows.push(CoverageRow {
249                producer: slice.name.clone(),
250                path: subject.path.clone(),
251                ttl_s: subject.ttl_s,
252                coverage,
253            });
254        }
255    }
256    rows
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn storage_extraction_tolerates_layouts() {
265        // 1.x config-subtree shape.
266        let v = serde_json::json!({"key_expr": "zs/v1/*/state/**", "volume": "fs"});
267        let s = storage_from_admin_entry(
268            "@/abc123/router/config/plugins/storage_manager/storages/latest",
269            &v,
270        )
271        .unwrap();
272        assert_eq!(s.zid, "abc123");
273        assert_eq!(s.name, "latest");
274        assert_eq!(s.key_expr.as_deref(), Some("zs/v1/*/state/**"));
275        // Status-subtree shape without key_expr still names the storage.
276        let s = storage_from_admin_entry(
277            "@/abc123/router/status/plugins/storage_manager/storages/latest/info",
278            &serde_json::json!("ok"),
279        )
280        .unwrap();
281        assert_eq!(s.name, "latest");
282        assert!(s.key_expr.is_none());
283        // Non-storage subtrees do not match.
284        assert!(
285            storage_from_admin_entry(
286                "@/abc123/router/config/plugins/storage_manager/volumes/fs",
287                &serde_json::json!({}),
288            )
289            .is_none()
290        );
291    }
292
293    fn slices_with_state() -> crate::registry::SliceSet {
294        let toml = r#"
295            [registry]
296            version = "1.0"
297            app = "t"
298            convention = 1
299            [producer]
300            name = "tc"
301            [[subject]]
302            path = "health"
303            class = "state"
304            type = "Health"
305            ttl_s = 60
306            [[subject]]
307            path = "config/{iface}"
308            class = "state"
309            type = "Config"
310            ttl_s = 120
311            [[subject]]
312            path = "bandwidth"
313            class = "telemetry"
314            type = "Point"
315        "#;
316        crate::registry::SliceSet::from_toml_for_tests(toml)
317    }
318
319    fn storage(name: &str, key_expr: &str) -> StorageInfo {
320        StorageInfo {
321            zid: "z1".into(),
322            name: name.into(),
323            key_expr: Some(key_expr.into()),
324            raw: serde_json::Value::Null,
325        }
326    }
327
328    #[test]
329    fn coverage_judges_covered_partial_uncovered() {
330        let slices = slices_with_state();
331        // Full state storage: everything covered; telemetry not judged.
332        let rows = state_coverage(&slices, "zs", &[storage("latest", "zs/v1/*/state/**")]);
333        assert_eq!(rows.len(), 2);
334        assert!(
335            rows.iter()
336                .all(|r| matches!(r.coverage, Coverage::Covered(_)))
337        );
338
339        // A one-interface storage: config/{iface} is partial, health uncovered.
340        let rows = state_coverage(
341            &slices,
342            "zs",
343            &[storage("one", "zs/v1/*/state/tc/config/eth0")],
344        );
345        let health = rows.iter().find(|r| r.path == "health").unwrap();
346        assert_eq!(health.coverage, Coverage::Uncovered);
347        let config = rows.iter().find(|r| r.path == "config/{iface}").unwrap();
348        assert!(matches!(config.coverage, Coverage::Partial(_)));
349
350        // No storages at all.
351        let rows = state_coverage(&slices, "zs", &[]);
352        assert!(rows.iter().all(|r| r.coverage == Coverage::Uncovered));
353
354        // The empty base composes a valid selector (`v1/…`, no leading
355        // slash) instead of silently dropping every family.
356        let rows = state_coverage(&slices, "", &[storage("latest", "v1/*/state/**")]);
357        assert_eq!(rows.len(), 2);
358        assert!(
359            rows.iter()
360                .all(|r| matches!(r.coverage, Coverage::Covered(_)))
361        );
362    }
363}