Skip to main content

zenkey_fleet/
discover.rs

1//! Deployment-base discovery — the sweep behind `zenctl base list`.
2//!
3//! An observer that does not yet know a deployment's base can recover the
4//! bases in use from the wire itself (RFC 09 §5: this is exactly what an
5//! un-namespaced session is for). Two independent signals:
6//!
7//! * **liveliness tokens** (RFC 04 §5): every producer holds
8//!   `<base>/v1/<origin>/state/<producer>/alive`, so a sweep with the base
9//!   wildcarded finds every fleet that is *up* — including one on the empty
10//!   base (a wire whose keys start at `v1/`, off-convention for a deployment
11//!   per RFC 03 §1.1 but precisely what a debug tool must be able to name);
12//! * **storage configs** (router admin space): a storage's `key_expr` /
13//!   `strip_prefix` names the base it captures, so a configured-but-idle
14//!   deployment is still discoverable while its producers are down.
15//!
16//! Blind spot, stated honestly: `*`/`**` never match a verbatim `@` chunk
17//! (property D4), so service origins are only swept for the well-known
18//! `@catalog` by name. A base populated *only* by other service origins, with
19//! no host producers and no storage config, is not discoverable here.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::time::Duration;
23
24use anyhow::Result;
25use serde::Serialize;
26use zenkey::grammar::{self, ClassOrPlane, SUBJECT_ALIVE, VERSION_CHUNK};
27use zenoh::Session;
28
29use crate::admin::StorageInfo;
30
31/// Host-form sweep: `**` matches zero or more chunks, so every base depth is
32/// covered — including the empty base. A verbatim origin is never matched
33/// (D4), hence the separate catalog sweep.
34const HOST_ALIVE_SWEEP: &str = "**/v1/*/state/*/alive";
35/// `@catalog` asked for by name at any base depth, mirroring [`crate::roster`]
36/// (a unit test pins this against the typed `selector::service_alive`).
37const CATALOG_ALIVE_SWEEP: &str = "**/v1/@catalog/state/alive";
38
39/// One alive token attributed to its base — the pure result of
40/// [`parse_alive_key`].
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct AliveToken {
43    /// `""` is the empty base.
44    pub base: String,
45    /// The origin chunk (`h-…` or `@<service>`).
46    pub origin: String,
47    /// `None` for service tokens — the service *is* the producer (RFC 03 §1.5).
48    pub producer: Option<String>,
49}
50
51/// Attribute a wire liveliness key to its base.
52///
53/// Fixed-arity from the right — the host form has a 5-chunk tail
54/// (`v1/<origin>/state/<producer>/alive`), the service form a 4-chunk tail
55/// (`v1/@<svc>/state/alive`) — with the tail validated by [`grammar::parse`].
56/// Never a "find the first `v1`" scan, so a base that itself contains a
57/// literal `v1` chunk attributes correctly. The two forms cannot collide:
58/// `v1` is not a valid origin chunk, so at most one tail parses.
59pub fn parse_alive_key(key: &str) -> Option<AliveToken> {
60    let chunks: Vec<&str> = key.split('/').collect();
61    // Host form (tail 5), then service form (tail 4).
62    for tail_len in [5usize, 4] {
63        let Some(split) = chunks.len().checked_sub(tail_len) else {
64            continue;
65        };
66        if chunks[split] != VERSION_CHUNK {
67            continue;
68        }
69        let tail = chunks[split..].join("/");
70        let Ok(parsed) = grammar::parse(&tail) else {
71            continue;
72        };
73        if !matches!(parsed.class, ClassOrPlane::Class(grammar::Class::State))
74            || parsed.subject != [SUBJECT_ALIVE]
75        {
76            continue;
77        }
78        // The 5-chunk tail is the host form (producer present), the 4-chunk
79        // tail the service form (no producer chunk).
80        if (tail_len == 5) != parsed.producer.is_some() {
81            continue;
82        }
83        return Some(AliveToken {
84            base: chunks[..split].join("/"),
85            origin: parsed.origin.chunk().to_string(),
86            producer: parsed.producer.as_ref().map(|p| p.chunk()),
87        });
88    }
89    None
90}
91
92/// The base a storage config names, if it names one.
93///
94/// `raw["strip_prefix"]` ending in a `v1` chunk is exact; otherwise the
95/// all-literal prefix of `key_expr` up to its first `v1` chunk (a heuristic —
96/// a base whose *own* trailing chunk is literally `v1` is ambiguous here,
97/// which is why `strip_prefix` wins when present). `None` when a wildcard or
98/// verbatim chunk precedes `v1`.
99pub fn base_of_storage(storage: &StorageInfo) -> Option<String> {
100    if let Some(prefix) = storage.raw.get("strip_prefix").and_then(|v| v.as_str()) {
101        if prefix == VERSION_CHUNK {
102            return Some(String::new());
103        }
104        if let Some(base) = prefix.strip_suffix("/v1") {
105            return Some(base.to_string());
106        }
107        // A strip_prefix not ending at the v1 boundary tells us nothing;
108        // fall through to the key_expr heuristic.
109    }
110    let key_expr = storage.key_expr.as_deref()?;
111    let mut base_chunks: Vec<&str> = Vec::new();
112    for chunk in key_expr.split('/') {
113        if chunk == VERSION_CHUNK {
114            return Some(base_chunks.join("/"));
115        }
116        if chunk.contains('*') || chunk.starts_with('@') {
117            return None;
118        }
119        base_chunks.push(chunk);
120    }
121    None
122}
123
124/// One discovered base and the evidence for it.
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
126pub struct DiscoveredBase {
127    /// `""` is the empty base (keys start at `v1/` on the wire).
128    pub base: String,
129    /// Origins holding alive tokens under this base.
130    pub origins: BTreeSet<String>,
131    /// Producer names alive under this base.
132    pub producers: BTreeSet<String>,
133    /// Storages whose config names this base, as `name@zid`.
134    pub storages: Vec<String>,
135}
136
137/// Merge the two signals into sorted, deduped rows (the empty base sorts
138/// first). Pure — [`discover_bases`] is the thin session wrapper.
139pub fn merge_signals(
140    tokens: impl IntoIterator<Item = AliveToken>,
141    storages: &[StorageInfo],
142) -> Vec<DiscoveredBase> {
143    let mut bases: BTreeMap<String, DiscoveredBase> = BTreeMap::new();
144    fn entry<'m>(
145        bases: &'m mut BTreeMap<String, DiscoveredBase>,
146        base: &str,
147    ) -> &'m mut DiscoveredBase {
148        bases
149            .entry(base.to_string())
150            .or_insert_with(|| DiscoveredBase {
151                base: base.to_string(),
152                ..DiscoveredBase::default()
153            })
154    }
155    for token in tokens {
156        let row = entry(&mut bases, &token.base);
157        // A service token's producer is the service itself (RFC 03 §1.5).
158        let producer = token
159            .producer
160            .unwrap_or_else(|| token.origin.trim_start_matches('@').to_string());
161        row.origins.insert(token.origin);
162        row.producers.insert(producer);
163    }
164    for storage in storages {
165        let Some(base) = base_of_storage(storage) else {
166            continue;
167        };
168        entry(&mut bases, &base)
169            .storages
170            .push(format!("{}@{}", storage.name, storage.zid));
171    }
172    for row in bases.values_mut() {
173        row.storages.sort();
174        row.storages.dedup();
175    }
176    bases.into_values().collect()
177}
178
179/// The sweep: the two liveliness gets plus the router storage configs, merged
180/// by [`merge_signals`]. Best-effort throughout — a peer-only mesh (no admin
181/// space) or a failed selector narrows the evidence, never errors. Zero rows
182/// is *not* proof of an empty mesh (RFC 05 §3.1): the caller renders that
183/// silence honestly.
184pub async fn discover_bases(session: &Session, timeout: Duration) -> Result<Vec<DiscoveredBase>> {
185    let mut tokens = Vec::new();
186    for sweep in [HOST_ALIVE_SWEEP, CATALOG_ALIVE_SWEEP] {
187        let Ok(replies) = session.liveliness().get(sweep).timeout(timeout).await else {
188            continue;
189        };
190        while let Ok(reply) = replies.recv_async().await {
191            let Ok(sample) = reply.result() else { continue };
192            if let Some(token) = parse_alive_key(sample.key_expr().as_str()) {
193                tokens.push(token);
194            }
195        }
196    }
197    let storages = crate::admin::storages(session, timeout)
198        .await
199        .unwrap_or_default();
200    Ok(merge_signals(tokens, &storages))
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn token(base: &str, origin: &str, producer: Option<&str>) -> AliveToken {
208        AliveToken {
209            base: base.into(),
210            origin: origin.into(),
211            producer: producer.map(str::to_string),
212        }
213    }
214
215    #[test]
216    fn alive_keys_attribute_by_fixed_arity() {
217        // Host form at every base depth, including the empty base.
218        assert_eq!(
219            parse_alive_key("v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
220            Some(token("", "h-3fa9c2d41b7e", Some("sysinfo")))
221        );
222        assert_eq!(
223            parse_alive_key("zensight/v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
224            Some(token("zensight", "h-3fa9c2d41b7e", Some("sysinfo")))
225        );
226        assert_eq!(
227            parse_alive_key("acme/fleet-a/v1/h-aaaaaaaaaaaa/state/netring/alive"),
228            Some(token("acme/fleet-a", "h-aaaaaaaaaaaa", Some("netring")))
229        );
230        // Fixed arity from the right: a base containing a literal `v1` chunk
231        // attributes correctly (a "first v1" scan would split too early).
232        assert_eq!(
233            parse_alive_key("acme/v1/v1/h-3fa9c2d41b7e/state/tc/alive"),
234            Some(token("acme/v1", "h-3fa9c2d41b7e", Some("tc")))
235        );
236        // Service form (4-chunk tail, no producer) at each depth.
237        assert_eq!(
238            parse_alive_key("v1/@catalog/state/alive"),
239            Some(token("", "@catalog", None))
240        );
241        assert_eq!(
242            parse_alive_key("acme/v1/v1/@catalog/state/alive"),
243            Some(token("acme/v1", "@catalog", None))
244        );
245    }
246
247    #[test]
248    fn alive_key_rejects_foreign_shapes() {
249        for key in [
250            "other/junk/alive",                                   // no v1 tail
251            "alive",                                              // too short
252            "zensight/v1/notanorigin/state/p/alive",              // invalid origin
253            "zensight/v1/h-3fa9c2d41b7e/telemetry/p/alive",       // wrong class
254            "zensight/v2/h-3fa9c2d41b7e/state/p/alive",           // wrong version
255            "zensight/v1/h-3fa9c2d41b7e/state/p/health",          // not an alive leaf
256            "zensight/v1/h-3fa9c2d41b7e/state/p/device/d0/alive", // device form (extra arity)
257        ] {
258            assert_eq!(parse_alive_key(key), None, "{key}");
259        }
260    }
261
262    #[test]
263    fn catalog_sweep_pins_to_the_typed_builder() {
264        assert_eq!(
265            CATALOG_ALIVE_SWEEP,
266            format!(
267                "**/{}",
268                zenkey::selector::service_alive(&zenkey::ServiceOrigin::catalog())
269            )
270        );
271        assert_eq!(
272            HOST_ALIVE_SWEEP,
273            format!(
274                "**/{}",
275                zenkey::selector::all_liveliness(zenkey::selector::Scope::fleet())
276            )
277        );
278    }
279
280    fn storage(strip_prefix: Option<&str>, key_expr: Option<&str>) -> StorageInfo {
281        StorageInfo {
282            zid: "z1".into(),
283            name: "latest".into(),
284            key_expr: key_expr.map(str::to_string),
285            strip_prefix: strip_prefix.map(str::to_string),
286            volume: None,
287            raw: match strip_prefix {
288                Some(p) => serde_json::json!({ "strip_prefix": p }),
289                None => serde_json::Value::Null,
290            },
291        }
292    }
293
294    #[test]
295    fn storage_bases_prefer_strip_prefix() {
296        assert_eq!(
297            base_of_storage(&storage(Some("zensight/v1"), None)),
298            Some("zensight".into())
299        );
300        assert_eq!(base_of_storage(&storage(Some("v1"), None)), Some("".into()));
301        // strip_prefix wins over key_expr when both are present.
302        assert_eq!(
303            base_of_storage(&storage(Some("acme/fleet-a/v1"), Some("other/v1/**"))),
304            Some("acme/fleet-a".into())
305        );
306        // A strip_prefix not ending at the v1 boundary falls back to key_expr.
307        assert_eq!(
308            base_of_storage(&storage(Some("zensight"), Some("zensight/v1/*/state/**"))),
309            Some("zensight".into())
310        );
311        // key_expr heuristic alone.
312        assert_eq!(
313            base_of_storage(&storage(None, Some("acme/fleet-a/v1/*/state/**"))),
314            Some("acme/fleet-a".into())
315        );
316        assert_eq!(
317            base_of_storage(&storage(None, Some("v1/*/state/**"))),
318            Some("".into())
319        );
320        // A wildcard or verbatim chunk before v1 names no single base.
321        assert_eq!(base_of_storage(&storage(None, Some("**"))), None);
322        assert_eq!(base_of_storage(&storage(None, Some("*/v1/**"))), None);
323        assert_eq!(base_of_storage(&storage(None, None)), None);
324    }
325
326    #[test]
327    fn signals_merge_sorted_and_deduped() {
328        let tokens = vec![
329            token("zensight", "h-3fa9c2d41b7e", Some("sysinfo")),
330            token("zensight", "h-aaaaaaaaaaaa", Some("sysinfo")), // dedup producer
331            token("zensight", "@catalog", None),                  // service: producer = catalog
332            token("", "h-3fa9c2d41b7e", Some("tc")),
333        ];
334        let storages = [
335            storage(Some("zensight/v1"), None),
336            storage(Some("zensight/v1"), None), // dedup name@zid
337            storage(Some("acme/v1"), None),     // storage-only base
338        ];
339        let rows = merge_signals(tokens, &storages);
340        // The empty base sorts first; output is deterministic.
341        let bases: Vec<&str> = rows.iter().map(|r| r.base.as_str()).collect();
342        assert_eq!(bases, vec!["", "acme", "zensight"]);
343        let zs = &rows[2];
344        assert_eq!(zs.origins.len(), 3);
345        assert_eq!(
346            zs.producers.iter().collect::<Vec<_>>(),
347            vec!["catalog", "sysinfo"]
348        );
349        assert_eq!(zs.storages, vec!["latest@z1"]);
350        let acme = &rows[1];
351        assert!(acme.origins.is_empty(), "storage-only base has no origins");
352        assert_eq!(acme.storages, vec!["latest@z1"]);
353    }
354}