geist_supervisor 0.1.28

Generic OTA supervisor for field devices
Documentation
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::warn;

/// Top-level supervisor configuration supporting multiple apps.
#[derive(Debug, Clone, Deserialize)]
pub struct SupervisorConfig {
    #[serde(default)]
    pub apps: HashMap<String, AppConfig>,
}

/// Configuration for a single managed application.
#[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 {
    /// Build the GCS registry URL for this app.
    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)
    }

    /// Get the application binary path, respecting test overrides.
    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);
        }
        // Production deployment check
        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)
    }

    /// Get the install path, respecting test overrides.
    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 {
    /// Load configuration from file, falling back to defaults.
    /// Search order: GEIST_CONFIG_PATH env → /etc/geist-supervisor.toml → ~/.config/geist-supervisor/config.toml → defaults.
    pub fn load() -> Self {
        // 1. Env var override
        if let Ok(path) = std::env::var("GEIST_CONFIG_PATH") {
            if let Some(config) = Self::load_from_file(&path) {
                return config;
            }
        }

        // 2. System config
        if let Some(config) = Self::load_from_file("/etc/geist-supervisor.toml") {
            return config;
        }

        // 3. User 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;
            }
        }

        // 4. Defaults
        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
            }
        }
    }

    /// Resolve which app to use based on the --app flag.
    /// - If only one app is configured, return it (flag optional).
    /// - If multiple apps, the flag is required.
    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");
        // Defaults
        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());
    }

    // --- Config loading tests ---

    #[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() {
        // Point to nonexistent file → falls back to 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");

        // Falls back to defaults
        assert_eq!(config.apps.len(), 1);
        assert!(config.apps.contains_key("default"));
    }

    // --- App resolution tests ---

    #[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'"));
    }

    // --- AppConfig method tests ---

    #[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"
        );
    }
}