Skip to main content

zenkey_fleet/
roster.rs

1//! The liveliness roster (RFC 04 §5).
2
3use std::collections::BTreeMap;
4use std::time::Duration;
5
6use anyhow::Result;
7use zenkey::grammar::with_base;
8use zenoh::Session;
9
10/// The fleet-presence roster: who is up, and what they run.
11///
12/// RFC 04 §5 — a liveliness query on `<base>/v1/*/state/*/alive`. Zero
13/// payload bytes: the token *key* is the record. `@catalog` is asked for by
14/// name because `*` can never match a verbatim service origin (property D4).
15pub async fn roster(
16    session: &Session,
17    base: &str,
18    timeout: Duration,
19) -> Result<BTreeMap<String, Vec<String>>> {
20    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
21
22    let catalog_alive = zenkey::selector::service_alive(&zenkey::ServiceOrigin::catalog());
23    // The builders are base-relative; this session is deliberately
24    // un-namespaced, so it must spell the base itself.
25    for expr in [
26        with_base(
27            base,
28            zenkey::selector::all_liveliness(zenkey::selector::Scope::fleet()),
29        ),
30        with_base(base, catalog_alive),
31    ] {
32        let Ok(replies) = session.liveliness().get(&expr).timeout(timeout).await else {
33            continue;
34        };
35        while let Ok(reply) = replies.recv_async().await {
36            let Ok(sample) = reply.result() else { continue };
37            let key = sample.key_expr().as_str();
38            let Some(parsed) = zenkey::grammar::parse_full(base, key) else {
39                continue;
40            };
41            let origin = parsed.origin.chunk().to_string();
42            // `@catalog`'s token has no producer chunk — the service *is* the
43            // producer. Everything else names its producer in position 5.
44            let producer = parsed
45                .producer
46                .as_ref()
47                .map(|p| p.chunk())
48                .unwrap_or_else(|| origin.trim_start_matches('@').to_string());
49            out.entry(origin).or_default().push(producer);
50        }
51    }
52    for producers in out.values_mut() {
53        producers.sort();
54        producers.dedup();
55    }
56    Ok(out)
57}
58
59/// One producer's story on one node — the enrichment §6.3 promised
60/// (issue #40). Every field is honest about its provenance: absent
61/// introspection is `None`, never a default (RFC 09 §5.1 O4).
62#[derive(Debug, Clone, serde::Serialize)]
63pub struct ProducerInfo {
64    pub name: String,
65    /// A liveliness token stands (RFC 04 §5 — the only presence signal).
66    pub alive: bool,
67    /// From this origin's served introspect slice, when it answered.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub app: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub registry_version: Option<String>,
72    pub subjects: usize,
73    pub procedures: usize,
74    #[serde(skip_serializing_if = "Vec::is_empty", default)]
75    pub blob_tiers: Vec<String>,
76    /// Declared `@media` streams (RFC 08 §2/§6, v1.16 — the slice finally
77    /// carries what §6 claimed through v1.7): discoverable off the bus, so
78    /// a viewer can enumerate streams without a compiled-in registry.
79    #[serde(skip_serializing_if = "Vec::is_empty", default)]
80    pub media: Vec<MediaStreamInfo>,
81    /// Deprecated subjects this build still serves — RFC 08 §6's headline
82    /// buy ("which hosts still serve a deprecated subject").
83    pub deprecated_served: usize,
84}
85
86/// One declared media stream, as the slice states it (RFC 08 §2).
87#[derive(Debug, Clone, serde::Serialize)]
88pub struct MediaStreamInfo {
89    /// The stream pattern after `@media/<producer>/`.
90    pub path: String,
91    /// The declared wire encoding (`image/jpeg`, `video/*`).
92    pub encoding: String,
93}
94
95/// Freshness of one declared state subject on this node (RFC 04 §1.2).
96#[derive(Debug, Clone, serde::Serialize)]
97pub struct Freshness {
98    pub producer: String,
99    pub path: String,
100    pub ttl_s: i64,
101    /// Seconds since the newest matching sample's HLC stamp; `None` when no
102    /// sample answered — which is "not seen", not "fresh" (O4).
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub age_s: Option<i64>,
105    /// `age > ttl`, or no sample at all for a declared live subject.
106    pub stale: bool,
107}
108
109/// One node, joined: liveliness × introspect × state freshness.
110#[derive(Debug, Clone, serde::Serialize)]
111pub struct NodeInfo {
112    pub origin: String,
113    pub producers: Vec<ProducerInfo>,
114    #[serde(skip_serializing_if = "Vec::is_empty", default)]
115    pub freshness: Vec<Freshness>,
116}
117
118/// How one origin string spells its two framework keys. A host and a service
119/// differ in both (`v1/<h>/state/*/alive` + a producer chunk in `@rpc`, versus
120/// `v1/@svc/state/alive` + no producer chunk), and a `*` can reach neither
121/// other's shape — so the split is made once, up front, rather than guessed
122/// per key (D4).
123enum Node {
124    Host(zenkey::origin::RemoteOrigin),
125    Service(zenkey::ServiceOrigin),
126}
127
128impl Node {
129    fn parse(origin: &str) -> Result<Node> {
130        if origin.starts_with('@') {
131            zenkey::ServiceOrigin::new(origin)
132                .map(Node::Service)
133                .map_err(|e| anyhow::anyhow!("{e}"))
134        } else {
135            zenkey::origin::RemoteOrigin::parse(origin)
136                .map(Node::Host)
137                .map_err(|e| anyhow::anyhow!("{e} — a hostname is not an origin (RFC 06 §6)"))
138        }
139    }
140
141    /// This node's liveliness tokens, and nothing else's.
142    fn alive_selector(&self) -> String {
143        match self {
144            Node::Host(o) => {
145                zenkey::selector::all_liveliness(zenkey::selector::Scope::origin(o)).to_string()
146            }
147            Node::Service(o) => zenkey::selector::service_alive(o).to_string(),
148        }
149    }
150
151    /// This node's producers' `introspect`, and nothing else's.
152    fn introspect_selector(&self) -> String {
153        match self {
154            Node::Host(o) => {
155                zenkey::selector::rpc(zenkey::selector::Scope::origin(o), "*", &["introspect"])
156                    .to_string()
157            }
158            Node::Service(o) => zenkey::selector::service_rpc(o, &["introspect"]).to_string(),
159        }
160    }
161
162    /// This node's state subtree. `**` cannot cross an `@` chunk (D2), so this
163    /// cannot pull a plane however deep the subject tail runs.
164    fn state_selector(&self) -> String {
165        let scope = match self {
166            Node::Host(o) => zenkey::selector::Scope::origin(o),
167            Node::Service(o) => zenkey::selector::Scope::origin(o),
168        };
169        zenkey::selector::all_state(scope).to_string()
170    }
171}
172
173/// Assemble one node's full story (issue #40; feeds `zenctl node info` and
174/// the zengui dashboard).
175///
176/// Three bounded sweeps, **all three scoped to the asked origin** (issue #96 —
177/// before it, this re-ran the whole fleet roster and a fleet-wide introspect
178/// fan-in per call and then filtered, which the zengui node dashboard pays for
179/// on every card click): this origin's liveliness tokens, this origin's
180/// producers' introspect replies (per-origin truth, not the fleet-deduped
181/// `SliceSet`), and — when `with_freshness` — one state GET on this origin
182/// only (D2 guarantees it cannot pull planes).
183///
184/// Narrower is also *more* honest: the answers can no longer be diluted by a
185/// deduplication across origins that never applied to this one.
186pub async fn node_info(
187    session: &Session,
188    base: &str,
189    origin: &str,
190    timeout: Duration,
191    with_freshness: bool,
192) -> Result<NodeInfo> {
193    let node = Node::parse(origin)?;
194
195    // Liveliness, this origin only. A producer chunk is position 5 for a host;
196    // `@catalog`'s token has none — the service *is* the producer.
197    let mut alive: Vec<String> = Vec::new();
198    let alive_expr = with_base(base, node.alive_selector());
199    if let Ok(replies) = session.liveliness().get(&alive_expr).timeout(timeout).await {
200        while let Ok(reply) = replies.recv_async().await {
201            let Ok(sample) = reply.result() else { continue };
202            let Some(parsed) = zenkey::grammar::parse_full(base, sample.key_expr().as_str()) else {
203                continue;
204            };
205            alive.push(
206                parsed
207                    .producer
208                    .as_ref()
209                    .map(|p| p.chunk())
210                    .unwrap_or_else(|| parsed.origin.chunk().trim_start_matches('@').to_string()),
211            );
212        }
213    }
214    alive.sort();
215    alive.dedup();
216
217    // Per-origin capabilities: one origin-scoped introspect GET. Replies are
218    // still attributed by reply key, so a router that answered for somebody
219    // else could not smuggle a slice in.
220    let introspect = with_base(base, node.introspect_selector());
221    let answers = crate::query::fleet_get(session, base, &introspect, None, timeout)
222        .await
223        .unwrap_or_default();
224    let served: Vec<zenkey::slice::RegistrySlice> = answers
225        .into_iter()
226        .filter(|a| a.origin == origin)
227        .filter_map(|a| {
228            let crate::query::Answer::Value(bytes) = a.answer else {
229                return None;
230            };
231            let toml = String::from_utf8_lossy(&bytes.to_bytes()).to_string();
232            match zenkey::parse_slice(&toml) {
233                Ok(slice) => Some(slice),
234                Err(e) => {
235                    tracing::warn!(origin, "introspect reply did not parse, skipping: {e}");
236                    None
237                }
238            }
239        })
240        .collect();
241    let mine: Vec<&zenkey::slice::RegistrySlice> = served.iter().collect();
242
243    let mut names: Vec<String> = alive.clone();
244    names.extend(mine.iter().map(|s| s.name.clone()));
245    names.sort();
246    names.dedup();
247
248    let producers: Vec<ProducerInfo> = names
249        .iter()
250        .map(|name| {
251            let slice = mine.iter().find(|s| &s.name == name);
252            ProducerInfo {
253                name: name.clone(),
254                alive: alive.iter().any(|a| a == name),
255                app: slice.map(|s| s.app.clone()),
256                registry_version: slice.map(|s| s.version.clone()),
257                subjects: slice.map(|s| s.subjects.len()).unwrap_or(0),
258                procedures: slice.map(|s| s.procedures.len()).unwrap_or(0),
259                blob_tiers: slice
260                    .map(|s| s.blob.iter().map(|b| b.tier.clone()).collect())
261                    .unwrap_or_default(),
262                media: slice
263                    .map(|s| {
264                        s.media
265                            .iter()
266                            .map(|m| MediaStreamInfo {
267                                path: m.path.clone(),
268                                encoding: m.encoding.clone(),
269                            })
270                            .collect()
271                    })
272                    .unwrap_or_default(),
273                deprecated_served: slice.map(|s| s.deprecated.len()).unwrap_or(0),
274            }
275        })
276        .collect();
277
278    let mut freshness = Vec::new();
279    if with_freshness && !mine.is_empty() {
280        // One origin-scoped state sweep; join against declared ttl_s.
281        let selector = with_base(base, node.state_selector());
282        let samples = crate::query::state_snapshot(session, &selector, timeout, None)
283            .await
284            .unwrap_or_default();
285        let now = std::time::SystemTime::now();
286        for slice in &mine {
287            for subject in &slice.subjects {
288                let Some(ttl) = subject.ttl_s else { continue };
289                if subject.class != "state" {
290                    continue;
291                }
292                // Newest sample whose tail refines to this subject.
293                let age = samples
294                    .iter()
295                    .filter_map(|s| {
296                        let parsed = zenkey::grammar::parse_full(base, &s.key)?;
297                        let p = parsed.producer.as_ref()?.name().to_string();
298                        if p != slice.name {
299                            return None;
300                        }
301                        let tail: Vec<&str> = parsed.subject.clone();
302                        let pattern = zenkey::pattern::SubjectPattern::parse(&subject.path).ok()?;
303                        pattern.matches(&tail)?;
304                        s.timestamp.map(|t| {
305                            now.duration_since(t.get_time().to_system_time())
306                                .map(|d| d.as_secs() as i64)
307                                .unwrap_or(0)
308                        })
309                    })
310                    .min();
311                freshness.push(Freshness {
312                    producer: slice.name.clone(),
313                    path: subject.path.clone(),
314                    ttl_s: ttl,
315                    age_s: age,
316                    stale: match age {
317                        Some(a) => a > ttl,
318                        // Declared live state with no sample anywhere: stale
319                        // in the sense that matters — but the age stays None.
320                        None => true,
321                    },
322                });
323            }
324        }
325    }
326
327    Ok(NodeInfo {
328        origin: origin.to_string(),
329        producers,
330        freshness,
331    })
332}
333
334/// One origin claiming a human identity label, through the health-document
335/// bridge (RFC 06 §6.2 bridge 1).
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct BridgeMatch {
338    /// The origin id — the payload `host_id`, which IS the origin (§6.1).
339    pub host_id: zenkey::origin::HostId,
340    /// The display label the document carried (`source`).
341    pub source: String,
342    /// The key the claim arrived on — self-certifying, because the doc is
343    /// origin-scoped and carries `host_id` beside `source`.
344    pub key: String,
345}
346
347/// Resolve a human identity (hostname, `source` label) to the origin(s)
348/// claiming it — the consumer identity bridge, run the sanctioned way
349/// (RFC 06 §6.2): GET the fleet's `state/<producer>/health` documents and
350/// read `host_id` beside `source`. Every match is returned; the *caller*
351/// prices zero (the bridge yielded nothing — a probe MUST fail there,
352/// RFC 09 §6) and more-than-one (a hostname collision is exactly the
353/// misrouting hazard §6.2 names).
354///
355/// A document without both fields is skipped silently here — it is not a
356/// claim about this label either way — but the total documents seen ride
357/// back so the caller can tell "no claims" from "nobody answered".
358pub async fn bridge_resolve(
359    session: &zenoh::Session,
360    base: &str,
361    producer: &str,
362    label: &str,
363    timeout: std::time::Duration,
364) -> Result<(Vec<BridgeMatch>, usize)> {
365    let relative =
366        zenkey::selector::producer_state(zenkey::selector::Scope::fleet(), producer, &["health"])
367            .to_string();
368    let key = zenkey::grammar::with_base(base, relative);
369    let answers = crate::query::fleet_get(session, base, &key, None, timeout).await?;
370    let mut matches = Vec::new();
371    let seen = answers.len();
372    for a in &answers {
373        let crate::query::Answer::Value(bytes) = &a.answer else {
374            continue;
375        };
376        let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&bytes.to_bytes()) else {
377            continue;
378        };
379        let (Some(host_id), Some(source)) = (
380            doc.get("host_id").and_then(|v| v.as_str()),
381            doc.get("source").and_then(|v| v.as_str()),
382        ) else {
383            continue;
384        };
385        if source == label
386            && let Ok(id) = zenkey::origin::HostId::parse(host_id)
387        {
388            matches.push(BridgeMatch {
389                host_id: id,
390                source: source.to_string(),
391                key: a.key.clone(),
392            });
393        }
394    }
395    matches.dedup_by(|a, b| a.host_id == b.host_id);
396    Ok((matches, seen))
397}