use std::path::Path;
use std::time::Duration;
use crate::{Error, Result};
use zenoh::Session;
pub const OPEN_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy)]
pub struct Fleet<'a> {
session: &'a Session,
base: &'a str,
}
impl<'a> Fleet<'a> {
pub fn new(session: &'a Session, base: &'a str) -> Self {
Fleet { session, base }
}
pub fn session(&self) -> &'a Session {
self.session
}
pub fn base(&self) -> &'a str {
self.base
}
pub fn wire(&self, relative: impl AsRef<str>) -> String {
zenkey::grammar::with_base(self.base, relative)
}
}
pub async fn open(connect: &[String], listen: &[String], scouting: bool) -> Result<Session> {
open_with_config(None, connect, listen, Some(scouting)).await
}
pub async fn open_with_config(
file: Option<&Path>,
connect: &[String],
listen: &[String],
scouting: Option<bool>,
) -> Result<Session> {
open_reporting(file, connect, listen, scouting)
.await
.map_err(OpenFailure::into_error)
}
#[derive(Debug)]
pub enum OpenFailure {
Config(Error),
Transport(Error),
}
impl OpenFailure {
pub fn into_error(self) -> Error {
match self {
OpenFailure::Config(e) | OpenFailure::Transport(e) => e,
}
}
}
pub async fn open_reporting(
file: Option<&Path>,
connect: &[String],
listen: &[String],
scouting: Option<bool>,
) -> Result<Session, OpenFailure> {
open_reporting_within(file, connect, listen, scouting, OPEN_TIMEOUT).await
}
pub async fn open_reporting_within(
file: Option<&Path>,
connect: &[String],
listen: &[String],
scouting: Option<bool>,
deadline: Duration,
) -> Result<Session, OpenFailure> {
let config = config_off_runtime(file, connect, listen, scouting)
.await
.map_err(OpenFailure::Config)?;
opened_within(deadline, async move { zenoh::open(config).await }).await
}
async fn opened_within<E: std::fmt::Display>(
deadline: Duration,
open: impl std::future::Future<Output = std::result::Result<Session, E>>,
) -> Result<Session, OpenFailure> {
match tokio::time::timeout(deadline, open).await {
Ok(Ok(session)) => Ok(session),
Ok(Err(e)) => Err(OpenFailure::Transport(Error::Bus {
op: "failed to open",
target: "the Zenoh session".into(),
source: e.to_string().into(),
})),
Err(_) => Err(OpenFailure::Transport(Error::Bus {
op: "failed to open",
target: "the Zenoh session".into(),
source: format!(
"did not open within {deadline:?} — the config parsed, so this \
is the transport: an endpoint that never settles, a listener \
that never binds, or a peer that never answers"
)
.into(),
})),
}
}
async fn config_off_runtime(
file: Option<&Path>,
connect: &[String],
listen: &[String],
scouting: Option<bool>,
) -> Result<zenoh::Config> {
let Some(path) = file else {
return build_config(None, connect, listen, scouting);
};
let path = path.to_path_buf();
let connect = connect.to_vec();
let listen = listen.to_vec();
tokio::task::spawn_blocking(move || build_config(Some(&path), &connect, &listen, scouting))
.await
.map_err(|e| Error::Internal(format!("the config read task did not join: {e}")))?
}
pub(crate) fn explorer_config(
connect: &[String],
listen: &[String],
multicast: bool,
) -> zenoh::Config {
build_config(None, connect, listen, Some(multicast)).expect("no file, no failure")
}
fn build_config(
file: Option<&Path>,
connect: &[String],
listen: &[String],
scouting: Option<bool>,
) -> Result<zenoh::Config> {
let mut config = match file {
Some(path) => {
let config = zenoh::Config::from_file(path).map_err(|e| {
Error::unaskable(format!("zenoh config {}", path.display()), e.to_string())
})?;
if let Ok(ns) = config.get_json("namespace")
&& ns != "null"
{
return Err(Error::unaskable(
format!("zenoh config {}", path.display()),
format!(
"sets a session namespace ({ns}) — an explorer runs \
un-namespaced so it sees the wire as it really is \
(RFC 09 §5); remove the namespace from the file, or \
use --base to name the deployment"
),
));
}
config
}
None => zenoh::Config::default(),
};
let json_list = |v: &[String]| {
let items: Vec<String> = v.iter().map(|e| format!("{e:?}")).collect();
format!("[{}]", items.join(","))
};
match scouting {
Some(on) => {
config
.insert_json5("scouting/multicast/enabled", &on.to_string())
.ok();
}
None if file.is_none() => {
config
.insert_json5("scouting/multicast/enabled", "false")
.ok();
}
None => {}
}
if !connect.is_empty() {
config
.insert_json5("connect/endpoints", &json_list(connect))
.ok();
}
if !listen.is_empty() {
config
.insert_json5("listen/endpoints", &json_list(listen))
.ok();
}
Ok(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_multicast_bit_follows_the_stated_intent() {
for on in [true, false] {
let config = explorer_config(&[], &[], on);
let json = config.get_json("scouting/multicast/enabled").unwrap();
assert_eq!(json, on.to_string());
}
}
#[test]
fn endpoints_ride_into_the_config() {
let config = explorer_config(&["tcp/127.0.0.1:7447".into()], &[], false);
let json = config.get_json("connect/endpoints").unwrap();
assert!(json.contains("tcp/127.0.0.1:7447"), "{json}");
}
fn temp_config(name: &str, body: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("zenkey-fleet-session-{name}.json5"));
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn the_file_is_the_base_and_given_knobs_win() {
let path = temp_config(
"layering",
r#"{ connect: { endpoints: ["tcp/10.0.0.9:7447"] },
scouting: { multicast: { enabled: true } } }"#,
);
let config = build_config(Some(&path), &[], &[], None).unwrap();
assert!(
config
.get_json("connect/endpoints")
.unwrap()
.contains("10.0.0.9"),
);
assert_eq!(
config.get_json("scouting/multicast/enabled").unwrap(),
"true"
);
let config = build_config(
Some(&path),
&["tcp/127.0.0.1:7447".to_string()],
&[],
Some(false),
)
.unwrap();
let endpoints = config.get_json("connect/endpoints").unwrap();
assert!(endpoints.contains("127.0.0.1"), "{endpoints}");
assert!(
!endpoints.contains("10.0.0.9"),
"flag replaces the knob it names"
);
assert_eq!(
config.get_json("scouting/multicast/enabled").unwrap(),
"false"
);
std::fs::remove_file(path).ok();
}
#[tokio::test]
async fn an_open_that_never_settles_is_a_transport_failure() {
let stalled = std::future::pending::<std::result::Result<Session, String>>();
match opened_within(Duration::from_millis(10), stalled).await {
Err(OpenFailure::Transport(e)) => {
let text = crate::one_line(&e);
assert!(
text.contains("did not open within"),
"the deadline is named, so an operator knows what to raise: {text}"
);
}
Err(OpenFailure::Config(e)) => panic!("a deadline is not a config error: {e}"),
Ok(_) => panic!("a pending future opened a session"),
}
}
#[test]
fn the_connect_deadline_is_its_own_number() {
assert_eq!(OPEN_TIMEOUT, Duration::from_secs(10));
}
#[test]
fn a_namespaced_file_is_refused_loudly() {
let path = temp_config("namespaced", r#"{ namespace: "acme" }"#);
let err = build_config(Some(&path), &[], &[], None)
.unwrap_err()
.to_string();
assert!(err.contains("RFC 09 §5"), "{err}");
assert!(err.contains("--base"), "{err}");
std::fs::remove_file(path).ok();
}
}