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)?;
if reaches_nothing(&config) {
return Err(OpenFailure::Transport(Error::Bus {
op: "failed to open",
target: "the Zenoh session".into(),
source: "nothing to connect to: no connect endpoint and multicast \
scouting off. An explorer session is a client (RFC 09 §5), \
and a client with no router to dial reaches nothing — name \
an endpoint (--connect, a context, or connect.endpoints in \
the zenoh config), or opt in to scouting"
.into(),
}));
}
opened_within(deadline, async move { zenoh::open(config).await }).await
}
fn reaches_nothing(config: &zenoh::Config) -> bool {
let read = |key: &str| {
config
.get_json(key)
.ok()
.and_then(|j| serde_json::from_str::<serde_json::Value>(&j).ok())
};
let client = read("mode").is_some_and(|m| m == "client");
let multicast = read("scouting/multicast/enabled").is_some_and(|m| m == true);
let endpoints = match read("connect/endpoints") {
Some(serde_json::Value::Array(list)) => list.len(),
Some(serde_json::Value::Object(by_mode)) => by_mode
.get("client")
.and_then(serde_json::Value::as_array)
.map_or(0, Vec::len),
_ => 0,
};
client && !multicast && endpoints == 0
}
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,
) -> Result<zenoh::Config> {
build_config(None, connect, listen, Some(multicast))
}
struct Stated(serde_json::Value);
impl Stated {
fn nothing() -> Stated {
Stated(serde_json::Value::Null)
}
fn read(path: &Path) -> Result<Stated> {
let refuse = |detail: String| {
Error::unaskable(
format!("zenoh config {}", path.display()),
format!(
"zenoh read this file, but the explorer could not read which \
keys it states ({detail}) — and it needs to: mode and \
multicast follow the file only where the file names them \
(RFC 09 §5)"
),
)
};
let text = std::fs::read_to_string(path).map_err(|e| refuse(e.to_string()))?;
let yaml = matches!(
path.extension().and_then(|e| e.to_str()),
Some("yaml" | "yml")
);
let value = if yaml {
serde_yaml::from_str(&text).map_err(|e| refuse(e.to_string()))?
} else {
json5::from_str(&text).map_err(|e| refuse(e.to_string()))?
};
Ok(Stated(value))
}
fn states(&self, path: &str) -> bool {
path.split('/')
.try_fold(&self.0, |node, key| node.get(key))
.is_some()
}
}
fn endpoint_list(knob: &str, endpoints: &[String]) -> Result<String> {
let mut items = Vec::with_capacity(endpoints.len());
for e in endpoints {
if let Err(why) = e.parse::<zenoh::config::EndPoint>() {
let detail = if e.contains('/') {
why.to_string()
} else {
format!(
"has no protocol — an endpoint is <protocol>/<address>, \
so this is probably tcp/{e}"
)
};
return Err(Error::unaskable(format!("{knob} endpoint {e:?}"), detail));
}
items.push(format!("{e:?}"));
}
Ok(format!("[{}]", items.join(",")))
}
fn set(config: &mut zenoh::Config, key: &str, value: &str) -> Result<()> {
config
.insert_json5(key, value)
.map_err(|e| Error::Internal(format!("setting {key} = {value}: {e}")))
}
fn build_config(
file: Option<&Path>,
connect: &[String],
listen: &[String],
scouting: Option<bool>,
) -> Result<zenoh::Config> {
let (mut config, stated) = 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, Stated::read(path)?)
}
None => (zenoh::Config::default(), Stated::nothing()),
};
if !connect.is_empty() {
let list = endpoint_list("connect", connect)?;
config
.insert_json5("connect/endpoints", &list)
.map_err(|e| Error::unaskable("connect endpoints", e.to_string()))?;
}
if !listen.is_empty() {
let list = endpoint_list("listen", listen)?;
config
.insert_json5("listen/endpoints", &list)
.map_err(|e| Error::unaskable("listen endpoints", e.to_string()))?;
}
if !stated.states("mode") {
let peer = !listen.is_empty() || stated.states("listen/endpoints");
set(
&mut config,
"mode",
if peer { r#""peer""# } else { r#""client""# },
)?;
}
match scouting {
Some(on) => set(&mut config, "scouting/multicast/enabled", &on.to_string())?,
None if stated.states("scouting/multicast/enabled") => {}
None => set(&mut config, "scouting/multicast/enabled", "false")?,
}
Ok(config)
}
#[cfg(test)]
pub(crate) async fn standalone() -> Session {
let mut config = zenoh::Config::default();
for (key, value) in [
("mode", r#""peer""#),
("listen/endpoints", "[]"),
("scouting/multicast/enabled", "false"),
("scouting/gossip/enabled", "false"),
] {
set(&mut config, key, value).expect("a fixed key");
}
zenoh::open(config).await.expect("a standalone peer")
}
#[cfg(test)]
mod tests {
use super::*;
fn mode(config: &zenoh::Config) -> String {
config.get_json("mode").unwrap()
}
fn multicast(config: &zenoh::Config) -> String {
config.get_json("scouting/multicast/enabled").unwrap()
}
#[test]
fn an_explorer_is_a_client_unless_it_listens() {
let ep = || vec!["tcp/127.0.0.1:7447".to_string()];
let client = build_config(None, &ep(), &[], None).unwrap();
assert_eq!(mode(&client), r#""client""#);
assert_eq!(multicast(&client), "false");
let nothing = build_config(None, &[], &[], None).unwrap();
assert_eq!(
mode(¬hing),
r#""client""#,
"no endpoints is no exception"
);
let peer = build_config(None, &[], &ep(), None).unwrap();
assert_eq!(mode(&peer), r#""peer""#, "--listen says I am a peer");
let listen = client.get_json("listen/endpoints").unwrap();
assert!(!listen.contains("client"), "{listen}");
}
#[test]
fn a_file_that_states_nothing_gets_the_explorer_posture() {
let path = temp_config(
"unstated",
r#"{ connect: { endpoints: ["tcp/127.0.0.1:7447"] } }"#,
);
let config = build_config(Some(&path), &[], &[], None).unwrap();
assert_eq!(mode(&config), r#""client""#);
assert_eq!(multicast(&config), "false");
let config = build_config(Some(&path), &[], &[], Some(true)).unwrap();
assert_eq!(multicast(&config), "true");
std::fs::remove_file(path).ok();
}
#[test]
fn what_the_file_states_stands() {
let path = temp_config(
"stated",
r#"{ mode: "peer", scouting: { multicast: { enabled: true } } }"#,
);
let config = build_config(Some(&path), &[], &[], None).unwrap();
assert_eq!(mode(&config), r#""peer""#);
assert_eq!(multicast(&config), "true");
let config = build_config(Some(&path), &[], &[], Some(false)).unwrap();
assert_eq!(multicast(&config), "false");
std::fs::remove_file(path).ok();
let path = temp_config("stated-client", r#"{ mode: "client" }"#);
let listen = ["tcp/127.0.0.1:7448".to_string()];
let config = build_config(Some(&path), &[], &listen, None).unwrap();
assert_eq!(mode(&config), r#""client""#);
std::fs::remove_file(path).ok();
}
#[test]
fn listen_endpoints_in_the_file_make_a_peer() {
let path = temp_config(
"file-listen",
r#"{ listen: { endpoints: ["tcp/127.0.0.1:7449"] } }"#,
);
let config = build_config(Some(&path), &[], &[], None).unwrap();
assert_eq!(mode(&config), r#""peer""#);
assert_eq!(multicast(&config), "false", "listening is not scouting");
std::fs::remove_file(path).ok();
}
#[test]
fn a_yaml_file_is_read_in_its_own_dialect() {
let path = std::env::temp_dir().join("zenkey-fleet-session-stated.yaml");
std::fs::write(
&path,
"mode: peer\nscouting:\n multicast:\n enabled: true\n",
)
.unwrap();
let config = build_config(Some(&path), &[], &[], None).unwrap();
assert_eq!(mode(&config), r#""peer""#);
assert_eq!(multicast(&config), "true");
std::fs::remove_file(path).ok();
}
#[test]
fn a_malformed_endpoint_is_refused_by_name() {
let err = build_config(None, &["127.0.0.1:7461".into()], &[], None).unwrap_err();
assert!(err.is_unaskable(), "{err}");
let text = err.to_string();
assert!(text.contains("\"127.0.0.1:7461\""), "{text}");
assert!(text.contains("tcp/127.0.0.1:7461"), "{text}");
let err = build_config(None, &[], &["tcp/".into()], None).unwrap_err();
assert!(err.is_unaskable(), "{err}");
assert!(err.to_string().contains("listen endpoint"), "{err}");
let path = temp_config(
"typo-over-file",
r#"{ connect: { endpoints: ["tcp/10.0.0.9:7447"] } }"#,
);
let err = build_config(Some(&path), &["10.0.0.9:7447".into()], &[], None).unwrap_err();
assert!(err.is_unaskable(), "{err}");
std::fs::remove_file(path).ok();
}
#[test]
fn a_client_with_nowhere_to_go_is_named() {
let ep = || vec!["tcp/127.0.0.1:7447".to_string()];
assert!(reaches_nothing(
&build_config(None, &[], &[], None).unwrap()
));
assert!(!reaches_nothing(
&build_config(None, &ep(), &[], None).unwrap()
));
assert!(!reaches_nothing(
&build_config(None, &[], &ep(), None).unwrap()
));
assert!(!reaches_nothing(
&build_config(None, &[], &[], Some(true)).unwrap()
));
let path = temp_config(
"per-mode",
r#"{ connect: { endpoints: { client: ["tcp/127.0.0.1:7447"] } } }"#,
);
assert!(!reaches_nothing(
&build_config(Some(&path), &[], &[], None).unwrap()
));
std::fs::remove_file(path).ok();
}
#[tokio::test]
async fn a_client_with_nowhere_to_go_is_a_transport_failure() {
match open_reporting(None, &[], &[], None).await {
Err(OpenFailure::Transport(e)) => {
let text = crate::one_line(&e);
assert!(text.contains("nothing to connect to"), "{text}");
}
Err(OpenFailure::Config(e)) => panic!("not the caller's config: {e}"),
Ok(_) => panic!("a session opened with nowhere to go"),
}
}
#[test]
fn the_scout_config_refuses_a_malformed_endpoint() {
assert!(explorer_config(&["localhost".into()], &[], true).is_err());
}
#[test]
fn the_multicast_bit_follows_the_stated_intent() {
for on in [true, false] {
let config = explorer_config(&[], &[], on).unwrap();
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).unwrap();
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();
}
}