Skip to main content

mecha10_deploy/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        let remote_cmd = format!("mkdir -p {}/staging", config.remote_dir);
127        self.run_remote_command(config, &remote_cmd)?;
128
129        let mut cmd = Command::new("rsync");
130
131        cmd.arg("-avz")
132            .arg("--progress")
133            .arg("-e")
134            .arg(format!("ssh -p {}", config.port));
135
136        if let Some(key_path) = &config.ssh_key {
137            cmd.arg("-e")
138                .arg(format!("ssh -p {} -i {}", config.port, key_path.display()));
139        }
140
141        cmd.arg(package_path).arg(format!(
142            "{}@{}:{}/staging/",
143            config.user, config.host, config.remote_dir
144        ));
145
146        let status = cmd.status().context("Failed to execute rsync")?;
147
148        if !status.success() {
149            return Err(anyhow::anyhow!("Package upload failed"));
150        }
151
152        let package_name = package_path.file_name().unwrap().to_string_lossy().to_string();
153        let extract_cmd = format!("cd {}/staging && tar -xzf {}", config.remote_dir, package_name);
154        self.run_remote_command(config, &extract_cmd)?;
155
156        Ok(())
157    }
158
159    /// Deploy package using specified strategy
160    ///
161    /// # Arguments
162    ///
163    /// * `config` - Deployment configuration
164    /// * `package_path` - Local path to package file
165    /// * `strategy` - Deployment strategy to use
166    pub async fn deploy(
167        &self,
168        config: &DeployConfig,
169        package_path: &Path,
170        strategy: DeployStrategy,
171    ) -> Result<DeployResult> {
172        let backup_created = self.create_backup(config).is_ok();
173
174        self.upload_package(config, package_path)?;
175
176        match strategy {
177            DeployStrategy::Direct => self.deploy_direct(config).await?,
178            DeployStrategy::Rolling => self.deploy_rolling(config).await?,
179            DeployStrategy::BlueGreen => self.deploy_blue_green(config).await?,
180        }
181
182        let health_check_passed = self.health_check(config).await.unwrap_or(false);
183
184        if !health_check_passed {
185            self.rollback(config).await?;
186
187            return Ok(DeployResult {
188                success: false,
189                message: "Deployment failed health check, rolled back".to_string(),
190                health_check_passed: false,
191                backup_created,
192            });
193        }
194
195        Ok(DeployResult {
196            success: true,
197            message: format!("Deployment successful using {} strategy", strategy.as_str()),
198            health_check_passed: true,
199            backup_created,
200        })
201    }
202
203    /// Direct deployment (stop -> deploy -> start)
204    async fn deploy_direct(&self, config: &DeployConfig) -> Result<()> {
205        let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
206        self.run_remote_command(config, &stop_cmd)?;
207
208        let deploy_cmd = format!("cp -r {}/staging/* {}/", config.remote_dir, config.remote_dir);
209        self.run_remote_command(config, &deploy_cmd)?;
210
211        let start_cmd = format!("sudo systemctl start {}", config.service_name);
212        self.run_remote_command(config, &start_cmd)?;
213
214        Ok(())
215    }
216
217    /// Rolling deployment (gradual rollout)
218    async fn deploy_rolling(&self, config: &DeployConfig) -> Result<()> {
219        self.deploy_direct(config).await
220    }
221
222    /// Blue-green deployment (switch between two environments)
223    async fn deploy_blue_green(&self, config: &DeployConfig) -> Result<()> {
224        self.deploy_direct(config).await
225    }
226
227    /// Perform health check on deployed service
228    ///
229    /// # Arguments
230    ///
231    /// * `config` - Deployment configuration
232    pub async fn health_check(&self, config: &DeployConfig) -> Result<bool> {
233        for attempt in 1..=config.health_check_retries {
234            let check_cmd = format!("systemctl is-active {}", config.service_name);
235
236            match self.run_remote_command(config, &check_cmd) {
237                Ok(output) if output.trim() == "active" => return Ok(true),
238                _ => {
239                    if attempt < config.health_check_retries {
240                        tokio::time::sleep(tokio::time::Duration::from_secs(
241                            config.health_check_timeout / config.health_check_retries as u64,
242                        ))
243                        .await;
244                    }
245                }
246            }
247        }
248
249        Ok(false)
250    }
251
252    /// Rollback to previous backup
253    ///
254    /// # Arguments
255    ///
256    /// * `config` - Deployment configuration
257    pub async fn rollback(&self, config: &DeployConfig) -> Result<()> {
258        let find_backup_cmd = format!("ls -t {}/backup_* | head -1", config.backup_dir);
259        let latest_backup = self.run_remote_command(config, &find_backup_cmd)?.trim().to_string();
260
261        if latest_backup.is_empty() {
262            return Err(anyhow::anyhow!("No backup found for rollback"));
263        }
264
265        let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
266        self.run_remote_command(config, &stop_cmd)?;
267
268        let restore_cmd = format!(
269            "rm -rf {} && cp -r {} {}",
270            config.remote_dir, latest_backup, config.remote_dir
271        );
272        self.run_remote_command(config, &restore_cmd)?;
273
274        let start_cmd = format!("sudo systemctl start {}", config.service_name);
275        self.run_remote_command(config, &start_cmd)?;
276
277        Ok(())
278    }
279
280    /// Run command on remote system via SSH
281    ///
282    /// # Arguments
283    ///
284    /// * `config` - Deployment configuration
285    /// * `command` - Command to execute
286    pub fn run_remote_command(&self, config: &DeployConfig, command: &str) -> Result<String> {
287        let mut cmd = Command::new("ssh");
288
289        cmd.arg("-p").arg(config.port.to_string());
290
291        if let Some(key_path) = &config.ssh_key {
292            cmd.arg("-i").arg(key_path);
293        }
294
295        cmd.arg("-o")
296            .arg("StrictHostKeyChecking=no")
297            .arg(format!("{}@{}", config.user, config.host))
298            .arg(command);
299
300        let output = cmd.output().context("Failed to execute SSH command")?;
301
302        if !output.status.success() {
303            let stderr = String::from_utf8_lossy(&output.stderr);
304            return Err(anyhow::anyhow!("Remote command failed: {}", stderr));
305        }
306
307        Ok(String::from_utf8_lossy(&output.stdout).to_string())
308    }
309
310    /// Get service status on remote system
311    ///
312    /// # Arguments
313    ///
314    /// * `config` - Deployment configuration
315    pub fn get_service_status(&self, config: &DeployConfig) -> Result<String> {
316        let cmd = format!("systemctl status {}", config.service_name);
317        self.run_remote_command(config, &cmd)
318    }
319
320    /// List available backups
321    ///
322    /// # Arguments
323    ///
324    /// * `config` - Deployment configuration
325    pub fn list_backups(&self, config: &DeployConfig) -> Result<Vec<String>> {
326        let cmd = format!("ls -t {}/backup_* 2>/dev/null || echo ''", config.backup_dir);
327        let output = self.run_remote_command(config, &cmd)?;
328
329        let backups: Vec<String> = output
330            .lines()
331            .filter(|line| !line.is_empty())
332            .map(|line| line.to_string())
333            .collect();
334
335        Ok(backups)
336    }
337}
338
339impl Default for DeploymentService {
340    fn default() -> Self {
341        Self::new()
342    }
343}