mecha10_deploy/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);
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 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 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 async fn deploy_rolling(&self, config: &DeployConfig) -> Result<()> {
219 self.deploy_direct(config).await
220 }
221
222 async fn deploy_blue_green(&self, config: &DeployConfig) -> Result<()> {
224 self.deploy_direct(config).await
225 }
226
227 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 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 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 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 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}