#![allow(dead_code)]
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
static CONFIG: OnceLock<RustployConfig> = OnceLock::new();
static CONFIG_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvBackupConfig {
pub dir: Option<String>,
#[serde(default = "default_env_backup_interval")]
pub interval_secs: u64,
}
impl Default for EnvBackupConfig {
fn default() -> Self {
Self {
dir: None,
interval_secs: default_env_backup_interval(),
}
}
}
fn default_env_backup_interval() -> u64 {
60
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RustployConfig {
pub daemon: DaemonConfig,
pub ingress: IngressConfig,
pub docker: DockerConfig,
pub deploy: DeployConfig,
pub metrics: MetricsConfig,
pub secrets: SecretsConfig,
#[serde(default)]
pub api: ApiConfig,
#[serde(default)]
pub env_backup: EnvBackupConfig,
#[serde(default)]
pub external_ports: ExternalPortsConfig,
#[serde(default)]
pub registry: RegistryConfig,
#[serde(default)]
pub update: UpdateConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateConfig {
#[serde(default = "default_update_enabled")]
pub enabled: bool,
#[serde(default = "default_update_repo")]
pub repo: String,
#[serde(default = "default_update_cache_hours")]
pub cache_hours: u64,
#[serde(default = "default_update_check_interval_hours")]
pub check_interval_hours: u64,
#[serde(default)]
pub allow_agent_apply: bool,
}
fn default_update_enabled() -> bool {
true
}
fn default_update_repo() -> String {
"antoniofernandodj/rustploy".into()
}
fn default_update_cache_hours() -> u64 {
6
}
fn default_update_check_interval_hours() -> u64 {
6
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
enabled: default_update_enabled(),
repo: default_update_repo(),
cache_hours: default_update_cache_hours(),
check_interval_hours: default_update_check_interval_hours(),
allow_agent_apply: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalPortsConfig {
#[serde(default = "default_external_range_start")]
pub range_start: u16,
#[serde(default = "default_external_range_end")]
pub range_end: u16,
}
fn default_external_range_start() -> u16 {
20000
}
fn default_external_range_end() -> u16 {
20999
}
impl Default for ExternalPortsConfig {
fn default() -> Self {
Self {
range_start: default_external_range_start(),
range_end: default_external_range_end(),
}
}
}
impl ExternalPortsConfig {
pub fn contains(&self, port: u16) -> bool {
port >= self.range_start && port <= self.range_end
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiConfig {
#[serde(default = "default_api_enabled")]
pub enabled: bool,
#[serde(default = "default_api_bind")]
pub bind_address: String,
#[serde(default = "default_api_port")]
pub port: u16,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub domain: Option<String>,
#[serde(default = "default_api_max_connections")]
pub max_connections: usize,
}
fn default_api_enabled() -> bool {
true
}
fn default_api_bind() -> String {
"127.0.0.1".into()
}
fn default_api_port() -> u16 {
9797
}
fn default_api_max_connections() -> usize {
32
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
enabled: default_api_enabled(),
bind_address: default_api_bind(),
port: default_api_port(),
token: None,
domain: None,
max_connections: default_api_max_connections(),
}
}
}
impl ApiConfig {
pub fn is_public_bind(&self) -> bool {
match self.bind_address.parse::<std::net::IpAddr>() {
Ok(ip) => !ip.is_loopback(),
Err(_) => self.bind_address != "localhost",
}
}
pub fn public_base_url(&self, fallback_host: &str) -> String {
match self
.domain
.as_deref()
.map(str::trim)
.filter(|d| !d.is_empty())
{
Some(domain) if self.port == 443 => format!("https://{domain}"),
Some(domain) => format!("https://{domain}:{}", self.port),
None => format!("http://{fallback_host}:{}", self.port),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_registry_port")]
pub port: u16,
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub storage_dir: Option<String>,
}
fn default_registry_port() -> u16 {
5100
}
impl Default for RegistryConfig {
fn default() -> Self {
Self {
enabled: false,
port: default_registry_port(),
domain: None,
storage_dir: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonConfig {
pub db_path: String,
pub log_level: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IngressConfig {
pub http_port: u16,
pub https_port: u16,
pub bind_address: String,
pub acme: AcmeConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AcmeConfig {
pub enabled: bool,
pub email: Option<String>,
pub directory: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerConfig {
pub socket_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployConfig {
pub drain_secs: u64,
pub image_cache: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsConfig {
pub interval_secs: u64,
pub history_points: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsConfig {
pub master_key_path: String,
#[serde(default)]
pub age_identity: Option<String>,
#[serde(default)]
pub recovery_recipient: Option<String>,
}
impl Default for RustployConfig {
fn default() -> Self {
Self {
daemon: DaemonConfig {
db_path: "/var/lib/rustploy/db".into(),
log_level: "info".into(),
},
ingress: IngressConfig {
http_port: 8080,
https_port: 443,
bind_address: "0.0.0.0".into(),
acme: AcmeConfig {
enabled: true,
email: None,
directory: "https://acme-v02.api.letsencrypt.org/directory".into(),
},
},
docker: DockerConfig {
socket_path: "/var/run/docker.sock".into(),
},
deploy: DeployConfig {
drain_secs: 10,
image_cache: 2,
},
metrics: MetricsConfig {
interval_secs: 2,
history_points: 60,
},
secrets: SecretsConfig {
master_key_path: "/etc/rustploy/master.key".into(),
age_identity: None,
recovery_recipient: None,
},
api: ApiConfig::default(),
env_backup: EnvBackupConfig::default(),
external_ports: ExternalPortsConfig::default(),
registry: RegistryConfig::default(),
update: UpdateConfig::default(),
}
}
}
impl RustployConfig {
pub fn global() -> &'static RustployConfig {
CONFIG.get_or_init(|| {
let (cfg, path) = Self::load_with_path();
let _ = CONFIG_PATH.set(path);
cfg
})
}
pub fn global_path() -> Option<&'static Path> {
Self::global();
CONFIG_PATH.get().and_then(|p| p.as_deref())
}
pub fn load() -> Self {
Self::load_with_path().0
}
pub fn load_with_path() -> (Self, Option<std::path::PathBuf>) {
let paths = [
std::env::var("RUSTPLOY_CONFIG")
.ok()
.map(std::path::PathBuf::from),
Some(std::path::PathBuf::from("/etc/rustploy/config.toml")),
dirs_config_path(),
];
for path in paths.into_iter().flatten() {
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(cfg) = toml::from_str(&contents)
{
return (Self::apply_env_overrides(cfg), Some(path));
}
}
(Self::apply_env_overrides(Self::default()), None)
}
fn apply_env_overrides(mut cfg: Self) -> Self {
if let Ok(v) = std::env::var("RUSTPLOY_DB_PATH") {
cfg.daemon.db_path = v;
}
if let Ok(v) = std::env::var("RUSTPLOY_LOG_LEVEL") {
cfg.daemon.log_level = v;
}
if let Ok(v) = std::env::var("RUSTPLOY_HTTP_PORT")
&& let Ok(p) = v.parse()
{
cfg.ingress.http_port = p;
}
if let Ok(v) = std::env::var("RUSTPLOY_API_ENABLED") {
cfg.api.enabled = matches!(v.as_str(), "1" | "true" | "yes" | "on");
}
if let Ok(v) = std::env::var("RUSTPLOY_API_BIND") {
cfg.api.bind_address = v;
}
if let Ok(v) = std::env::var("RUSTPLOY_API_PORT")
&& let Ok(p) = v.parse()
{
cfg.api.port = p;
}
if let Ok(v) = std::env::var("RUSTPLOY_API_TOKEN") {
cfg.api.token = Some(v).filter(|s| !s.is_empty());
}
if let Ok(v) = std::env::var("RUSTPLOY_API_DOMAIN") {
cfg.api.domain = Some(v).filter(|s| !s.is_empty());
}
if let Ok(v) = std::env::var("RUSTPLOY_REGISTRY_ENABLED") {
cfg.registry.enabled = matches!(v.as_str(), "1" | "true" | "yes" | "on");
}
if let Ok(v) = std::env::var("RUSTPLOY_REGISTRY_PORT")
&& let Ok(p) = v.parse()
{
cfg.registry.port = p;
}
if let Ok(v) = std::env::var("RUSTPLOY_REGISTRY_DOMAIN") {
cfg.registry.domain = Some(v).filter(|s| !s.is_empty());
}
if let Ok(v) = std::env::var("RUSTPLOY_REGISTRY_STORAGE_DIR") {
cfg.registry.storage_dir = Some(v).filter(|s| !s.is_empty());
}
cfg
}
}
pub fn user_home() -> Option<String> {
std::env::var("HOME").ok().filter(|s| !s.is_empty())
}
pub fn fallback_data_dir() -> PathBuf {
let home = user_home().unwrap_or_else(|| "/tmp".into());
PathBuf::from(home)
.join(".local")
.join("share")
.join("rustploy")
}
fn dirs_config_path() -> Option<PathBuf> {
user_home().map(|home| {
PathBuf::from(home)
.join(".config")
.join("rustploy")
.join("config.toml")
})
}
pub fn outbound_ip() -> String {
use std::net::UdpSocket;
UdpSocket::bind("0.0.0.0:0")
.and_then(|s| {
s.connect("8.8.8.8:80")?;
s.local_addr()
})
.map(|addr| addr.ip().to_string())
.unwrap_or_else(|_| "localhost".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partial_api_block_uses_field_defaults() {
let toml_str = r#"
[api]
token = "abc123"
"#;
let cfg: ApiConfig = toml::from_str(toml_str)
.map(|w: WrapApi| w.api)
.expect("partial [api] must parse");
assert_eq!(cfg.token.as_deref(), Some("abc123"));
assert_eq!(cfg.port, 9797);
assert_eq!(cfg.bind_address, "127.0.0.1");
assert_eq!(cfg.max_connections, 32);
}
#[derive(Deserialize)]
struct WrapApi {
api: ApiConfig,
}
#[test]
fn partial_registry_block_uses_field_defaults() {
let toml_str = r#"
[registry]
enabled = true
"#;
let cfg: RegistryConfig = toml::from_str(toml_str)
.map(|w: WrapRegistry| w.registry)
.expect("partial [registry] must parse");
assert!(cfg.enabled);
assert_eq!(cfg.port, 5100);
assert_eq!(cfg.domain, None);
assert_eq!(cfg.storage_dir, None);
}
#[derive(Deserialize)]
struct WrapRegistry {
registry: RegistryConfig,
}
#[test]
fn update_block_is_optional_and_partial() {
assert!(UpdateConfig::default().enabled);
assert_eq!(UpdateConfig::default().cache_hours, 6);
assert_eq!(UpdateConfig::default().check_interval_hours, 6);
let cfg: UpdateConfig = toml::from_str("enabled = false").unwrap();
assert!(!cfg.enabled);
assert_eq!(cfg.repo, "antoniofernandodj/rustploy");
assert!(!cfg.allow_agent_apply);
let cfg: UpdateConfig = toml::from_str("allow_agent_apply = true").unwrap();
assert!(cfg.allow_agent_apply);
}
}