1use std::time::Duration;
19
20use crate::{Error, Result};
21use zenoh::Session;
22
23use crate::bus::query::GetOpts;
24use crate::report::{
25 AdminAnswer, ConsumersReport, Coverage, CoverageRow, DeclaredEntities, DeclaredEntity,
26 EntityKind, MeshLink, OriginAttachment, RouterInfo, StorageInfo, SubjectImpact, TopologyEdge,
27 TopologyNode, TopologyReport,
28};
29
30#[derive(Debug, Clone)]
32pub struct AdminEntry {
33 pub key: String,
34 pub value: serde_json::Value,
35}
36
37pub async fn admin_get(
45 session: &Session,
46 selector: &str,
47 timeout: Duration,
48) -> Result<Vec<AdminEntry>> {
49 admin_get_within(session, selector, &GetOpts::new(timeout)).await
50}
51
52pub async fn admin_get_within(
55 session: &Session,
56 selector: &str,
57 opts: &GetOpts,
58) -> Result<Vec<AdminEntry>> {
59 let replies = crate::bus::query::disciplined_get(session, selector, opts)
60 .await
61 .map_err(|e| Error::bus("admin get", selector, e))?;
62 let mut out = Vec::new();
63 let mut elided = 0u64;
64 while let Ok(reply) = replies.recv_async().await {
65 if out.len() >= opts.reply_bound() {
68 elided += 1;
69 continue;
70 }
71 let Ok(sample) = reply.result() else { continue };
72 let bytes = sample.payload().to_bytes();
73 let value = serde_json::from_slice(&bytes).unwrap_or_else(|_| {
74 serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
75 });
76 out.push(AdminEntry {
77 key: sample.key_expr().as_str().to_string(),
78 value,
79 });
80 }
81 opts.note_elided(elided);
82 out.sort_by(|a, b| a.key.cmp(&b.key));
83 Ok(out)
84}
85
86pub async fn routers(session: &Session, timeout: Duration) -> Result<Vec<RouterInfo>> {
89 let entries = admin_get(session, "@/*/router", timeout).await?;
90 Ok(entries
91 .into_iter()
92 .map(|e| {
93 let zid = e
94 .value
95 .get("zid")
96 .and_then(|v| v.as_str())
97 .map(str::to_string)
98 .unwrap_or_else(|| {
99 e.key.split('/').nth(1).unwrap_or("?").to_string()
101 });
102 let version = e
103 .value
104 .get("version")
105 .and_then(|v| v.as_str())
106 .map(str::to_string);
107 let locators = e
108 .value
109 .get("locators")
110 .and_then(|v| v.as_array())
111 .map(|a| {
112 a.iter()
113 .filter_map(|l| l.as_str().map(str::to_string))
114 .collect()
115 })
116 .unwrap_or_default();
117 RouterInfo {
118 zid,
119 version,
120 locators,
121 raw: e.value,
122 }
123 })
124 .collect())
125}
126
127pub fn storage_from_admin_entry(key: &str, value: &serde_json::Value) -> Option<StorageInfo> {
132 let chunks: Vec<&str> = key.split('/').collect();
133 let storages_pos = chunks.iter().position(|c| *c == "storages")?;
134 if chunks.get(storages_pos.checked_sub(1)?) != Some(&"storage_manager") {
136 return None;
137 }
138 let name = chunks.get(storages_pos + 1)?;
139 let zid = chunks.get(1).unwrap_or(&"?");
140 let text = |field: &str| {
141 value
142 .get(field)
143 .and_then(|v| v.as_str())
144 .map(str::to_string)
145 };
146 let volume = text("volume").or_else(|| {
149 value
150 .get("volume")
151 .and_then(|v| v.get("id"))
152 .and_then(|v| v.as_str())
153 .map(str::to_string)
154 });
155 Some(StorageInfo {
156 zid: (*zid).to_string(),
157 name: (*name).to_string(),
158 key_expr: text("key_expr"),
159 strip_prefix: text("strip_prefix"),
160 volume,
161 raw: value.clone(),
162 })
163}
164
165pub fn merge_storage_rows(mut rows: Vec<StorageInfo>) -> Vec<StorageInfo> {
173 rows.sort_by(|a, b| (&a.zid, &a.name).cmp(&(&b.zid, &b.name)));
174 let mut out: Vec<StorageInfo> = Vec::with_capacity(rows.len());
175 for row in rows {
176 match out.last_mut() {
177 Some(prev) if prev.zid == row.zid && prev.name == row.name => {
178 prev.key_expr = prev.key_expr.take().or(row.key_expr);
179 prev.strip_prefix = prev.strip_prefix.take().or(row.strip_prefix);
180 prev.volume = prev.volume.take().or(row.volume);
181 if prev.raw.as_object().map(|o| o.len()).unwrap_or(0)
184 < row.raw.as_object().map(|o| o.len()).unwrap_or(0)
185 {
186 prev.raw = row.raw;
187 }
188 }
189 _ => out.push(row),
190 }
191 }
192 out
193}
194
195pub async fn storages(session: &Session, timeout: Duration) -> Result<Vec<StorageInfo>> {
198 let entries = admin_get(
199 session,
200 "@/*/router/**/storage_manager/storages/**",
201 timeout,
202 )
203 .await?;
204 let rows: Vec<StorageInfo> = entries
205 .iter()
206 .filter_map(|e| storage_from_admin_entry(&e.key, &e.value))
207 .collect();
208 Ok(merge_storage_rows(rows))
209}
210
211pub fn state_coverage(
215 slices: &crate::model::registry::SliceSet,
216 base: &str,
217 storages: &[StorageInfo],
218) -> Vec<CoverageRow> {
219 use zenoh::key_expr::keyexpr;
220
221 let storage_kes: Vec<(&StorageInfo, &keyexpr)> = storages
222 .iter()
223 .filter_map(|s| {
224 let ke = s.key_expr.as_deref()?;
225 keyexpr::new(ke).ok().map(|ke| (s, ke))
226 })
227 .collect();
228 let mut rows = Vec::new();
229 for slice in slices.slices() {
230 for subject in &slice.subjects {
231 if !subject.class.is(&zenkey::Class::State) {
232 continue;
233 }
234 let Ok(pattern) = zenkey::pattern::SubjectPattern::parse(&subject.path) else {
235 continue;
236 };
237 let selector = match &slice.service_origin {
241 Some(origin) => zenkey::grammar::with_base(
242 base,
243 format!("v1/{origin}/state/{}", pattern.selector_tail()),
244 ),
245 None => zenkey::grammar::with_base(
246 base,
247 format!("v1/*/state/{}/{}", slice.name, pattern.selector_tail()),
248 ),
249 };
250 let Ok(family) = keyexpr::new(selector.as_str()) else {
251 continue;
252 };
253 let mut coverage = Coverage::Uncovered;
254 for (info, ke) in &storage_kes {
255 if ke.includes(family) {
256 coverage = Coverage::Covered(format!("{}@{}", info.name, info.zid));
257 break;
258 }
259 if ke.intersects(family) && coverage == Coverage::Uncovered {
260 coverage = Coverage::Partial(format!("{}@{}", info.name, info.zid));
261 }
262 }
263 rows.push(CoverageRow {
264 producer: slice.name.clone(),
265 path: subject.path.clone(),
266 ttl_s: subject.ttl_s,
267 coverage,
268 });
269 }
270 }
271 rows
272}
273
274impl EntityKind {
275 pub const ALL: [EntityKind; 5] = [
277 EntityKind::Subscriber,
278 EntityKind::Publisher,
279 EntityKind::Queryable,
280 EntityKind::Querier,
281 EntityKind::Token,
282 ];
283
284 fn from_chunk(chunk: &str) -> Option<EntityKind> {
285 Some(match chunk {
286 "subscriber" => EntityKind::Subscriber,
287 "publisher" => EntityKind::Publisher,
288 "queryable" => EntityKind::Queryable,
289 "querier" => EntityKind::Querier,
290 "token" => EntityKind::Token,
291 _ => return None,
292 })
293 }
294
295 fn chunk(self) -> &'static str {
296 match self {
297 EntityKind::Subscriber => "subscriber",
298 EntityKind::Publisher => "publisher",
299 EntityKind::Queryable => "queryable",
300 EntityKind::Querier => "querier",
301 EntityKind::Token => "token",
302 }
303 }
304}
305
306pub fn declared_from_admin_entry(key: &str, value: &serde_json::Value) -> Option<DeclaredEntity> {
310 let mut chunks = key.split('/');
311 if chunks.next()? != "@" {
312 return None;
313 }
314 let zid = chunks.next()?;
315 let _whatami = chunks.next()?;
316 let kind = EntityKind::from_chunk(chunks.next()?)?;
317 let keyexpr: Vec<&str> = chunks.collect();
318 if keyexpr.is_empty() {
319 return None;
320 }
321 Some(DeclaredEntity {
322 kind,
323 keyexpr: keyexpr.join("/"),
324 node_zid: zid.to_string(),
325 sources: value.clone(),
326 })
327}
328
329pub async fn declared_entities(
339 session: &Session,
340 timeout: Duration,
341) -> Result<Option<DeclaredEntities>> {
342 declared_entities_within(session, &GetOpts::new(timeout)).await
343}
344
345pub fn declared_entity_selectors() -> Vec<String> {
348 EntityKind::ALL
349 .iter()
350 .map(|k| format!("@/*/*/{}/**", k.chunk()))
351 .collect()
352}
353
354pub async fn declared_entities_within(
357 session: &Session,
358 opts: &GetOpts,
359) -> Result<Option<DeclaredEntities>> {
360 let mut entities = Vec::new();
361
362 let mut any_reply = false;
363
364 for selector in declared_entity_selectors() {
365 let entries = admin_get_within(session, &selector, opts).await?;
366 any_reply |= !entries.is_empty();
367 entities.extend(
368 entries
369 .iter()
370 .filter_map(|e| declared_from_admin_entry(&e.key, &e.value)),
371 );
372 }
373 if !any_reply {
374 return Ok(None);
375 }
376 Ok(Some(DeclaredEntities { entities }))
377}
378
379pub fn mesh_links(report: &TopologyReport) -> Vec<MeshLink> {
386 let mut out: Vec<MeshLink> = Vec::new();
387 for e in &report.edges {
388 let (a, b) = if e.reporter <= e.peer {
389 (e.reporter.clone(), e.peer.clone())
390 } else {
391 (e.peer.clone(), e.reporter.clone())
392 };
393 match out.iter_mut().find(|l| l.a == a && l.b == b) {
394 Some(link) => link.corroborated = true,
395 None => out.push(MeshLink {
396 a,
397 b,
398 corroborated: false,
399 links: e.links.clone(),
400 }),
401 }
402 }
403 out
404}
405
406pub fn render_dot(report: &TopologyReport, attachments: &[OriginAttachment]) -> String {
409 use std::fmt::Write as _;
410 let mut out = String::from("graph zenoh_mesh {\n");
411 for n in &report.nodes {
412 let shape = if n.whatami == "router" {
413 "box"
414 } else {
415 "ellipse"
416 };
417 let mut style = Vec::new();
418 if !n.answered {
419 style.push("dashed");
420 }
421 if n.zid == report.self_zid {
422 style.push("bold");
423 }
424 let label = match (&n.answered, &n.version) {
425 (false, _) => format!("{}\\n{} (heard of)", n.zid, n.whatami),
426 (true, Some(v)) => format!("{}\\n{} {v}", n.zid, n.whatami),
427 (true, None) => format!("{}\\n{}", n.zid, n.whatami),
428 };
429 let _ = writeln!(
430 out,
431 " \"{}\" [shape={shape}, label=\"{label}\"{}];",
432 n.zid,
433 if style.is_empty() {
434 String::new()
435 } else {
436 format!(", style=\"{}\"", style.join(","))
437 }
438 );
439 }
440 for link in mesh_links(report) {
441 let proto = link
442 .links
443 .first()
444 .and_then(|l| l.split('/').next())
445 .unwrap_or("");
446 let _ = writeln!(
447 out,
448 " \"{}\" -- \"{}\"{};",
449 link.a,
450 link.b,
451 if proto.is_empty() {
452 String::new()
453 } else {
454 format!(" [label=\"{proto}\"]")
455 }
456 );
457 }
458 for (i, a) in attachments.iter().enumerate() {
462 let id = format!("origin_{i}");
463 let _ = writeln!(
464 out,
465 " \"{id}\" [shape=hexagon, label=\"{}\", fontsize=10];",
466 a.origin
467 );
468 match &a.session_zid {
469 Some(z) => {
470 let _ = writeln!(out, " \"{id}\" -- \"{z}\";");
471 }
472 None => {
473 let _ = writeln!(
474 out,
475 " \"{id}\" -- \"{}\" [style=dotted, label=\"reported\"];",
476 a.reporter_zid
477 );
478 }
479 }
480 }
481 out.push('}');
482 out
483}
484
485pub(crate) fn source_zids(sources: &serde_json::Value) -> Vec<String> {
489 let mut out = Vec::new();
490 for kind in ["routers", "peers", "clients"] {
491 if let Some(list) = sources.get(kind).and_then(|v| v.as_array()) {
492 out.extend(list.iter().filter_map(|z| z.as_str().map(str::to_string)));
493 }
494 }
495 out.sort();
496 out.dedup();
497 out
498}
499
500pub async fn origin_attachments(
510 fleet: &crate::Fleet<'_>,
511 timeout: Duration,
512) -> Result<Vec<OriginAttachment>> {
513 let entries = admin_get(fleet.session(), "@/*/*/token/**", timeout).await?;
514 let tokens: Vec<DeclaredEntity> = entries
515 .iter()
516 .filter_map(|e| declared_from_admin_entry(&e.key, &e.value))
517 .collect();
518 Ok(attach_tokens(fleet.base(), &tokens))
519}
520
521pub fn attach_tokens(base: &str, entities: &[DeclaredEntity]) -> Vec<OriginAttachment> {
526 let mut out: Vec<OriginAttachment> = Vec::new();
527
528 for decl in entities {
529 if decl.kind != EntityKind::Token {
530 continue;
531 }
532 let Some(parsed) = zenkey::grammar::parse_full(base, &decl.keyexpr) else {
533 continue;
534 };
535 if parsed.subject.last().copied() != Some("alive") {
539 continue;
540 }
541 let origin = parsed.origin.chunk().to_string();
542 let zids = source_zids(&decl.sources);
543 let session_zid = match zids.as_slice() {
544 [only] => Some(only.clone()),
545 _ => None,
546 };
547 let attachment = OriginAttachment {
548 origin,
549 session_zid,
550 reporter_zid: decl.node_zid.clone(),
551 token_key: decl.keyexpr.clone(),
552 };
553 if !out.iter().any(|a| {
556 a.origin == attachment.origin
557 && a.session_zid == attachment.session_zid
558 && a.reporter_zid == attachment.reporter_zid
559 }) {
560 out.push(attachment);
561 }
562 }
563 out
564}
565
566pub fn admin_doc_omits_loopback(version: &str) -> bool {
576 let nums: Vec<u64> = version
577 .trim_start_matches(|c: char| !c.is_ascii_digit())
578 .split(|c: char| !c.is_ascii_digit())
579 .take(2)
580 .map_while(|p| p.parse().ok())
581 .collect();
582 matches!(nums.as_slice(), [maj, min] if (*maj, *min) >= (1, 10))
583}
584
585pub async fn topology(session: &Session, timeout: Duration) -> Result<TopologyReport> {
598 const ASKED: &str = "@/*/*";
599 let entries = admin_get(session, ASKED, timeout).await?;
600 let mut nodes: Vec<TopologyNode> = Vec::new();
601 let mut edges: Vec<TopologyEdge> = Vec::new();
602 for e in &entries {
603 let mut chunks = e.key.split('/');
606 let (Some("@"), Some(zid), Some(whatami), None) =
607 (chunks.next(), chunks.next(), chunks.next(), chunks.next())
608 else {
609 continue;
610 };
611 let doc = &e.value;
612 nodes.push(TopologyNode {
613 zid: doc
614 .get("zid")
615 .and_then(|v| v.as_str())
616 .unwrap_or(zid)
617 .to_string(),
618 whatami: whatami.to_string(),
619 version: doc
620 .get("version")
621 .and_then(|v| v.as_str())
622 .map(str::to_string),
623 locators: doc
624 .get("locators")
625 .and_then(|v| v.as_array())
626 .map(|a| {
627 a.iter()
628 .filter_map(|l| l.as_str().map(str::to_string))
629 .collect()
630 })
631 .unwrap_or_default(),
632 locators_via_links: Vec::new(),
633 answered: true,
634 });
635 for s in doc
636 .get("sessions")
637 .and_then(|v| v.as_array())
638 .map(|a| a.as_slice())
639 .unwrap_or_default()
640 {
641 let Some(peer) = s.get("peer").and_then(|v| v.as_str()) else {
642 continue;
643 };
644 edges.push(TopologyEdge {
645 reporter: zid.to_string(),
646 peer: peer.to_string(),
647 whatami: s
648 .get("whatami")
649 .and_then(|v| v.as_str())
650 .unwrap_or("unknown")
651 .to_string(),
652 region: s.get("region").and_then(|v| v.as_str()).map(str::to_string),
653 links: s
654 .get("links")
655 .and_then(|v| v.as_array())
656 .map(|a| {
657 a.iter()
658 .filter_map(|l| {
659 Some(format!(
660 "{} -> {}",
661 l.get("src")?.as_str()?,
662 l.get("dst")?.as_str()?
663 ))
664 })
665 .collect()
666 })
667 .unwrap_or_default(),
668 });
669 }
670 }
671 let answered = nodes.len();
672 for e in &edges {
675 if !nodes.iter().any(|n| n.zid == e.peer) {
676 nodes.push(TopologyNode {
677 zid: e.peer.clone(),
678 whatami: e.whatami.clone(),
679 version: None,
680 locators: Vec::new(),
681 locators_via_links: Vec::new(),
682 answered: false,
683 });
684 }
685 }
686 nodes.sort_by(|a, b| a.zid.cmp(&b.zid));
687 nodes.dedup_by(|a, b| a.zid == b.zid);
688 for n in nodes.iter_mut().filter(|n| n.locators.is_empty()) {
693 for e in &edges {
694 let reporter_side = if e.reporter == n.zid {
695 true
696 } else if e.peer == n.zid {
697 false
698 } else {
699 continue;
700 };
701 for l in &e.links {
702 let mut parts = l.splitn(2, " -> ");
703 let (Some(src), Some(dst)) = (parts.next(), parts.next()) else {
704 continue;
705 };
706 let end = if reporter_side { src } else { dst };
707 if !n.locators_via_links.iter().any(|x| x == end) {
708 n.locators_via_links.push(end.to_string());
709 }
710 }
711 }
712 }
713 Ok(TopologyReport {
714 nodes,
715 edges,
716 asked: ASKED.to_string(),
717 answered,
718 self_zid: session.zid().to_string(),
719 })
720}
721
722struct AdminSweep {
726 topology: TopologyReport,
727 declared: Option<DeclaredEntities>,
728 attachments: Vec<OriginAttachment>,
729 asked: Vec<String>,
730 elided: u64,
731}
732
733async fn admin_sweep(fleet: &crate::Fleet<'_>, timeout: Duration) -> Result<AdminSweep> {
734 let session = fleet.session();
735 let opts = GetOpts::new(timeout);
736 let topology = topology(session, timeout).await?;
739 let declared = declared_entities_within(session, &opts).await?;
740 let attachments = declared
741 .as_ref()
742 .map(|d| attach_tokens(fleet.base(), &d.entities))
743 .unwrap_or_default();
744 let mut asked = vec![topology.asked.clone()];
745 asked.extend(declared_entity_selectors());
746 Ok(AdminSweep {
747 topology,
748 declared,
749 attachments,
750 asked,
751 elided: opts.elided(),
752 })
753}
754
755impl AdminSweep {
756 fn consumers(&self, target: &str) -> ConsumersReport {
757 let self_zid = self.topology.self_zid.clone();
758 let (admin, rows) = match &self.declared {
759 Some(declared) => (
760 AdminAnswer::Answered {
761 answered: self.topology.answered,
762 nodes: self.topology.nodes.len(),
763 },
764 crate::model::consumers::join_consumers(
765 target,
766 declared,
767 &self.attachments,
768 Some(&self.topology),
769 &self_zid,
770 ),
771 ),
772 None => (AdminAnswer::NotAvailable, Vec::new()),
775 };
776 ConsumersReport {
777 target: target.to_string(),
778 asked: self.asked.clone(),
779 self_zid,
780 admin,
781 rows,
782 reply_elided: self.elided,
783 }
784 }
785}
786
787fn consumers_target(target: &str) -> Result<()> {
790 zenoh::key_expr::keyexpr::new(target)
791 .map(|_| ())
792 .map_err(|e| Error::unaskable_from(format!("consumers target {target:?}"), e))
793}
794
795pub async fn consumers(
809 fleet: &crate::Fleet<'_>,
810 target: &str,
811 timeout: Duration,
812) -> Result<ConsumersReport> {
813 consumers_target(target)?;
814 let sweep = admin_sweep(fleet, timeout).await?;
815 Ok(sweep.consumers(target))
816}
817
818pub async fn subject_impact(
828 fleet: &crate::Fleet<'_>,
829 slices: &crate::SliceSet,
830 producer: &str,
831 path: &str,
832 timeout: Duration,
833) -> Result<SubjectImpact> {
834 let base = fleet.base();
835 let Some(target) = crate::model::consumers::subject_target(slices, base, producer, path) else {
836 return Err(Error::unaskable(
837 format!("subject {producer}/{path}"),
838 match slices.get(producer) {
839 Some(_) => "the producer's slice declares no such subject, and its \
840 [[deprecated]] ledger does not retire one"
841 .to_string(),
842 None => "no loaded slice names that producer".to_string(),
843 },
844 ));
845 };
846 let sweep = admin_sweep(fleet, timeout).await?;
847 let consumers = sweep.consumers(&target.selector);
848 let (coverage, declared_publishers, declared_queryables) = match &sweep.declared {
849 Some(declared) => {
850 let storages = storages(fleet.session(), timeout).await?;
851 let rows: Vec<CoverageRow> = state_coverage(slices, base, &storages)
852 .into_iter()
853 .filter(|r| r.producer == producer && r.path == path)
854 .collect();
855 (
856 Some(rows),
857 Some(crate::model::consumers::declaring_sessions(
858 &target.selector,
859 declared,
860 EntityKind::Publisher,
861 )),
862 Some(crate::model::consumers::declaring_sessions(
863 &target.selector,
864 declared,
865 EntityKind::Queryable,
866 )),
867 )
868 }
869 None => (None, None, None),
870 };
871 Ok(SubjectImpact {
872 producer: producer.to_string(),
873 path: path.to_string(),
874 class: target.class,
875 selector: target.selector,
876 consumers,
877 coverage,
878 declared_publishers,
879 declared_queryables,
880 deprecated: target.deprecated,
881 })
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887
888 #[test]
889 fn storage_extraction_tolerates_layouts() {
890 let v = serde_json::json!({"key_expr": "zs/v1/*/state/**", "volume": "fs"});
892 let s = storage_from_admin_entry(
893 "@/abc123/router/config/plugins/storage_manager/storages/latest",
894 &v,
895 )
896 .unwrap();
897 assert_eq!(s.zid, "abc123");
898 assert_eq!(s.name, "latest");
899 assert_eq!(s.key_expr.as_deref(), Some("zs/v1/*/state/**"));
900 assert_eq!(s.volume.as_deref(), Some("fs"), "a bare-string volume");
901 let v = serde_json::json!({
903 "key_expr": "zs/v1/*/state/**",
904 "strip_prefix": "zs/v1",
905 "volume": {"id": "rocksdb", "dir": "latest"},
906 });
907 let s = storage_from_admin_entry(
908 "@/abc123/router/config/plugins/storage_manager/storages/durable",
909 &v,
910 )
911 .unwrap();
912 assert_eq!(s.volume.as_deref(), Some("rocksdb"), "an object volume");
913 assert_eq!(s.strip_prefix.as_deref(), Some("zs/v1"));
914 let s = storage_from_admin_entry(
916 "@/abc123/router/status/plugins/storage_manager/storages/latest/info",
917 &serde_json::json!("ok"),
918 )
919 .unwrap();
920 assert_eq!(s.name, "latest");
921 assert!(s.key_expr.is_none());
922 assert!(
924 storage_from_admin_entry(
925 "@/abc123/router/config/plugins/storage_manager/volumes/fs",
926 &serde_json::json!({}),
927 )
928 .is_none()
929 );
930 }
931
932 #[test]
935 fn merging_a_storage_keeps_every_field_either_side_named() {
936 let config = StorageInfo {
937 zid: "z1".into(),
938 name: "latest".into(),
939 key_expr: Some("zs/**".into()),
940 strip_prefix: Some("zs".into()),
941 volume: None,
942 raw: serde_json::json!({"key_expr": "zs/**", "strip_prefix": "zs"}),
943 };
944 let status = StorageInfo {
945 zid: "z1".into(),
946 name: "latest".into(),
947 key_expr: None,
948 strip_prefix: None,
949 volume: Some("fs".into()),
950 raw: serde_json::json!({"volume": "fs"}),
951 };
952 for rows in [
953 vec![config.clone(), status.clone()],
954 vec![status, config.clone()],
955 ] {
956 let merged = merge_storage_rows(rows);
957 assert_eq!(merged.len(), 1, "one row per (zid, name)");
958 assert_eq!(merged[0].key_expr.as_deref(), Some("zs/**"));
959 assert_eq!(merged[0].strip_prefix.as_deref(), Some("zs"));
960 assert_eq!(merged[0].volume.as_deref(), Some("fs"));
961 }
962
963 let other = StorageInfo {
965 zid: "z1".into(),
966 name: "history".into(),
967 key_expr: None,
968 strip_prefix: None,
969 volume: None,
970 raw: serde_json::Value::Null,
971 };
972 assert_eq!(merge_storage_rows(vec![config, other]).len(), 2);
973 }
974
975 fn slices_with_state() -> crate::model::registry::SliceSet {
976 let toml = r#"
977 [registry]
978 version = "1.0"
979 app = "t"
980 convention = 1
981 [producer]
982 name = "tc"
983 [[subject]]
984 path = "health"
985 class = "state"
986 type = "Health"
987 ttl_s = 60
988 [[subject]]
989 path = "config/{iface}"
990 class = "state"
991 type = "Config"
992 ttl_s = 120
993 [[subject]]
994 path = "bandwidth"
995 class = "telemetry"
996 type = "Point"
997 "#;
998 crate::model::registry::SliceSet::from_toml_for_tests(toml)
999 }
1000
1001 fn storage(name: &str, key_expr: &str) -> StorageInfo {
1002 StorageInfo {
1003 zid: "z1".into(),
1004 name: name.into(),
1005 key_expr: Some(key_expr.into()),
1006 strip_prefix: None,
1007 volume: None,
1008 raw: serde_json::Value::Null,
1009 }
1010 }
1011
1012 #[test]
1013 fn coverage_judges_covered_partial_uncovered() {
1014 let slices = slices_with_state();
1015 let rows = state_coverage(&slices, "zs", &[storage("latest", "zs/v1/*/state/**")]);
1017 assert_eq!(rows.len(), 2);
1018 assert!(
1019 rows.iter()
1020 .all(|r| matches!(r.coverage, Coverage::Covered(_)))
1021 );
1022
1023 let rows = state_coverage(
1025 &slices,
1026 "zs",
1027 &[storage("one", "zs/v1/*/state/tc/config/eth0")],
1028 );
1029 let health = rows.iter().find(|r| r.path == "health").unwrap();
1030 assert_eq!(health.coverage, Coverage::Uncovered);
1031 let config = rows.iter().find(|r| r.path == "config/{iface}").unwrap();
1032 assert!(matches!(config.coverage, Coverage::Partial(_)));
1033
1034 let rows = state_coverage(&slices, "zs", &[]);
1036 assert!(rows.iter().all(|r| r.coverage == Coverage::Uncovered));
1037
1038 let rows = state_coverage(&slices, "", &[storage("latest", "v1/*/state/**")]);
1041 assert_eq!(rows.len(), 2);
1042 assert!(
1043 rows.iter()
1044 .all(|r| matches!(r.coverage, Coverage::Covered(_)))
1045 );
1046 }
1047
1048 #[test]
1051 fn declared_entities_parse_the_admin_key_shape() {
1052 let v = serde_json::json!({"routers": [], "peers": ["p1"], "clients": []});
1053 let e = declared_from_admin_entry("@/a1b2c3/router/subscriber/zensight/v1/*/state/**", &v)
1054 .unwrap();
1055 assert_eq!(e.kind, EntityKind::Subscriber);
1056 assert_eq!(e.keyexpr, "zensight/v1/*/state/**");
1057 assert_eq!(e.node_zid, "a1b2c3");
1058
1059 let e = declared_from_admin_entry("@/z/peer/publisher/v1/h-a/telemetry/x/m", &v).unwrap();
1060 assert_eq!(e.kind, EntityKind::Publisher);
1061 assert_eq!(e.keyexpr, "v1/h-a/telemetry/x/m");
1062
1063 assert!(declared_from_admin_entry("@/z/router/config/x", &v).is_none());
1065 assert!(declared_from_admin_entry("@/z/router/subscriber", &v).is_none());
1066 assert!(declared_from_admin_entry("not/admin/at/all", &v).is_none());
1067 }
1068
1069 fn report() -> TopologyReport {
1070 TopologyReport {
1071 nodes: vec![
1072 TopologyNode {
1073 zid: "aaa".into(),
1074 whatami: "router".into(),
1075 version: Some("1.9.0".into()),
1076 locators: vec!["tcp/10.0.0.1:7447".into()],
1077 locators_via_links: vec![],
1078 answered: true,
1079 },
1080 TopologyNode {
1081 zid: "bbb".into(),
1082 whatami: "peer".into(),
1083 version: None,
1084 locators: vec![],
1085 locators_via_links: vec![],
1086 answered: false,
1087 },
1088 ],
1089 edges: vec![
1090 TopologyEdge {
1091 reporter: "aaa".into(),
1092 peer: "bbb".into(),
1093 whatami: "peer".into(),
1094 region: None,
1095 links: vec!["tcp/10.0.0.1:7447 -> tcp/10.0.0.2:53210".into()],
1096 },
1097 TopologyEdge {
1098 reporter: "bbb".into(),
1099 peer: "aaa".into(),
1100 whatami: "router".into(),
1101 region: None,
1102 links: vec![],
1103 },
1104 ],
1105 asked: "@/*/*".into(),
1106 answered: 1,
1107 self_zid: "bbb".into(),
1108 }
1109 }
1110
1111 #[test]
1115 fn the_loopback_filter_is_judged_from_the_docs_own_version() {
1116 assert!(admin_doc_omits_loopback("1.10.0"));
1117 assert!(admin_doc_omits_loopback(
1118 "v1.10.0-12-gabcdef built with rustc"
1119 ));
1120 assert!(admin_doc_omits_loopback("1.11.2"));
1121 assert!(admin_doc_omits_loopback("2.0.0"));
1122 assert!(!admin_doc_omits_loopback("1.9.0"));
1123 assert!(!admin_doc_omits_loopback("0.11.0-dev"));
1124 assert!(!admin_doc_omits_loopback("unknown"));
1125 assert!(!admin_doc_omits_loopback(""));
1126 }
1127
1128 #[test]
1131 fn reciprocal_reports_corroborate_one_edge() {
1132 let edges = mesh_links(&report());
1133 assert_eq!(edges.len(), 1);
1134 let link = &edges[0];
1135 assert_eq!((link.a.as_str(), link.b.as_str()), ("aaa", "bbb"));
1136 assert!(link.corroborated, "both ends reported it");
1137 }
1138
1139 #[test]
1142 fn the_dot_form_marks_what_the_join_knows() {
1143 let dot = render_dot(&report(), &[]);
1144 assert!(dot.starts_with("graph zenoh_mesh {"), "{dot}");
1145 assert!(dot.contains("\"aaa\" [shape=box"), "{dot}");
1146 assert!(dot.contains("heard of"), "{dot}");
1147 assert!(dot.contains("style=\"dashed,bold\""), "{dot}");
1148 assert!(dot.contains("\"aaa\" -- \"bbb\" [label=\"tcp\"]"), "{dot}");
1149 assert!(dot.ends_with('}'), "{dot}");
1150 }
1151
1152 #[test]
1156 fn the_dot_form_keeps_the_attachment_evidence_distinction() {
1157 let attachments = vec![
1158 OriginAttachment {
1159 origin: "h-cccccccccccc".into(),
1160 session_zid: Some("bbb".into()),
1161 reporter_zid: "aaa".into(),
1162 token_key: "v1/h-cccccccccccc/state/demo/alive".into(),
1163 },
1164 OriginAttachment {
1165 origin: "h-dddddddddddd".into(),
1166 session_zid: None,
1167 reporter_zid: "aaa".into(),
1168 token_key: "v1/h-dddddddddddd/state/demo/alive".into(),
1169 },
1170 ];
1171 let dot = render_dot(&report(), &attachments);
1172 assert!(dot.contains("label=\"h-cccccccccccc\""), "{dot}");
1173 assert!(dot.contains("\"origin_0\" -- \"bbb\";"), "{dot}");
1174 assert!(
1175 dot.contains("\"origin_1\" -- \"aaa\" [style=dotted, label=\"reported\"]"),
1176 "{dot}"
1177 );
1178 }
1179}