mecha10_cli/services/deployment/
mod.rs

1#![allow(dead_code)]
2
3//! Deployment service for remote robot deployment
4//!
5//! This service provides operations for deploying packages to target systems
6//! via SSH, including backup, upload, service restart, and health checks.
7
8mod types;
9
10pub use types::{DeployConfig, DeployResult, DeployStrategy};
11
12use anyhow::{Context, Result};
13use std::path::Path;
14use std::process::Command;
15
16/// Deployment service for managing remote deployments
17///
18/// # Examples
19///
20/// ```rust,ignore
21/// use mecha10_cli::services::{DeploymentService, DeployConfig, DeployStrategy};
22/// use std::path::Path;
23///
24/// # async fn example() -> anyhow::Result<()> {
25/// let service = DeploymentService::new();
26///
27/// // Configure deployment
28/// let mut config = DeployConfig::default();
29/// config.host = "192.168.1.100".to_string();
30/// config.user = "robot".to_string();
31///
32/// // Test connection
33/// service.test_connection(&config)?;
34///
35/// // Deploy package
36/// let result = service.deploy(
37///     &config,
38///     Path::new("target/packages/my-robot-1.0.0.tar.gz"),
39///     DeployStrategy::Direct
40/// ).await?;
41///
42/// println!("Deployment: {}", result.message);
43/// # Ok(())
44/// # }
45/// ```
46pub struct DeploymentService;
47
48impl DeploymentService {
49    /// Create a new deployment service
50    pub fn new() -> Self {
51        Self
52    }
53
54    /// Test SSH connection to target
55    ///
56    /// # Arguments
57    ///
58    /// * `config` - Deployment configuration
59    pub fn test_connection(&self, config: &DeployConfig) -> Result<()> {
60        let mut cmd = Command::new("ssh");
61
62        cmd.arg("-p").arg(config.port.to_string());
63
64        if let Some(key_path) = &config.ssh_key {
65            cmd.arg("-i").arg(key_path);
66        }
67
68        cmd.arg("-o")
69            .arg("ConnectTimeout=5")
70            .arg("-o")
71            .arg("StrictHostKeyChecking=no")
72            .arg(format!("{}@{}", config.user, config.host))
73            .arg("echo 'Connection test successful'");
74
75        let output = cmd.output().context("Failed to execute SSH command")?;
76
77        if !output.status.success() {
78            let stderr = String::from_utf8_lossy(&output.stderr);
79            return Err(anyhow::anyhow!(
80                "SSH connection failed: {}. Please check:\n\
81                 - Host is reachable: {}\n\
82                 - Port is correct: {}\n\
83                 - SSH key is valid: {:?}\n\
84                 - User has access: {}",
85                stderr,
86                config.host,
87                config.port,
88                config.ssh_key,
89                config.user
90            ));
91        }
92
93        Ok(())
94    }
95
96    /// Create backup on remote system
97    ///
98    /// # Arguments
99    ///
100    /// * `config` - Deployment configuration
101    pub fn create_backup(&self, config: &DeployConfig) -> Result<()> {
102        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
103        let backup_name = format!("backup_{}", timestamp);
104
105        let remote_cmd = format!(
106            "mkdir -p {} && [ -d {} ] && cp -r {} {}/{} || echo 'No previous installation to backup'",
107            config.backup_dir, config.remote_dir, config.remote_dir, config.backup_dir, backup_name
108        );
109
110        self.run_remote_command(config, &remote_cmd)?;
111
112        Ok(())
113    }
114
115    /// Upload package to remote system
116    ///
117    /// # Arguments
118    ///
119    /// * `config` - Deployment configuration
120    /// * `package_path` - Local path to package file
121    pub fn upload_package(&self, config: &DeployConfig, package_path: &Path) -> Result<()> {
122        if !package_path.exists() {
123            return Err(anyhow::anyhow!("Package not found: {}", package_path.display()));
124        }
125
126        // Create remote staging directory
127        let remote_cmd = format!("mkdir -p {}/staging", config.remote_dir);
128        self.run_remote_command(config, &remote_cmd)?;
129
130        // Use rsync for efficient transfer
131        let mut cmd = Command::new("rsync");
132
133        cmd.arg("-avz")
134            .arg("--progress")
135            .arg("-e")
136            .arg(format!("ssh -p {}", config.port));
137
138        if let Some(key_path) = &config.ssh_key {
139            cmd.arg("-e")
140                .arg(format!("ssh -p {} -i {}", config.port, key_path.display()));
141        }
142
143        cmd.arg(package_path).arg(format!(
144            "{}@{}:{}/staging/",
145            config.user, config.host, config.remote_dir
146        ));
147
148        let status = cmd.status().context("Failed to execute rsync")?;
149
150        if !status.success() {
151            return Err(anyhow::anyhow!("Package upload failed"));
152        }
153
154        // Extract package on remote
155        let package_name = package_path.file_name().unwrap().to_string_lossy().to_string();
156        let extract_cmd = format!("cd {}/staging && tar -xzf {}", config.remote_dir, package_name);
157        self.run_remote_command(config, &extract_cmd)?;
158
159        Ok(())
160    }
161
162    /// Deploy package using specified strategy
163    ///
164    /// # Arguments
165    ///
166    /// * `config` - Deployment configuration
167    /// * `package_path` - Local path to package file
168    /// * `strategy` - Deployment strategy to use
169    pub async fn deploy(
170        &self,
171        config: &DeployConfig,
172        package_path: &Path,
173        strategy: DeployStrategy,
174    ) -> Result<DeployResult> {
175        // Create backup
176        let backup_created = self.create_backup(config).is_ok();
177
178        // Upload package
179        self.upload_package(config, package_path)?;
180
181        // Deploy using strategy
182        match strategy {
183            DeployStrategy::Direct => self.deploy_direct(config).await?,
184            DeployStrategy::Canary => self.deploy_canary(config).await?,
185            DeployStrategy::Rolling => self.deploy_rolling(config).await?,
186            DeployStrategy::BlueGreen => self.deploy_blue_green(config).await?,
187        }
188
189        // Health check
190        let health_check_passed = self.health_check(config).await.unwrap_or(false);
191
192        if !health_check_passed {
193            // Rollback on failure
194            self.rollback(config).await?;
195
196            return Ok(DeployResult {
197                success: false,
198                message: "Deployment failed health check, rolled back".to_string(),
199                health_check_passed: false,
200                backup_created,
201            });
202        }
203
204        Ok(DeployResult {
205            success: true,
206            message: format!("Deployment successful using {} strategy", strategy.as_str()),
207            health_check_passed: true,
208            backup_created,
209        })
210    }
211
212    /// Direct deployment (stop -> deploy -> start)
213    async fn deploy_direct(&self, config: &DeployConfig) -> Result<()> {
214        // Stop service
215        let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
216        self.run_remote_command(config, &stop_cmd)?;
217
218        // Copy new binaries
219        let deploy_cmd = format!("cp -r {}/staging/* {}/", config.remote_dir, config.remote_dir);
220        self.run_remote_command(config, &deploy_cmd)?;
221
222        // Start service
223        let start_cmd = format!("sudo systemctl start {}", config.service_name);
224        self.run_remote_command(config, &start_cmd)?;
225
226        Ok(())
227    }
228
229    /// Canary deployment (partial rollout)
230    async fn deploy_canary(&self, config: &DeployConfig) -> Result<()> {
231        // Simplified canary: deploy to service but don't restart all instances
232        self.deploy_direct(config).await
233    }
234
235    /// Rolling deployment (gradual rollout)
236    async fn deploy_rolling(&self, config: &DeployConfig) -> Result<()> {
237        // Simplified rolling: same as direct for single-instance
238        self.deploy_direct(config).await
239    }
240
241    /// Blue-green deployment (switch between two environments)
242    async fn deploy_blue_green(&self, config: &DeployConfig) -> Result<()> {
243        // Simplified blue-green: same as direct
244        self.deploy_direct(config).await
245    }
246
247    /// Perform health check on deployed service
248    ///
249    /// # Arguments
250    ///
251    /// * `config` - Deployment configuration
252    pub async fn health_check(&self, config: &DeployConfig) -> Result<bool> {
253        for attempt in 1..=config.health_check_retries {
254            let check_cmd = format!("systemctl is-active {}", config.service_name);
255
256            match self.run_remote_command(config, &check_cmd) {
257                Ok(output) if output.trim() == "active" => return Ok(true),
258                _ => {
259                    if attempt < config.health_check_retries {
260                        tokio::time::sleep(tokio::time::Duration::from_secs(
261                            config.health_check_timeout / config.health_check_retries as u64,
262                        ))
263                        .await;
264                    }
265                }
266            }
267        }
268
269        Ok(false)
270    }
271
272    /// Rollback to previous backup
273    ///
274    /// # Arguments
275    ///
276    /// * `config` - Deployment configuration
277    pub async fn rollback(&self, config: &DeployConfig) -> Result<()> {
278        // Find latest backup
279        let find_backup_cmd = format!("ls -t {}/backup_* | head -1", config.backup_dir);
280        let latest_backup = self.run_remote_command(config, &find_backup_cmd)?.trim().to_string();
281
282        if latest_backup.is_empty() {
283            return Err(anyhow::anyhow!("No backup found for rollback"));
284        }
285
286        // Stop service
287        let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
288        self.run_remote_command(config, &stop_cmd)?;
289
290        // Restore from backup
291        let restore_cmd = format!(
292            "rm -rf {} && cp -r {} {}",
293            config.remote_dir, latest_backup, config.remote_dir
294        );
295        self.run_remote_command(config, &restore_cmd)?;
296
297        // Start service
298        let start_cmd = format!("sudo systemctl start {}", config.service_name);
299        self.run_remote_command(config, &start_cmd)?;
300
301        Ok(())
302    }
303
304    /// Run command on remote system via SSH
305    ///
306    /// # Arguments
307    ///
308    /// * `config` - Deployment configuration
309    /// * `command` - Command to execute
310    pub fn run_remote_command(&self, config: &DeployConfig, command: &str) -> Result<String> {
311        let mut cmd = Command::new("ssh");
312
313        cmd.arg("-p").arg(config.port.to_string());
314
315        if let Some(key_path) = &config.ssh_key {
316            cmd.arg("-i").arg(key_path);
317        }
318
319        cmd.arg("-o")
320            .arg("StrictHostKeyChecking=no")
321            .arg(format!("{}@{}", config.user, config.host))
322            .arg(command);
323
324        let output = cmd.output().context("Failed to execute SSH command")?;
325
326        if !output.status.success() {
327            let stderr = String::from_utf8_lossy(&output.stderr);
328            return Err(anyhow::anyhow!("Remote command failed: {}", stderr));
329        }
330
331        Ok(String::from_utf8_lossy(&output.stdout).to_string())
332    }
333
334    /// Get service status on remote system
335    ///
336    /// # Arguments
337    ///
338    /// * `config` - Deployment configuration
339    pub fn get_service_status(&self, config: &DeployConfig) -> Result<String> {
340        let cmd = format!("systemctl status {}", config.service_name);
341        self.run_remote_command(config, &cmd)
342    }
343
344    /// List available backups
345    ///
346    /// # Arguments
347    ///
348    /// * `config` - Deployment configuration
349    pub fn list_backups(&self, config: &DeployConfig) -> Result<Vec<String>> {
350        let cmd = format!("ls -t {}/backup_* 2>/dev/null || echo ''", config.backup_dir);
351        let output = self.run_remote_command(config, &cmd)?;
352
353        let backups: Vec<String> = output
354            .lines()
355            .filter(|line| !line.is_empty())
356            .map(|line| line.to_string())
357            .collect();
358
359        Ok(backups)
360    }
361}
362
363impl Default for DeploymentService {
364    fn default() -> Self {
365        Self::new()
366    }
367}