1use std::collections::BTreeMap;
4use std::time::Duration;
5
6use crate::report::{Freshness, MediaStreamInfo, NodeInfo, ProducerInfo};
7use crate::{Error, Result};
8use zenkey::grammar::with_base;
9
10pub async fn roster(
16 fleet: &crate::Fleet<'_>,
17 timeout: Duration,
18) -> Result<BTreeMap<String, Vec<String>>> {
19 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
20
21 let catalog_alive = zenkey::selector::service_alive(&zenkey::ServiceOrigin::catalog());
22
23 for expr in [
26 fleet.wire(zenkey::selector::all_liveliness(
27 zenkey::selector::Scope::fleet(),
28 )),
29 fleet.wire(catalog_alive),
30 ] {
31 let Ok(replies) = fleet
32 .session()
33 .liveliness()
34 .get(&expr)
35 .timeout(timeout)
36 .await
37 else {
38 continue;
39 };
40 while let Ok(reply) = replies.recv_async().await {
41 let Ok(sample) = reply.result() else { continue };
42 let key = sample.key_expr().as_str();
43 let Some((origin, producer)) = token_identity(fleet.base(), key) else {
44 continue;
45 };
46 out.entry(origin).or_default().push(producer);
47 }
48 }
49 for producers in out.values_mut() {
50 producers.sort();
51 producers.dedup();
52 }
53 Ok(out)
54}
55
56pub struct RosterWatch {
68 monitor: crate::Monitor,
69 events: crate::EventStream,
70 roster: BTreeMap<String, Vec<String>>,
71 base: String,
72 pending: RosterChange,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub struct RosterChange {
81 pub node_up: bool,
85 pub node_down: bool,
87}
88
89const BURST_QUIET: Duration = Duration::from_millis(50);
93
94impl RosterWatch {
95 pub async fn start(fleet: &crate::Fleet<'_>, timeout: Duration) -> Result<RosterWatch> {
102 let liveliness = vec![
103 fleet.wire(zenkey::selector::all_liveliness(
104 zenkey::selector::Scope::fleet(),
105 )),
106 fleet.wire(zenkey::selector::service_alive(
107 &zenkey::ServiceOrigin::catalog(),
108 )),
109 ];
110 let monitor = crate::Monitor::start(
111 fleet.session(),
112 crate::MonitorSpec {
113 selectors: vec![],
114 liveliness,
115 ..Default::default()
116 },
117 )
118 .await?;
119 let events = monitor.events();
120 let roster = roster(fleet, timeout).await?;
121 Ok(RosterWatch {
122 monitor,
123 events,
124 roster,
125 base: fleet.base().to_string(),
126 pending: RosterChange::default(),
127 })
128 }
129
130 pub fn roster(&self) -> &BTreeMap<String, Vec<String>> {
134 &self.roster
135 }
136
137 pub async fn next_change(&mut self) -> Option<RosterChange> {
153 next_change_in(
154 &mut self.events,
155 &mut self.roster,
156 &self.base,
157 &mut self.pending,
158 )
159 .await
160 }
161
162 pub async fn stop(self) -> Result<()> {
174 self.monitor.shutdown().await
175 }
176}
177
178async fn next_change_in(
182 events: &mut crate::EventStream,
183 roster: &mut BTreeMap<String, Vec<String>>,
184 base: &str,
185 pending: &mut RosterChange,
186) -> Option<RosterChange> {
187 loop {
188 if let Some(change) = take_change(pending) {
190 return Some(change);
191 }
192 let mut item = events.recv().await;
193 loop {
194 match item {
195 None => return take_change(pending),
199 Some(crate::StreamItem::Dropped(_)) => {}
200 Some(crate::StreamItem::Event(ev)) => {
201 let transition = match ev {
202 crate::FleetEvent::NodeUp(key) => Some((key, true)),
203 crate::FleetEvent::NodeDown(key) => Some((key, false)),
204 _ => None,
205 };
206 if let Some((key, up)) = transition
207 && apply_token(roster, base, &key, up)
208 {
209 if up {
210 pending.node_up = true;
211 } else {
212 pending.node_down = true;
213 }
214 }
215 }
216 }
217 match tokio::time::timeout(BURST_QUIET, events.recv()).await {
218 Ok(next) => item = next,
219 Err(_) => break,
220 }
221 }
222 }
223}
224
225fn take_change(pending: &mut RosterChange) -> Option<RosterChange> {
228 if !pending.node_up && !pending.node_down {
229 return None;
230 }
231 Some(std::mem::take(pending))
232}
233
234pub fn token_identity(base: &str, key: &str) -> Option<(String, String)> {
243 let parsed = zenkey::grammar::parse_full(base, key)?;
244 let origin = parsed.origin.chunk().to_string();
245 let producer = parsed
246 .producer()
247 .map(|p| p.chunk())
248 .unwrap_or_else(|| origin.trim_start_matches('@').to_string());
249 Some((origin, producer))
250}
251
252pub fn apply_token(
260 roster: &mut BTreeMap<String, Vec<String>>,
261 base: &str,
262 key: &str,
263 up: bool,
264) -> bool {
265 let Some((origin, producer)) = token_identity(base, key) else {
266 return false;
267 };
268 if up {
269 let entry = roster.entry(origin).or_default();
270 if entry.contains(&producer) {
271 return false;
272 }
273 entry.push(producer);
274 entry.sort();
275 return true;
276 }
277 let Some(entry) = roster.get_mut(&origin) else {
278 return false;
279 };
280 let before = entry.len();
281 entry.retain(|p| p != &producer);
282 let changed = entry.len() != before;
283 if entry.is_empty() {
284 roster.remove(&origin);
285 }
286 changed
287}
288
289pub fn node_rows(
292 roster: &BTreeMap<String, Vec<String>>,
293 slices: Option<&crate::SliceSet>,
294) -> crate::report::NodeList {
295 let mut nodes = Vec::new();
296
297 for (origin, producers) in roster {
298 for producer in producers {
299 let joined = slices.and_then(|s| {
300 let base_name = zenkey::grammar::Producer::parse_chunk(producer)
302 .map(|pr| pr.name().to_string())
303 .unwrap_or_else(|_| producer.clone());
304 s.get(&base_name)
305 });
306 nodes.push(crate::report::NodeRow {
307 origin: origin.clone(),
308 producer: producer.clone(),
309 app: joined.map(|s| s.app.clone()),
310 registry_version: joined.map(|s| s.version.clone()),
311 });
312 }
313 }
314 crate::report::NodeList {
315 nodes,
316 slices_joined: slices.is_some(),
317 }
318}
319
320enum Node {
326 Host(zenkey::origin::RemoteOrigin),
327 Service(zenkey::ServiceOrigin),
328}
329
330impl Node {
331 fn parse(origin: &str) -> Result<Node> {
332 if origin.starts_with('@') {
333 zenkey::ServiceOrigin::new(origin)
334 .map(Node::Service)
335 .map_err(Error::from)
336 } else {
337 zenkey::origin::RemoteOrigin::parse(origin)
338 .map(Node::Host)
339 .map_err(|e| {
340 Error::unaskable(
341 "origin",
342 format!("{e} — a hostname is not an origin (RFC 06 §6)"),
343 )
344 })
345 }
346 }
347
348 fn alive_selector(&self) -> String {
350 match self {
351 Node::Host(o) => {
352 zenkey::selector::all_liveliness(zenkey::selector::Scope::origin(o)).to_string()
353 }
354 Node::Service(o) => zenkey::selector::service_alive(o).to_string(),
355 }
356 }
357
358 fn introspect_selector(&self) -> String {
360 match self {
361 Node::Host(o) => zenkey::selector::rpc(
362 zenkey::selector::Scope::origin(o),
363 zenkey::selector::Producers::all(),
364 &["introspect"],
365 )
366 .to_string(),
367 Node::Service(o) => zenkey::selector::service_rpc(o, &["introspect"]).to_string(),
368 }
369 }
370
371 fn state_selector(&self) -> String {
374 let scope = match self {
375 Node::Host(o) => zenkey::selector::Scope::origin(o),
376 Node::Service(o) => zenkey::selector::Scope::origin(o),
377 };
378 zenkey::selector::all_state(scope).to_string()
379 }
380}
381
382pub async fn node_info(
396 fleet: &crate::Fleet<'_>,
397 origin: &str,
398 timeout: Duration,
399 with_freshness: bool,
400) -> Result<NodeInfo> {
401 let (session, base) = (fleet.session(), fleet.base());
402
403 let node = Node::parse(origin)?;
404
405 let mut alive: Vec<String> = Vec::new();
409 let alive_expr = with_base(base, node.alive_selector());
410 if let Ok(replies) = session.liveliness().get(&alive_expr).timeout(timeout).await {
411 while let Ok(reply) = replies.recv_async().await {
412 let Ok(sample) = reply.result() else { continue };
413 let Some(parsed) = zenkey::grammar::parse_full(base, sample.key_expr().as_str()) else {
414 continue;
415 };
416 alive.push(
417 parsed
418 .producer()
419 .map(|p| p.chunk())
420 .unwrap_or_else(|| parsed.origin.chunk().trim_start_matches('@').to_string()),
421 );
422 }
423 }
424 alive.sort();
425 alive.dedup();
426
427 let introspect = with_base(base, node.introspect_selector());
431 let answers = crate::bus::query::fleet_get(
432 fleet,
433 &introspect,
434 &crate::bus::query::GetOpts::new(timeout),
435 )
436 .await
437 .unwrap_or_default();
438 let served: Vec<zenkey::slice::RegistrySlice> = answers
439 .into_iter()
440 .filter(|a| a.origin == origin)
441 .filter_map(|a| {
442 let crate::bus::query::Answer::Value(bytes) = a.answer else {
443 return None;
444 };
445 let toml = String::from_utf8_lossy(&bytes.to_bytes()).to_string();
446 match zenkey::parse_slice(&toml) {
447 Ok(slice) => Some(slice),
448 Err(e) => {
449 tracing::warn!(origin, "introspect reply did not parse, skipping: {e}");
450 None
451 }
452 }
453 })
454 .collect();
455 let mine: Vec<&zenkey::slice::RegistrySlice> = served.iter().collect();
456
457 let mut names: Vec<String> = alive.clone();
458 names.extend(mine.iter().map(|s| s.name.clone()));
459 names.sort();
460 names.dedup();
461
462 let producers: Vec<ProducerInfo> = names
463 .iter()
464 .map(|name| {
465 let slice = mine.iter().find(|s| &s.name == name);
466 ProducerInfo {
467 name: name.clone(),
468 alive: alive.iter().any(|a| a == name),
469 app: slice.map(|s| s.app.clone()),
470 registry_version: slice.map(|s| s.version.clone()),
471 subjects: slice.map(|s| s.subjects.len()).unwrap_or(0),
472 procedures: slice.map(|s| s.procedures.len()).unwrap_or(0),
473 blob_tiers: slice
474 .map(|s| s.blob.iter().map(|b| b.tier.token().to_string()).collect())
475 .unwrap_or_default(),
476 media: slice
477 .map(|s| {
478 s.media
479 .iter()
480 .map(|m| MediaStreamInfo {
481 path: m.path.clone(),
482 encoding: m.encoding.as_encoding_str().to_string(),
483 })
484 .collect()
485 })
486 .unwrap_or_default(),
487 deprecated_served: slice.map(|s| s.deprecated.len()).unwrap_or(0),
488 }
489 })
490 .collect();
491
492 let mut freshness = Vec::new();
493 if with_freshness && !mine.is_empty() {
494 let selector = with_base(base, node.state_selector());
496 let samples = crate::bus::query::state_snapshot(session, &selector, timeout, None)
497 .await
498 .unwrap_or_default();
499 let now = std::time::SystemTime::now();
500 for slice in &mine {
501 for subject in &slice.subjects {
502 let Some(ttl) = subject.ttl_s else { continue };
503 if !subject.class.is(&zenkey::Class::State) {
504 continue;
505 }
506 let age = samples
508 .iter()
509 .filter_map(|s| {
510 let parsed = zenkey::grammar::parse_full(base, &s.key)?;
511 let p = parsed.producer()?.name().to_string();
512 if p != slice.name {
513 return None;
514 }
515 let tail: Vec<&str> = parsed.subject.clone();
516 let pattern = zenkey::pattern::SubjectPattern::parse(&subject.path).ok()?;
517 pattern.matches(&tail)?;
518 s.timestamp.map(|t| {
519 now.duration_since(t.get_time().to_system_time())
520 .map(|d| d.as_secs() as i64)
521 .unwrap_or(0)
522 })
523 })
524 .min();
525 freshness.push(Freshness {
526 producer: slice.name.clone(),
527 path: subject.path.clone(),
528 ttl_s: ttl,
529 age_s: age,
530 stale: match age {
531 Some(a) => a > ttl,
532 None => true,
535 },
536 });
537 }
538 }
539 }
540
541 Ok(NodeInfo {
542 origin: origin.to_string(),
543 producers,
544 freshness,
545 })
546}
547
548#[derive(Debug, Clone, PartialEq, Eq)]
551pub struct BridgeMatch {
552 pub host_id: zenkey::origin::HostId,
554 pub source: String,
556 pub key: String,
559}
560
561pub async fn bridge_resolve(
573 fleet: &crate::Fleet<'_>,
574 producer: &str,
575 label: &str,
576 timeout: std::time::Duration,
577) -> Result<(Vec<BridgeMatch>, usize)> {
578 let relative =
579 zenkey::selector::producer_state(zenkey::selector::Scope::fleet(), producer, &["health"])
580 .to_string();
581 let key = fleet.wire(relative);
582 let answers =
583 crate::bus::query::fleet_get(fleet, &key, &crate::bus::query::GetOpts::new(timeout))
584 .await?;
585 let mut matches = Vec::new();
586 let seen = answers.len();
587 for a in &answers {
588 let crate::bus::query::Answer::Value(bytes) = &a.answer else {
589 continue;
590 };
591 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&bytes.to_bytes()) else {
592 continue;
593 };
594 let (Some(host_id), Some(source)) = (
595 doc.get("host_id").and_then(|v| v.as_str()),
596 doc.get("source").and_then(|v| v.as_str()),
597 ) else {
598 continue;
599 };
600 if source == label
601 && let Ok(id) = zenkey::origin::HostId::parse(host_id)
602 {
603 matches.push(BridgeMatch {
604 host_id: id,
605 source: source.to_string(),
606 key: a.key.clone(),
607 });
608 }
609 }
610 matches.dedup_by(|a, b| a.host_id == b.host_id);
611 Ok((matches, seen))
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
621 fn a_token_names_its_origin_and_producer() {
622 assert_eq!(
623 token_identity("acme", "acme/v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
624 Some(("h-3fa9c2d41b7e".into(), "sysinfo".into()))
625 );
626 assert_eq!(
629 token_identity("acme", "acme/v1/@catalog/state/alive"),
630 Some(("@catalog".into(), "catalog".into()))
631 );
632 assert_eq!(
634 token_identity("", "v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
635 Some(("h-3fa9c2d41b7e".into(), "sysinfo".into()))
636 );
637 assert_eq!(token_identity("acme", "demo/not/a/token"), None);
638 }
639
640 #[test]
645 fn applying_a_token_reports_only_real_changes() {
646 let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
647 let key = "acme/v1/h-3fa9c2d41b7e/state/sysinfo/alive";
648
649 assert!(apply_token(&mut roster, "acme", key, true));
650 assert!(
651 !apply_token(&mut roster, "acme", key, true),
652 "a replayed history token is not a change"
653 );
654 assert_eq!(roster["h-3fa9c2d41b7e"], ["sysinfo"]);
655
656 let other = "acme/v1/h-3fa9c2d41b7e/state/alerts/alive";
658 assert!(apply_token(&mut roster, "acme", other, true));
659 assert_eq!(roster["h-3fa9c2d41b7e"], ["alerts", "sysinfo"]);
660
661 assert!(apply_token(&mut roster, "acme", key, false));
662 assert!(
663 !apply_token(&mut roster, "acme", key, false),
664 "retracting what is already gone is not a change"
665 );
666 assert_eq!(roster["h-3fa9c2d41b7e"], ["alerts"]);
667
668 assert!(apply_token(&mut roster, "acme", other, false));
671 assert!(roster.is_empty());
672
673 assert!(!apply_token(&mut roster, "acme", "demo/foreign", true));
675 }
676
677 #[test]
680 fn rows_say_whether_a_slice_was_even_asked_for() {
681 let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
682 roster.insert(
683 "h-3fa9c2d41b7e".into(),
684 vec!["sysinfo".into(), "sysinfo-2".into()],
685 );
686
687 let unasked = node_rows(&roster, None);
688 assert!(!unasked.slices_joined, "no join was attempted");
689 assert!(unasked.nodes.iter().all(|n| n.app.is_none()));
690
691 let slice = zenkey::parse_slice(
692 "[registry]\nversion = \"1.0\"\napp = \"demo\"\nconvention = 1\n\
693 [producer]\nname = \"sysinfo\"\n",
694 )
695 .expect("fixture slice parses");
696 let joined = node_rows(&roster, Some(&crate::SliceSet::from_slices(vec![slice])));
697 assert!(joined.slices_joined);
698 assert_eq!(joined.nodes.len(), 2);
699 for row in &joined.nodes {
700 assert_eq!(
701 row.app.as_deref(),
702 Some("demo"),
703 "an instance suffix shares the base producer's slice: {}",
704 row.producer
705 );
706 }
707 assert_eq!(
708 joined.nodes[1].producer, "sysinfo-2",
709 "the row keeps the suffix"
710 );
711 }
712
713 #[tokio::test(start_paused = true)]
723 async fn a_cancelled_poll_keeps_the_change_it_already_applied() {
724 let core = crate::MonitorCore::new(16);
725 let mut events = core.events();
726 let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
727 let mut pending = RosterChange::default();
728
729 core.node_event("v1/h-3fa9c2d41b7e/state/sysinfo/alive".into(), true);
730
731 let cancelled = tokio::time::timeout(
732 BURST_QUIET / 2,
733 next_change_in(&mut events, &mut roster, "", &mut pending),
734 )
735 .await;
736 assert!(cancelled.is_err(), "the poll is still draining the burst");
737 assert!(
738 roster.contains_key("h-3fa9c2d41b7e"),
739 "the token was applied before the drop"
740 );
741
742 let change = tokio::time::timeout(
745 BURST_QUIET / 2,
746 next_change_in(&mut events, &mut roster, "", &mut pending),
747 )
748 .await
749 .expect("the applied change is reported, not waited on")
750 .expect("a change, not a closed stream");
751 assert_eq!(
752 change,
753 RosterChange {
754 node_up: true,
755 node_down: false
756 }
757 );
758 }
759}