Skip to main content

xbp_cli/strategies/
deployment_executor.rs

1//! Deployment execution module
2//!
3//! This module handles the actual deployment process including port management,
4//! git operations, building, and process management.
5
6use super::deployment_config::DeploymentConfig;
7use super::project_detector::{ProjectDetector, ProjectType};
8use std::time::Instant;
9use tokio::process::Command;
10use tracing::{debug, info};
11
12pub struct DeploymentExecutor {
13    config: DeploymentConfig,
14    debug: bool,
15}
16
17impl DeploymentExecutor {
18    pub fn new(config: DeploymentConfig, debug: bool) -> Self {
19        Self { config, debug }
20    }
21
22    /// Execute the full deployment process
23    pub async fn deploy(&mut self) -> Result<(), String> {
24        let start_time: Instant = Instant::now();
25
26        self.detect_and_configure_project().await?;
27        self.ensure_port_available().await?;
28        self.validate_project_directory().await?;
29        self.git_reset_and_pull().await?;
30        if let Some(install_cmd) = &self.config.install_command.clone() {
31            self.run_install_command(install_cmd).await?;
32        }
33        if let Some(build_cmd) = &self.config.build_command.clone() {
34            self.run_build_command(build_cmd).await?;
35        }
36        self.stop_existing_processes().await?;
37        self.start_application().await?;
38        self.save_pm2_config().await?;
39        self.config.save_xbp_config(None).await?;
40
41        let elapsed: std::time::Duration = start_time.elapsed();
42        info!(
43            "Successfully deployed {} on port {} in {:.2?}",
44            self.config.app_name, self.config.port, elapsed
45        );
46
47        Ok(())
48    }
49
50    /// Detect project type and configure deployment accordingly
51    async fn detect_and_configure_project(&mut self) -> Result<(), String> {
52        if self.debug {
53            debug!(
54                "Detecting project type in: {}",
55                self.config.app_dir.display()
56            );
57        }
58
59        let project_type = ProjectDetector::detect_project_type(&self.config.app_dir).await?;
60
61        match &project_type {
62            ProjectType::Docker { .. } => {
63                info!(" Detected Docker project (Dockerfile found)");
64            }
65            ProjectType::DockerCompose {
66                compose_files,
67                detected_ports,
68            } => {
69                info!(
70                    " Detected Docker Compose project: {}",
71                    compose_files.join(", ")
72                );
73                if !detected_ports.is_empty() {
74                    info!("     Detected ports: {:?}", detected_ports);
75                }
76            }
77            ProjectType::Railway {
78                has_railway_json,
79                has_railway_toml,
80            } => {
81                let mut parts = Vec::new();
82                if *has_railway_json {
83                    parts.push("railway.json");
84                }
85                if *has_railway_toml {
86                    parts.push("railway.toml");
87                }
88                info!(" Detected Railway manifest: {}", parts.join(", "));
89            }
90            ProjectType::Python {
91                has_requirements_txt,
92                has_pyproject_toml,
93            } => {
94                let mut parts = Vec::new();
95                if *has_requirements_txt {
96                    parts.push("requirements.txt");
97                }
98                if *has_pyproject_toml {
99                    parts.push("pyproject.toml");
100                }
101                info!(" Detected Python project: {}", parts.join(", "));
102            }
103            ProjectType::OpenApi { spec_files } => {
104                info!(" Detected OpenAPI spec: {}", spec_files.join(", "));
105            }
106            ProjectType::Terraform { tf_file_count } => {
107                info!(" Detected Terraform files: {}", tf_file_count);
108            }
109            ProjectType::NextJs {
110                package_json,
111                has_next_config,
112            } => {
113                info!(
114                    " Detected Next.js project: {} v{}",
115                    package_json.name, package_json.version
116                );
117                if *has_next_config {
118                    info!("     Next.js configuration found");
119                }
120            }
121            ProjectType::NodeJs { package_json } => {
122                info!(
123                    " Detected Node.js project: {} v{}",
124                    package_json.name, package_json.version
125                );
126            }
127            ProjectType::Rust { cargo_toml } => {
128                info!(
129                    " Detected Rust project: {} v{}",
130                    cargo_toml.name, cargo_toml.version
131                );
132            }
133            ProjectType::Unknown => {
134                info!("  Unknown project type, using default configuration");
135            }
136        }
137
138        let recommendations =
139            ProjectDetector::get_deployment_recommendations(&self.config.app_dir, &project_type);
140        self.config.merge_with_recommendations(&recommendations);
141
142        if self.debug {
143            debug!("Build command: {:?}", self.config.build_command);
144            debug!("Start command: {:?}", self.config.start_command);
145            debug!("Install command: {:?}", self.config.install_command);
146        }
147
148        Ok(())
149    }
150
151    /// Ensure the target port is available, kill existing processes or find new port
152    async fn ensure_port_available(&mut self) -> Result<(), String> {
153        info!(" Checking port {} availability...", self.config.port);
154
155        let port_check = Command::new("sh")
156            .arg("-c")
157            .arg(format!("sudo fuser {}/tcp", self.config.port))
158            .output()
159            .await;
160
161        match port_check {
162            Ok(output) if output.status.success() => {
163                info!(
164                    "  Port {} is in use, attempting to kill process...",
165                    self.config.port
166                );
167
168                let kill_output = Command::new("sh")
169                    .arg("-c")
170                    .arg(format!("sudo fuser -k {}/tcp", self.config.port))
171                    .output()
172                    .await
173                    .map_err(|e| {
174                        format!("Failed to kill process on port {}: {}", self.config.port, e)
175                    })?;
176
177                if kill_output.status.success() {
178                    info!(" Successfully killed process on port {}", self.config.port);
179                } else {
180                    info!("  Failed to kill process, searching for available port...");
181                    let new_port = self.find_available_port().await?;
182                    self.config.update_port(new_port);
183                    info!(" Found available port: {}", new_port);
184                }
185            }
186            _ => {
187                info!(" Port {} is available", self.config.port);
188            }
189        }
190
191        Ok(())
192    }
193
194    /// Find an available port in the range 1025-65535
195    async fn find_available_port(&self) -> Result<u16, String> {
196        for port in 1025..=65535 {
197            let check_output = Command::new("sh")
198                .arg("-c")
199                .arg(format!("sudo fuser {}/tcp", port))
200                .output()
201                .await;
202
203            if let Ok(output) = check_output {
204                if !output.status.success() {
205                    return Ok(port);
206                }
207            }
208        }
209
210        Err("No available ports found in range 1025-65535".to_string())
211    }
212
213    /// Validate that the project directory exists and is accessible
214    async fn validate_project_directory(&self) -> Result<(), String> {
215        if !self.config.app_dir.exists() {
216            return Err(format!(
217                "Project directory does not exist: {}",
218                self.config.app_dir.display()
219            ));
220        }
221
222        if !self.config.app_dir.is_dir() {
223            return Err(format!(
224                "Project path is not a directory: {}",
225                self.config.app_dir.display()
226            ));
227        }
228
229        info!(" Project directory: {}", self.config.app_dir.display());
230        Ok(())
231    }
232
233    /// Reset git repository and pull latest changes
234    async fn git_reset_and_pull(&self) -> Result<(), String> {
235        info!(" Resetting git repository...");
236
237        let reset_output = Command::new("git")
238            .arg("reset")
239            .arg("--hard")
240            .current_dir(&self.config.app_dir)
241            .output()
242            .await
243            .map_err(|e| format!("Failed to execute git reset: {}", e))?;
244
245        if !reset_output.status.success() {
246            return Err(format!(
247                "Git reset failed: {}",
248                String::from_utf8_lossy(&reset_output.stderr)
249            ));
250        }
251
252        info!("  Pulling latest changes...");
253
254        let pull_output = Command::new("git")
255            .arg("pull")
256            .arg("origin")
257            .arg("main")
258            .current_dir(&self.config.app_dir)
259            .output()
260            .await
261            .map_err(|e| format!("Failed to execute git pull: {}", e))?;
262
263        if !pull_output.status.success() {
264            return Err(format!(
265                "Git pull failed: {}",
266                String::from_utf8_lossy(&pull_output.stderr)
267            ));
268        }
269
270        info!(" Git operations completed");
271        Ok(())
272    }
273
274    /// Run the install command (e.g., pnpm install, cargo fetch)
275    async fn run_install_command(&self, install_cmd: &str) -> Result<(), String> {
276        info!(" Installing dependencies: {}", install_cmd);
277
278        let install_output = Command::new("sh")
279            .arg("-c")
280            .arg(install_cmd)
281            .envs(&self.config.environment)
282            .current_dir(&self.config.app_dir)
283            .output()
284            .await
285            .map_err(|e| format!("Failed to execute install command: {}", e))?;
286
287        if !install_output.status.success() {
288            return Err(format!(
289                "Install command failed: {}",
290                String::from_utf8_lossy(&install_output.stderr)
291            ));
292        }
293
294        info!(" Dependencies installed successfully");
295        Ok(())
296    }
297
298    /// Run the build command
299    async fn run_build_command(&self, build_cmd: &str) -> Result<(), String> {
300        info!(" Building project: {}", build_cmd);
301
302        let build_output = Command::new("sh")
303            .arg("-c")
304            .arg(build_cmd)
305            .envs(&self.config.environment)
306            .current_dir(&self.config.app_dir)
307            .output()
308            .await
309            .map_err(|e| format!("Failed to execute build command: {}", e))?;
310
311        if !build_output.status.success() {
312            return Err(format!(
313                "Build command failed: {}",
314                String::from_utf8_lossy(&build_output.stderr)
315            ));
316        }
317
318        info!("Project built successfully");
319        Ok(())
320    }
321
322    /// Stop existing PM2 processes and kill processes on the target port
323    async fn stop_existing_processes(&self) -> Result<(), String> {
324        info!("Stopping existing processes...");
325
326        let pm2_stop = Command::new("pm2")
327            .arg("stop")
328            .arg(&self.config.app_name)
329            .output()
330            .await;
331
332        match pm2_stop {
333            Ok(output) if output.status.success() => {
334                info!("  Stopped PM2 process: {}", self.config.app_name);
335            }
336            _ => {
337                info!(" No existing PM2 process found");
338            }
339        }
340
341        let kill_port = Command::new("sh")
342            .arg("-c")
343            .arg(format!("sudo fuser -k {}/tcp", self.config.port))
344            .output()
345            .await;
346
347        match kill_port {
348            Ok(output) if output.status.success() => {
349                info!("  Killed processes on port {}", self.config.port);
350            }
351            _ => {
352                info!(" No processes found on port {}", self.config.port);
353            }
354        }
355
356        Ok(())
357    }
358
359    /// Start the application using PM2
360    async fn start_application(&self) -> Result<(), String> {
361        let start_cmd = self
362            .config
363            .start_command
364            .as_ref()
365            .ok_or("No start command configured")?;
366
367        info!(" Starting application: {}", start_cmd);
368
369        // Construct PM2 start command with port
370        let pm2_cmd = format!("{} --port {}", start_cmd, self.config.port);
371
372        let start_output = Command::new("pm2")
373            .arg("start")
374            .arg(&pm2_cmd)
375            .arg("--name")
376            .arg(&self.config.app_name)
377            .arg("--")
378            .arg("--port")
379            .arg(self.config.port.to_string())
380            .envs(&self.config.environment)
381            .current_dir(&self.config.app_dir)
382            .output()
383            .await
384            .map_err(|e| format!("Failed to start PM2 process: {}", e))?;
385
386        if !start_output.status.success() {
387            return Err(format!(
388                "Failed to start application: {}",
389                String::from_utf8_lossy(&start_output.stderr)
390            ));
391        }
392
393        info!("Application started successfully");
394        Ok(())
395    }
396
397    /// Save PM2 process list
398    async fn save_pm2_config(&self) -> Result<(), String> {
399        info!("Saving PM2 configuration...");
400
401        let save_output = Command::new("pm2")
402            .arg("save")
403            .output()
404            .await
405            .map_err(|e| format!("Failed to save PM2 config: {}", e))?;
406
407        if !save_output.status.success() {
408            return Err(format!(
409                "PM2 save failed: {}",
410                String::from_utf8_lossy(&save_output.stderr)
411            ));
412        }
413
414        info!("PM2 configuration saved");
415        Ok(())
416    }
417}