use std::fmt;
use serde::{Deserialize, Serialize};
use super::role::ClusterRole;
pub const ENV_NODE_ID: &str = "ZEBFLOW_CLUSTER_NODE_ID";
pub const ENV_NODE_LABEL: &str = "ZEBFLOW_CLUSTER_NODE_LABEL";
pub const ENV_MASTER_URL: &str = "ZEBFLOW_CLUSTER_MASTER_URL";
pub const ENV_ADVERTISE_URL: &str = "ZEBFLOW_CLUSTER_ADVERTISE_URL";
pub const ENV_JOIN_TOKEN: &str = "ZEBFLOW_CLUSTER_JOIN_TOKEN";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ClusterSettings {
pub role: ClusterRole,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub master_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub advertise_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub join_token: Option<String>,
}
impl Default for ClusterSettings {
fn default() -> Self {
Self {
role: ClusterRole::Standalone,
node_id: None,
node_label: None,
master_url: None,
advertise_url: None,
join_token: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClusterConfigError {
pub role: ClusterRole,
pub missing: Vec<&'static str>,
}
impl fmt::Display for ClusterConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"`zebflow {}` is missing required cluster configuration: {}. {}",
self.role.mode_word(),
self.missing.join(", "),
incomplete_consequence(self.role),
)
}
}
impl std::error::Error for ClusterConfigError {}
fn incomplete_consequence(role: ClusterRole) -> &'static str {
match role {
ClusterRole::Standalone => "A standalone process needs no cluster configuration.",
ClusterRole::Master => {
"A controller mints and verifies per-office tokens from its own catalog, so it \
needs no cluster variables of its own."
}
ClusterRole::Worker => {
"An office that cannot reach a controller would serve traffic while belonging to \
no cluster, which looks healthy and is not."
}
}
}
impl ClusterSettings {
pub fn from_lookup(
role: ClusterRole,
default_advertise_url: &str,
lookup: impl Fn(&str) -> Option<String>,
) -> Self {
let value = |name: &str| lookup(name).filter(|item| !item.trim().is_empty());
Self {
role,
node_id: value(ENV_NODE_ID),
node_label: value(ENV_NODE_LABEL),
master_url: value(ENV_MASTER_URL),
advertise_url: value(ENV_ADVERTISE_URL)
.or_else(|| Some(default_advertise_url.to_string())),
join_token: value(ENV_JOIN_TOKEN),
}
}
pub fn from_env(role: ClusterRole, default_advertise_url: &str) -> Self {
Self::from_lookup(role, default_advertise_url, |name| std::env::var(name).ok())
}
pub fn missing_required_env(&self, stored_join_token: bool) -> Vec<&'static str> {
let present =
|value: &Option<String>| value.as_deref().is_some_and(|item| !item.trim().is_empty());
let mut missing = Vec::new();
match self.role {
ClusterRole::Standalone => {}
ClusterRole::Master => {}
ClusterRole::Worker => {
if !present(&self.master_url) {
missing.push(ENV_MASTER_URL);
}
if !present(&self.join_token) && !stored_join_token {
missing.push(ENV_JOIN_TOKEN);
}
if !present(&self.advertise_url) {
missing.push(ENV_ADVERTISE_URL);
}
}
}
missing
}
pub fn validate(&self, stored_join_token: bool) -> Result<(), ClusterConfigError> {
let missing = self.missing_required_env(stored_join_token);
if missing.is_empty() {
return Ok(());
}
Err(ClusterConfigError {
role: self.role,
missing,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let owned: Vec<(String, String)> = pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect();
move |name: &str| {
owned
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.clone())
}
}
#[test]
fn standalone_needs_no_cluster_configuration() {
let settings = ClusterSettings::from_lookup(
ClusterRole::Standalone,
"http://127.0.0.1:10610",
lookup_from(&[]),
);
assert!(settings.validate(false).is_ok());
}
#[test]
fn office_without_cluster_configuration_refuses_and_names_every_missing_variable() {
let settings = ClusterSettings::from_lookup(ClusterRole::Worker, "", lookup_from(&[]));
let err = settings
.validate(false)
.expect_err("office must refuse to start");
assert_eq!(
err.missing,
vec![ENV_MASTER_URL, ENV_JOIN_TOKEN, ENV_ADVERTISE_URL]
);
let message = err.to_string();
for name in [ENV_MASTER_URL, ENV_JOIN_TOKEN, ENV_ADVERTISE_URL] {
assert!(
message.contains(name),
"message must name {name}: {message}"
);
}
}
#[test]
fn office_with_complete_configuration_starts() {
let settings = ClusterSettings::from_lookup(
ClusterRole::Worker,
"http://127.0.0.1:10610",
lookup_from(&[
(ENV_MASTER_URL, "http://controller:10610"),
(ENV_JOIN_TOKEN, "zfjoin2:office-a:key:secret"),
]),
);
assert_eq!(
settings.advertise_url.as_deref(),
Some("http://127.0.0.1:10610")
);
assert!(settings.validate(false).is_ok());
}
#[test]
fn a_stored_token_satisfies_startup_with_the_variable_gone() {
let settings = ClusterSettings::from_lookup(
ClusterRole::Worker,
"http://127.0.0.1:10610",
lookup_from(&[(ENV_MASTER_URL, "http://controller:10610")]),
);
assert_eq!(
settings.missing_required_env(false),
vec![ENV_JOIN_TOKEN],
"a first join still needs the variable"
);
assert!(
settings.missing_required_env(true).is_empty(),
"a joined office restarts on what it stored"
);
assert!(settings.validate(true).is_ok());
let bare = ClusterSettings::from_lookup(ClusterRole::Worker, "", lookup_from(&[]));
assert_eq!(
bare.missing_required_env(true),
vec![ENV_MASTER_URL, ENV_ADVERTISE_URL]
);
}
#[test]
fn blank_variables_count_as_unset_rather_than_as_values() {
let settings = ClusterSettings::from_lookup(
ClusterRole::Worker,
"http://127.0.0.1:10610",
lookup_from(&[
(ENV_MASTER_URL, " "),
(ENV_JOIN_TOKEN, "zfjoin2:office-a:key:secret"),
(ENV_ADVERTISE_URL, ""),
]),
);
assert_eq!(
settings.advertise_url.as_deref(),
Some("http://127.0.0.1:10610")
);
assert_eq!(
settings
.validate(false)
.expect_err("blank master url")
.missing,
vec![ENV_MASTER_URL]
);
}
#[test]
fn a_controller_holds_no_token_of_its_own() {
let settings = ClusterSettings::from_lookup(
ClusterRole::Master,
"http://127.0.0.1:10610",
lookup_from(&[]),
);
assert!(settings.missing_required_env(false).is_empty());
assert!(settings.validate(false).is_ok());
}
#[test]
fn node_identity_variables_are_optional() {
let settings = ClusterSettings::from_lookup(
ClusterRole::Worker,
"http://127.0.0.1:10610",
lookup_from(&[
(ENV_MASTER_URL, "http://controller:10610"),
(ENV_JOIN_TOKEN, "zfjoin2:office-a:key:secret"),
(ENV_NODE_ID, "office-a"),
(ENV_NODE_LABEL, "Office A"),
]),
);
assert_eq!(settings.node_id.as_deref(), Some("office-a"));
assert_eq!(settings.node_label.as_deref(), Some("Office A"));
assert!(settings.validate(false).is_ok());
}
}