1use std::time::Duration;
20
21use anyhow::{Result, anyhow, bail};
22use zenkey::origin::{HostId, ServiceOrigin};
23use zenkey::qos::QosProfile;
24use zenoh::Session;
25
26use crate::registry::SliceSet;
27use crate::report::{CallAnswer, CallError, CallReport};
28
29pub struct Publication {
31 publisher: zenoh::pubsub::Publisher<'static>,
32 encoding: Option<String>,
33}
34
35impl std::fmt::Debug for Publication {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.debug_struct("Publication")
38 .field("key", &self.publisher.key_expr().as_str())
39 .finish_non_exhaustive()
40 }
41}
42
43pub async fn declare_publication(
50 session: &Session,
51 key: &str,
52 qos: QosProfile,
53 encoding: Option<&str>,
54) -> Result<Publication> {
55 let publisher = session
56 .declare_publisher(key.to_string())
57 .reliability(qos.reliability())
58 .congestion_control(qos.congestion_control())
59 .priority(qos.priority())
60 .express(qos.express())
61 .await
62 .map_err(|e| anyhow!("declare publisher {key}: {e}"))?;
63 Ok(Publication {
64 publisher,
65 encoding: encoding.map(str::to_string),
66 })
67}
68
69impl Publication {
70 pub async fn send(&self, payload: Vec<u8>, attachment: Option<Vec<u8>>) -> Result<()> {
75 let put = self.publisher.put(payload);
76 let put = match &self.encoding {
77 Some(e) => put.encoding(e.as_str()),
78 None => put,
79 };
80 let put = match attachment {
81 Some(a) => put.attachment(a),
82 None => put,
83 };
84 put.await
85 .map_err(|e| anyhow!("put {}: {e}", self.publisher.key_expr()))
86 }
87
88 pub async fn retire(&self) -> Result<()> {
94 self.publisher
95 .delete()
96 .await
97 .map_err(|e| anyhow!("delete {}: {e}", self.publisher.key_expr()))
98 }
99
100 pub async fn undeclare(self) -> Result<()> {
102 self.publisher
103 .undeclare()
104 .await
105 .map_err(|e| anyhow!("undeclare publisher: {e}"))
106 }
107
108 pub async fn matching_status(&self) -> Result<bool> {
115 self.publisher
116 .matching_status()
117 .await
118 .map(|s| s.matching())
119 .map_err(|e| anyhow!("matching status: {e}"))
120 }
121
122 pub async fn matching_events(&self) -> Result<MatchingEvents> {
125 let listener = self
126 .publisher
127 .matching_listener()
128 .await
129 .map_err(|e| anyhow!("matching listener: {e}"))?;
130 Ok(MatchingEvents { listener })
131 }
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum RetireClass {
138 State {
140 registered: bool,
144 ttl_s: Option<i64>,
147 },
148 NonState { class: String },
151 Unclassified { reason: String },
153}
154
155pub fn check_retire(
165 base: &str,
166 key: &str,
167 slices: Option<&SliceSet>,
168 force: bool,
169) -> Result<RetireClass> {
170 if key.contains('*') || key.contains('$') {
171 bail!(
172 "{key} is a wildcard — a tombstone is addressed to one concrete key; \
173 a wildcard delete is not an operator act, it is a blast radius \
174 (RFC 04 §1.2, v1.12). Not overridable."
175 );
176 }
177 let facts = crate::facts::describe_key(base, key, slices).facts;
178 use crate::facts::{ClassKind, KeyShape, Registration};
179 match &facts.shape {
180 KeyShape::V1(v) if v.class_kind == ClassKind::State => {
181 let (registered, ttl_s) = match &facts.registration {
182 Registration::Registered(s) => (true, s.ttl_s),
183 _ => (false, None),
184 };
185 Ok(RetireClass::State { registered, ttl_s })
186 }
187 KeyShape::V1(v) if matches!(v.class_kind, ClassKind::Telemetry | ClassKind::Events) => {
188 if force {
189 return Ok(RetireClass::NonState {
190 class: v.class.clone(),
191 });
192 }
193 bail!(
194 "{key} is {class}-shaped — RFC 04 §1: a delete there is meaningless \
195 and MUST NOT be sent by the class's publisher. Retiring it anyway \
196 is an operator cleanup (RFC 04 §1.2, v1.12) — pass --i-know to \
197 mean it.",
198 class = v.class
199 );
200 }
201 KeyShape::V1(v) => {
202 if force {
203 return Ok(RetireClass::NonState {
204 class: v.class.clone(),
205 });
206 }
207 bail!(
208 "{key} sits on the {class} plane — a plane key answers GETs or \
209 carries frames; a tombstone there is at most a storage purge \
210 (RFC 04 §1.2, v1.12) — pass --i-know to mean it.",
211 class = v.class
212 );
213 }
214 KeyShape::NotUnderBase | KeyShape::Unparsed { .. } => {
215 let reason = match &facts.shape {
216 KeyShape::Unparsed { reason } => reason.clone(),
217 _ => format!("not under base {base:?}"),
218 };
219 if force {
220 return Ok(RetireClass::Unclassified { reason });
221 }
222 bail!(
223 "cannot classify {key} under base {base:?} ({reason}) — 'not asked' \
224 is not 'state' (RFC 09 §5.1 O4); pass --i-know to retire an \
225 unclassified key."
226 );
227 }
228 }
229}
230
231pub struct MatchingEvents {
237 listener: zenoh::matching::MatchingListener<
238 zenoh::handlers::FifoChannelHandler<zenoh::matching::MatchingStatus>,
239 >,
240}
241
242impl MatchingEvents {
243 pub(crate) async fn for_querier(querier: &zenoh::query::Querier<'_>) -> Result<Self> {
244 let listener = querier
245 .matching_listener()
246 .await
247 .map_err(|e| anyhow!("matching listener: {e}"))?;
248 Ok(MatchingEvents { listener })
249 }
250
251 pub async fn recv(&self) -> Option<bool> {
254 self.listener.recv_async().await.ok().map(|s| s.matching())
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
262pub enum CallTarget {
263 Host(HostId),
265 Fleet,
268 Service(ServiceOrigin),
270}
271
272impl CallTarget {
273 pub fn parse(s: &str) -> Result<CallTarget> {
277 if s == "*" {
278 return Ok(CallTarget::Fleet);
279 }
280 if s.starts_with('@') {
281 return Ok(CallTarget::Service(
282 ServiceOrigin::new(s).map_err(|e| anyhow!("{e}"))?,
283 ));
284 }
285 HostId::parse(s)
286 .map(CallTarget::Host)
287 .map_err(|e| anyhow!("{e} — a hostname is not an origin; resolve it first (RFC 06 §6)"))
288 }
289}
290
291fn attachment_value(bytes: &[u8]) -> serde_json::Value {
297 if let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
298 v
299 } else if let Ok(s) = std::str::from_utf8(bytes) {
300 serde_json::Value::String(s.to_string())
301 } else {
302 serde_json::Value::String(format!("<{} bytes>", bytes.len()))
303 }
304}
305
306#[allow(clippy::too_many_arguments)]
320pub async fn call(
321 session: &Session,
322 base: &str,
323 target: &CallTarget,
324 producer: &str,
325 procedure: &str,
326 params: &[String],
327 body: Option<Vec<u8>>,
328 attachment: Option<Vec<u8>>,
329 timeout: Duration,
330 slices: Option<&SliceSet>,
331) -> Result<CallReport> {
332 if matches!(target, CallTarget::Fleet)
333 && let Some(slices) = slices
334 && let Some(slice) = slices.get(producer)
335 && let Some(proc_decl) = slice.procedures.iter().find(|p| p.path == procedure)
336 && proc_decl.fanout.as_deref() == Some("forbidden")
337 {
338 bail!(
339 "procedure {producer}/{procedure} declares fanout = \"forbidden\" — a \
340 fleet (`*`) call to it is refused (RFC 05 §2.1); name one origin"
341 );
342 }
343
344 let segments: Vec<&str> = procedure.split('/').collect();
345 let relative = match target {
346 CallTarget::Host(id) => {
347 let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
348 zenkey::selector::rpc_at(&origin, producer, &segments).to_string()
349 }
350 CallTarget::Fleet => zenkey::selector::fleet_rpc(producer, &segments).to_string(),
351 CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
352 };
353 let mut key = zenkey::grammar::with_base(base, relative);
354 if !params.is_empty() {
355 key.push('?');
356 key.push_str(¶ms.join(";"));
357 }
358
359 let answers =
360 crate::query::fleet_get_call(session, base, &key, body, attachment, timeout).await?;
361 Ok(CallReport {
362 key: key.clone(),
363 answers: answers
364 .iter()
365 .map(|a| {
366 let (att, att_bytes) = match &a.attachment {
370 Some(z) => {
371 let bytes = z.to_bytes();
372 (Some(attachment_value(&bytes)), Some(bytes.len()))
373 }
374 None => (None, None),
375 };
376 match &a.answer {
377 crate::query::Answer::Value(bytes) => {
378 let bytes = bytes.to_bytes();
379 match serde_json::from_slice::<serde_json::Value>(&bytes) {
380 Ok(v) => CallAnswer {
381 origin: a.origin.clone(),
382 ok: true,
383 value: Some(v),
384 text: None,
385 attachment: att,
386 attachment_bytes: att_bytes,
387 error: None,
388 },
389 Err(_) => CallAnswer {
390 origin: a.origin.clone(),
391 ok: true,
392 value: None,
393 text: Some(String::from_utf8_lossy(&bytes).to_string()),
394 attachment: att,
395 attachment_bytes: att_bytes,
396 error: None,
397 },
398 }
399 }
400 crate::query::Answer::Error { name, message } => CallAnswer {
401 origin: a.origin.clone(),
402 ok: false,
403 value: None,
404 text: None,
405 attachment: att,
406 attachment_bytes: att_bytes,
407 error: Some(CallError {
408 name: name.clone(),
409 message: message.clone(),
410 }),
411 },
412 }
413 })
414 .collect(),
415 })
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use zenkey::slice::{ProcedureDecl, RegistrySlice, SubjectDecl};
422
423 fn slice_with_state_subject() -> SliceSet {
424 SliceSet::from_slices(vec![RegistrySlice {
425 version: "1.0".into(),
426 app: "t".into(),
427 convention: 1,
428 name: "sysinfo".into(),
429 service_origin: None,
430 description: None,
431 subjects: vec![SubjectDecl {
432 path: "health".into(),
433 class: "state".into(),
434 type_name: "Health".into(),
435 common: None,
436 since: None,
437 description: None,
438 qos: None,
439 ttl_s: Some(900),
440 unit: None,
441 rate: None,
442 cardinality: None,
443 encoding: None,
444 }],
445 procedures: vec![],
446 blob: vec![],
447 media: vec![],
448 deprecated: vec![],
449 }])
450 }
451
452 #[test]
455 fn a_wildcard_retire_is_refused_unconditionally() {
456 for force in [false, true] {
457 let err = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/**", None, force)
458 .unwrap_err()
459 .to_string();
460 assert!(err.contains("blast radius"), "{err}");
461 }
462 }
463
464 #[test]
465 fn a_state_key_retires_without_a_registry() {
466 let got = check_retire("", "v1/h-3fa9c2d41b7e/state/sysinfo/health", None, false).unwrap();
469 assert_eq!(
470 got,
471 RetireClass::State {
472 registered: false,
473 ttl_s: None
474 }
475 );
476 let slices = slice_with_state_subject();
478 let got = check_retire(
479 "",
480 "v1/h-3fa9c2d41b7e/state/sysinfo/health",
481 Some(&slices),
482 false,
483 )
484 .unwrap();
485 assert_eq!(
486 got,
487 RetireClass::State {
488 registered: true,
489 ttl_s: Some(900)
490 }
491 );
492 }
493
494 #[test]
495 fn a_telemetry_retire_needs_i_know_and_cites_the_rfc() {
496 let key = "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu/usage";
497 let err = check_retire("", key, None, false).unwrap_err().to_string();
498 assert!(err.contains("MUST NOT"), "{err}");
499 assert!(err.contains("v1.12"), "{err}");
500 assert!(err.contains("--i-know"), "{err}");
501 assert_eq!(
502 check_retire("", key, None, true).unwrap(),
503 RetireClass::NonState {
504 class: "telemetry".into()
505 }
506 );
507 }
508
509 #[test]
510 fn a_plane_retire_needs_i_know_too() {
511 let key = "v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect";
512 let err = check_retire("", key, None, false).unwrap_err().to_string();
513 assert!(err.contains("plane"), "{err}");
514 assert!(matches!(
515 check_retire("", key, None, true).unwrap(),
516 RetireClass::NonState { class } if class == "@rpc"
517 ));
518 }
519
520 #[test]
521 fn an_unclassified_retire_needs_i_know_and_names_o4() {
522 let err = check_retire("", "some/foreign/key", None, false)
524 .unwrap_err()
525 .to_string();
526 assert!(err.contains("O4"), "{err}");
527 assert!(matches!(
528 check_retire("", "some/foreign/key", None, true).unwrap(),
529 RetireClass::Unclassified { .. }
530 ));
531 let err = check_retire("acme", "other/v1/h-3fa9c2d41b7e/state/x/y", None, false)
533 .unwrap_err()
534 .to_string();
535 assert!(err.contains("cannot classify"), "{err}");
536 }
537
538 fn slice_with_proc(fanout: Option<&str>) -> SliceSet {
539 SliceSet::from_slices(vec![RegistrySlice {
540 version: "1.0".into(),
541 app: "t".into(),
542 convention: 1,
543 name: "netring".into(),
544 service_origin: None,
545 description: None,
546 subjects: vec![],
547 procedures: vec![ProcedureDecl {
548 path: "capture/trigger".into(),
549 kind: "write".into(),
550 reply: Some("Ack".into()),
551 request: None,
552 encoding: None,
553 fanout: fanout.map(str::to_string),
554 idempotent: Some(false),
555 since: None,
556 description: None,
557 }],
558 blob: vec![],
559 media: vec![],
560 deprecated: vec![],
561 }])
562 }
563
564 #[test]
565 fn call_targets_parse_and_validate() {
566 assert_eq!(CallTarget::parse("*").unwrap(), CallTarget::Fleet);
567 assert!(matches!(
568 CallTarget::parse("@catalog").unwrap(),
569 CallTarget::Service(_)
570 ));
571 assert!(matches!(
572 CallTarget::parse("h-3fa9c2d41b7e").unwrap(),
573 CallTarget::Host(_)
574 ));
575 let err = CallTarget::parse("toolbx").unwrap_err().to_string();
577 assert!(err.contains("RFC 06 §6"), "{err}");
578 }
579
580 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
583 async fn fleet_calls_to_forbidden_fanout_are_refused() {
584 let session = crate::session::open(&[], &[], false).await.unwrap();
585 let slices = slice_with_proc(Some("forbidden"));
586 let err = call(
587 &session,
588 "",
589 &CallTarget::Fleet,
590 "netring",
591 "capture/trigger",
592 &[],
593 None,
594 None,
595 Duration::from_millis(100),
596 Some(&slices),
597 )
598 .await
599 .unwrap_err()
600 .to_string();
601 assert!(err.contains("fanout"), "{err}");
602 assert!(err.contains("RFC 05 §2.1"), "{err}");
603
604 let report = call(
607 &session,
608 "",
609 &CallTarget::Fleet,
610 "netring",
611 "capture/trigger",
612 &[],
613 None,
614 None,
615 Duration::from_millis(100),
616 Some(&slice_with_proc(None)),
617 )
618 .await
619 .unwrap();
620 assert_eq!(report.exit_code(), 2, "silence stays exit 2");
621 }
622}