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>,
#[serde(default)]
pub interval_secs: Option<u64>,
#[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,
}
const REMOTE_DEFAULT_INTERVAL_SECS: u64 = 10;
impl HostConfig {
pub fn effective_interval_secs(&self, global_default: u64) -> u64 {
self.interval_secs.unwrap_or_else(|| {
if self.is_local_endpoint() {
global_default
} else {
REMOTE_DEFAULT_INTERVAL_SECS
}
})
}
fn is_local_endpoint(&self) -> bool {
let endpoint = self.docker.trim();
let scheme = endpoint.split_once("://").map_or(endpoint, |(s, _)| s);
scheme.eq_ignore_ascii_case("unix") || scheme.eq_ignore_ascii_case("npipe")
}
}
#[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(),
interval_secs: None,
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);
}
if h.interval_secs == Some(0) {
bail!(
"host {:?} has interval_secs = 0; it must be at least 1",
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"
interval_secs = 15
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);
assert_eq!(file.host[1].interval_secs, Some(15));
assert_eq!(file.host[0].interval_secs, None);
}
fn host_with(docker: &str, interval_secs: Option<u64>) -> HostConfig {
HostConfig {
name: "h".into(),
docker: docker.into(),
public_host: None,
interval_secs,
tls_ca: None,
tls_insecure: false,
}
}
#[test]
fn per_host_interval_resolution() {
assert_eq!(
host_with("unix:///x", Some(5)).effective_interval_secs(3),
5
);
assert_eq!(
host_with("tcp://h:2375", Some(2)).effective_interval_secs(3),
2
);
assert_eq!(host_with("unix:///x", None).effective_interval_secs(3), 3);
for remote in ["tcp://h:2375", "http://h:2375", "https://h:2376"] {
assert_eq!(
host_with(remote, None).effective_interval_secs(3),
REMOTE_DEFAULT_INTERVAL_SECS
);
}
}
#[test]
fn zero_per_host_interval_is_rejected() {
assert!(validate_hosts(&[host_with("unix:///x", Some(0))]).is_err());
}
#[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,
interval_secs: None,
tls_ca: None,
tls_insecure: false,
},
HostConfig {
name: "dup".into(),
docker: "tcp://y:2375".into(),
public_host: None,
interval_secs: 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,
interval_secs: 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"));
}
}