mecha10_cli/services/deployment/
mod.rs1#![allow(dead_code)]
2
3mod 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;
47
48impl DeploymentService {
49 pub fn new() -> Self {
51 Self
52 }
53
54 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 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 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);
128 self.run_remote_command(config, &remote_cmd)?;
129
130 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 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 pub async fn deploy(
170 &self,
171 config: &DeployConfig,
172 package_path: &Path,
173 strategy: DeployStrategy,
174 ) -> Result<DeployResult> {
175 let backup_created = self.create_backup(config).is_ok();
177
178 self.upload_package(config, package_path)?;
180
181 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 let health_check_passed = self.health_check(config).await.unwrap_or(false);
191
192 if !health_check_passed {
193 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 async fn deploy_direct(&self, config: &DeployConfig) -> Result<()> {
214 let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
216 self.run_remote_command(config, &stop_cmd)?;
217
218 let deploy_cmd = format!("cp -r {}/staging/* {}/", config.remote_dir, config.remote_dir);
220 self.run_remote_command(config, &deploy_cmd)?;
221
222 let start_cmd = format!("sudo systemctl start {}", config.service_name);
224 self.run_remote_command(config, &start_cmd)?;
225
226 Ok(())
227 }
228
229 async fn deploy_canary(&self, config: &DeployConfig) -> Result<()> {
231 self.deploy_direct(config).await
233 }
234
235 async fn deploy_rolling(&self, config: &DeployConfig) -> Result<()> {
237 self.deploy_direct(config).await
239 }
240
241 async fn deploy_blue_green(&self, config: &DeployConfig) -> Result<()> {
243 self.deploy_direct(config).await
245 }
246
247 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 pub async fn rollback(&self, config: &DeployConfig) -> Result<()> {
278 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 let stop_cmd = format!("sudo systemctl stop {}", config.service_name);
288 self.run_remote_command(config, &stop_cmd)?;
289
290 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 let start_cmd = format!("sudo systemctl start {}", config.service_name);
299 self.run_remote_command(config, &start_cmd)?;
300
301 Ok(())
302 }
303
304 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 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 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}