mecha10-deploy 0.7.0

Mecha10 deploy services — Docker management, binary distribution, remote deployment
Documentation
//! Docker Compose operations

use anyhow::{Context, Result};
use std::process::Command;

/// Run a simple `docker compose <subcommand> [service]` invocation.
///
/// Shared by the compose subcommands that only ever need an optional
/// `-f <compose_file>` flag, a fixed subcommand, and an optional trailing
/// service name (`build`, `stop`, `down`, `restart`).
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `subcommand` - The docker compose subcommand to run (e.g. `"build"`)
/// * `service` - Optional service name argument
/// * `fail_context` - Error message to return if the command exits non-zero
///
/// `compose_build`/`compose_stop`/`compose_restart` below already delegate through this
/// helper, so their one-line bodies (differing only in the subcommand/error-message
/// arguments passed in) are the thinnest possible named wrappers - cargo-dupes still flags
/// them as an exact-duplicate group (LAB-2762, see `.dupes-ignore.toml`) since it matches on
/// AST shape, not on whether the real logic was already factored out.
fn run_compose(compose_file: Option<&str>, subcommand: &str, service: Option<&str>, fail_context: &str) -> Result<()> {
    let mut args = vec!["compose"];

    if let Some(file) = compose_file {
        args.push("-f");
        args.push(file);
    }

    args.push(subcommand);

    if let Some(svc) = service {
        args.push(svc);
    }

    let status = Command::new("docker")
        .args(&args)
        .status()
        .with_context(|| format!("Failed to execute 'docker compose {}'", subcommand))?;

    if !status.success() {
        return Err(anyhow::anyhow!("{}", fail_context));
    }

    Ok(())
}

/// Build Docker images using Docker Compose
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `service` - Optional service name to build (builds all if None)
pub fn compose_build(compose_file: Option<&str>, service: Option<&str>) -> Result<()> {
    run_compose(compose_file, "build", service, "Failed to build Docker images")
}

/// Run a `docker compose up [-d] [service]` invocation.
///
/// Shared by [`compose_up`]/[`compose_up_service`], which only differ in whether a specific
/// service name is appended and in the error message returned on failure (cargo-dupes flagged
/// their near-identical arg-building bodies as a clone group).
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `service` - Optional service name to start (starts all if None)
/// * `detach` - Run in detached mode
/// * `fail_context` - Error message to return if the command exits non-zero
fn run_compose_up(compose_file: Option<&str>, service: Option<&str>, detach: bool, fail_context: &str) -> Result<()> {
    let args = compose_up_args(compose_file, service, detach);

    let status = Command::new("docker")
        .args(&args)
        .status()
        .with_context(|| "Failed to execute 'docker compose up'".to_string())?;

    if !status.success() {
        return Err(anyhow::anyhow!("{}", fail_context));
    }

    Ok(())
}

/// Build the argument list for a `docker compose up` invocation. Split out from
/// [`run_compose_up`] as a pure function so the arg-ordering logic (`-f` before `up`, `-d`
/// before the trailing service name) is unit-testable without actually shelling out to
/// `docker`.
fn compose_up_args<'a>(compose_file: Option<&'a str>, service: Option<&'a str>, detach: bool) -> Vec<&'a str> {
    let mut args = vec!["compose"];

    if let Some(file) = compose_file {
        args.push("-f");
        args.push(file);
    }

    args.push("up");

    if detach {
        args.push("-d");
    }

    if let Some(svc) = service {
        args.push(svc);
    }

    args
}

/// Start services using Docker Compose
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `detach` - Run in detached mode
pub async fn compose_up(compose_file: Option<&str>, detach: bool) -> Result<()> {
    run_compose_up(compose_file, None, detach, "Failed to start services")
}

/// Start a specific service using Docker Compose
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `service` - Service name to start
/// * `detach` - Run in detached mode
pub async fn compose_up_service(compose_file: Option<&str>, service: &str, detach: bool) -> Result<()> {
    run_compose_up(
        compose_file,
        Some(service),
        detach,
        &format!("Failed to start service: {}", service),
    )
}

/// Run a one-off command in a service container
///
/// Uses `docker compose run` to start a service with custom command arguments.
/// This is useful for passing runtime arguments that aren't in docker-compose.yml.
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `service` - Service name to run
/// * `detach` - Run in detached mode
/// * `service_ports` - Publish service ports (useful for detached mode)
/// * `command_args` - Additional arguments to pass to the container's entrypoint
pub async fn compose_run_service(
    compose_file: Option<&str>,
    service: &str,
    detach: bool,
    service_ports: bool,
    command_args: &[String],
) -> Result<()> {
    let mut args = vec!["compose"];

    if let Some(file) = compose_file {
        args.push("-f");
        args.push(file);
    }

    args.push("run");

    if detach {
        args.push("-d");
    }

    if service_ports {
        args.push("--service-ports");
    }

    if !detach {
        args.push("--rm");
    }

    args.push(service);

    let cmd_args: Vec<&str> = command_args.iter().map(|s| s.as_str()).collect();
    args.extend(cmd_args);

    let status = Command::new("docker")
        .args(&args)
        .status()
        .context(format!("Failed to run service: {}", service))?;

    if !status.success() {
        return Err(anyhow::anyhow!("Failed to run service: {}", service));
    }

    Ok(())
}

/// Stop services using Docker Compose
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `service` - Optional service name to stop (stops all if None)
pub async fn compose_stop(compose_file: Option<&str>, service: Option<&str>) -> Result<()> {
    run_compose(compose_file, "stop", service, "Failed to stop services")
}

/// Stop and remove services using Docker Compose
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
pub async fn compose_down(compose_file: Option<&str>) -> Result<()> {
    run_compose(compose_file, "down", None, "Failed to stop and remove services")
}

/// Restart services using Docker Compose
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `service` - Optional service name to restart (restarts all if None)
pub async fn compose_restart(compose_file: Option<&str>, service: Option<&str>) -> Result<()> {
    run_compose(compose_file, "restart", service, "Failed to restart services")
}

/// Get logs from Docker Compose services
///
/// # Arguments
///
/// * `compose_file` - Optional path to docker-compose.yml
/// * `service` - Optional service name (all services if None)
/// * `follow` - Follow log output
/// * `tail` - Number of lines to show from the end
pub fn compose_logs(
    compose_file: Option<&str>,
    service: Option<&str>,
    follow: bool,
    tail: Option<usize>,
) -> Result<()> {
    let mut cmd = Command::new("docker");

    cmd.arg("compose");

    if let Some(file) = compose_file {
        cmd.arg("-f").arg(file);
    }

    cmd.arg("logs");

    if follow {
        cmd.arg("-f");
    }

    if let Some(n) = tail {
        cmd.arg("--tail").arg(n.to_string());
    }

    if let Some(svc) = service {
        cmd.arg(svc);
    }

    let status = cmd.status().context("Failed to execute 'docker compose logs'")?;

    if !status.success() {
        return Err(anyhow::anyhow!("Failed to get logs"));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn compose_up_args_defaults_to_all_services_in_foreground() {
        assert_eq!(compose_up_args(None, None, false), vec!["compose", "up"]);
    }

    #[test]
    fn compose_up_args_adds_detach_flag_before_service() {
        assert_eq!(
            compose_up_args(None, Some("redis"), true),
            vec!["compose", "up", "-d", "redis"]
        );
    }

    #[test]
    fn compose_up_args_places_compose_file_flag_before_the_subcommand() {
        assert_eq!(
            compose_up_args(Some("docker-compose.dev.yml"), None, false),
            vec!["compose", "-f", "docker-compose.dev.yml", "up"]
        );
    }
}