use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use crate::Cli;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostConfig {
pub name: String,
pub docker: String,
#[serde(default)]
pub public_host: Option<String>,
#[allow(dead_code, reason = "wired up in the M3 connection layer")]
#[serde(default)]
pub tls_ca: Option<PathBuf>,
#[allow(dead_code, reason = "wired up in the M3 connection layer")]
#[serde(default)]
pub tls_insecure: bool,
}
#[derive(Debug, Clone)]
pub struct AppConfig {
pub bind: String,
pub db_path: PathBuf,
pub interval_secs: u64,
pub raw_retention_secs: u64,
pub trend_bucket_secs: u64,
pub trend_retention_secs: u64,
pub prune_interval_secs: u64,
pub allowed_hosts: Vec<String>,
pub apprise_url: Option<String>,
pub notify_delay_secs: u64,
pub auth_user: Option<String>,
pub auth_password: Option<String>,
pub cookie_secure: bool,
pub log: String,
pub hosts: Vec<HostConfig>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileConfig {
bind: Option<String>,
db_path: Option<PathBuf>,
interval_secs: Option<u64>,
raw_retention_secs: Option<u64>,
trend_bucket_secs: Option<u64>,
trend_retention_secs: Option<u64>,
prune_interval_secs: Option<u64>,
allowed_hosts: Option<Vec<String>>,
apprise_url: Option<String>,
notify_delay_secs: Option<u64>,
auth_user: Option<String>,
auth_password: Option<String>,
cookie_secure: Option<bool>,
log: Option<String>,
#[serde(default)]
host: Vec<HostConfig>,
}
pub fn load(path: Option<&Path>, cli: &Cli) -> Result<AppConfig> {
let file = match path {
Some(p) => {
let text = std::fs::read_to_string(p)
.with_context(|| format!("reading config file {}", p.display()))?;
toml::from_str::<FileConfig>(&text)
.with_context(|| format!("parsing config file {}", p.display()))?
}
None => FileConfig::default(),
};
let hosts = if file.host.is_empty() {
vec![HostConfig {
name: "local".to_string(),
docker: default_docker_endpoint(),
public_host: cli.port_host.clone(),
tls_ca: None,
tls_insecure: false,
}]
} else {
file.host
};
validate_hosts(&hosts)?;
Ok(AppConfig {
bind: file.bind.unwrap_or_else(|| cli.bind.clone()),
db_path: file.db_path.unwrap_or_else(|| cli.db_path.clone()),
interval_secs: file.interval_secs.unwrap_or(cli.interval_secs),
raw_retention_secs: file.raw_retention_secs.unwrap_or(cli.raw_retention_secs),
trend_bucket_secs: file.trend_bucket_secs.unwrap_or(cli.trend_bucket_secs),
trend_retention_secs: file
.trend_retention_secs
.unwrap_or(cli.trend_retention_secs),
prune_interval_secs: file
.prune_interval_secs
.unwrap_or(cli.prune_interval_secs),
allowed_hosts: file
.allowed_hosts
.unwrap_or_else(|| cli.allowed_hosts.clone()),
apprise_url: file.apprise_url.or_else(|| cli.apprise_url.clone()),
notify_delay_secs: file.notify_delay_secs.unwrap_or(cli.notify_delay_secs),
auth_user: file.auth_user.or_else(|| cli.auth_user.clone()),
auth_password: file.auth_password.or_else(|| cli.auth_password.clone()),
cookie_secure: file.cookie_secure.unwrap_or(cli.cookie_secure),
log: file.log.unwrap_or_else(|| cli.log.clone()),
hosts,
})
}
fn default_docker_endpoint() -> String {
std::env::var("DOCKER_HOST").unwrap_or_else(|_| "unix:///var/run/docker.sock".to_string())
}
fn validate_hosts(hosts: &[HostConfig]) -> Result<()> {
if hosts.is_empty() {
bail!("no hosts configured");
}
let mut seen = HashSet::new();
for h in hosts {
if !is_valid_slug(&h.name) {
bail!(
"invalid host name {:?}: use only letters, digits, '.', '_' and '-'",
h.name
);
}
if !seen.insert(h.name.as_str()) {
bail!("duplicate host name {:?}", h.name);
}
if h.docker.trim().is_empty() {
bail!("host {:?} has an empty docker endpoint", h.name);
}
}
Ok(())
}
fn is_valid_slug(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn default_cli() -> Cli {
Cli::try_parse_from(["dockdoe"]).expect("default CLI parses")
}
#[test]
fn no_file_synthesises_one_local_host() {
let cfg = load(None, &default_cli()).expect("load without a file");
assert_eq!(cfg.hosts.len(), 1);
assert_eq!(cfg.hosts[0].name, "local");
assert!(!cfg.hosts[0].tls_insecure);
assert_eq!(cfg.bind, "127.0.0.1:8080");
}
#[test]
fn file_hosts_and_globals_override_cli() {
let file: FileConfig = toml::from_str(
r#"
bind = "0.0.0.0:9000"
interval_secs = 5
[[host]]
name = "local"
docker = "unix:///var/run/docker.sock"
[[host]]
name = "nas"
docker = "https://nas.lan:2375"
public_host = "nas.lan"
tls_insecure = true
"#,
)
.expect("parse");
assert_eq!(file.bind.as_deref(), Some("0.0.0.0:9000"));
assert_eq!(file.host.len(), 2);
assert_eq!(file.host[1].docker, "https://nas.lan:2375");
assert!(file.host[1].tls_insecure);
}
#[test]
fn slug_validation() {
assert!(is_valid_slug("local"));
assert!(is_valid_slug("nas-01.lan_2"));
assert!(!is_valid_slug(""));
assert!(!is_valid_slug("has space"));
assert!(!is_valid_slug("slash/name"));
}
#[test]
fn duplicate_names_are_rejected() {
let hosts = vec![
HostConfig {
name: "dup".into(),
docker: "unix:///x".into(),
public_host: None,
tls_ca: None,
tls_insecure: false,
},
HostConfig {
name: "dup".into(),
docker: "tcp://y:2375".into(),
public_host: None,
tls_ca: None,
tls_insecure: false,
},
];
assert!(validate_hosts(&hosts).is_err());
}
#[test]
fn empty_endpoint_is_rejected() {
let hosts = vec![HostConfig {
name: "x".into(),
docker: " ".into(),
public_host: None,
tls_ca: None,
tls_insecure: false,
}];
assert!(validate_hosts(&hosts).is_err());
}
#[test]
fn unknown_keys_are_rejected() {
let err = toml::from_str::<FileConfig>("nonsense_key = 1\n").unwrap_err();
assert!(err.to_string().contains("nonsense_key"));
}
}