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
287#[derive(Debug, Clone, PartialEq, Eq)]
291pub enum CallTarget {
292 Host(HostId),
294 Fleet,
297 Service(ServiceOrigin),
299}
300
301impl CallTarget {
302 pub fn parse(s: &str) -> Result<CallTarget> {
306 if s == "*" {
307 return Ok(CallTarget::Fleet);
308 }
309 if s.starts_with('@') {
310 return Ok(CallTarget::Service(ServiceOrigin::new(s)?));
311 }
312 HostId::parse(s).map(CallTarget::Host).map_err(|e| {
313 Error::unaskable(
314 "origin",
315 format!("{e} — a hostname is not an origin; resolve it first (RFC 06 §6)"),
316 )
317 })
318 }
319}
320
321fn attachment_value(bytes: &[u8]) -> serde_json::Value {
327 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
328 v
329 } else if let Ok(s) = std::str::from_utf8(bytes) {
330 serde_json::Value::String(s.to_string())
331 } else {
332 serde_json::Value::String(format!("<{} bytes>", bytes.len()))
333 }
334}
335
336pub struct CallSpec<'a> {
343 pub target: &'a CallTarget,
344 pub producer: &'a str,
345 pub procedure: &'a str,
347 pub params: &'a [String],
349 pub body: Option<Vec<u8>>,
351 pub attachment: Option<Vec<u8>>,
354 pub timeout: Duration,
355 pub slices: Option<&'a SliceSet>,
358}
359
360pub async fn call(fleet: &crate::Fleet<'_>, spec: CallSpec<'_>) -> Result<CallReport> {
376 let CallSpec {
377 target,
378 producer,
379 procedure,
380 params,
381 body,
382 attachment,
383 timeout,
384 slices,
385 } = spec;
386 if matches!(target, CallTarget::Fleet)
387 && let Some(slices) = slices
388 && let Some(slice) = slices.get(producer)
389 && let Some(proc_decl) = slice.procedures.iter().find(|p| p.path == procedure)
390 {
391 let forbidden = match proc_decl.fanout.as_ref().and_then(Declared::known) {
397 Some(Fanout::Forbidden) => true,
398 Some(Fanout::Allowed) => false,
399 None => matches!(
404 proc_decl.kind.as_ref().and_then(Declared::known),
405 Some(ProcedureKind::Write)
406 ),
407 };
408 if forbidden {
409 let declared = match proc_decl.fanout.as_ref() {
416 Some(f) if f.is(&Fanout::Forbidden) => {
417 "declares fanout = \"forbidden\"".to_string()
418 }
419 Some(f) => format!(
420 "declares fanout = {:?}, a token this build does not know — \
421 RFC 08 §2 defaults a write to forbidden and an unreadable \
422 spelling is not a licence",
423 f.token()
424 ),
425 None => "is a write with no declared fanout, which defaults to forbidden \
426 (RFC 08 §2)"
427 .to_string(),
428 };
429 return Err(Error::unaskable(
430 format!("procedure {producer}/{procedure}"),
431 format!(
432 "{declared} — a fleet (`*`) call to it is refused \
433 (RFC 05 §2.1); name one origin"
434 ),
435 ));
436 }
437 }
438
439 let segments: Vec<&str> = procedure.split('/').collect();
440 let relative = match target {
441 CallTarget::Host(id) => {
442 let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
443 zenkey::selector::rpc_at(&origin, producer, &segments).to_string()
444 }
445 CallTarget::Fleet => zenkey::selector::fleet_rpc(producer, &segments).to_string(),
446 CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
447 };
448 let mut key = fleet.wire(relative);
449 if !params.is_empty() {
450 key.push('?');
451 key.push_str(¶ms.join(";"));
452 }
453
454 let answers = crate::bus::query::fleet_get(
455 fleet,
456 &key,
457 &crate::bus::query::GetOpts::new(timeout)
458 .payload(body)
459 .attachment(attachment),
460 )
461 .await?;
462 Ok(CallReport {
463 key: key.clone(),
464 timeout_s: timeout.as_secs_f64(),
467 answers: answers
468 .iter()
469 .map(|a| {
470 let (att, att_bytes) = match &a.attachment {
474 Some(z) => {
475 let bytes = z.to_bytes();
476 (Some(attachment_value(&bytes)), Some(bytes.len()))
477 }
478 None => (None, None),
479 };
480 let outcome = match &a.answer {
481 crate::bus::query::Answer::Value(bytes) => {
482 let bytes = bytes.to_bytes();
483 match serde_json::from_slice::<serde_json::Value>(&bytes) {
484 Ok(v) => CallOutcome::Ok {
485 value: Some(v),
486 text: None,
487 },
488 Err(_) => CallOutcome::Ok {
489 value: None,
490 text: Some(String::from_utf8_lossy(&bytes).to_string()),
491 },
492 }
493 }
494 crate::bus::query::Answer::Error { name, message } => {
495 CallOutcome::Err(CallError {
496 name: name.clone(),
497 message: message.clone(),
498 })
499 }
500 };
501 CallAnswer {
502 origin: a.origin.clone(),
503 outcome,
504 attachment: att,
505 attachment_bytes: att_bytes,
506 }
507 })
508 .collect(),
509 })
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use zenkey::slice::{ProcedureDecl, RegistrySlice, SubjectDecl};
516
517 fn slice_with_state_subject() -> SliceSet {
518 let mut health = SubjectDecl::new("health", zenkey::Class::State);
519 health.type_name = "Health".into();
520 health.ttl_s = Some(900);
521 let mut slice = RegistrySlice::new("1.0", "t", "sysinfo");
522 slice.subjects = vec![health];
523 SliceSet::from_slices(vec![slice])
524 }
525
526 #[test]
529 fn a_wildcard_retire_is_refused_unconditionally() {
530 for force in [false, true] {
531 let err = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/**", None, force)
532 .unwrap_err()
533 .to_string();
534 assert!(err.contains("blast radius"), "{err}");
535 }
536 }
537
538 #[test]
539 fn a_state_key_retires_without_a_registry() {
540 let got = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/health", None, false).unwrap();
543 assert_eq!(
544 got,
545 RetireClass::State {
546 registered: false,
547 ttl_s: None
548 }
549 );
550 let slices = slice_with_state_subject();
552 let got = check_retire(
553 "",
554 "v1/h-3fa9c2d41b7e/state/sysinfo/health",
555 Some(&slices),
556 false,
557 )
558 .unwrap();
559 assert_eq!(
560 got,
561 RetireClass::State {
562 registered: true,
563 ttl_s: Some(900)
564 }
565 );
566 }
567
568 #[test]
569 fn a_telemetry_retire_needs_i_know_and_cites_the_rfc() {
570 let key = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
571 let err = check_retire("", key, None, false).unwrap_err().to_string();
572 assert!(err.contains("MUST NOT"), "{err}");
573 assert!(err.contains("v1.12"), "{err}");
574 assert!(err.contains("--i-know"), "{err}");
575 assert_eq!(
576 check_retire("", key, None, true).unwrap(),
577 RetireClass::NonState {
578 class: "telemetry".to_string()
579 }
580 );
581 }
582
583 #[test]
584 fn a_plane_retire_needs_i_know_too() {
585 let key = "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect";
586 let err = check_retire("", key, None, false).unwrap_err().to_string();
587 assert!(err.contains("plane"), "{err}");
588 assert!(matches!(
589 check_retire("", key, None, true).unwrap(),
590 RetireClass::NonState { class } if class == "@rpc"
591 ));
592 }
593
594 #[test]
595 fn an_unclassified_retire_needs_i_know_and_names_o4() {
596 let err = check_retire("", "some/foreign/key", None, false)
598 .unwrap_err()
599 .to_string();
600 assert!(err.contains("O4"), "{err}");
601 assert!(matches!(
602 check_retire("", "some/foreign/key", None, true).unwrap(),
603 RetireClass::Unclassified { .. }
604 ));
605 let err = check_retire("acme", "other/v1/h-3fa9c2d41b7e/state/x/y", None, false)
607 .unwrap_err()
608 .to_string();
609 assert!(err.contains("cannot be classified"), "{err}");
610 }
611
612 fn slice_with_proc(kind: &str, fanout: Option<&str>) -> SliceSet {
613 let mut trigger = ProcedureDecl::new("capture/trigger");
614 trigger.kind = Some(Declared::parse(kind));
615 trigger.reply = Some("Ack".into());
616 trigger.fanout = fanout.map(Declared::parse);
617 trigger.idempotent = Some(false);
618 let mut slice = RegistrySlice::new("1.0", "t", "netring");
619 slice.procedures = vec![trigger];
620 SliceSet::from_slices(vec![slice])
621 }
622
623 #[test]
624 fn call_targets_parse_and_validate() {
625 assert_eq!(CallTarget::parse("*").unwrap(), CallTarget::Fleet);
626 assert!(matches!(
627 CallTarget::parse("@catalog").unwrap(),
628 CallTarget::Service(_)
629 ));
630 assert!(matches!(
631 CallTarget::parse("h-3fa9c2d41b7e").unwrap(),
632 CallTarget::Host(_)
633 ));
634 let err = CallTarget::parse("toolbx").unwrap_err().to_string();
636 assert!(err.contains("RFC 06 §6"), "{err}");
637 }
638
639 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
642 async fn fleet_calls_to_forbidden_fanout_are_refused() {
643 let session = crate::bus::session::open(&[], &[], false).await.unwrap();
644 let slices = slice_with_proc("write", Some("forbidden"));
645 let err = call(
646 &crate::Fleet::new(&session, ""),
647 CallSpec {
648 target: &CallTarget::Fleet,
649 producer: "netring",
650 procedure: "capture/trigger",
651 params: &[],
652 body: None,
653 attachment: None,
654 timeout: Duration::from_millis(100),
655 slices: Some(&slices),
656 },
657 )
658 .await
659 .unwrap_err()
660 .to_string();
661 assert!(err.contains("fanout"), "{err}");
662 assert!(err.contains("RFC 05 §2.1"), "{err}");
663
664 let err = call(
668 &crate::Fleet::new(&session, ""),
669 CallSpec {
670 target: &CallTarget::Fleet,
671 producer: "netring",
672 procedure: "capture/trigger",
673 params: &[],
674 body: None,
675 attachment: None,
676 timeout: Duration::from_millis(100),
677 slices: Some(&slice_with_proc("write", None)),
678 },
679 )
680 .await
681 .unwrap_err()
682 .to_string();
683 assert!(err.contains("defaults to forbidden"), "{err}");
684 assert!(err.contains("RFC 08 §2"), "{err}");
685 assert!(err.contains("RFC 05 §2.1"), "{err}");
686
687 let err = call(
692 &crate::Fleet::new(&session, ""),
693 CallSpec {
694 target: &CallTarget::Fleet,
695 producer: "netring",
696 procedure: "capture/trigger",
697 params: &[],
698 body: None,
699 attachment: None,
700 timeout: Duration::from_millis(100),
701 slices: Some(&slice_with_proc("write", Some("per-iface"))),
702 },
703 )
704 .await
705 .unwrap_err()
706 .to_string();
707 assert!(err.contains("per-iface"), "{err}");
708 assert!(err.contains("does not know"), "{err}");
709 assert!(
710 !err.contains("declares fanout = \"forbidden\""),
711 "the slice declared no such thing: {err}"
712 );
713 assert!(err.contains("RFC 05 §2.1"), "{err}");
714
715 for slices in [
719 slice_with_proc("write", Some("allowed")),
720 slice_with_proc("read", None),
721 ] {
722 let report = call(
723 &crate::Fleet::new(&session, ""),
724 CallSpec {
725 target: &CallTarget::Fleet,
726 producer: "netring",
727 procedure: "capture/trigger",
728 params: &[],
729 body: None,
730 attachment: None,
731 timeout: Duration::from_millis(100),
732 slices: Some(&slices),
733 },
734 )
735 .await
736 .unwrap();
737 assert_eq!(report.exit_code(), 2, "silence stays exit 2");
738 }
739 }
740}