photoncloud-monorepo/deployer/crates/deployer-server/src/config.rs

284 lines
8.6 KiB
Rust

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<String>,
/// 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<PathBuf>,
/// Optional tar.gz bundle containing the PhotonCloud flake source tree for bootstrap installs
#[serde(default)]
pub bootstrap_flake_bundle_path: Option<PathBuf>,
/// Shared bootstrap token required for phone-home/admin APIs
#[serde(default)]
pub bootstrap_token: Option<String>,
/// Shared admin token required for admin APIs
#[serde(default)]
pub admin_token: Option<String>,
/// 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<String>,
/// Optional CA private key path for issuing node TLS certs
#[serde(default)]
pub tls_ca_key_path: Option<String>,
/// 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("<redacted>".to_string());
}
if redacted.admin_token.is_some() {
redacted.admin_token = Some("<redacted>".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<String>,
/// 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<Config> {
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<String> {
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<PathBuf> {
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);
}
}