depl 2.4.3

Toolkit for a bunch of local and remote CI/CD actions
Documentation
//! Compose module.
//!
//! Contains code for generating `docker-compose.yaml` files and orchestrating
//! multi-service pipeline runs using Docker Compose. The main application
//! service reuses the containerized build (Dockerfile generation), while
//! additional services (databases, caches, etc.) are defined via
//! [`ComposeOpts`](crate::entities::compose_opts::ComposeOpts).

use colored::Colorize;
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::process::exit;

use crate::containered::{generate_dockerfile, generate_dockerignore};
use crate::entities::compose_opts::{ComposeOpts, ComposeServiceOpts};
use crate::entities::containered_opts::PortBinding;
use crate::entities::custom_command::CustomCommand;
use crate::entities::environment::RunEnvironment;
use crate::entities::info::ShortName;
use crate::entities::requirements::Requirement;
use crate::entities::variables::Variable;
use crate::pipelines::DescribedPipeline;
use crate::project::DeployerProjectOptions;

/// Generates `docker-compose.yaml` for a given pipeline.
///
/// The compose file includes:
/// - An `app` service built from the generated Dockerfile (same as containered build).
/// - Additional services defined in `compose_opts.services`.
pub async fn generate_compose_file(
  config: &DeployerProjectOptions,
  env: &RunEnvironment<'_>,
  pipeline: &DescribedPipeline,
  opts: &ComposeOpts,
  exclusive_exec_tag: &str,
  vars: &BTreeMap<String, Variable>,
) -> anyhow::Result<()> {
  // First, generate the Dockerfile for the app service (reuse containered logic).
  generate_dockerfile(config, env, pipeline, &opts.app, exclusive_exec_tag, vars).await?;

  let dockerfile_name = format!("Dockerfile.{exclusive_exec_tag}");
  let image_name = format!("{}/{}", config.project_name, pipeline.title);

  let mut compose = String::new();
  compose.push_str("# Generated by Deployer 2.X\n");
  compose.push_str("services:\n");

  // App service (main pipeline).
  compose.push_str("  app:\n");
  compose.push_str("    build:\n");
  compose.push_str("      context: .\n");
  compose.push_str(&format!("      dockerfile: {dockerfile_name}\n"));
  compose.push_str(&format!("    image: {image_name}\n"));
  compose.push_str(&format!("    container_name: {exclusive_exec_tag}-app\n"));

  // Port bindings for the app service.
  if !opts.app.port_bindings.is_empty() {
    compose.push_str("    ports:\n");
    for PortBinding { from, to } in &opts.app.port_bindings {
      compose.push_str(&format!("      - \"{from}:{to}\"\n"));
    }
  }

  // Add host.docker.internal binding.
  if opts.app.allow_internal_host_bind && opts.effective_executor().is_docker() {
    compose.push_str("    extra_hosts:\n");
    compose.push_str("      - \"host.docker.internal:host-gateway\"\n");
  }

  // Mount the artifacts volume for the app.
  compose.push_str("    volumes:\n");
  compose.push_str(&format!(
    "      - {}:/app/artifacts\n",
    env.artifacts_dir.join(&pipeline.title).to_string_lossy()
  ));

  // If there are additional services, the app depends on them.
  if !opts.services.is_empty() {
    compose.push_str("    depends_on:\n");
    for (service_name, service_opts) in &opts.services {
      if service_opts.healthcheck_cmd.is_some() {
        compose.push_str(&format!("      {service_name}:\n"));
        compose.push_str("        condition: service_healthy\n");
      } else {
        compose.push_str(&format!("      {service_name}:\n"));
        compose.push_str("        condition: service_started\n");
      }
    }
  }

  // Environment for app service (link service hostnames).
  let mut app_env: BTreeMap<String, String> = BTreeMap::new();
  app_env.insert("DEPLOYER_CONTAINERED_RUN".to_string(), "1".to_string());
  // Resolve compose-level variables.
  for (placeholder, short_name) in &opts.with {
    if let Some((_, variable)) = config
      .variables
      .iter()
      .find(|(k, _): &(&ShortName, _)| k.as_str() == short_name.as_str())
      && let Ok(value) = variable.get_value(env).await
    {
      app_env.insert(placeholder.clone(), value);
    }
  }
  if !app_env.is_empty() {
    compose.push_str("    environment:\n");
    for (key, value) in &app_env {
      compose.push_str(&format!("      {key}: \"{}\"\n", escape_yaml_value(value)));
    }
  }

  compose.push('\n');

  // Additional services.
  for (service_name, service_opts) in &opts.services {
    write_service(&mut compose, service_name, service_opts, env, &opts.with, config).await?;
  }

  // Named volumes (collect from services).
  let named_volumes = collect_named_volumes(opts);
  if !named_volumes.is_empty() {
    compose.push_str("volumes:\n");
    for vol_name in &named_volumes {
      compose.push_str(&format!("  {vol_name}:\n"));
    }
  }

  let compose_filepath = env.run_dir.join(format!("docker-compose.{exclusive_exec_tag}.yaml"));
  let mut compose_file = fs::File::options()
    .create(true)
    .write(true)
    .truncate(true)
    .open(&compose_filepath)
    .map_err(|e| {
      anyhow::anyhow!(
        "Can't open `docker-compose.{}.yaml` due to: `{}`; filepath: {:?}",
        exclusive_exec_tag,
        e,
        compose_filepath
      )
    })?;
  compose_file.write_all(compose.as_bytes())?;

  Ok(())
}

/// Writes a single service block into the compose content string.
async fn write_service(
  compose: &mut String,
  name: &str,
  opts: &ComposeServiceOpts,
  env: &RunEnvironment<'_>,
  with: &BTreeMap<String, ShortName>,
  config: &DeployerProjectOptions,
) -> anyhow::Result<()> {
  compose.push_str(&format!("  {name}:\n"));
  compose.push_str(&format!("    image: {}\n", opts.image));

  if let Some(cmd) = &opts.command {
    compose.push_str(&format!("    command: {cmd}\n"));
  }

  if !opts.ports.is_empty() {
    compose.push_str("    ports:\n");
    for PortBinding { from, to } in &opts.ports {
      compose.push_str(&format!("      - \"{from}:{to}\"\n"));
    }
  }

  if !opts.environment.is_empty() || !with.is_empty() {
    compose.push_str("    environment:\n");
    // Resolve compose-level variables in environment values.
    let resolved_with = resolve_with_vars(with, env, config).await;
    for (key, value) in &opts.environment {
      let resolved = resolve_placeholders(value, &resolved_with);
      compose.push_str(&format!("      {key}: \"{}\"\n", escape_yaml_value(&resolved)));
    }
  }

  if !opts.volumes.is_empty() {
    compose.push_str("    volumes:\n");
    for volume in &opts.volumes {
      compose.push_str(&format!("      - {volume}\n"));
    }
  }

  if let Some(healthcheck_cmd) = &opts.healthcheck_cmd {
    compose.push_str("    healthcheck:\n");
    compose.push_str(&format!(
      "      test: [\"CMD-SHELL\", \"{}\"]\n",
      escape_yaml_value(healthcheck_cmd)
    ));
    compose.push_str(&format!("      interval: {}s\n", opts.healthcheck_interval_secs));
    compose.push_str(&format!("      retries: {}\n", opts.healthcheck_retries));
  }

  compose.push('\n');
  Ok(())
}

/// Resolves `with` variables map into placeholder->value pairs.
async fn resolve_with_vars(
  with: &BTreeMap<String, ShortName>,
  env: &RunEnvironment<'_>,
  config: &DeployerProjectOptions,
) -> BTreeMap<String, String> {
  let mut resolved = BTreeMap::new();
  for (placeholder, short_name) in with {
    if let Some((_, variable)) = config
      .variables
      .iter()
      .find(|(k, _): &(&ShortName, _)| k.as_str() == short_name.as_str())
      && let Ok(value) = variable.get_value(env).await
    {
      resolved.insert(placeholder.clone(), value);
    }
  }
  resolved
}

/// Replaces placeholders in a string with resolved values.
fn resolve_placeholders(input: &str, vars: &BTreeMap<String, String>) -> String {
  let mut result = input.to_string();
  for (placeholder, value) in vars {
    result = result.replace(placeholder.as_str(), value.as_str());
  }
  result
}

/// Collects named volumes from all services.
///
/// A named volume is one that does not start with `.` or `/` and contains a `:` separator.
fn collect_named_volumes(opts: &ComposeOpts) -> Vec<String> {
  let mut volumes = Vec::new();
  for service_opts in opts.services.values() {
    for volume in &service_opts.volumes {
      if let Some(vol_name) = volume.split(':').next()
        && !vol_name.starts_with('.')
        && !vol_name.starts_with('/')
        && volume.contains(':')
        && !volumes.contains(&vol_name.to_string())
      {
        volumes.push(vol_name.to_string());
      }
    }
  }
  volumes
}

/// Escapes a string value for use in YAML double-quoted strings.
fn escape_yaml_value(value: &str) -> String {
  value.replace('\\', "\\\\").replace('"', "\\\"")
}

fn choose_compose_executor(opts: &ComposeOpts, sudo: bool) -> String {
  let executor = opts.effective_executor();
  if executor.is_docker() {
    format!("{}docker", if sudo { "" } else { "sudo " })
  } else {
    "sudo podman".to_string()
  }
}

/// Executes selected pipeline using Docker Compose.
///
/// This function:
/// 1. Validates that the container executor (Docker/Podman) is available.
/// 2. Generates the Dockerfile for the app service (reuses containerized build).
/// 3. Generates the `docker-compose.{tag}.yaml` file with all services.
/// 4. Runs `docker compose build` followed by `docker compose up`.
pub async fn execute_pipeline_compose(
  config: &DeployerProjectOptions,
  env: &RunEnvironment<'_>,
  pipeline: &DescribedPipeline,
) -> anyhow::Result<bool> {
  println!("Run path: {}", env.run_dir.canonicalize()?.to_string_lossy());

  let sudo = is_user_in_group("docker")?;

  let opts = pipeline
    .compose_opts
    .as_ref()
    .expect("No compose_opts for this pipeline!");

  let exclusive_exec_tag = pipeline.exclusive_exec_tag.clone().unwrap_or(String::from("default")) + "-compose";

  let executor = opts.effective_executor();
  if executor.is_docker() && Requirement::in_path("docker").satisfy(env).await.is_err() {
    println!("{}", "Docker is not installed!".red());
    exit(1);
  } else if !executor.is_docker() && Requirement::in_path("podman").satisfy(env).await.is_err() {
    println!("{}", "Podman is not installed!".red());
    exit(1);
  }

  opts.app.sync_fake_content(env)?;

  let app_vars = config.variables_for(&opts.app.with)?;
  generate_compose_file(config, env, pipeline, opts, &exclusive_exec_tag, &app_vars).await?;
  generate_dockerignore(env, config)?;

  println!(
    "Started `{}` compose build...",
    format!("{}/{}", config.project_name, pipeline.title).green()
  );

  let compose_file = format!("docker-compose.{exclusive_exec_tag}.yaml");
  let compose_project = opts
    .project_name
    .clone()
    .unwrap_or_else(|| format!("{}-{}", config.project_name, pipeline.title));
  let exec = choose_compose_executor(opts, sudo);

  // Build phase.
  let build_cmd = format!(
    "{exec} compose -p {compose_project} -f {compose_file} build{}",
    if env.new_build { " --no-cache" } else { "" },
  );
  if CustomCommand::run_simple_observer(env, &build_cmd).await.is_err() {
    println!("{}", "Compose build failed!".red());
    exit(1);
  }
  println!("{}", "Compose images built successfully.".green());

  // Create artifacts directory.
  let volume_path = env.artifacts_dir.join(&pipeline.title);
  std::fs::create_dir_all(&volume_path)?;

  // Up phase.
  let up_cmd = format!(
    "{exec} compose -p {compose_project} -f {compose_file} up{}{}{}",
    if opts.detach { " -d" } else { "" },
    if opts.abort_on_container_exit && !opts.detach {
      " --abort-on-container-exit"
    } else {
      ""
    },
    if opts.remove_on_exit && !opts.detach {
      " --force-recreate --remove-orphans"
    } else {
      ""
    },
  );
  if CustomCommand::run_simple_observer(env, &up_cmd).await.is_err() {
    println!("{}", "Compose run failed!".red());
    // Cleanup on failure.
    let _ = CustomCommand::run_simple_observer(
      env,
      format!("{exec} compose -p {compose_project} -f {compose_file} down --remove-orphans"),
    )
    .await;
    exit(1);
  }

  // Cleanup after non-detached runs.
  if !opts.detach && opts.remove_on_exit {
    let _ = CustomCommand::run_simple_observer(
      env,
      format!("{exec} compose -p {compose_project} -f {compose_file} down --remove-orphans -v"),
    )
    .await;
  }

  println!("{}", "Compose run is done.".green());

  Ok(true)
}

fn is_user_in_group(group_name: &str) -> anyhow::Result<bool> {
  let groups = match nix::unistd::getgroups() {
    Ok(groups) => groups,
    Err(e) => anyhow::bail!("Failed to get user groups: {}", e),
  };
  for gid in groups {
    if let Ok(Some(group)) = nix::unistd::Group::from_gid(gid)
      && group.name.as_str().eq(group_name)
    {
      return Ok(true);
    }
  }
  Ok(false)
}