use photon_config::load_toml_config; use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; /// Deployer server configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { /// HTTP server bind address #[serde(default = "default_bind_addr")] pub bind_addr: SocketAddr, /// ChainFire cluster endpoints #[serde(default)] pub chainfire: ChainFireConfig, /// PhotonCloud cluster ID (for writing desired state under photoncloud/clusters/...) #[serde(default)] pub cluster_id: Option, /// Namespace prefix for PhotonCloud cluster state #[serde(default = "default_cluster_namespace")] pub cluster_namespace: String, /// Node heartbeat timeout (seconds) #[serde(default = "default_heartbeat_timeout")] pub heartbeat_timeout_secs: u64, /// Local state path for bootstrapper mode (file or directory) #[serde(default = "default_local_state_path")] pub local_state_path: Option, /// Optional tar.gz bundle containing the PhotonCloud flake source tree for bootstrap installs #[serde(default)] pub bootstrap_flake_bundle_path: Option, /// Shared bootstrap token required for phone-home/admin APIs #[serde(default)] pub bootstrap_token: Option, /// Shared admin token required for admin APIs #[serde(default)] pub admin_token: Option, /// Allow admin APIs to fall back to bootstrap token (unsafe; for dev only) #[serde(default = "default_allow_admin_fallback")] pub allow_admin_fallback: bool, /// Allow unauthenticated requests (unsafe; for dev only) #[serde(default = "default_allow_unauthenticated")] pub allow_unauthenticated: bool, /// Require ChainFire to be available at startup (fail fast if unavailable) #[serde(default = "default_require_chainfire")] pub require_chainfire: bool, /// Allow nodes with unknown machine-id to auto-register (unsafe) #[serde(default = "default_allow_unknown_nodes")] pub allow_unknown_nodes: bool, /// Enable hardcoded test machine mappings (unsafe) #[serde(default = "default_allow_test_mappings")] pub allow_test_mappings: bool, /// Optional CA certificate path for issuing node TLS certs #[serde(default)] pub tls_ca_cert_path: Option, /// Optional CA private key path for issuing node TLS certs #[serde(default)] pub tls_ca_key_path: Option, /// Allow self-signed TLS certificates when no CA is configured #[serde(default = "default_tls_self_signed")] pub tls_self_signed: bool, } impl Default for Config { fn default() -> Self { Self { bind_addr: default_bind_addr(), chainfire: ChainFireConfig::default(), cluster_id: None, cluster_namespace: default_cluster_namespace(), heartbeat_timeout_secs: default_heartbeat_timeout(), local_state_path: default_local_state_path(), bootstrap_flake_bundle_path: None, bootstrap_token: None, admin_token: None, allow_admin_fallback: default_allow_admin_fallback(), allow_unauthenticated: default_allow_unauthenticated(), require_chainfire: default_require_chainfire(), allow_unknown_nodes: default_allow_unknown_nodes(), allow_test_mappings: default_allow_test_mappings(), tls_ca_cert_path: None, tls_ca_key_path: None, tls_self_signed: default_tls_self_signed(), } } } impl Config { pub fn redacted(&self) -> Self { let mut redacted = self.clone(); if redacted.bootstrap_token.is_some() { redacted.bootstrap_token = Some("".to_string()); } if redacted.admin_token.is_some() { redacted.admin_token = Some("".to_string()); } redacted } pub fn apply_secret_env_overrides(&mut self) { if let Ok(token) = std::env::var("DEPLOYER_BOOTSTRAP_TOKEN") { let trimmed = token.trim(); if !trimmed.is_empty() { self.bootstrap_token = Some(trimmed.to_string()); } } if let Ok(token) = std::env::var("DEPLOYER_ADMIN_TOKEN") { let trimmed = token.trim(); if !trimmed.is_empty() { self.admin_token = Some(trimmed.to_string()); } } } } /// ChainFire configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChainFireConfig { /// ChainFire cluster endpoints #[serde(default = "default_chainfire_endpoints")] pub endpoints: Vec, /// Namespace for deployer state #[serde(default = "default_chainfire_namespace")] pub namespace: String, } impl Default for ChainFireConfig { fn default() -> Self { Self { endpoints: default_chainfire_endpoints(), namespace: default_chainfire_namespace(), } } } pub fn load_config(path: &Path) -> anyhow::Result { let mut config: Config = load_toml_config(path)?; config.apply_secret_env_overrides(); Ok(config) } fn default_bind_addr() -> SocketAddr { "0.0.0.0:8080".parse().unwrap() } fn default_chainfire_endpoints() -> Vec { vec![] } fn default_chainfire_namespace() -> String { "deployer".to_string() } fn default_cluster_namespace() -> String { "photoncloud".to_string() } fn default_heartbeat_timeout() -> u64 { 300 // 5 minutes } fn default_local_state_path() -> Option { Some(PathBuf::from("/var/lib/deployer/state")) } fn default_allow_unauthenticated() -> bool { false } fn default_require_chainfire() -> bool { true } fn default_allow_admin_fallback() -> bool { false } fn default_allow_unknown_nodes() -> bool { false } fn default_allow_test_mappings() -> bool { false } fn default_tls_self_signed() -> bool { false } #[cfg(test)] mod tests { use super::*; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_path(name: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir().join(format!("{}-{}-{}.toml", name, std::process::id(), nanos)) } #[test] fn test_default_config() { let config = Config::default(); assert_eq!(config.bind_addr.to_string(), "0.0.0.0:8080"); assert_eq!(config.chainfire.namespace, "deployer"); assert_eq!(config.cluster_namespace, "photoncloud"); assert!(config.cluster_id.is_none()); assert_eq!(config.heartbeat_timeout_secs, 300); assert_eq!( config.local_state_path, Some(PathBuf::from("/var/lib/deployer/state")) ); assert!(config.bootstrap_flake_bundle_path.is_none()); assert!(config.bootstrap_token.is_none()); assert!(config.admin_token.is_none()); assert!(!config.allow_admin_fallback); assert!(!config.allow_unauthenticated); assert!(!config.allow_unknown_nodes); assert!(!config.allow_test_mappings); assert!(config.tls_ca_cert_path.is_none()); assert!(config.tls_ca_key_path.is_none()); assert!(!config.tls_self_signed); assert!(config.chainfire.endpoints.is_empty()); } #[test] fn test_config_serialization() { let config = Config::default(); let json = serde_json::to_string(&config).unwrap(); let deserialized: Config = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.bind_addr, config.bind_addr); } #[test] fn test_loads_toml_config() { let path = temp_path("deployer-config"); fs::write( &path, r#" bind_addr = "127.0.0.1:18080" cluster_id = "cluster-a" allow_unauthenticated = true bootstrap_flake_bundle_path = "/tmp/plasmacloud-flake-bundle.tar.gz" [chainfire] endpoints = ["http://10.0.0.1:2379"] namespace = "bootstrap" "#, ) .unwrap(); let config = load_config(&path).unwrap(); assert_eq!(config.bind_addr.to_string(), "127.0.0.1:18080"); assert_eq!(config.cluster_id.as_deref(), Some("cluster-a")); assert_eq!( config.bootstrap_flake_bundle_path, Some(PathBuf::from("/tmp/plasmacloud-flake-bundle.tar.gz")) ); assert!(config.allow_unauthenticated); assert_eq!(config.chainfire.namespace, "bootstrap"); assert_eq!(config.chainfire.endpoints, vec!["http://10.0.0.1:2379"]); let _ = fs::remove_file(path); } }