use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use crate::Cli;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScaleMode {
Linear,
Sqrt,
Log,
}
impl ScaleMode {
pub fn as_str(self) -> &'static str {
match self {
ScaleMode::Linear => "linear",
ScaleMode::Sqrt => "sqrt",
ScaleMode::Log => "log",
}
}
}
impl std::fmt::Display for ScaleMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy)]
pub struct OverviewConfig {
pub cpu_scale: ScaleMode,
pub mem_scale: ScaleMode,
pub mem_cap: u64,
}
pub const OVERVIEW_MEM_FLOOR: u64 = 16 * 1024 * 1024;
pub fn parse_size(s: &str) -> Result<u64, String> {
let s = s.trim();
let digits = s.trim_end_matches(|c: char| !c.is_ascii_digit());
let value: u64 = digits.parse().map_err(|_| {
format!("invalid size {s:?}: expected digits with an optional K/M/G/T suffix")
})?;
let unit = s[digits.len()..].trim();
let shift = match unit.to_ascii_uppercase().as_str() {
"" | "B" => 0,
"K" | "KB" | "KIB" => 10,
"M" | "MB" | "MIB" => 20,
"G" | "GB" | "GIB" => 30,
"T" | "TB" | "TIB" => 40,
_ => return Err(format!("invalid size suffix {unit:?}: use K, M, G or T")),
};
if value.leading_zeros() < shift {
return Err(format!("size {s:?} overflows"));
}
Ok(value << shift)
}
#[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 overview: OverviewConfig,
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>,
overview_cpu_scale: Option<ScaleMode>,
overview_mem_scale: Option<ScaleMode>,
overview_mem_cap: 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)?;
let overview_mem_cap = match &file.overview_mem_cap {
Some(s) => parse_size(s)
.map_err(|e| anyhow::anyhow!(e))
.context("parsing overview_mem_cap")?,
None => cli.overview_mem_cap,
};
if overview_mem_cap < 2 * OVERVIEW_MEM_FLOOR {
bail!("overview mem cap must be at least 32M");
}
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()),
overview: OverviewConfig {
cpu_scale: file.overview_cpu_scale.unwrap_or(cli.overview_cpu_scale),
mem_scale: file.overview_mem_scale.unwrap_or(cli.overview_mem_scale),
mem_cap: overview_mem_cap,
},
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"));
}
#[test]
fn parse_size_accepts_suffixes_and_bytes() {
assert_eq!(parse_size("68719476736"), Ok(64 << 30));
assert_eq!(parse_size("64G"), Ok(64 << 30));
assert_eq!(parse_size("64GB"), Ok(64 << 30));
assert_eq!(parse_size("64GiB"), Ok(64 << 30));
assert_eq!(parse_size("64g"), Ok(64 << 30));
assert_eq!(parse_size(" 512M "), Ok(512 << 20));
assert_eq!(parse_size("1K"), Ok(1024));
assert_eq!(parse_size("2T"), Ok(2 << 40));
assert_eq!(parse_size("0"), Ok(0));
}
#[test]
fn parse_size_rejects_garbage_and_overflow() {
assert!(parse_size("").is_err());
assert!(parse_size("G").is_err());
assert!(parse_size("12X").is_err());
assert!(parse_size("1.5G").is_err());
assert!(parse_size("-3M").is_err());
assert!(parse_size("1152921504606846976K").is_err());
}
#[test]
fn overview_defaults_are_cpu_linear_mem_log_64g() {
let cfg = load(None, &default_cli()).expect("load without a file");
assert_eq!(cfg.overview.cpu_scale, ScaleMode::Linear);
assert_eq!(cfg.overview.mem_scale, ScaleMode::Log);
assert_eq!(cfg.overview.mem_cap, 64 << 30);
}
#[test]
fn overview_file_keys_parse() {
let file: FileConfig = toml::from_str(
r#"
overview_cpu_scale = "sqrt"
overview_mem_scale = "linear"
overview_mem_cap = "256G"
"#,
)
.expect("parse");
assert_eq!(file.overview_cpu_scale, Some(ScaleMode::Sqrt));
assert_eq!(file.overview_mem_scale, Some(ScaleMode::Linear));
assert_eq!(file.overview_mem_cap.as_deref(), Some("256G"));
}
}