geist_supervisor 0.1.28

Generic OTA supervisor for field devices
Documentation
use crate::app_config::AppConfig;
use crate::config::Config;
use crate::services::ota::OtaCoordinator;
use crate::services::{GcsService, UpdateService, VersionService};
use anyhow::Result;
use clap::{Args, Subcommand};
use serde_json;
use tracing::{info, warn};

#[derive(Subcommand)]
pub enum Commands {
    /// Update the managed application
    Update(UpdateArgs),
    /// Check current status
    Status(StatusArgs),
    /// List available versions
    List,
    /// Show current version
    Version,
    /// Uninstall the application
    Uninstall,
    /// Rollback to a previous version
    Rollback(RollbackArgs),
    /// Check for updates
    #[command(name = "check-update")]
    CheckUpdate(CheckUpdateArgs),
    /// Run as a watchdog daemon — periodically check for updates and auto-apply
    Watch(WatchArgs),
}

#[derive(Args)]
pub struct UpdateArgs {
    /// Version to update to (e.g., "1.2.3" or "latest")
    pub version: Option<String>,
    /// Perform a dry run - show what would be done without making changes
    #[arg(long)]
    pub dry_run: bool,
}

#[derive(Args)]
pub struct RollbackArgs {
    /// Specific backup to rollback to (optional, lists available backups if not provided)
    pub backup_name: Option<String>,
}

#[derive(Args)]
pub struct StatusArgs {
    /// Output in JSON format
    #[arg(long)]
    pub json: bool,
}

#[derive(Args)]
pub struct CheckUpdateArgs {
    /// Output in JSON format
    #[arg(long)]
    pub json: bool,
}

#[derive(Args)]
pub struct WatchArgs {
    /// Poll interval in seconds (default: 3600 = 1 hour)
    #[arg(long, default_value = "3600")]
    pub interval: u64,
}

pub fn handle_command(command: Commands, app: &AppConfig) -> Result<()> {
    match command {
        Commands::Update(args) => handle_update(args.version, args.dry_run, app),
        Commands::Status(args) => handle_status(args.json, app),
        Commands::List => handle_list(app),
        Commands::Version => handle_version(),
        Commands::Uninstall => handle_uninstall(app),
        Commands::Rollback(args) => handle_rollback(args.backup_name, app),
        Commands::CheckUpdate(args) => handle_check_update(args.json, app),
        Commands::Watch(args) => handle_watch(args.interval, app),
    }
}

fn handle_update(version: Option<String>, dry_run: bool, app: &AppConfig) -> Result<()> {
    if dry_run {
        info!("Starting dry-run update process...");
        let update_service = UpdateService::with_config(app);
        update_service.dry_run_update(version)?;
    } else {
        info!("Starting update process...");
        let update_service = UpdateService::with_config(app);
        update_service.update(version)?;
        println!("Update completed successfully!");
    }
    Ok(())
}

fn handle_status(json_output: bool, app: &AppConfig) -> Result<()> {
    info!("Checking status...");

    let app_binary_path = app.app_binary_path();
    let registry_url = app.registry_url();

    // Initialize services
    let ota_coordinator = OtaCoordinator::new(app_binary_path.clone(), app.service_name.clone());
    let version_service = VersionService::with_service_name(app.service_name.clone());

    // Get comprehensive system information
    let system_info = version_service.get_system_info(&app_binary_path);

    // Check service status (read-only, no sudo required)
    let service_status = ota_coordinator.get_service_status_readonly();

    // Check for available updates
    let gcs = GcsService::with_bundle_names(
        String::new(),
        registry_url,
        app.release_bundle_name.clone(),
        app.checksum_file_name.clone(),
    );
    let (latest_version, update_available) = match gcs.get_latest_version() {
        Ok(latest) => {
            // Ensure both versions have consistent format for comparison
            let formatted_current = Config::format_version(&system_info.binary_version);
            let formatted_latest = Config::format_version(&latest);
            let available = formatted_current != formatted_latest;
            (formatted_latest, available)
        }
        Err(e) => {
            warn!("Failed to check for updates: {}", e);
            (Config::format_version(&system_info.binary_version), false)
        }
    };

    if json_output {
        // Create comprehensive JSON response
        let json_response = serde_json::json!({
            "binary_version": system_info.binary_version,
            "git_hash": system_info.git_hash,
            "build_date": system_info.build_date,
            "target_platform": system_info.target_platform,
            "deployment_mode": system_info.deployment_mode,
            "deployment_size_mb": system_info.deployment_size_mb,
            "binary_location": system_info.binary_location,
            "service_status": service_status,
            "supervisor_version": system_info.supervisor_version,
            "required_dependencies": system_info.required_dependencies,
            "update_available": update_available,
            "latest_version": latest_version,
            "ota_metadata": system_info.ota_metadata
        });

        println!("{}", serde_json::to_string_pretty(&json_response)?);
    } else {
        // Output in human-readable format
        println!("Service status: {}", service_status);
        println!("Current Version: {}", system_info.binary_version);
        println!("Latest Version: {}", latest_version);
        println!(
            "Update Available: {}",
            if update_available { "Yes" } else { "No" }
        );
        println!("Deployment Mode: {}", system_info.deployment_mode);
        println!("Supervisor Version: {}", system_info.supervisor_version);
        println!("Git Hash: {}", system_info.git_hash);
        println!("Build Date: {}", system_info.build_date);
    }

    Ok(())
}

fn handle_rollback(backup_name: Option<String>, app: &AppConfig) -> Result<()> {
    info!("Processing rollback request...");

    let app_binary_path = app.app_binary_path();

    // Initialize OTA coordinator
    let ota_coordinator = OtaCoordinator::new(app_binary_path, app.service_name.clone());

    if let Some(backup_name) = backup_name {
        // Rollback to specific backup
        info!("Rolling back to backup: {}", backup_name);
        ota_coordinator.rollback_to_backup(&backup_name)?;
        println!("Successfully rolled back to backup: {}", backup_name);
    } else {
        // List available backups
        info!("Listing available backups...");
        let backups = ota_coordinator.list_backups()?;

        if backups.is_empty() {
            println!("No backups available");
        } else {
            println!("Available backups:");
            for backup in backups {
                println!("  {}", backup);
            }
        }
    }

    Ok(())
}

fn handle_check_update(json_output: bool, app: &AppConfig) -> Result<()> {
    info!("Checking for updates...");

    let current_version = Config::format_version(&Config::get_current_version());
    let gcs = GcsService::with_bundle_names(
        String::new(),
        app.registry_url(),
        app.release_bundle_name.clone(),
        app.checksum_file_name.clone(),
    );

    let (latest_version, update_available, error) = match gcs.get_latest_version() {
        Ok(latest) => {
            // Ensure both versions have consistent format for comparison
            let formatted_current = Config::format_version(&current_version);
            let formatted_latest = Config::format_version(&latest);
            let available = formatted_current != formatted_latest;
            (Some(formatted_latest), available, None)
        }
        Err(e) => {
            warn!("Failed to check for updates: {}", e);
            (None, false, Some(e.to_string()))
        }
    };

    if json_output {
        // Output in JSON format
        let json_response = if let Some(error) = error {
            format!(
                r#"{{"update_available":false,"current_version":"{}","error":"{}"}}"#,
                current_version, error
            )
        } else if let Some(latest) = latest_version {
            format!(
                r#"{{"update_available":{},"current_version":"{}","latest_version":"{}"}}"#,
                update_available, current_version, latest
            )
        } else {
            format!(
                r#"{{"update_available":false,"current_version":"{}","error":"Unknown error"}}"#,
                current_version
            )
        };
        println!("{}", json_response);
    } else {
        // Output in human-readable format
        println!("Current Version: {}", current_version);
        if let Some(latest) = latest_version {
            println!("Latest Version: {}", latest);
            println!(
                "Update Available: {}",
                if update_available { "Yes" } else { "No" }
            );
        } else if let Some(error) = error {
            println!("Update Check Failed: {}", error);
        }
    }

    Ok(())
}

fn handle_list(app: &AppConfig) -> Result<()> {
    info!("Listing available versions...");

    // Current installed version
    let current_version = Config::format_version(&Config::get_current_version());
    println!("Installed:");
    println!("  {} (current)", current_version);

    // Available backups (rollback targets)
    let ota_coordinator = OtaCoordinator::new(app.app_binary_path(), app.service_name.clone());
    match ota_coordinator.list_backups() {
        Ok(backups) if !backups.is_empty() => {
            println!("\nBackups (rollback targets):");
            for backup in &backups {
                println!("  {}", backup);
            }
        }
        _ => {
            println!("\nBackups: none");
        }
    }

    // Latest remote version
    let gcs = GcsService::with_bundle_names(
        String::new(),
        app.registry_url(),
        app.release_bundle_name.clone(),
        app.checksum_file_name.clone(),
    );
    match gcs.get_latest_version() {
        Ok(latest_version) => {
            let update_marker = if latest_version != current_version {
                " (update available)"
            } else {
                " (up to date)"
            };
            println!("\nRemote:");
            println!("  {}{}", latest_version, update_marker);
        }
        Err(e) => {
            println!("\nRemote: unavailable ({})", e);
        }
    }

    Ok(())
}

fn handle_version() -> Result<()> {
    let current_version = Config::format_version(&Config::get_current_version());
    println!("Current version: {}", current_version);
    Ok(())
}

fn handle_uninstall(app: &AppConfig) -> Result<()> {
    info!("Starting uninstallation process...");

    let app_binary_path = app.app_binary_path();

    // Initialize OTA coordinator
    let ota_coordinator = OtaCoordinator::new(app_binary_path, app.service_name.clone());

    // Perform uninstallation
    ota_coordinator.uninstall()?;

    info!("Uninstallation completed successfully!");
    Ok(())
}

fn handle_watch(interval_secs: u64, app: &AppConfig) -> Result<()> {
    info!(
        "Starting watchdog daemon (poll interval: {}s)",
        interval_secs
    );
    println!(
        "Watching for updates every {}s. Press Ctrl+C to stop.",
        interval_secs
    );

    let interval = std::time::Duration::from_secs(interval_secs);

    loop {
        let current_version = Config::format_version(&Config::get_current_version());

        // Check for new version
        let gcs = GcsService::with_bundle_names(
            String::new(),
            app.registry_url(),
            app.release_bundle_name.clone(),
            app.checksum_file_name.clone(),
        );

        match gcs.get_latest_version() {
            Ok(latest_version) => {
                if latest_version != current_version {
                    info!(
                        "New version available: {} -> {}",
                        current_version, latest_version
                    );
                    println!(
                        "[watch] Update available: {} -> {}. Applying...",
                        current_version, latest_version
                    );

                    let update_service = UpdateService::with_config(app);
                    match update_service.update(Some("latest".to_string())) {
                        Ok(()) => {
                            info!("Auto-update to {} succeeded", latest_version);
                            println!("[watch] Updated to {}", latest_version);
                        }
                        Err(e) => {
                            warn!("Auto-update failed: {}", e);
                            println!("[watch] Update failed: {}. Will retry next cycle.", e);
                        }
                    }
                } else {
                    info!("No update available (current: {})", current_version);
                }
            }
            Err(e) => {
                warn!("Failed to check for updates: {}", e);
            }
        }

        // Run health check if configured
        if let Some(ref url) = app.health_check_url {
            let total_timeout =
                std::time::Duration::from_secs(app.health_check_timeout_secs as u64);
            let per_retry_delay = total_timeout / app.health_check_retries.max(1);
            let health = crate::services::HealthCheckService::new(
                url.clone(),
                app.health_check_retries,
                per_retry_delay,
            );
            if let Err(e) = health.check_health() {
                warn!("Health check failed: {}", e);
                println!("[watch] Health check FAILED: {}", e);
            }
        }

        std::thread::sleep(interval);
    }
}