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    detected_project_type: Option<ProjectType>,
16}
17
18impl DeploymentExecutor {
19    pub fn new(config: DeploymentConfig, debug: bool) -> Self {
20        Self {
21            config,
22            debug,
23            detected_project_type: None,
24        }
25    }
26
27    /// Execute the full deployment process
28    pub async fn deploy(&mut self) -> Result<(), String> {
29        let start_time: Instant = Instant::now();
30
31        self.validate_project_directory().await?;
32        self.detect_and_configure_project().await?;
33        if self.is_provider_managed_deploy() {
34            self.deploy_provider_managed_project().await?;
35            self.config.save_xbp_config(None).await?;
36
37            let elapsed: std::time::Duration = start_time.elapsed();
38            info!(
39                "Successfully deployed {} via provider CLI in {:.2?}",
40                self.config.app_name, elapsed
41            );
42            return Ok(());
43        }
44
45        self.ensure_port_available().await?;
46        self.git_reset_and_pull().await?;
47        if let Some(install_cmd) = &self.config.install_command.clone() {
48            self.run_install_command(install_cmd).await?;
49        }
50        if let Some(build_cmd) = &self.config.build_command.clone() {
51            self.run_build_command(build_cmd).await?;
52        }
53        self.stop_existing_processes().await?;
54        self.start_application().await?;
55        self.save_pm2_config().await?;
56        self.config.save_xbp_config(None).await?;
57
58        let elapsed: std::time::Duration = start_time.elapsed();
59        info!(
60            "Successfully deployed {} on port {} in {:.2?}",
61            self.config.app_name, self.config.port, elapsed
62        );
63
64        Ok(())
65    }
66
67    /// Detect project type and configure deployment accordingly
68    async fn detect_and_configure_project(&mut self) -> Result<(), String> {
69        if self.debug {
70            debug!(
71                "Detecting project type in: {}",
72                self.config.app_dir.display()
73            );
74        }
75
76        let project_type = ProjectDetector::detect_project_type(&self.config.app_dir).await?;
77        self.detected_project_type = Some(project_type.clone());
78
79        match &project_type {
80            ProjectType::Docker { .. } => {
81                info!(" Detected Docker project (Dockerfile found)");
82            }
83            ProjectType::DockerCompose {
84                compose_files,
85                detected_ports,
86            } => {
87                info!(
88                    " Detected Docker Compose project: {}",
89                    compose_files.join(", ")
90                );
91                if !detected_ports.is_empty() {
92                    info!("     Detected ports: {:?}", detected_ports);
93                }
94            }
95            ProjectType::Railway {
96                has_railway_json,
97                has_railway_toml,
98            } => {
99                let mut parts = Vec::new();
100                if *has_railway_json {
101                    parts.push("railway.json");
102                }
103                if *has_railway_toml {
104                    parts.push("railway.toml");
105                }
106                info!(" Detected Railway manifest: {}", parts.join(", "));
107            }
108            ProjectType::Vercel {
109                has_vercel_json,
110                has_project_link,
111            } => {
112                let mut parts = Vec::new();
113                if *has_vercel_json {
114                    parts.push("vercel.json");
115                }
116                if *has_project_link {
117                    parts.push(".vercel/project.json");
118                }
119                info!(" Detected Vercel manifest: {}", parts.join(", "));
120            }
121            ProjectType::Python {
122                has_requirements_txt,
123                has_pyproject_toml,
124            } => {
125                let mut parts = Vec::new();
126                if *has_requirements_txt {
127                    parts.push("requirements.txt");
128                }
129                if *has_pyproject_toml {
130                    parts.push("pyproject.toml");
131                }
132                info!(" Detected Python project: {}", parts.join(", "));
133            }
134            ProjectType::OpenApi { spec_files } => {
135                info!(" Detected OpenAPI spec: {}", spec_files.join(", "));
136            }
137            ProjectType::Terraform { tf_file_count } => {
138                info!(" Detected Terraform files: {}", tf_file_count);
139            }
140            ProjectType::NextJs {
141                package_json,
142                has_next_config,
143            } => {
144                info!(
145                    " Detected Next.js project: {} v{}",
146                    package_json.name, package_json.version
147                );
148                if *has_next_config {
149                    info!("     Next.js configuration found");
150                }
151            }
152            ProjectType::NodeJs { package_json } => {
153                info!(
154                    " Detected Node.js project: {} v{}",
155                    package_json.name, package_json.version
156                );
157            }
158            ProjectType::Rust { cargo_toml } => {
159                info!(
160                    " Detected Rust project: {} v{}",
161                    cargo_toml.name, cargo_toml.version
162                );
163            }
164            ProjectType::Unknown => {
165                info!("  Unknown project type, using default configuration");
166            }
167        }
168
169        let recommendations =
170            ProjectDetector::get_deployment_recommendations(&self.config.app_dir, &project_type);
171        self.config.merge_with_recommendations(&recommendations);
172
173        if self.debug {
174            debug!("Build command: {:?}", self.config.build_command);
175            debug!("Start command: {:?}", self.config.start_command);
176            debug!("Install command: {:?}", self.config.install_command);
177        }
178
179        Ok(())
180    }
181
182    fn is_provider_managed_deploy(&self) -> bool {
183        matches!(
184            self.detected_project_type,
185            Some(ProjectType::Railway { .. }) | Some(ProjectType::Vercel { .. })
186        )
187    }
188
189    async fn deploy_provider_managed_project(&self) -> Result<(), String> {
190        if let Some(install_cmd) = &self.config.install_command {
191            self.run_install_command(install_cmd).await?;
192        }
193        if let Some(build_cmd) = &self.config.build_command {
194            self.run_build_command(build_cmd).await?;
195        }
196
197        let (program, args, install_target, label) = match self.detected_project_type.as_ref() {
198            Some(ProjectType::Railway { .. }) => (
199                "railway",
200                vec!["up".to_string()],
201                "railway",
202                "Railway deploy",
203            ),
204            Some(ProjectType::Vercel { .. }) => (
205                "vercel",
206                Vec::new(),
207                "vercel",
208                "Vercel deploy (default CLI command)",
209            ),
210            _ => {
211                return Err(
212                    "Provider-managed deployment requested without provider project type"
213                        .to_string(),
214                )
215            }
216        };
217
218        if !crate::utils::command_exists(program) {
219            return Err(format!(
220                "{} requires `{}` on PATH. Install it with `xbp install {}` first.",
221                label, program, install_target
222            ));
223        }
224
225        let command_display = if args.is_empty() {
226            program.to_string()
227        } else {
228            format!("{} {}", program, args.join(" "))
229        };
230        info!(" Running {}: {}", label, command_display);
231
232        let output = Command::new(program)
233            .args(&args)
234            .envs(&self.config.environment)
235            .current_dir(&self.config.app_dir)
236            .output()
237            .await
238            .map_err(|e| format!("Failed to execute {}: {}", label, e))?;
239
240        if !output.status.success() {
241            return Err(format!(
242                "{} failed: {}",
243                label,
244                String::from_utf8_lossy(&output.stderr)
245            ));
246        }
247
248        let stdout = String::from_utf8_lossy(&output.stdout);
249        if !stdout.trim().is_empty() {
250            info!("{}", stdout.trim());
251        }
252
253        Ok(())
254    }
255
256    /// Ensure the target port is available, kill existing processes or find new port
257    async fn ensure_port_available(&mut self) -> Result<(), String> {
258        info!(" Checking port {} availability...", self.config.port);
259
260        let port_check = Command::new("sh")
261            .arg("-c")
262            .arg(format!("sudo fuser {}/tcp", self.config.port))
263            .output()
264            .await;
265
266        match port_check {
267            Ok(output) if output.status.success() => {
268                info!(
269                    "  Port {} is in use, attempting to kill process...",
270                    self.config.port
271                );
272
273                let kill_output = Command::new("sh")
274                    .arg("-c")
275                    .arg(format!("sudo fuser -k {}/tcp", self.config.port))
276                    .output()
277                    .await
278                    .map_err(|e| {
279                        format!("Failed to kill process on port {}: {}", self.config.port, e)
280                    })?;
281
282                if kill_output.status.success() {
283                    info!(" Successfully killed process on port {}", self.config.port);
284                } else {
285                    info!("  Failed to kill process, searching for available port...");
286                    let new_port = self.find_available_port().await?;
287                    self.config.update_port(new_port);
288                    info!(" Found available port: {}", new_port);
289                }
290            }
291            _ => {
292                info!(" Port {} is available", self.config.port);
293            }
294        }
295
296        Ok(())
297    }
298
299    /// Find an available port in the range 1025-65535
300    async fn find_available_port(&self) -> Result<u16, String> {
301        for port in 1025..=65535 {
302            let check_output = Command::new("sh")
303                .arg("-c")
304                .arg(format!("sudo fuser {}/tcp", port))
305                .output()
306                .await;
307
308            if let Ok(output) = check_output {
309                if !output.status.success() {
310                    return Ok(port);
311                }
312            }
313        }
314
315        Err("No available ports found in range 1025-65535".to_string())
316    }
317
318    /// Validate that the project directory exists and is accessible
319    async fn validate_project_directory(&self) -> Result<(), String> {
320        if !self.config.app_dir.exists() {
321            return Err(format!(
322                "Project directory does not exist: {}",
323                self.config.app_dir.display()
324            ));
325        }
326
327        if !self.config.app_dir.is_dir() {
328            return Err(format!(
329                "Project path is not a directory: {}",
330                self.config.app_dir.display()
331            ));
332        }
333
334        info!(" Project directory: {}", self.config.app_dir.display());
335        Ok(())
336    }
337
338    /// Reset git repository and pull latest changes
339    async fn git_reset_and_pull(&self) -> Result<(), String> {
340        info!(" Resetting git repository...");
341
342        let reset_output = Command::new("git")
343            .arg("reset")
344            .arg("--hard")
345            .current_dir(&self.config.app_dir)
346            .output()
347            .await
348            .map_err(|e| format!("Failed to execute git reset: {}", e))?;
349
350        if !reset_output.status.success() {
351            return Err(format!(
352                "Git reset failed: {}",
353                String::from_utf8_lossy(&reset_output.stderr)
354            ));
355        }
356
357        info!("  Pulling latest changes...");
358
359        let pull_output = Command::new("git")
360            .arg("pull")
361            .arg("origin")
362            .arg("main")
363            .current_dir(&self.config.app_dir)
364            .output()
365            .await
366            .map_err(|e| format!("Failed to execute git pull: {}", e))?;
367
368        if !pull_output.status.success() {
369            return Err(format!(
370                "Git pull failed: {}",
371                String::from_utf8_lossy(&pull_output.stderr)
372            ));
373        }
374
375        info!(" Git operations completed");
376        Ok(())
377    }
378
379    /// Run the install command (e.g., pnpm install, cargo fetch)
380    async fn run_install_command(&self, install_cmd: &str) -> Result<(), String> {
381        info!(" Installing dependencies: {}", install_cmd);
382
383        let install_output = Command::new("sh")
384            .arg("-c")
385            .arg(install_cmd)
386            .envs(&self.config.environment)
387            .current_dir(&self.config.app_dir)
388            .output()
389            .await
390            .map_err(|e| format!("Failed to execute install command: {}", e))?;
391
392        if !install_output.status.success() {
393            return Err(format!(
394                "Install command failed: {}",
395                String::from_utf8_lossy(&install_output.stderr)
396            ));
397        }
398
399        info!(" Dependencies installed successfully");
400        Ok(())
401    }
402
403    /// Run the build command
404    async fn run_build_command(&self, build_cmd: &str) -> Result<(), String> {
405        info!(" Building project: {}", build_cmd);
406
407        let build_output = Command::new("sh")
408            .arg("-c")
409            .arg(build_cmd)
410            .envs(&self.config.environment)
411            .current_dir(&self.config.app_dir)
412            .output()
413            .await
414            .map_err(|e| format!("Failed to execute build command: {}", e))?;
415
416        if !build_output.status.success() {
417            return Err(format!(
418                "Build command failed: {}",
419                String::from_utf8_lossy(&build_output.stderr)
420            ));
421        }
422
423        info!("Project built successfully");
424        Ok(())
425    }
426
427    /// Stop existing PM2 processes and kill processes on the target port
428    async fn stop_existing_processes(&self) -> Result<(), String> {
429        info!("Stopping existing processes...");
430
431        let pm2_stop = Command::new("pm2")
432            .arg("stop")
433            .arg(&self.config.app_name)
434            .output()
435            .await;
436
437        match pm2_stop {
438            Ok(output) if output.status.success() => {
439                info!("  Stopped PM2 process: {}", self.config.app_name);
440            }
441            _ => {
442                info!(" No existing PM2 process found");
443            }
444        }
445
446        let kill_port = Command::new("sh")
447            .arg("-c")
448            .arg(format!("sudo fuser -k {}/tcp", self.config.port))
449            .output()
450            .await;
451
452        match kill_port {
453            Ok(output) if output.status.success() => {
454                info!("  Killed processes on port {}", self.config.port);
455            }
456            _ => {
457                info!(" No processes found on port {}", self.config.port);
458            }
459        }
460
461        Ok(())
462    }
463
464    /// Start the application using PM2
465    async fn start_application(&self) -> Result<(), String> {
466        let start_cmd = self
467            .config
468            .start_command
469            .as_ref()
470            .ok_or("No start command configured")?;
471
472        info!(" Starting application: {}", start_cmd);
473
474        // Construct PM2 start command with port
475        let pm2_cmd = format!("{} --port {}", start_cmd, self.config.port);
476
477        let start_output = Command::new("pm2")
478            .arg("start")
479            .arg(&pm2_cmd)
480            .arg("--name")
481            .arg(&self.config.app_name)
482            .arg("--")
483            .arg("--port")
484            .arg(self.config.port.to_string())
485            .envs(&self.config.environment)
486            .current_dir(&self.config.app_dir)
487            .output()
488            .await
489            .map_err(|e| format!("Failed to start PM2 process: {}", e))?;
490
491        if !start_output.status.success() {
492            return Err(format!(
493                "Failed to start application: {}",
494                String::from_utf8_lossy(&start_output.stderr)
495            ));
496        }
497
498        info!("Application started successfully");
499        Ok(())
500    }
501
502    /// Save PM2 process list
503    async fn save_pm2_config(&self) -> Result<(), String> {
504        info!("Saving PM2 configuration...");
505
506        let save_output = Command::new("pm2")
507            .arg("save")
508            .output()
509            .await
510            .map_err(|e| format!("Failed to save PM2 config: {}", e))?;
511
512        if !save_output.status.success() {
513            return Err(format!(
514                "PM2 save failed: {}",
515                String::from_utf8_lossy(&save_output.stderr)
516            ));
517        }
518
519        info!("PM2 configuration saved");
520        Ok(())
521    }
522}