xbp 10.15.4

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! redeploy service module
//!
//! handles redeployment of individual services via pm2
//! performs git operations build steps and pm2 process management
//! creates versioned dist folders and writes logs to xbp/logs
//! updates version via api after successful deployment

use std::env;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command;

use crate::commands::pm2::{pm2_cleanup, pm2_delete, pm2_save, pm2_start, pm2_stop};
use crate::commands::service::load_xbp_config_with_root;
use crate::logging::{log_debug, log_info, log_success};
use crate::strategies::get_service_by_name;
use crate::utils::version::{fetch_version, increment_version};

/// Redeploy a specific service
pub async fn run_redeploy_service(service_name: &str, debug: bool) -> Result<(), String> {
    let _ = log_info(
        "redeploy",
        &format!("Starting redeploy for service '{}'", service_name),
        None,
    )
    .await;

    // Load config
    let (project_root, config) = load_xbp_config_with_root().await?;
    let service = get_service_by_name(&config, service_name)?;
    let envs = merge_envs(config.environment.as_ref(), service.environment.as_ref());

    // Determine working directory
    let working_dir = if service.force_run_from_root.unwrap_or(false) {
        project_root.clone()
    } else if let Some(root_dir) = &service.root_directory {
        if root_dir.starts_with('/') {
            PathBuf::from(root_dir)
        } else {
            project_root.join(root_dir)
        }
    } else {
        project_root.clone()
    };

    let _ = log_info(
        "redeploy",
        &format!("Working directory: {}", working_dir.display()),
        None,
    )
    .await;

    // Step 1: Git operations
    let _ = log_info("redeploy", "Resetting local changes...", None).await;
    let mut git_reset = Command::new("git");
    git_reset.arg("reset").arg("--hard");
    git_reset.current_dir(&working_dir);
    let reset_output = git_reset
        .output()
        .await
        .map_err(|e| format!("Failed to reset git changes: {}", e))?;
    if !reset_output.status.success() {
        return Err(format!(
            "Git reset failed: {}",
            String::from_utf8_lossy(&reset_output.stderr)
        ));
    }

    let _ = log_info("redeploy", "Pulling latest changes...", None).await;
    let mut git_pull = Command::new("git");
    git_pull.arg("pull").arg("origin").arg(&service.branch);
    git_pull.current_dir(&working_dir);
    let pull_output = git_pull
        .output()
        .await
        .map_err(|e| format!("Failed to pull git changes: {}", e))?;
    if !pull_output.status.success() {
        return Err(format!(
            "Git pull failed: {}",
            String::from_utf8_lossy(&pull_output.stderr)
        ));
    }

    // Step 2: Fetch version
    let _ = log_info("redeploy", "Fetching version from API...", None).await;
    let version = fetch_version(&service.name).await?;
    let _ = log_info("redeploy", &format!("Version: {}", version), None).await;

    // Step 3: Create dist folder
    let username = env::var("USER")
        .or_else(|_| env::var("USERNAME"))
        .unwrap_or_else(|_| "unknown".to_string());
    let dist_base = PathBuf::from(format!("/home/{}/.xbp/dist", username));
    let dist_folder = dist_base.join(&version);

    std::fs::create_dir_all(&dist_folder)
        .map_err(|e| format!("Failed to create dist folder: {}", e))?;

    let _ = log_info(
        "redeploy",
        &format!("Dist folder: {}", dist_folder.display()),
        None,
    )
    .await;

    // Step 4: Run pre-command if exists
    if let Some(commands) = &service.commands {
        if let Some(pre_cmd) = &commands.pre {
            if !pre_cmd.is_empty() {
                let _ = log_info("redeploy", "Running pre-command...", Some(pre_cmd)).await;
                run_command_in_dir(&working_dir, pre_cmd, envs.as_ref(), debug).await?;
            }
        }
    }

    // Step 5: Run install command
    if let Some(commands) = &service.commands {
        if let Some(install_cmd) = &commands.install {
            if !install_cmd.is_empty() {
                let _ = log_info("redeploy", "Installing dependencies...", Some(install_cmd)).await;
                run_command_in_dir(&working_dir, install_cmd, envs.as_ref(), debug).await?;
            }
        }
    }

    // Step 6: Run build command
    if let Some(commands) = &service.commands {
        if let Some(build_cmd) = &commands.build {
            if !build_cmd.is_empty() {
                let _ = log_info("redeploy", "Building...", Some(build_cmd)).await;
                run_command_in_dir(&working_dir, build_cmd, envs.as_ref(), debug).await?;
            } else {
                let _ = log_info("redeploy", "Build command is empty, skipping", None).await;
            }
        }
    }

    // Step 7: Stop existing PM2 process (lowest downtime approach)
    let _ = log_info(
        "redeploy",
        &format!("Stopping existing PM2 process '{}'...", service.name),
        None,
    )
    .await;
    pm2_stop(&service.name, debug).await?;

    // Step 8: Copy/move build artifacts to dist folder
    // This depends on the target type
    match service.target.as_str() {
        "rust" => {
            // For Rust, copy the binary
            // Try to find the binary - check common locations
            let possible_binary_names = vec![
                &service.name,
                &config.project_name,
                config.crate_name.as_deref().unwrap_or(&service.name),
            ];

            for binary_name in possible_binary_names {
                let binary_path = working_dir.join("target").join("release").join(binary_name);
                if binary_path.exists() {
                    let dist_binary = dist_folder.join(format!("{}-binary", service.name));
                    std::fs::copy(&binary_path, &dist_binary)
                        .map_err(|e| format!("Failed to copy binary: {}", e))?;
                    let _ = log_info(
                        "redeploy",
                        &format!("Copied binary to {}", dist_binary.display()),
                        None,
                    )
                    .await;
                    break;
                }
            }
        }
        "nextjs" | "expressjs" => {
            // For Node.js apps, the build artifacts are typically in .next or dist
            // We'll use the working directory as-is for now
            let _ = log_info("redeploy", "Node.js build artifacts in place", None).await;
        }
        "python" => {
            // For Python, typically no build step needed
            let _ = log_info("redeploy", "Python service ready", None).await;
        }
        _ => {}
    }

    // Step 9: Start PM2 with wrapped start command
    let log_dir = project_root.join(".xbp").join("logs").join(&service.name);
    std::fs::create_dir_all(&log_dir)
        .map_err(|e| format!("Failed to create log directory: {}", e))?;

    if let Some(commands) = &service.commands {
        if let Some(start_cmd) = &commands.start {
            if !start_cmd.is_empty() {
                // Build PM2 start command with port
                let pm2_command = if start_cmd.contains("--port") {
                    start_cmd.clone()
                } else {
                    format!("{} --port {}", start_cmd, service.port)
                };

                let _ = log_info(
                    "redeploy",
                    &format!("Starting service with PM2: {}", pm2_command),
                    None,
                )
                .await;

                // Delete old process if it exists (to ensure clean start)
                pm2_delete(&service.name, debug).await.ok();

                let envs = merge_envs(config.environment.as_ref(), service.environment.as_ref());
                pm2_start(
                    &service.name,
                    &pm2_command,
                    Some(&log_dir),
                    envs.as_ref(),
                    debug,
                )
                .await?;

                let _ = log_success(
                    "redeploy",
                    &format!("Service '{}' started successfully", service.name),
                    None,
                )
                .await;
            }
        }
    }

    // Step 10: Save PM2 process list
    pm2_save(debug).await?;

    // Step 11: Update version via API
    let _ = log_info("redeploy", "Updating version in database...", None).await;
    increment_version(&service.name, "minor").await?;

    // Step 12: Clean up stopped/errored PM2 processes
    let _ = log_info("redeploy", "Cleaning up PM2 processes...", None).await;
    pm2_cleanup(debug).await?;

    let _ = log_success(
        "redeploy",
        &format!("Redeploy completed for service '{}'", service_name),
        None,
    )
    .await;

    Ok(())
}

fn merge_envs(
    global: Option<&std::collections::HashMap<String, String>>,
    service: Option<&std::collections::HashMap<String, String>>,
) -> Option<std::collections::HashMap<String, String>> {
    if global.is_none() && service.is_none() {
        return None;
    }
    let mut out = std::collections::HashMap::new();
    if let Some(g) = global {
        out.extend(g.clone());
    }
    if let Some(s) = service {
        out.extend(s.clone());
    }
    Some(out)
}

/// Run a shell command in a specific directory
async fn run_command_in_dir(
    dir: &PathBuf,
    command: &str,
    envs: Option<&std::collections::HashMap<String, String>>,
    _debug: bool,
) -> Result<(), String> {
    let _ = log_debug(
        "redeploy",
        &format!("Executing: {} in {}", command, dir.display()),
        None,
    )
    .await;

    #[cfg(unix)]
    let mut cmd = Command::new("sh");
    #[cfg(unix)]
    cmd.arg("-c");

    #[cfg(windows)]
    let mut cmd = Command::new("cmd");
    #[cfg(windows)]
    cmd.arg("/C");

    cmd.arg(command);
    cmd.current_dir(dir);
    if let Some(envs) = envs {
        cmd.envs(envs);
    }
    cmd.stdout(Stdio::inherit());
    cmd.stderr(Stdio::inherit());

    let status = cmd
        .status()
        .await
        .map_err(|e| format!("Failed to execute command: {}", e))?;

    if !status.success() {
        return Err(format!(
            "Command failed with exit code: {}",
            status.code().unwrap_or(-1)
        ));
    }

    Ok(())
}