use figment::Figment; use figment::providers::{Env, Format, Serialized, Toml}; use serde::Deserialize; use crate::error::ConfigError; pub const RUNNER_ENV_PREFIX: &str = "APP_RUNNER__"; pub const DEFAULT_CONFIG_PATH: &str = "config/default.toml"; pub const LOCAL_CONFIG_PATH: &str = "config/local.toml"; #[derive(Clone, Debug, Deserialize, serde::Serialize)] #[serde(default)] pub struct RunnerConfig { pub server_url: String, pub registration_token: String, pub name: String, pub labels: Vec, pub capacity: i32, pub workspace_root: String, pub credential_path: String, pub docker_enabled: bool, pub default_shell: String, pub pat: String, pub git_binary: String, pub docker_binary: String, pub clone_timeout_secs: u64, pub default_job_timeout_secs: u64, pub poll_timeout_secs: u64, pub heartbeat_interval_secs: u64, pub log_flush_interval_ms: u64, pub log_flush_bytes: usize, pub keep_workspace: bool, pub once: bool, } impl Default for RunnerConfig { fn default() -> Self { Self { server_url: "http://127.0.0.1:3005".to_string(), registration_token: String::new(), name: String::new(), labels: vec!["self-hosted".to_string()], capacity: 1, workspace_root: "/var/lib/gitdata-runner".to_string(), credential_path: "/var/lib/gitdata-runner/credential".to_string(), docker_enabled: false, default_shell: "sh".to_string(), pat: String::new(), git_binary: "git".to_string(), docker_binary: "docker".to_string(), clone_timeout_secs: 1800, default_job_timeout_secs: 3600, poll_timeout_secs: 30, heartbeat_interval_secs: 15, log_flush_interval_ms: 1000, log_flush_bytes: 65536, keep_workspace: false, once: false, } } } impl RunnerConfig { pub fn load() -> Result { let _ = dotenvy::dotenv(); let mut figment = Figment::new(); if std::path::Path::new(DEFAULT_CONFIG_PATH).exists() { figment = figment.merge(Toml::file(DEFAULT_CONFIG_PATH)); } if std::path::Path::new(LOCAL_CONFIG_PATH).exists() { figment = figment.merge(Toml::file(LOCAL_CONFIG_PATH)); } // The shared config files keep runner settings under `[runner]`, while // `APP_RUNNER__*` maps onto the flat keys of this struct; layer both so // a file-only config is not silently ignored. let from_file: Self = figment.extract_inner("runner").unwrap_or_default(); let figment = Figment::from(Serialized::defaults(from_file)).merge(Env::prefixed(RUNNER_ENV_PREFIX)); let cfg: Self = figment.extract().map_err(ConfigError::from)?; cfg.validate()?; Ok(cfg) } pub fn validate(&self) -> Result<(), ConfigError> { if self.server_url.trim().is_empty() { return Err(ConfigError::Validation( "runner.server_url must not be empty".to_string(), )); } if self.labels.is_empty() || self.labels.iter().any(|l| l.trim().is_empty()) { return Err(ConfigError::Validation( "runner.labels must contain at least one non-empty label".to_string(), )); } if self.capacity <= 0 { return Err(ConfigError::Validation( "runner.capacity must be greater than zero".to_string(), )); } if self.workspace_root.trim().is_empty() || self.credential_path.trim().is_empty() { return Err(ConfigError::Validation( "runner workspace_root and credential_path must not be empty".to_string(), )); } if self.default_shell.trim().is_empty() { return Err(ConfigError::Validation( "runner.default_shell must not be empty".to_string(), )); } if self.clone_timeout_secs == 0 || self.default_job_timeout_secs == 0 || self.poll_timeout_secs == 0 || self.heartbeat_interval_secs == 0 { return Err(ConfigError::Validation( "runner timeouts must be greater than zero".to_string(), )); } let _ = Env::prefixed(RUNNER_ENV_PREFIX); Ok(()) } #[must_use] pub fn resolved_name(&self) -> String { if self.name.trim().is_empty() { hostname() } else { self.name.clone() } } } fn hostname() -> String { std::env::var("HOSTNAME") .ok() .filter(|h| !h.trim().is_empty()) .unwrap_or_else(|| "gitdata-runner".to_string()) }