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