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