Skip to main content

zenkey_fleet/bus/
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//!
11//! **Base-less by design.** Everything here but [`origin_attachments`] takes a
12//! bare `&Session`, not a [`crate::Fleet`]: `@/**` is the middleware's own
13//! space and sits outside every deployment namespace, so there is no base for
14//! these calls to run *against* — a `Fleet` would offer one they must ignore.
15//! [`origin_attachments`] is the exception because it joins admin tokens back
16//! onto convention keys, which only parse under a base.
17
18use 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/// One admin-space entry.
31#[derive(Debug, Clone)]
32pub struct AdminEntry {
33    pub key: String,
34    pub value: serde_json::Value,
35}
36
37/// GET an admin selector (default `@/**`). Fans to every node (target All,
38/// consolidation None — several routers may answer).
39///
40/// Bounded at [`crate::DEFAULT_MAX_REPLIES`] (#339): `@/**` against a router
41/// with a large storage is a lot of replies, each holding a payload. To see
42/// what the bound cost — or to raise it — use [`admin_get_within`], which
43/// takes the options the count rides on.
44pub 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
52/// [`admin_get`] under the caller's own options — the reply bound and, after
53/// the call, what it cost ([`GetOpts::elided`], RFC 13 §3 O6).
54pub 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        // Past the bound the replies are drained but not kept: the count
66        // stays exact, the memory stays bounded.
67        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
86/// Enumerate routers/peers from `@/*/router` (and the fields every layout
87/// carries).
88pub 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                    // Fall back to the key's zid chunk: @/<zid>/router.
100                    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
127/// Extract a storage from one admin entry, tolerantly: the key shape is
128/// `@/<zid>/router/…/storage_manager/storages/<name>[…]`, the value a config
129/// document whose `key_expr` field names what it captures. Pure — the
130/// version-variance lives here, unit-tested.
131pub 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    // Only storage_manager subtrees qualify (volumes etc. share the plugin).
135    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    // The volume is a bare string in some layouts and an object with an `id`
147    // in others. Absorbing both here is what this pure parser is for.
148    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
165/// One row per `(zid, name)`, merging field by field.
166///
167/// The config and status subtrees both answer a storage sweep, and neither is
168/// reliably the richer one — so a row that names a `key_expr` and a row that
169/// names a `volume` must combine rather than one winning outright. Pure, and
170/// separated from [`storages`] because with three optional fields a hand-rolled
171/// `dedup_by` is where a quietly-dropped field would hide.
172pub 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                // Keep the document that said more, so the raw disclosure is
182                // the useful one.
183                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
195/// Enumerate configured storages across the mesh (issue #14). Zero routers
196/// (peer mesh, admin disabled) is an empty vec, never an error.
197pub 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
211/// Judge every declared **state** family against the configured storages
212/// (issue #14): the family's wire selector vs each storage's key expression,
213/// by key algebra (`includes` ⇒ covered, `intersects` ⇒ partial). Pure.
214pub 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            // Composed via `with_base` so the empty base stays a valid
238            // keyexpr (`format!("{base}/…")` would grow a leading slash and
239            // silently drop every family below).
240            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    /// Every kind the admin space declares, in sweep order.
276    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
306/// Parse one admin entry (`@/<zid>/<whatami>/<kind>/<keyexpr...>`) into a
307/// declared entity. Pure; unknown shapes yield `None` (the admin space is
308/// version-dependent surface — tolerate, never fail).
309pub 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
329/// Enumerate declared subscribers/publishers/queryables/tokens from every
330/// reachable admin space.
331///
332/// `Ok(None)` when **nothing answered at all**: zenoh's `adminspace.enabled`
333/// defaults to *false* (routers ship with it on; a pure peer mesh has none),
334/// so an empty sweep means "not available", never "nothing declared" —
335/// callers MUST render the difference (RFC 09 §5.1 O4). A *publisher* is
336/// visible only if it was declared (P7's rule for the data planes); a bare
337/// `session.put()` never appears here.
338pub 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
345/// The admin selectors [`declared_entities`] sweeps, in sweep order — the
346/// coverage claim a report built on the sweep states (RFC 13 §3 O5).
347pub fn declared_entity_selectors() -> Vec<String> {
348    EntityKind::ALL
349        .iter()
350        .map(|k| format!("@/*/*/{}/**", k.chunk()))
351        .collect()
352}
353
354/// [`declared_entities`] under the caller's own options — the reply bound
355/// and, after the call, what it cost ([`GetOpts::elided`], RFC 13 §3 O6).
356pub 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
379/// Collapse the per-reporter edges into undirected links.
380///
381/// [`TopologyEdge`]'s own doc has anticipated this since it was written — "a
382/// renderer that wants an undirected mesh dedups by unordered zid pair" — and
383/// two renderers now want it, which is why it stopped being a private helper
384/// in the CLI (issue #207).
385pub 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
406/// Graphviz, self-contained: routers as boxes, peers/clients as ellipses,
407/// heard-of nodes dashed, our own session bold.
408pub 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    // Origins as their own small nodes (#131): attached by a solid edge to
459    // the session the admin sources named, or by a dotted one to the mere
460    // reporter — the picture keeps the evidence distinction the join made.
461    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
485/// Collect every zid string under the zenoh 1.9 `Sources` shape
486/// (`{ routers: [...], peers: [...], clients: [...] }`) — tolerant of the
487/// layout varying by version: unknown shapes yield nothing, never an error.
488pub(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
500/// Join the admin space's declared liveliness tokens against the keyspace:
501/// which origin hangs off which session (#131).
502///
503/// One `@/*/*/token/**` sweep; each token whose keyexpr parses under `base`
504/// as an `alive` leaf yields an attachment. The session zid is taken from
505/// the token's `sources` **only when they name exactly one** — several
506/// candidates or none degrade to reporter-only, stated rather than guessed.
507/// An empty result means the admin space served no tokens (or none parse
508/// under this base) — an observation, not an empty fleet (O4).
509pub 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
521/// The join itself, pure: every token-kind entity whose keyexpr parses
522/// under `base` as an `alive` leaf becomes an attachment (#224 split it out
523/// of [`origin_attachments`] so a sweep that already holds the declared
524/// entities need not ask the token selector twice).
525pub 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        // The framework liveliness shape: an `alive` leaf on the state
536        // class (RFC 04 §5). Anything else declared as a token is not an
537        // origin claim and is left alone.
538        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        // One origin can hold several sessions (one per producer process);
554        // dedup only exact repeats.
555        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
566/// Whether a node's admin root doc filters loopback endpoints out of its
567/// `locators` — true from zenoh 1.10.0 (eclipse-zenoh/zenoh#2671, the
568/// loopback scouting fix: the root doc switched to
569/// `get_locators_noloopback()`). Judged from the leading `major.minor`
570/// of the version string the doc itself declares; a version that does
571/// not parse answers `false` — "cannot say", never a claim (O4).
572///
573/// One definition, used by both renderers, so the two tools explain an
574/// empty locator column with one voice (#155).
575pub 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
585/// Join the admin root docs (`@/<zid>/<whatami>`) into a topology: every
586/// answering node with its locators and version, every session it reports
587/// as an edge, and every zid that is *only* mentioned as a
588/// heard-of-not-queryable node.
589///
590/// Where a root doc declares no locators — since zenoh 1.10.0 that is the
591/// normal answer for a loopback-only node (eclipse-zenoh/zenoh#2671
592/// filters loopback endpoints from the root doc) — the join corroborates
593/// from session links instead: the node-side endpoint of each reported
594/// link lands in [`TopologyNode::locators_via_links`], kept apart from
595/// `locators` because it is link evidence, not a listen-endpoint claim.
596/// Nothing is invented: a node no link names stays honestly empty.
597pub 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        // Root docs only: @/<zid>/<whatami>. Anything deeper is a
604        // different handler and not a node document.
605        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    // Heard-of nodes: mentioned as a session peer, but no root doc answered
673    // for them (admin space off, or out of reach). Shown, never omitted.
674    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    // Corroborate where the root doc declared nothing (see the fn doc):
689    // for each link `src -> dst`, `src` is an address on the reporter's
690    // side and `dst` one on the peer's. That is what a link *used*, no
691    // more — kept out of `locators` and labelled by the renderers.
692    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
722/// One pass over the admin space for the consumer joins (#224): the
723/// topology, every declared entity, and the attachments the token entities
724/// yield — each selector asked once, the elided count kept.
725struct 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    // The topology's own GET keeps its bound ledger to itself; what it may
737    // have elided is a root doc, which is a node, not a declaration.
738    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            // Not asked is not an empty answer: no rows, and the
773            // discriminator says why (RFC 13 §3 O4).
774            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
787/// The target as a key expression, or the refusal: an ask that could not
788/// be put is exit 2's business, never an empty consumer set.
789fn 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
795/// Who declares a reader of `target` (#224): every declared subscriber and
796/// querier the admin space serves, related to the selector by key algebra,
797/// one row per declaring session, joined to the origins their tokens
798/// attach (#131) and the topology's `whatami`.
799///
800/// Three facts the report keeps straight rather than smoothing over: no
801/// admin space answering is [`AdminAnswer::NotAvailable`] with no rows —
802/// *not asked*, never an empty set (O4); a declaration is evidence a
803/// session asked for the key, not proof anything reads it (RFC 12 §9 —
804/// foreign matching status is deferred permanently); and a `**`
805/// declaration intersects everything, so it is flagged as total rather
806/// than presented as a consumer of this subject in particular. The tool's
807/// own session appears in its own results and is named.
808pub 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
818/// The blast radius of one declared subject (#224): its consumers, its
819/// storage coverage, what else declares on its family, and its ledger
820/// entry — the facts a schema change wants in one place.
821///
822/// The storage sweep is made only when an admin space answered: under
823/// [`AdminAnswer::NotAvailable`] an empty storage list would render as
824/// "uncovered", which is a verdict nobody obtained, so `coverage` stays
825/// `None` (not asked). `Err` when the slices do not name the subject — the
826/// caller named it, so nothing was asked of the bus.
827pub 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        // 1.x config-subtree shape.
891        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        // The other spelling of the same field.
902        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        // Status-subtree shape without key_expr still names the storage.
915        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        // Non-storage subtrees do not match.
923        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    /// Config and status subtrees both answer, neither is reliably richer, and
933    /// a field named by only one of them must survive either arrival order.
934    #[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        // Two names under one zid stay two rows.
964        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        // Full state storage: everything covered; telemetry not judged.
1016        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        // A one-interface storage: config/{iface} is partial, health uncovered.
1024        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        // No storages at all.
1035        let rows = state_coverage(&slices, "zs", &[]);
1036        assert!(rows.iter().all(|r| r.coverage == Coverage::Uncovered));
1037
1038        // The empty base composes a valid selector (`v1/…`, no leading
1039        // slash) instead of silently dropping every family.
1040        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    /// The zenoh 1.9 admin key shape (`@/<zid>/<whatami>/<kind>/<keyexpr...>`)
1049    /// parses into a declared entity; foreign shapes are tolerated as None.
1050    #[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        // Tolerated, never fatal:
1064        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    /// The version gate for the 1.10 loopback filter: judged from the
1112    /// doc's own version string, and an unparseable one answers "cannot
1113    /// say" — false — never a claim either way.
1114    #[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    /// Reciprocal reports collapse to one undirected edge, marked as
1129    /// corroborated — not drawn twice, not silently merged.
1130    #[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    /// The DOT form: routers boxed, heard-of nodes dashed, our session
1140    /// bold, edges labeled by protocol — pipeable to `dot -Tsvg` as-is.
1141    #[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    /// The origin overlay (#131): a sources-named attachment is a solid
1153    /// edge; a reporter-only one is dotted and says "reported" — the DOT
1154    /// keeps the evidence distinction the join made.
1155    #[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}