use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::warn;
#[derive(Debug, Clone, Deserialize)]
pub struct SupervisorConfig {
#[serde(default)]
pub apps: HashMap<String, AppConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AppConfig {
pub service_name: String,
pub install_path: PathBuf,
pub binary_names: Vec<String>,
pub gcs_bucket: String,
#[serde(default = "default_release_bundle_name")]
pub release_bundle_name: String,
#[serde(default = "default_checksum_file_name")]
pub checksum_file_name: String,
#[serde(default)]
pub health_check_url: Option<String>,
#[serde(default = "default_health_check_timeout")]
pub health_check_timeout_secs: u32,
#[serde(default = "default_health_check_retries")]
pub health_check_retries: u32,
}
fn default_release_bundle_name() -> String {
"release.tar.gz".to_string()
}
fn default_checksum_file_name() -> String {
"checksums.txt".to_string()
}
fn default_health_check_timeout() -> u32 {
30
}
fn default_health_check_retries() -> u32 {
6
}
impl Default for AppConfig {
fn default() -> Self {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
Self {
service_name: "geist_supervisor".to_string(),
install_path: PathBuf::from(&home).join(".local/share/geist-supervisor"),
binary_names: vec!["geist_supervisor".to_string()],
gcs_bucket: "roc-camera-releases".to_string(),
release_bundle_name: default_release_bundle_name(),
checksum_file_name: default_checksum_file_name(),
health_check_url: None,
health_check_timeout_secs: default_health_check_timeout(),
health_check_retries: default_health_check_retries(),
}
}
}
impl AppConfig {
pub fn registry_url(&self) -> String {
if let Ok(url) = std::env::var("GEIST_REGISTRY_URL_TEST") {
return url;
}
format!("https://storage.googleapis.com/{}", self.gcs_bucket)
}
pub fn app_binary_path(&self) -> PathBuf {
if let Ok(test_path) = std::env::var("GEIST_APP_BINARY_PATH_TEST") {
return PathBuf::from(test_path);
}
let prod_path = PathBuf::from("/opt")
.join(&self.service_name)
.join(&self.service_name);
if prod_path.exists() {
return prod_path;
}
self.install_path.join("bin").join(&self.service_name)
}
pub fn effective_install_path(&self) -> PathBuf {
if let Ok(test_dir) = std::env::var("GEIST_INSTALL_PATH_TEST") {
return PathBuf::from(test_dir);
}
self.install_path.clone()
}
}
impl Default for SupervisorConfig {
fn default() -> Self {
let mut apps = HashMap::new();
apps.insert("default".to_string(), AppConfig::default());
Self { apps }
}
}
impl SupervisorConfig {
pub fn load() -> Self {
if let Ok(path) = std::env::var("GEIST_CONFIG_PATH") {
if let Some(config) = Self::load_from_file(&path) {
return config;
}
}
if let Some(config) = Self::load_from_file("/etc/geist-supervisor.toml") {
return config;
}
if let Ok(home) = std::env::var("HOME") {
let user_config = PathBuf::from(home).join(".config/geist-supervisor/config.toml");
if let Some(config) = Self::load_from_file(user_config.to_str().unwrap_or("")) {
return config;
}
}
Self::default()
}
fn load_from_file(path: &str) -> Option<Self> {
if path.is_empty() {
return None;
}
let content = std::fs::read_to_string(path).ok()?;
match toml::from_str::<SupervisorConfig>(&content) {
Ok(config) if !config.apps.is_empty() => Some(config),
Ok(_) => {
warn!("Config file {} has no apps defined, using defaults", path);
None
}
Err(e) => {
warn!(
"Failed to parse config file {}: {}, using defaults",
path, e
);
None
}
}
}
pub fn resolve_app(&self, app_flag: Option<&str>) -> Result<&AppConfig, String> {
if let Some(name) = app_flag {
return self.apps.get(name).ok_or_else(|| {
let available: Vec<&str> = self.apps.keys().map(|s| s.as_str()).collect();
format!(
"Unknown app '{}'. Available apps: {}",
name,
available.join(", ")
)
});
}
match self.apps.len() {
0 => Err("No apps configured".to_string()),
1 => Ok(self.apps.values().next().unwrap()),
_ => {
let available: Vec<&str> = self.apps.keys().map(|s| s.as_str()).collect();
Err(format!(
"Multiple apps configured. Use --app to specify one: {}",
available.join(", ")
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_parse_single_app_config() {
let toml_str = r#"
[apps.my_app]
service_name = "my_app"
install_path = "/opt/my_app"
binary_names = ["my_app", "helper"]
gcs_bucket = "my-app-releases"
"#;
let config: SupervisorConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.apps.len(), 1);
let app = config.apps.get("my_app").unwrap();
assert_eq!(app.service_name, "my_app");
assert_eq!(app.install_path, PathBuf::from("/opt/my_app"));
assert_eq!(app.binary_names, vec!["my_app", "helper"]);
assert_eq!(app.gcs_bucket, "my-app-releases");
assert_eq!(app.release_bundle_name, "release.tar.gz");
assert_eq!(app.checksum_file_name, "checksums.txt");
assert!(app.health_check_url.is_none());
assert_eq!(app.health_check_timeout_secs, 30);
assert_eq!(app.health_check_retries, 6);
}
#[test]
fn test_parse_multi_app_config() {
let toml_str = r#"
[apps.app_a]
service_name = "app_a"
install_path = "/opt/app_a"
binary_names = ["app_a"]
gcs_bucket = "app-a-releases"
[apps.app_b]
service_name = "app_b"
install_path = "/opt/app_b"
binary_names = ["app_b", "app_b_worker"]
gcs_bucket = "app-b-releases"
release_bundle_name = "app-b.tar.gz"
checksum_file_name = "app-b-checksums.txt"
health_check_url = "http://localhost:8080/health"
"#;
let config: SupervisorConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.apps.len(), 2);
let app_b = config.apps.get("app_b").unwrap();
assert_eq!(app_b.service_name, "app_b");
assert_eq!(app_b.release_bundle_name, "app-b.tar.gz");
assert_eq!(app_b.checksum_file_name, "app-b-checksums.txt");
assert_eq!(
app_b.health_check_url,
Some("http://localhost:8080/health".to_string())
);
}
#[test]
fn test_app_config_default() {
let app = AppConfig::default();
assert_eq!(app.service_name, "geist_supervisor");
assert!(app
.install_path
.to_str()
.unwrap()
.contains("geist-supervisor"));
assert_eq!(app.binary_names, vec!["geist_supervisor"]);
assert_eq!(app.release_bundle_name, "release.tar.gz");
assert_eq!(app.checksum_file_name, "checksums.txt");
assert!(app.health_check_url.is_none());
}
#[test]
fn test_load_config_from_file() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[apps.my_app]
service_name = "my_app"
install_path = "/opt/my_app"
binary_names = ["my_app"]
gcs_bucket = "my-app-releases"
"#,
)
.unwrap();
std::env::set_var("GEIST_CONFIG_PATH", config_path.to_str().unwrap());
let config = SupervisorConfig::load();
std::env::remove_var("GEIST_CONFIG_PATH");
assert_eq!(config.apps.len(), 1);
assert!(config.apps.contains_key("my_app"));
}
#[test]
fn test_load_config_fallback_defaults() {
std::env::set_var("GEIST_CONFIG_PATH", "/nonexistent/path/config.toml");
let config = SupervisorConfig::load();
std::env::remove_var("GEIST_CONFIG_PATH");
assert_eq!(config.apps.len(), 1);
assert!(config.apps.contains_key("default"));
}
#[test]
fn test_load_config_invalid_toml() {
let temp = tempdir().unwrap();
let config_path = temp.path().join("bad.toml");
std::fs::write(&config_path, "this is not valid toml {{{{").unwrap();
std::env::set_var("GEIST_CONFIG_PATH", config_path.to_str().unwrap());
let config = SupervisorConfig::load();
std::env::remove_var("GEIST_CONFIG_PATH");
assert_eq!(config.apps.len(), 1);
assert!(config.apps.contains_key("default"));
}
#[test]
fn test_resolve_single_app_no_flag() {
let config = SupervisorConfig::default();
let app = config.resolve_app(None).unwrap();
assert_eq!(app.service_name, "geist_supervisor");
}
#[test]
fn test_resolve_multi_requires_flag() {
let mut config = SupervisorConfig::default();
config.apps.insert(
"orb".to_string(),
AppConfig {
service_name: "orb".to_string(),
install_path: PathBuf::from("/opt/orb"),
binary_names: vec!["orb".to_string()],
gcs_bucket: "orb-releases".to_string(),
..AppConfig::default()
},
);
let result = config.resolve_app(None);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Multiple apps configured"));
assert!(err.contains("--app"));
}
#[test]
fn test_resolve_multi_with_flag() {
let mut config = SupervisorConfig::default();
config.apps.insert(
"orb".to_string(),
AppConfig {
service_name: "orb".to_string(),
install_path: PathBuf::from("/opt/orb"),
binary_names: vec!["orb".to_string()],
gcs_bucket: "orb-releases".to_string(),
..AppConfig::default()
},
);
let app = config.resolve_app(Some("orb")).unwrap();
assert_eq!(app.service_name, "orb");
}
#[test]
fn test_resolve_unknown_app() {
let config = SupervisorConfig::default();
let result = config.resolve_app(Some("nonexistent"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Unknown app 'nonexistent'"));
}
#[test]
fn test_registry_url_default() {
std::env::remove_var("GEIST_REGISTRY_URL_TEST");
let app = AppConfig {
gcs_bucket: "my-releases".to_string(),
..AppConfig::default()
};
assert_eq!(
app.registry_url(),
"https://storage.googleapis.com/my-releases"
);
}
#[test]
fn test_registry_url_custom_bucket() {
std::env::remove_var("GEIST_REGISTRY_URL_TEST");
let app = AppConfig {
service_name: "orb".to_string(),
install_path: PathBuf::from("/opt/orb"),
binary_names: vec!["orb".to_string()],
gcs_bucket: "orb-releases".to_string(),
..AppConfig::default()
};
assert_eq!(
app.registry_url(),
"https://storage.googleapis.com/orb-releases"
);
}
}