use std::io;
use std::path::PathBuf;
use crate::infra::cluster::config::settings::ENV_JOIN_TOKEN;
use crate::infra::cluster::config::{ClusterRole, ClusterSettings};
use crate::platform::model::{DataAdapterKind, FileAdapterKind, PlatformConfig};
pub const DATA_DIR_VAR: &str = "ZEBFLOW_PLATFORM_DATA_DIR";
const HOMELESS_FALLBACK: &str = ".zebflow-platform-data";
#[cfg(any(target_os = "macos", target_os = "windows"))]
const INSTANCE_DIR: &str = "Zebflow";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
const INSTANCE_DIR: &str = "zebflow";
pub fn default_data_root() -> PathBuf {
data_root_from(|name| std::env::var(name).ok())
}
pub fn data_root_from(lookup: impl Fn(&str) -> Option<String>) -> PathBuf {
lookup(DATA_DIR_VAR)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(os_data_root)
}
pub fn os_data_root() -> PathBuf {
match dirs_next::data_local_dir() {
Some(base) => base.join(INSTANCE_DIR),
None => PathBuf::from(HOMELESS_FALLBACK),
}
}
pub fn configured_host() -> String {
std::env::var("ZEBFLOW_PLATFORM_HOST").unwrap_or_else(|_| "127.0.0.1".to_string())
}
pub fn configured_port() -> u16 {
std::env::var("ZEBFLOW_PLATFORM_PORT")
.ok()
.and_then(|v| v.parse::<u16>().ok())
.unwrap_or(10610)
}
pub fn default_advertise_url(host: &str, port: u16) -> String {
let host = if host == "0.0.0.0" { "127.0.0.1" } else { host };
format!("http://{host}:{port}")
}
pub fn local_instance_url() -> String {
default_advertise_url(&configured_host(), configured_port())
}
fn stored_office_token(data_root: &std::path::Path) -> bool {
crate::platform::services::cluster::join_token::office_token_path(data_root)
.metadata()
.map(|meta| meta.len() > 0)
.unwrap_or(false)
}
pub fn load_platform_config(role: ClusterRole) -> Result<PlatformConfig, io::Error> {
let mut config = PlatformConfig::default();
let host = configured_host();
let port = configured_port();
config.data_root = default_data_root();
if let Ok(owner) = std::env::var("ZEBFLOW_PLATFORM_DEFAULT_OWNER") {
config.default_owner = owner;
}
config.default_password = std::env::var("ZEBFLOW_PLATFORM_DEFAULT_PASSWORD")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_default();
if let Ok(project) = std::env::var("ZEBFLOW_PLATFORM_DEFAULT_PROJECT") {
config.default_project = project;
}
if let Ok(value) = std::env::var("ZEBFLOW_SECRET_ROTATION_EPOCH") {
config.secret_rotation_epoch = value.trim().parse::<i64>().map_err(|err| {
io::Error::other(format!(
"invalid ZEBFLOW_SECRET_ROTATION_EPOCH '{}': {err}",
value.trim()
))
})?;
}
config.cluster = ClusterSettings::from_env(role, &default_advertise_url(&host, port));
config
.cluster
.validate(stored_office_token(&config.data_root))
.map_err(|err| io::Error::other(err.to_string()))?;
if let Some(token) = config.cluster.join_token.as_deref() {
crate::infra::cluster::security::JoinToken::parse(token)
.map_err(|err| io::Error::other(format!("{ENV_JOIN_TOKEN}: {err}")))?;
}
config.data_adapter = DataAdapterKind::Sqlite;
config.file_adapter = FileAdapterKind::Filesystem;
let allow_insecure_default = std::env::var("ZEBFLOW_PLATFORM_ALLOW_INSECURE_DEFAULT_PASSWORD")
.ok()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false);
if role != ClusterRole::Worker
&& crate::platform::services::bootstrap::is_insecure_password(&config.default_password)
&& !allow_insecure_default
{
return Err(io::Error::other(
"refusing insecure ZEBFLOW_PLATFORM_DEFAULT_PASSWORD=secret; choose a strong password or set ZEBFLOW_PLATFORM_ALLOW_INSECURE_DEFAULT_PASSWORD=1 only for disposable local development",
));
}
Ok(config)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::path::Path;
use super::*;
use crate::platform::services::PlatformService;
#[test]
fn the_data_root_has_exactly_two_cases() {
assert_eq!(
data_root_from(|_| Some("/srv/zebflow".to_string())),
PathBuf::from("/srv/zebflow")
);
assert_eq!(data_root_from(|_| None), os_data_root());
assert_eq!(data_root_from(|_| Some(" ".to_string())), os_data_root());
}
#[test]
fn the_os_data_root_is_absolute_and_named_for_the_product() {
let root = os_data_root();
if root == PathBuf::from(HOMELESS_FALLBACK) {
return; }
assert!(root.is_absolute(), "{}", root.display());
assert_eq!(
root.file_name().and_then(|name| name.to_str()),
Some(INSTANCE_DIR)
);
}
fn tree(root: &Path) -> BTreeSet<PathBuf> {
fn walk(base: &Path, dir: &Path, out: &mut BTreeSet<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(rel) = path.strip_prefix(base) {
out.insert(rel.to_path_buf());
}
walk(base, &path, out);
}
}
}
let mut out = BTreeSet::new();
walk(root, root, &mut out);
out
}
#[test]
fn the_auto_and_explicit_roots_produce_the_same_tree() {
let explicit = tempfile::tempdir().expect("explicit root");
let automatic = tempfile::tempdir().expect("automatic root");
for root in [explicit.path(), automatic.path()] {
let config = PlatformConfig {
data_root: root.to_path_buf(),
default_password: "tree-shape-check".to_string(),
..PlatformConfig::default()
};
PlatformService::from_config(config).expect("open the data root");
}
assert_eq!(
tree(explicit.path()),
tree(automatic.path()),
"the two data-root cases produced different trees"
);
let shape = tree(explicit.path());
for expected in [
"platform",
"services",
"users/superadmin/default/repo",
"users/superadmin/default/data",
"users/superadmin/default/files",
] {
assert!(
shape.contains(&PathBuf::from(expected)),
"missing {expected} in {shape:?}"
);
}
}
}