1use std::time::Duration;
12
13use anyhow::{Result, anyhow};
14use zenoh::Session;
15use zenoh::query::{ConsolidationMode, QueryTarget};
16
17#[derive(Debug, Clone)]
19pub struct AdminEntry {
20 pub key: String,
21 pub value: serde_json::Value,
22}
23
24pub 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#[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 pub raw: serde_json::Value,
64}
65
66pub 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 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#[derive(Debug, Clone, serde::Serialize)]
109pub struct StorageInfo {
110 pub zid: String,
111 pub name: String,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub key_expr: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
119 pub strip_prefix: Option<String>,
120 #[serde(skip_serializing_if = "Option::is_none")]
125 pub volume: Option<String>,
126 pub raw: serde_json::Value,
128}
129
130pub fn storage_from_admin_entry(key: &str, value: &serde_json::Value) -> Option<StorageInfo> {
135 let chunks: Vec<&str> = key.split('/').collect();
136 let storages_pos = chunks.iter().position(|c| *c == "storages")?;
137 if chunks.get(storages_pos.checked_sub(1)?) != Some(&"storage_manager") {
139 return None;
140 }
141 let name = chunks.get(storages_pos + 1)?;
142 let zid = chunks.get(1).unwrap_or(&"?");
143 let text = |field: &str| {
144 value
145 .get(field)
146 .and_then(|v| v.as_str())
147 .map(str::to_string)
148 };
149 let volume = text("volume").or_else(|| {
152 value
153 .get("volume")
154 .and_then(|v| v.get("id"))
155 .and_then(|v| v.as_str())
156 .map(str::to_string)
157 });
158 Some(StorageInfo {
159 zid: (*zid).to_string(),
160 name: (*name).to_string(),
161 key_expr: text("key_expr"),
162 strip_prefix: text("strip_prefix"),
163 volume,
164 raw: value.clone(),
165 })
166}
167
168pub fn merge_storage_rows(mut rows: Vec<StorageInfo>) -> Vec<StorageInfo> {
176 rows.sort_by(|a, b| (&a.zid, &a.name).cmp(&(&b.zid, &b.name)));
177 let mut out: Vec<StorageInfo> = Vec::with_capacity(rows.len());
178 for row in rows {
179 match out.last_mut() {
180 Some(prev) if prev.zid == row.zid && prev.name == row.name => {
181 prev.key_expr = prev.key_expr.take().or(row.key_expr);
182 prev.strip_prefix = prev.strip_prefix.take().or(row.strip_prefix);
183 prev.volume = prev.volume.take().or(row.volume);
184 if prev.raw.as_object().map(|o| o.len()).unwrap_or(0)
187 < row.raw.as_object().map(|o| o.len()).unwrap_or(0)
188 {
189 prev.raw = row.raw;
190 }
191 }
192 _ => out.push(row),
193 }
194 }
195 out
196}
197
198pub async fn storages(session: &Session, timeout: Duration) -> Result<Vec<StorageInfo>> {
201 let entries = admin_get(
202 session,
203 "@/*/router/**/storage_manager/storages/**",
204 timeout,
205 )
206 .await?;
207 let rows: Vec<StorageInfo> = entries
208 .iter()
209 .filter_map(|e| storage_from_admin_entry(&e.key, &e.value))
210 .collect();
211 Ok(merge_storage_rows(rows))
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
216#[serde(tag = "coverage", content = "storage")]
217pub enum Coverage {
218 Covered(String),
220 Partial(String),
222 Uncovered,
226}
227
228#[derive(Debug, Clone, serde::Serialize)]
229pub struct CoverageRow {
230 pub producer: String,
231 pub path: String,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub ttl_s: Option<i64>,
234 #[serde(flatten)]
235 pub coverage: Coverage,
236}
237
238pub fn state_coverage(
242 slices: &crate::registry::SliceSet,
243 base: &str,
244 storages: &[StorageInfo],
245) -> Vec<CoverageRow> {
246 use zenoh::key_expr::keyexpr;
247 let storage_kes: Vec<(&StorageInfo, &keyexpr)> = storages
248 .iter()
249 .filter_map(|s| {
250 let ke = s.key_expr.as_deref()?;
251 keyexpr::new(ke).ok().map(|ke| (s, ke))
252 })
253 .collect();
254 let mut rows = Vec::new();
255 for slice in slices.slices() {
256 for subject in &slice.subjects {
257 if subject.class != "state" {
258 continue;
259 }
260 let Ok(pattern) = zenkey::pattern::SubjectPattern::parse(&subject.path) else {
261 continue;
262 };
263 let selector = match &slice.service_origin {
267 Some(origin) => zenkey::grammar::with_base(
268 base,
269 format!("v1/{origin}/state/{}", pattern.selector_tail()),
270 ),
271 None => zenkey::grammar::with_base(
272 base,
273 format!("v1/*/state/{}/{}", slice.name, pattern.selector_tail()),
274 ),
275 };
276 let Ok(family) = keyexpr::new(selector.as_str()) else {
277 continue;
278 };
279 let mut coverage = Coverage::Uncovered;
280 for (info, ke) in &storage_kes {
281 if ke.includes(family) {
282 coverage = Coverage::Covered(format!("{}@{}", info.name, info.zid));
283 break;
284 }
285 if ke.intersects(family) && coverage == Coverage::Uncovered {
286 coverage = Coverage::Partial(format!("{}@{}", info.name, info.zid));
287 }
288 }
289 rows.push(CoverageRow {
290 producer: slice.name.clone(),
291 path: subject.path.clone(),
292 ttl_s: subject.ttl_s,
293 coverage,
294 });
295 }
296 }
297 rows
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
302#[serde(rename_all = "snake_case")]
303pub enum EntityKind {
304 Subscriber,
305 Publisher,
306 Queryable,
307 Querier,
308 Token,
309}
310
311impl EntityKind {
312 fn from_chunk(chunk: &str) -> Option<EntityKind> {
313 Some(match chunk {
314 "subscriber" => EntityKind::Subscriber,
315 "publisher" => EntityKind::Publisher,
316 "queryable" => EntityKind::Queryable,
317 "querier" => EntityKind::Querier,
318 "token" => EntityKind::Token,
319 _ => return None,
320 })
321 }
322
323 fn chunk(self) -> &'static str {
324 match self {
325 EntityKind::Subscriber => "subscriber",
326 EntityKind::Publisher => "publisher",
327 EntityKind::Queryable => "queryable",
328 EntityKind::Querier => "querier",
329 EntityKind::Token => "token",
330 }
331 }
332}
333
334#[derive(Debug, Clone, serde::Serialize)]
339pub struct DeclaredEntity {
340 pub kind: EntityKind,
341 pub keyexpr: String,
343 pub node_zid: String,
345 pub sources: serde_json::Value,
348}
349
350#[derive(Debug, Clone, Default, serde::Serialize)]
352pub struct DeclaredEntities {
353 pub entities: Vec<DeclaredEntity>,
354}
355
356pub fn declared_from_admin_entry(key: &str, value: &serde_json::Value) -> Option<DeclaredEntity> {
360 let mut chunks = key.split('/');
361 if chunks.next()? != "@" {
362 return None;
363 }
364 let zid = chunks.next()?;
365 let _whatami = chunks.next()?;
366 let kind = EntityKind::from_chunk(chunks.next()?)?;
367 let keyexpr: Vec<&str> = chunks.collect();
368 if keyexpr.is_empty() {
369 return None;
370 }
371 Some(DeclaredEntity {
372 kind,
373 keyexpr: keyexpr.join("/"),
374 node_zid: zid.to_string(),
375 sources: value.clone(),
376 })
377}
378
379pub async fn declared_entities(
389 session: &Session,
390 timeout: Duration,
391) -> Result<Option<DeclaredEntities>> {
392 let mut entities = Vec::new();
393 let mut any_reply = false;
394 for kind in [
395 EntityKind::Subscriber,
396 EntityKind::Publisher,
397 EntityKind::Queryable,
398 EntityKind::Querier,
399 EntityKind::Token,
400 ] {
401 let selector = format!("@/*/*/{}/**", kind.chunk());
402 let entries = admin_get(session, &selector, timeout).await?;
403 any_reply |= !entries.is_empty();
404 entities.extend(
405 entries
406 .iter()
407 .filter_map(|e| declared_from_admin_entry(&e.key, &e.value)),
408 );
409 }
410 if !any_reply {
411 return Ok(None);
412 }
413 Ok(Some(DeclaredEntities { entities }))
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn storage_extraction_tolerates_layouts() {
422 let v = serde_json::json!({"key_expr": "zs/v1/*/state/**", "volume": "fs"});
424 let s = storage_from_admin_entry(
425 "@/abc123/router/config/plugins/storage_manager/storages/latest",
426 &v,
427 )
428 .unwrap();
429 assert_eq!(s.zid, "abc123");
430 assert_eq!(s.name, "latest");
431 assert_eq!(s.key_expr.as_deref(), Some("zs/v1/*/state/**"));
432 assert_eq!(s.volume.as_deref(), Some("fs"), "a bare-string volume");
433 let v = serde_json::json!({
435 "key_expr": "zs/v1/*/state/**",
436 "strip_prefix": "zs/v1",
437 "volume": {"id": "rocksdb", "dir": "latest"},
438 });
439 let s = storage_from_admin_entry(
440 "@/abc123/router/config/plugins/storage_manager/storages/durable",
441 &v,
442 )
443 .unwrap();
444 assert_eq!(s.volume.as_deref(), Some("rocksdb"), "an object volume");
445 assert_eq!(s.strip_prefix.as_deref(), Some("zs/v1"));
446 let s = storage_from_admin_entry(
448 "@/abc123/router/status/plugins/storage_manager/storages/latest/info",
449 &serde_json::json!("ok"),
450 )
451 .unwrap();
452 assert_eq!(s.name, "latest");
453 assert!(s.key_expr.is_none());
454 assert!(
456 storage_from_admin_entry(
457 "@/abc123/router/config/plugins/storage_manager/volumes/fs",
458 &serde_json::json!({}),
459 )
460 .is_none()
461 );
462 }
463
464 #[test]
467 fn merging_a_storage_keeps_every_field_either_side_named() {
468 let config = StorageInfo {
469 zid: "z1".into(),
470 name: "latest".into(),
471 key_expr: Some("zs/**".into()),
472 strip_prefix: Some("zs".into()),
473 volume: None,
474 raw: serde_json::json!({"key_expr": "zs/**", "strip_prefix": "zs"}),
475 };
476 let status = StorageInfo {
477 zid: "z1".into(),
478 name: "latest".into(),
479 key_expr: None,
480 strip_prefix: None,
481 volume: Some("fs".into()),
482 raw: serde_json::json!({"volume": "fs"}),
483 };
484 for rows in [
485 vec![config.clone(), status.clone()],
486 vec![status, config.clone()],
487 ] {
488 let merged = merge_storage_rows(rows);
489 assert_eq!(merged.len(), 1, "one row per (zid, name)");
490 assert_eq!(merged[0].key_expr.as_deref(), Some("zs/**"));
491 assert_eq!(merged[0].strip_prefix.as_deref(), Some("zs"));
492 assert_eq!(merged[0].volume.as_deref(), Some("fs"));
493 }
494
495 let other = StorageInfo {
497 zid: "z1".into(),
498 name: "history".into(),
499 key_expr: None,
500 strip_prefix: None,
501 volume: None,
502 raw: serde_json::Value::Null,
503 };
504 assert_eq!(merge_storage_rows(vec![config, other]).len(), 2);
505 }
506
507 fn slices_with_state() -> crate::registry::SliceSet {
508 let toml = r#"
509 [registry]
510 version = "1.0"
511 app = "t"
512 convention = 1
513 [producer]
514 name = "tc"
515 [[subject]]
516 path = "health"
517 class = "state"
518 type = "Health"
519 ttl_s = 60
520 [[subject]]
521 path = "config/{iface}"
522 class = "state"
523 type = "Config"
524 ttl_s = 120
525 [[subject]]
526 path = "bandwidth"
527 class = "telemetry"
528 type = "Point"
529 "#;
530 crate::registry::SliceSet::from_toml_for_tests(toml)
531 }
532
533 fn storage(name: &str, key_expr: &str) -> StorageInfo {
534 StorageInfo {
535 zid: "z1".into(),
536 name: name.into(),
537 key_expr: Some(key_expr.into()),
538 strip_prefix: None,
539 volume: None,
540 raw: serde_json::Value::Null,
541 }
542 }
543
544 #[test]
545 fn coverage_judges_covered_partial_uncovered() {
546 let slices = slices_with_state();
547 let rows = state_coverage(&slices, "zs", &[storage("latest", "zs/v1/*/state/**")]);
549 assert_eq!(rows.len(), 2);
550 assert!(
551 rows.iter()
552 .all(|r| matches!(r.coverage, Coverage::Covered(_)))
553 );
554
555 let rows = state_coverage(
557 &slices,
558 "zs",
559 &[storage("one", "zs/v1/*/state/tc/config/eth0")],
560 );
561 let health = rows.iter().find(|r| r.path == "health").unwrap();
562 assert_eq!(health.coverage, Coverage::Uncovered);
563 let config = rows.iter().find(|r| r.path == "config/{iface}").unwrap();
564 assert!(matches!(config.coverage, Coverage::Partial(_)));
565
566 let rows = state_coverage(&slices, "zs", &[]);
568 assert!(rows.iter().all(|r| r.coverage == Coverage::Uncovered));
569
570 let rows = state_coverage(&slices, "", &[storage("latest", "v1/*/state/**")]);
573 assert_eq!(rows.len(), 2);
574 assert!(
575 rows.iter()
576 .all(|r| matches!(r.coverage, Coverage::Covered(_)))
577 );
578 }
579
580 #[test]
583 fn declared_entities_parse_the_admin_key_shape() {
584 let v = serde_json::json!({"routers": [], "peers": ["p1"], "clients": []});
585 let e = declared_from_admin_entry("@/a1b2c3/router/subscriber/zensight/v1/*/state/**", &v)
586 .unwrap();
587 assert_eq!(e.kind, EntityKind::Subscriber);
588 assert_eq!(e.keyexpr, "zensight/v1/*/state/**");
589 assert_eq!(e.node_zid, "a1b2c3");
590
591 let e = declared_from_admin_entry("@/z/peer/publisher/v1/h-a/telemetry/x/m", &v).unwrap();
592 assert_eq!(e.kind, EntityKind::Publisher);
593 assert_eq!(e.keyexpr, "v1/h-a/telemetry/x/m");
594
595 assert!(declared_from_admin_entry("@/z/router/config/x", &v).is_none());
597 assert!(declared_from_admin_entry("@/z/router/subscriber", &v).is_none());
598 assert!(declared_from_admin_entry("not/admin/at/all", &v).is_none());
599 }
600}
601
602#[derive(Debug, Clone, serde::Serialize)]
607pub struct OriginAttachment {
608 pub origin: String,
610 #[serde(skip_serializing_if = "Option::is_none")]
615 pub session_zid: Option<String>,
616 pub reporter_zid: String,
619 pub token_key: String,
621}
622
623fn source_zids(sources: &serde_json::Value) -> Vec<String> {
627 let mut out = Vec::new();
628 for kind in ["routers", "peers", "clients"] {
629 if let Some(list) = sources.get(kind).and_then(|v| v.as_array()) {
630 out.extend(list.iter().filter_map(|z| z.as_str().map(str::to_string)));
631 }
632 }
633 out.sort();
634 out.dedup();
635 out
636}
637
638pub async fn origin_attachments(
648 session: &Session,
649 base: &str,
650 timeout: Duration,
651) -> Result<Vec<OriginAttachment>> {
652 let entries = admin_get(session, "@/*/*/token/**", timeout).await?;
653 let mut out: Vec<OriginAttachment> = Vec::new();
654 for e in &entries {
655 let Some(decl) = declared_from_admin_entry(&e.key, &e.value) else {
656 continue;
657 };
658 if decl.kind != EntityKind::Token {
659 continue;
660 }
661 let Some(parsed) = zenkey::grammar::parse_full(base, &decl.keyexpr) else {
662 continue;
663 };
664 if parsed.subject.last().copied() != Some("alive") {
668 continue;
669 }
670 let origin = parsed.origin.chunk().to_string();
671 let zids = source_zids(&decl.sources);
672 let session_zid = match zids.as_slice() {
673 [only] => Some(only.clone()),
674 _ => None,
675 };
676 let attachment = OriginAttachment {
677 origin,
678 session_zid,
679 reporter_zid: decl.node_zid.clone(),
680 token_key: decl.keyexpr.clone(),
681 };
682 if !out.iter().any(|a| {
685 a.origin == attachment.origin
686 && a.session_zid == attachment.session_zid
687 && a.reporter_zid == attachment.reporter_zid
688 }) {
689 out.push(attachment);
690 }
691 }
692 Ok(out)
693}
694
695#[derive(Debug, Clone, serde::Serialize)]
697pub struct TopologyNode {
698 pub zid: String,
699 pub whatami: String,
702 #[serde(skip_serializing_if = "Option::is_none")]
703 pub version: Option<String>,
704 #[serde(skip_serializing_if = "Vec::is_empty")]
705 pub locators: Vec<String>,
706 pub answered: bool,
710}
711
712#[derive(Debug, Clone, serde::Serialize)]
716pub struct TopologyEdge {
717 pub reporter: String,
719 pub peer: String,
721 pub whatami: String,
723 #[serde(skip_serializing_if = "Vec::is_empty")]
725 pub links: Vec<String>,
726}
727
728#[derive(Debug, Clone, serde::Serialize)]
731pub struct TopologyReport {
732 pub nodes: Vec<TopologyNode>,
733 pub edges: Vec<TopologyEdge>,
734 pub asked: String,
736 pub answered: usize,
739 pub self_zid: String,
741}
742
743pub async fn topology(session: &Session, timeout: Duration) -> Result<TopologyReport> {
748 const ASKED: &str = "@/*/*";
749 let entries = admin_get(session, ASKED, timeout).await?;
750 let mut nodes: Vec<TopologyNode> = Vec::new();
751 let mut edges: Vec<TopologyEdge> = Vec::new();
752 for e in &entries {
753 let mut chunks = e.key.split('/');
756 let (Some("@"), Some(zid), Some(whatami), None) =
757 (chunks.next(), chunks.next(), chunks.next(), chunks.next())
758 else {
759 continue;
760 };
761 let doc = &e.value;
762 nodes.push(TopologyNode {
763 zid: doc
764 .get("zid")
765 .and_then(|v| v.as_str())
766 .unwrap_or(zid)
767 .to_string(),
768 whatami: whatami.to_string(),
769 version: doc
770 .get("version")
771 .and_then(|v| v.as_str())
772 .map(str::to_string),
773 locators: doc
774 .get("locators")
775 .and_then(|v| v.as_array())
776 .map(|a| {
777 a.iter()
778 .filter_map(|l| l.as_str().map(str::to_string))
779 .collect()
780 })
781 .unwrap_or_default(),
782 answered: true,
783 });
784 for s in doc
785 .get("sessions")
786 .and_then(|v| v.as_array())
787 .map(|a| a.as_slice())
788 .unwrap_or_default()
789 {
790 let Some(peer) = s.get("peer").and_then(|v| v.as_str()) else {
791 continue;
792 };
793 edges.push(TopologyEdge {
794 reporter: zid.to_string(),
795 peer: peer.to_string(),
796 whatami: s
797 .get("whatami")
798 .and_then(|v| v.as_str())
799 .unwrap_or("unknown")
800 .to_string(),
801 links: s
802 .get("links")
803 .and_then(|v| v.as_array())
804 .map(|a| {
805 a.iter()
806 .filter_map(|l| {
807 Some(format!(
808 "{} -> {}",
809 l.get("src")?.as_str()?,
810 l.get("dst")?.as_str()?
811 ))
812 })
813 .collect()
814 })
815 .unwrap_or_default(),
816 });
817 }
818 }
819 let answered = nodes.len();
820 for e in &edges {
823 if !nodes.iter().any(|n| n.zid == e.peer) {
824 nodes.push(TopologyNode {
825 zid: e.peer.clone(),
826 whatami: e.whatami.clone(),
827 version: None,
828 locators: Vec::new(),
829 answered: false,
830 });
831 }
832 }
833 nodes.sort_by(|a, b| a.zid.cmp(&b.zid));
834 nodes.dedup_by(|a, b| a.zid == b.zid);
835 Ok(TopologyReport {
836 nodes,
837 edges,
838 asked: ASKED.to_string(),
839 answered,
840 self_zid: session.zid().to_string(),
841 })
842}