mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
#![allow(dead_code)]

//! Deployment service for remote robot deployment
//!
//! This service provides operations for deploying packages to target systems
//! via SSH, including backup, upload, service restart, and health checks.

mod types;

pub use types::{DeployConfig, DeployResult, DeployStrategy};

use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;

/// Deployment service for managing remote deployments
///
/// # Examples
///
/// ```rust,ignore
/// use mecha10_cli::services::{DeploymentService, DeployConfig, DeployStrategy};
/// use std::path::Path;
///
/// # async fn example() -> anyhow::Result<()> {
/// let service = DeploymentService::new();
///
/// // Configure deployment
/// let mut config = DeployConfig::default();
/// config.host = "192.168.1.100".to_string();
/// config.user = "robot".to_string();
///
/// // Test connection
/// service.test_connection(&config)?;
///
/// // Deploy package
/// let result = service.deploy(
///     &config,
///     Path::new("target/packages/my-robot-1.0.0.tar.gz"),
///     DeployStrategy::Direct
/// ).await?;
///
/// println!("Deployment: {}", result.message);
/// # Ok(())
/// # }
/// ```
pub struct DeploymentService;

impl DeploymentService {
    /// Create a new deployment service
    pub fn new() -> Self {
        Self
    }

    /// Test SSH connection to target
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    pub fn test_connection(&self, config: &DeployConfig) -> Result<()> {
        let mut cmd = Command::new("ssh");

        cmd.arg("-p").arg(config.port.to_string());

        if let Some(key_path) = &config.ssh_key {
            cmd.arg("-i").arg(key_path);
        }

        cmd.arg("-o")
            .arg("ConnectTimeout=5")
            .arg("-o")
            .arg("StrictHostKeyChecking=no")
            .arg(format!("{}@{}", config.user, config.host))
            .arg("echo 'Connection test successful'");

        let output = cmd.output().context("Failed to execute SSH command")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!(
                "SSH connection failed: {}. Please check:\n\
                 - Host is reachable: {}\n\
                 - Port is correct: {}\n\
                 - SSH key is valid: {:?}\n\
                 - User has access: {}",
                stderr,
                config.host,
                config.port,
                config.ssh_key,
                config.user
            ));
        }

        Ok(())
    }

    /// Create backup on remote system
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    pub fn create_backup(&self, config: &DeployConfig) -> Result<()> {
        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
        let backup_name = format!("backup_{}", timestamp);

        let remote_cmd = format!(
            "mkdir -p {} && [ -d {} ] && cp -r {} {}/{} || echo 'No previous installation to backup'",
            config.backup_dir, config.remote_dir, config.remote_dir, config.backup_dir, backup_name
        );

        self.run_remote_command(config, &remote_cmd)?;

        Ok(())
    }

    /// Upload package to remote system
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    /// * `package_path` - Local path to package file
    pub fn upload_package(&self, config: &DeployConfig, package_path: &Path) -> Result<()> {
        if !package_path.exists() {
            return Err(anyhow::anyhow!("Package not found: {}", package_path.display()));
        }

        // Create remote staging directory
        let remote_cmd = format!("mkdir -p {}/staging", config.remote_dir);
        self.run_remote_command(config, &remote_cmd)?;

        // Use rsync for efficient transfer
        let mut cmd = Command::new("rsync");

        cmd.arg("-avz")
            .arg("--progress")
            .arg("-e")
            .arg(format!("ssh -p {}", config.port));

        if let Some(key_path) = &config.ssh_key {
            cmd.arg("-e")
                .arg(format!("ssh -p {} -i {}", config.port, key_path.display()));
        }

        cmd.arg(package_path).arg(format!(
            "{}@{}:{}/staging/",
            config.user, config.host, config.remote_dir
        ));

        let status = cmd.status().context("Failed to execute rsync")?;

        if !status.success() {
            return Err(anyhow::anyhow!("Package upload failed"));
        }

        // Extract package on remote
        let package_name = package_path.file_name().unwrap().to_string_lossy().to_string();
        let extract_cmd = format!("cd {}/staging && tar -xzf {}", config.remote_dir, package_name);
        self.run_remote_command(config, &extract_cmd)?;

        Ok(())
    }

    /// Deploy package using specified strategy
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    /// * `package_path` - Local path to package file
    /// * `strategy` - Deployment strategy to use
    pub async fn deploy(
        &self,
        config: &DeployConfig,
        package_path: &Path,
        strategy: DeployStrategy,
    ) -> Result<DeployResult> {
        // Create backup
        let backup_created = self.create_backup(config).is_ok();

        // Upload package
        self.upload_package(config, package_path)?;

        // Deploy using strategy
        match strategy {
            DeployStrategy::Direct => self.deploy_direct(config).await?,
            DeployStrategy::Canary => self.deploy_canary(config).await?,
            DeployStrategy::Rolling => self.deploy_rolling(config).await?,
            DeployStrategy::BlueGreen => self.deploy_blue_green(config).await?,
        }

        // Health check
        let health_check_passed = self.health_check(config).await.unwrap_or(false);

        if !health_check_passed {
            // Rollback on failure
            self.rollback(config).await?;

            return Ok(DeployResult {
                success: false,
                message: "Deployment failed health check, rolled back".to_string(),
                health_check_passed: false,
                backup_created,
            });
        }

        Ok(DeployResult {
            success: true,
            message: format!("Deployment successful using {} strategy", strategy.as_str()),
            health_check_passed: true,
            backup_created,
        })
    }

    /// Direct deployment (stop -> deploy -> start)
    async fn deploy_direct(&self, config: &DeployConfig) -> Result<()> {
        // Stop service
        let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
        self.run_remote_command(config, &stop_cmd)?;

        // Copy new binaries
        let deploy_cmd = format!("cp -r {}/staging/* {}/", config.remote_dir, config.remote_dir);
        self.run_remote_command(config, &deploy_cmd)?;

        // Start service
        let start_cmd = format!("sudo systemctl start {}", config.service_name);
        self.run_remote_command(config, &start_cmd)?;

        Ok(())
    }

    /// Canary deployment (partial rollout)
    async fn deploy_canary(&self, config: &DeployConfig) -> Result<()> {
        // Simplified canary: deploy to service but don't restart all instances
        self.deploy_direct(config).await
    }

    /// Rolling deployment (gradual rollout)
    async fn deploy_rolling(&self, config: &DeployConfig) -> Result<()> {
        // Simplified rolling: same as direct for single-instance
        self.deploy_direct(config).await
    }

    /// Blue-green deployment (switch between two environments)
    async fn deploy_blue_green(&self, config: &DeployConfig) -> Result<()> {
        // Simplified blue-green: same as direct
        self.deploy_direct(config).await
    }

    /// Perform health check on deployed service
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    pub async fn health_check(&self, config: &DeployConfig) -> Result<bool> {
        for attempt in 1..=config.health_check_retries {
            let check_cmd = format!("systemctl is-active {}", config.service_name);

            match self.run_remote_command(config, &check_cmd) {
                Ok(output) if output.trim() == "active" => return Ok(true),
                _ => {
                    if attempt < config.health_check_retries {
                        tokio::time::sleep(tokio::time::Duration::from_secs(
                            config.health_check_timeout / config.health_check_retries as u64,
                        ))
                        .await;
                    }
                }
            }
        }

        Ok(false)
    }

    /// Rollback to previous backup
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    pub async fn rollback(&self, config: &DeployConfig) -> Result<()> {
        // Find latest backup
        let find_backup_cmd = format!("ls -t {}/backup_* | head -1", config.backup_dir);
        let latest_backup = self.run_remote_command(config, &find_backup_cmd)?.trim().to_string();

        if latest_backup.is_empty() {
            return Err(anyhow::anyhow!("No backup found for rollback"));
        }

        // Stop service
        let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
        self.run_remote_command(config, &stop_cmd)?;

        // Restore from backup
        let restore_cmd = format!(
            "rm -rf {} && cp -r {} {}",
            config.remote_dir, latest_backup, config.remote_dir
        );
        self.run_remote_command(config, &restore_cmd)?;

        // Start service
        let start_cmd = format!("sudo systemctl start {}", config.service_name);
        self.run_remote_command(config, &start_cmd)?;

        Ok(())
    }

    /// Run command on remote system via SSH
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    /// * `command` - Command to execute
    pub fn run_remote_command(&self, config: &DeployConfig, command: &str) -> Result<String> {
        let mut cmd = Command::new("ssh");

        cmd.arg("-p").arg(config.port.to_string());

        if let Some(key_path) = &config.ssh_key {
            cmd.arg("-i").arg(key_path);
        }

        cmd.arg("-o")
            .arg("StrictHostKeyChecking=no")
            .arg(format!("{}@{}", config.user, config.host))
            .arg(command);

        let output = cmd.output().context("Failed to execute SSH command")?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(anyhow::anyhow!("Remote command failed: {}", stderr));
        }

        Ok(String::from_utf8_lossy(&output.stdout).to_string())
    }

    /// Get service status on remote system
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    pub fn get_service_status(&self, config: &DeployConfig) -> Result<String> {
        let cmd = format!("systemctl status {}", config.service_name);
        self.run_remote_command(config, &cmd)
    }

    /// List available backups
    ///
    /// # Arguments
    ///
    /// * `config` - Deployment configuration
    pub fn list_backups(&self, config: &DeployConfig) -> Result<Vec<String>> {
        let cmd = format!("ls -t {}/backup_* 2>/dev/null || echo ''", config.backup_dir);
        let output = self.run_remote_command(config, &cmd)?;

        let backups: Vec<String> = output
            .lines()
            .filter(|line| !line.is_empty())
            .map(|line| line.to_string())
            .collect();

        Ok(backups)
    }
}

impl Default for DeploymentService {
    fn default() -> Self {
        Self::new()
    }
}