1use std::time::Duration;
20
21use crate::{Error, Result};
22use zenkey::origin::{HostId, ServiceOrigin};
23use zenkey::qos::QosProfile;
24use zenkey::{Declared, Fanout, ProcedureKind};
25use zenoh::Session;
26
27use crate::model::registry::SliceSet;
28use crate::report::{CallAnswer, CallError, CallOutcome, CallReport};
29
30pub struct Publication {
32 publisher: zenoh::pubsub::Publisher<'static>,
33 encoding: Option<String>,
34}
35
36impl std::fmt::Debug for Publication {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("Publication")
39 .field("key", &self.publisher.key_expr().as_str())
40 .finish_non_exhaustive()
41 }
42}
43
44pub async fn declare_publication(
51 session: &Session,
52 key: &str,
53 qos: QosProfile,
54 encoding: Option<&str>,
55) -> Result<Publication> {
56 let publisher = session
57 .declare_publisher(key.to_string())
58 .reliability(qos.reliability())
59 .congestion_control(qos.congestion_control())
60 .priority(qos.priority())
61 .express(qos.express())
62 .await
63 .map_err(|e| Error::bus("declare publisher", key, e))?;
64 Ok(Publication {
65 publisher,
66 encoding: encoding.map(str::to_string),
67 })
68}
69
70impl Publication {
71 pub async fn send(&self, payload: Vec<u8>, attachment: Option<Vec<u8>>) -> Result<()> {
76 self.send_stamped(payload, attachment, None).await
77 }
78
79 pub async fn send_stamped(
85 &self,
86 payload: Vec<u8>,
87 attachment: Option<Vec<u8>>,
88 timestamp: Option<zenoh::time::Timestamp>,
89 ) -> Result<()> {
90 let put = self.publisher.put(payload);
91 let put = match &self.encoding {
92 Some(e) => put.encoding(e.as_str()),
93 None => put,
94 };
95 let put = match attachment {
96 Some(a) => put.attachment(a),
97 None => put,
98 };
99 let put = match timestamp {
100 Some(ts) => put.timestamp(ts),
101 None => put,
102 };
103 put.await
104 .map_err(|e| Error::bus("put", self.publisher.key_expr().as_str(), e))
105 }
106
107 pub async fn retire(&self) -> Result<()> {
113 self.publisher
114 .delete()
115 .await
116 .map_err(|e| Error::bus("delete", self.publisher.key_expr().as_str(), e))
117 }
118
119 pub async fn undeclare(self) -> Result<()> {
121 self.publisher
122 .undeclare()
123 .await
124 .map_err(|e| Error::bus("undeclare publisher", "", e))
125 }
126
127 pub async fn matching_status(&self) -> Result<bool> {
134 self.publisher
135 .matching_status()
136 .await
137 .map(|s| s.matching())
138 .map_err(|e| Error::bus("matching status", "", e))
139 }
140
141 pub async fn matching_events(&self) -> Result<MatchingEvents> {
144 let listener = self
145 .publisher
146 .matching_listener()
147 .await
148 .map_err(|e| Error::bus("matching listener", "", e))?;
149 Ok(MatchingEvents { listener })
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum RetireClass {
157 State {
159 registered: bool,
163 ttl_s: Option<i64>,
166 },
167 NonState { class: String },
170 Unclassified { reason: String },
172}
173
174pub fn check_retire(
184 base: &str,
185 key: &str,
186 slices: Option<&SliceSet>,
187 force: bool,
188) -> Result<RetireClass> {
189 if key.contains('*') || key.contains('$') {
190 return Err(Error::unaskable(
191 key,
192 "is a wildcard — a tombstone is addressed to one concrete key; a \
193 wildcard delete is not an operator act, it is a blast radius \
194 (RFC 04 §1.2, v1.12). Not overridable.",
195 ));
196 }
197 let facts = crate::model::facts::describe_key(base, key, slices).facts;
198 use crate::model::facts::{ClassKind, KeyShape, Registration};
199 match &facts.shape {
200 KeyShape::V1(v) if v.class_kind == ClassKind::State => {
201 let (registered, ttl_s) = match &facts.registration {
202 Registration::Registered(s) => (true, s.ttl_s),
203 _ => (false, None),
204 };
205 Ok(RetireClass::State { registered, ttl_s })
206 }
207 KeyShape::V1(v) if matches!(v.class_kind, ClassKind::Telemetry | ClassKind::Events) => {
208 if force {
209 return Ok(RetireClass::NonState {
210 class: v.class.clone(),
211 });
212 }
213 Err(Error::unaskable(
214 key,
215 format!(
216 "is {}-shaped — RFC 04 §1: a delete there is meaningless and \
217 MUST NOT be sent by the class's publisher. Retiring it anyway \
218 is an operator cleanup (RFC 04 §1.2, v1.12) — pass --i-know \
219 to mean it.",
220 v.class
221 ),
222 ))
223 }
224 KeyShape::V1(v) => {
225 if force {
226 return Ok(RetireClass::NonState {
227 class: v.class.clone(),
228 });
229 }
230 Err(Error::unaskable(
231 key,
232 format!(
233 "sits on the {} plane — a plane key answers GETs or carries \
234 frames; a tombstone there is at most a storage purge \
235 (RFC 04 §1.2, v1.12) — pass --i-know to mean it.",
236 v.class
237 ),
238 ))
239 }
240 KeyShape::NotUnderBase | KeyShape::Unparsed { .. } => {
241 let reason = match &facts.shape {
242 KeyShape::Unparsed { reason } => reason.clone(),
243 _ => format!("not under base {base:?}"),
244 };
245 if force {
246 return Ok(RetireClass::Unclassified { reason });
247 }
248 Err(Error::unaskable(
249 key,
250 format!(
251 "cannot be classified under base {base:?} ({reason}) — 'not \
252 asked' is not 'state' (RFC 09 §5.1 O4); pass --i-know to \
253 retire an unclassified key."
254 ),
255 ))
256 }
257 }
258}
259
260pub struct MatchingEvents {
266 listener: zenoh::matching::MatchingListener<
267 zenoh::handlers::FifoChannelHandler<zenoh::matching::MatchingStatus>,
268 >,
269}
270
271impl MatchingEvents {
272 pub(crate) async fn for_querier(querier: &zenoh::query::Querier<'_>) -> Result<Self> {
273 let listener = querier
274 .matching_listener()
275 .await
276 .map_err(|e| Error::bus("matching listener", "", e))?;
277 Ok(MatchingEvents { listener })
278 }
279
280 pub async fn recv(&self) -> Option<bool> {
283 self.listener.recv_async().await.ok().map(|s| s.matching())
284 }
285
286 pub fn stream(&self) -> impl futures_core::Stream<Item = bool> + '_ {
292 futures_util::StreamExt::map(self.listener.stream(), |s| s.matching())
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum CallTarget {
301 Host(HostId),
303 Fleet,
306 Service(ServiceOrigin),
308}
309
310impl CallTarget {
311 pub fn parse(s: &str) -> Result<CallTarget> {
315 if s == "*" {
316 return Ok(CallTarget::Fleet);
317 }
318 if s.starts_with('@') {
319 return Ok(CallTarget::Service(ServiceOrigin::new(s)?));
320 }
321 HostId::parse(s).map(CallTarget::Host).map_err(|e| {
322 Error::unaskable(
323 "origin",
324 format!("{e} — a hostname is not an origin; resolve it first (RFC 06 §6)"),
325 )
326 })
327 }
328}
329
330fn attachment_value(bytes: &[u8]) -> serde_json::Value {
336 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
337 v
338 } else if let Ok(s) = std::str::from_utf8(bytes) {
339 serde_json::Value::String(s.to_string())
340 } else {
341 serde_json::Value::String(format!("<{} bytes>", bytes.len()))
342 }
343}
344
345pub struct CallSpec<'a> {
352 pub target: &'a CallTarget,
353 pub producer: &'a str,
354 pub procedure: &'a str,
356 pub params: &'a [String],
358 pub body: Option<Vec<u8>>,
360 pub attachment: Option<Vec<u8>>,
363 pub timeout: Duration,
364 pub slices: Option<&'a SliceSet>,
367}
368
369pub async fn call(fleet: &crate::Fleet<'_>, spec: CallSpec<'_>) -> Result<CallReport> {
385 let CallSpec {
386 target,
387 producer,
388 procedure,
389 params,
390 body,
391 attachment,
392 timeout,
393 slices,
394 } = spec;
395 if matches!(target, CallTarget::Fleet)
396 && let Some(slices) = slices
397 && let Some(slice) = slices.get(producer)
398 && let Some(proc_decl) = slice.procedures.iter().find(|p| p.path == procedure)
399 {
400 let forbidden = match proc_decl.fanout.as_ref().and_then(Declared::known) {
406 Some(Fanout::Forbidden) => true,
407 Some(Fanout::Allowed) => false,
408 None => matches!(
413 proc_decl.kind.as_ref().and_then(Declared::known),
414 Some(ProcedureKind::Write)
415 ),
416 };
417 if forbidden {
418 let declared = match proc_decl.fanout.as_ref() {
425 Some(f) if f.is(&Fanout::Forbidden) => {
426 "declares fanout = \"forbidden\"".to_string()
427 }
428 Some(f) => format!(
429 "declares fanout = {:?}, a token this build does not know — \
430 RFC 08 §2 defaults a write to forbidden and an unreadable \
431 spelling is not a licence",
432 f.token()
433 ),
434 None => "is a write with no declared fanout, which defaults to forbidden \
435 (RFC 08 §2)"
436 .to_string(),
437 };
438 return Err(Error::unaskable(
439 format!("procedure {producer}/{procedure}"),
440 format!(
441 "{declared} — a fleet (`*`) call to it is refused \
442 (RFC 05 §2.1); name one origin"
443 ),
444 ));
445 }
446 }
447
448 let segments: Vec<&str> = procedure.split('/').collect();
449 let relative = match target {
450 CallTarget::Host(id) => {
451 let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
452 zenkey::selector::rpc_at(&origin, producer, &segments).to_string()
453 }
454 CallTarget::Fleet => zenkey::selector::fleet_rpc(producer, &segments).to_string(),
455 CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
456 };
457 let mut key = fleet.wire(relative);
458 if !params.is_empty() {
459 key.push('?');
460 key.push_str(¶ms.join(";"));
461 }
462
463 let answers = crate::bus::query::fleet_get(
464 fleet,
465 &key,
466 &crate::bus::query::GetOpts::new(timeout)
467 .payload(body)
468 .attachment(attachment),
469 )
470 .await?;
471 Ok(CallReport {
472 key: key.clone(),
473 timeout_s: timeout.as_secs_f64(),
476 answers: answers
477 .iter()
478 .map(|a| {
479 let (att, att_bytes) = match &a.attachment {
483 Some(z) => {
484 let bytes = z.to_bytes();
485 (Some(attachment_value(&bytes)), Some(bytes.len()))
486 }
487 None => (None, None),
488 };
489 let outcome = match &a.answer {
490 crate::bus::query::Answer::Value(bytes) => {
491 let bytes = bytes.to_bytes();
492 match serde_json::from_slice::<serde_json::Value>(&bytes) {
493 Ok(v) => CallOutcome::Ok {
494 value: Some(v),
495 text: None,
496 },
497 Err(_) => CallOutcome::Ok {
498 value: None,
499 text: Some(String::from_utf8_lossy(&bytes).to_string()),
500 },
501 }
502 }
503 crate::bus::query::Answer::Error { name, message } => {
504 CallOutcome::Err(CallError {
505 name: name.clone(),
506 message: message.clone(),
507 })
508 }
509 };
510 CallAnswer {
511 origin: a.origin.clone(),
512 outcome,
513 attachment: att,
514 attachment_bytes: att_bytes,
515 }
516 })
517 .collect(),
518 })
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use zenkey::slice::{ProcedureDecl, RegistrySlice, SubjectDecl};
525
526 fn slice_with_state_subject() -> SliceSet {
527 let mut health = SubjectDecl::new("health", zenkey::Class::State);
528 health.type_name = "Health".into();
529 health.ttl_s = Some(900);
530 let mut slice = RegistrySlice::new("1.0", "t", "sysinfo");
531 slice.subjects = vec![health];
532 SliceSet::from_slices(vec![slice])
533 }
534
535 #[test]
538 fn a_wildcard_retire_is_refused_unconditionally() {
539 for force in [false, true] {
540 let err = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/**", None, force)
541 .unwrap_err()
542 .to_string();
543 assert!(err.contains("blast radius"), "{err}");
544 }
545 }
546
547 #[test]
548 fn a_state_key_retires_without_a_registry() {
549 let got = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/health", None, false).unwrap();
552 assert_eq!(
553 got,
554 RetireClass::State {
555 registered: false,
556 ttl_s: None
557 }
558 );
559 let slices = slice_with_state_subject();
561 let got = check_retire(
562 "",
563 "v1/h-3fa9c2d41b7e/state/sysinfo/health",
564 Some(&slices),
565 false,
566 )
567 .unwrap();
568 assert_eq!(
569 got,
570 RetireClass::State {
571 registered: true,
572 ttl_s: Some(900)
573 }
574 );
575 }
576
577 #[test]
578 fn a_telemetry_retire_needs_i_know_and_cites_the_rfc() {
579 let key = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
580 let err = check_retire("", key, None, false).unwrap_err().to_string();
581 assert!(err.contains("MUST NOT"), "{err}");
582 assert!(err.contains("v1.12"), "{err}");
583 assert!(err.contains("--i-know"), "{err}");
584 assert_eq!(
585 check_retire("", key, None, true).unwrap(),
586 RetireClass::NonState {
587 class: "telemetry".to_string()
588 }
589 );
590 }
591
592 #[test]
593 fn a_plane_retire_needs_i_know_too() {
594 let key = "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect";
595 let err = check_retire("", key, None, false).unwrap_err().to_string();
596 assert!(err.contains("plane"), "{err}");
597 assert!(matches!(
598 check_retire("", key, None, true).unwrap(),
599 RetireClass::NonState { class } if class == "@rpc"
600 ));
601 }
602
603 #[test]
604 fn an_unclassified_retire_needs_i_know_and_names_o4() {
605 let err = check_retire("", "some/foreign/key", None, false)
607 .unwrap_err()
608 .to_string();
609 assert!(err.contains("O4"), "{err}");
610 assert!(matches!(
611 check_retire("", "some/foreign/key", None, true).unwrap(),
612 RetireClass::Unclassified { .. }
613 ));
614 let err = check_retire("acme", "other/v1/h-3fa9c2d41b7e/state/x/y", None, false)
616 .unwrap_err()
617 .to_string();
618 assert!(err.contains("cannot be classified"), "{err}");
619 }
620
621 fn slice_with_proc(kind: &str, fanout: Option<&str>) -> SliceSet {
622 let mut trigger = ProcedureDecl::new("capture/trigger");
623 trigger.kind = Some(Declared::parse(kind));
624 trigger.reply = Some("Ack".into());
625 trigger.fanout = fanout.map(Declared::parse);
626 trigger.idempotent = Some(false);
627 let mut slice = RegistrySlice::new("1.0", "t", "netring");
628 slice.procedures = vec![trigger];
629 SliceSet::from_slices(vec![slice])
630 }
631
632 #[test]
633 fn call_targets_parse_and_validate() {
634 assert_eq!(CallTarget::parse("*").unwrap(), CallTarget::Fleet);
635 assert!(matches!(
636 CallTarget::parse("@catalog").unwrap(),
637 CallTarget::Service(_)
638 ));
639 assert!(matches!(
640 CallTarget::parse("h-3fa9c2d41b7e").unwrap(),
641 CallTarget::Host(_)
642 ));
643 let err = CallTarget::parse("toolbx").unwrap_err().to_string();
645 assert!(err.contains("RFC 06 §6"), "{err}");
646 }
647
648 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
651 async fn fleet_calls_to_forbidden_fanout_are_refused() {
652 let session = crate::bus::session::open(&[], &[], false).await.unwrap();
653 let slices = slice_with_proc("write", Some("forbidden"));
654 let err = call(
655 &crate::Fleet::new(&session, ""),
656 CallSpec {
657 target: &CallTarget::Fleet,
658 producer: "netring",
659 procedure: "capture/trigger",
660 params: &[],
661 body: None,
662 attachment: None,
663 timeout: Duration::from_millis(100),
664 slices: Some(&slices),
665 },
666 )
667 .await
668 .unwrap_err()
669 .to_string();
670 assert!(err.contains("fanout"), "{err}");
671 assert!(err.contains("RFC 05 §2.1"), "{err}");
672
673 let err = call(
677 &crate::Fleet::new(&session, ""),
678 CallSpec {
679 target: &CallTarget::Fleet,
680 producer: "netring",
681 procedure: "capture/trigger",
682 params: &[],
683 body: None,
684 attachment: None,
685 timeout: Duration::from_millis(100),
686 slices: Some(&slice_with_proc("write", None)),
687 },
688 )
689 .await
690 .unwrap_err()
691 .to_string();
692 assert!(err.contains("defaults to forbidden"), "{err}");
693 assert!(err.contains("RFC 08 §2"), "{err}");
694 assert!(err.contains("RFC 05 §2.1"), "{err}");
695
696 let err = call(
701 &crate::Fleet::new(&session, ""),
702 CallSpec {
703 target: &CallTarget::Fleet,
704 producer: "netring",
705 procedure: "capture/trigger",
706 params: &[],
707 body: None,
708 attachment: None,
709 timeout: Duration::from_millis(100),
710 slices: Some(&slice_with_proc("write", Some("per-iface"))),
711 },
712 )
713 .await
714 .unwrap_err()
715 .to_string();
716 assert!(err.contains("per-iface"), "{err}");
717 assert!(err.contains("does not know"), "{err}");
718 assert!(
719 !err.contains("declares fanout = \"forbidden\""),
720 "the slice declared no such thing: {err}"
721 );
722 assert!(err.contains("RFC 05 §2.1"), "{err}");
723
724 for slices in [
728 slice_with_proc("write", Some("allowed")),
729 slice_with_proc("read", None),
730 ] {
731 let report = call(
732 &crate::Fleet::new(&session, ""),
733 CallSpec {
734 target: &CallTarget::Fleet,
735 producer: "netring",
736 procedure: "capture/trigger",
737 params: &[],
738 body: None,
739 attachment: None,
740 timeout: Duration::from_millis(100),
741 slices: Some(&slices),
742 },
743 )
744 .await
745 .unwrap();
746 assert_eq!(report.exit_code(), 2, "silence stays exit 2");
747 }
748 }
749}