Skip to main content

zenkey_fleet/bus/
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;
22use std::time::Duration;
23
24use crate::Result;
25use zenkey::grammar::{self, ClassOrPlane, SUBJECT_ALIVE, VERSION_CHUNK};
26use zenoh::Session;
27
28use crate::report::{DiscoveredBase, StorageInfo};
29
30/// Host-form sweep: `**` matches zero or more chunks, so every base depth is
31/// covered — including the empty base. A verbatim origin is never matched
32/// (D4), hence the separate catalog sweep.
33const HOST_ALIVE_SWEEP: &str = "**/v1/*/state/*/alive";
34
35/// `@catalog` asked for by name at any base depth, mirroring [`crate::bus::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().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/// Merge the two signals into sorted, deduped rows (the empty base sorts
125/// first). Pure — [`discover_bases`] is the thin session wrapper.
126pub fn merge_signals(
127    tokens: impl IntoIterator<Item = AliveToken>,
128    storages: &[StorageInfo],
129) -> Vec<DiscoveredBase> {
130    let mut bases: BTreeMap<String, DiscoveredBase> = BTreeMap::new();
131
132    fn entry<'m>(
133        bases: &'m mut BTreeMap<String, DiscoveredBase>,
134        base: &str,
135    ) -> &'m mut DiscoveredBase {
136        bases
137            .entry(base.to_string())
138            .or_insert_with(|| DiscoveredBase {
139                base: base.to_string(),
140                ..DiscoveredBase::default()
141            })
142    }
143    for token in tokens {
144        let row = entry(&mut bases, &token.base);
145        // A service token's producer is the service itself (RFC 03 §1.5).
146        let producer = token
147            .producer
148            .unwrap_or_else(|| token.origin.trim_start_matches('@').to_string());
149        row.origins.insert(token.origin);
150        row.producers.insert(producer);
151    }
152    for storage in storages {
153        let Some(base) = base_of_storage(storage) else {
154            continue;
155        };
156        entry(&mut bases, &base)
157            .storages
158            .push(format!("{}@{}", storage.name, storage.zid));
159    }
160    for row in bases.values_mut() {
161        row.storages.sort();
162        row.storages.dedup();
163    }
164    bases.into_values().collect()
165}
166
167/// The sweep: the two liveliness gets plus the router storage configs, merged
168/// by [`merge_signals`]. Best-effort throughout — a peer-only mesh (no admin
169/// space) or a failed selector narrows the evidence, never errors. Zero rows
170/// is *not* proof of an empty mesh (RFC 05 §3.1): the caller renders that
171/// silence honestly.
172///
173/// Base-less by design, so a bare `&Session` rather than a [`crate::Fleet`]:
174/// this is the verb that *finds* bases, and requiring one to run would be
175/// circular.
176pub async fn discover_bases(session: &Session, timeout: Duration) -> Result<Vec<DiscoveredBase>> {
177    let mut tokens = Vec::new();
178    for sweep in [HOST_ALIVE_SWEEP, CATALOG_ALIVE_SWEEP] {
179        let Ok(replies) = session.liveliness().get(sweep).timeout(timeout).await else {
180            continue;
181        };
182        while let Ok(reply) = replies.recv_async().await {
183            let Ok(sample) = reply.result() else { continue };
184            if let Some(token) = parse_alive_key(sample.key_expr().as_str()) {
185                tokens.push(token);
186            }
187        }
188    }
189    let storages = crate::bus::admin::storages(session, timeout)
190        .await
191        .unwrap_or_default();
192    Ok(merge_signals(tokens, &storages))
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn token(base: &str, origin: &str, producer: Option<&str>) -> AliveToken {
200        AliveToken {
201            base: base.into(),
202            origin: origin.into(),
203            producer: producer.map(str::to_string),
204        }
205    }
206
207    #[test]
208    fn alive_keys_attribute_by_fixed_arity() {
209        // Host form at every base depth, including the empty base.
210        assert_eq!(
211            parse_alive_key("v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
212            Some(token("", "h-3fa9c2d41b7e", Some("sysinfo")))
213        );
214        assert_eq!(
215            parse_alive_key("zensight/v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
216            Some(token("zensight", "h-3fa9c2d41b7e", Some("sysinfo")))
217        );
218        assert_eq!(
219            parse_alive_key("acme/fleet-a/v1/h-aaaaaaaaaaaa/state/netring/alive"),
220            Some(token("acme/fleet-a", "h-aaaaaaaaaaaa", Some("netring")))
221        );
222        // Fixed arity from the right: a base containing a literal `v1` chunk
223        // attributes correctly (a "first v1" scan would split too early).
224        assert_eq!(
225            parse_alive_key("acme/v1/v1/h-3fa9c2d41b7e/state/tc/alive"),
226            Some(token("acme/v1", "h-3fa9c2d41b7e", Some("tc")))
227        );
228        // Service form (4-chunk tail, no producer) at each depth.
229        assert_eq!(
230            parse_alive_key("v1/@catalog/state/alive"),
231            Some(token("", "@catalog", None))
232        );
233        assert_eq!(
234            parse_alive_key("acme/v1/v1/@catalog/state/alive"),
235            Some(token("acme/v1", "@catalog", None))
236        );
237    }
238
239    #[test]
240    fn alive_key_rejects_foreign_shapes() {
241        for key in [
242            "other/junk/alive",                                   // no v1 tail
243            "alive",                                              // too short
244            "zensight/v1/notanorigin/state/p/alive",              // invalid origin
245            "zensight/v1/h-3fa9c2d41b7e/telemetry/p/alive",       // wrong class
246            "zensight/v2/h-3fa9c2d41b7e/state/p/alive",           // wrong version
247            "zensight/v1/h-3fa9c2d41b7e/state/p/health",          // not an alive leaf
248            "zensight/v1/h-3fa9c2d41b7e/state/p/device/d0/alive", // device form (extra arity)
249        ] {
250            assert_eq!(parse_alive_key(key), None, "{key}");
251        }
252    }
253
254    #[test]
255    fn catalog_sweep_pins_to_the_typed_builder() {
256        assert_eq!(
257            CATALOG_ALIVE_SWEEP,
258            format!(
259                "**/{}",
260                zenkey::selector::service_alive(&zenkey::ServiceOrigin::catalog())
261            )
262        );
263        assert_eq!(
264            HOST_ALIVE_SWEEP,
265            format!(
266                "**/{}",
267                zenkey::selector::all_liveliness(zenkey::selector::Scope::fleet())
268            )
269        );
270    }
271
272    fn storage(strip_prefix: Option<&str>, key_expr: Option<&str>) -> StorageInfo {
273        StorageInfo {
274            zid: "z1".into(),
275            name: "latest".into(),
276            key_expr: key_expr.map(str::to_string),
277            strip_prefix: strip_prefix.map(str::to_string),
278            volume: None,
279            raw: match strip_prefix {
280                Some(p) => serde_json::json!({ "strip_prefix": p }),
281                None => serde_json::Value::Null,
282            },
283        }
284    }
285
286    #[test]
287    fn storage_bases_prefer_strip_prefix() {
288        assert_eq!(
289            base_of_storage(&storage(Some("zensight/v1"), None)),
290            Some("zensight".into())
291        );
292        assert_eq!(base_of_storage(&storage(Some("v1"), None)), Some("".into()));
293        // strip_prefix wins over key_expr when both are present.
294        assert_eq!(
295            base_of_storage(&storage(Some("acme/fleet-a/v1"), Some("other/v1/**"))),
296            Some("acme/fleet-a".into())
297        );
298        // A strip_prefix not ending at the v1 boundary falls back to key_expr.
299        assert_eq!(
300            base_of_storage(&storage(Some("zensight"), Some("zensight/v1/*/state/**"))),
301            Some("zensight".into())
302        );
303        // key_expr heuristic alone.
304        assert_eq!(
305            base_of_storage(&storage(None, Some("acme/fleet-a/v1/*/state/**"))),
306            Some("acme/fleet-a".into())
307        );
308        assert_eq!(
309            base_of_storage(&storage(None, Some("v1/*/state/**"))),
310            Some("".into())
311        );
312        // A wildcard or verbatim chunk before v1 names no single base.
313        assert_eq!(base_of_storage(&storage(None, Some("**"))), None);
314        assert_eq!(base_of_storage(&storage(None, Some("*/v1/**"))), None);
315        assert_eq!(base_of_storage(&storage(None, None)), None);
316    }
317
318    #[test]
319    fn signals_merge_sorted_and_deduped() {
320        let tokens = vec![
321            token("zensight", "h-3fa9c2d41b7e", Some("sysinfo")),
322            token("zensight", "h-aaaaaaaaaaaa", Some("sysinfo")), // dedup producer
323            token("zensight", "@catalog", None),                  // service: producer = catalog
324            token("", "h-3fa9c2d41b7e", Some("tc")),
325        ];
326        let storages = [
327            storage(Some("zensight/v1"), None),
328            storage(Some("zensight/v1"), None), // dedup name@zid
329            storage(Some("acme/v1"), None),     // storage-only base
330        ];
331        let rows = merge_signals(tokens, &storages);
332        // The empty base sorts first; output is deterministic.
333        let bases: Vec<&str> = rows.iter().map(|r| r.base.as_str()).collect();
334        assert_eq!(bases, vec!["", "acme", "zensight"]);
335        let zs = &rows[2];
336        assert_eq!(zs.origins.len(), 3);
337        assert_eq!(
338            zs.producers.iter().collect::<Vec<_>>(),
339            vec!["catalog", "sysinfo"]
340        );
341        assert_eq!(zs.storages, vec!["latest@z1"]);
342        let acme = &rows[1];
343        assert!(acme.origins.is_empty(), "storage-only base has no origins");
344        assert_eq!(acme.storages, vec!["latest@z1"]);
345    }
346}