#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROD_COMPOSE_FILE="${ROOT_DIR}/docker-compose.production.yml" PORTAINER_COMPOSE_FILE="${ROOT_DIR}/docker-compose.portainer.production.yml" TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml" ENV_FILE="${ROOT_DIR}/.env.docker.production" PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}" PORTAINER_PROJECT_NAME="${DOCKER_PORTAINER_PROJECT_NAME:-rentaldrivego-portainer-prod}" TRAEFIK_NETWORK="${DOCKER_PROD_TRAEFIK_NETWORK:-traefik-proxy}" VOLUME_BACKUP_IMAGE="${DOCKER_VOLUME_BACKUP_IMAGE:-postgres:16-alpine}" API_UPLOADS_VOLUME="${DOCKER_PROD_API_UPLOADS_VOLUME:-${PROJECT_NAME}_api_uploads}" ensure_env_file() { if [[ ! -f "${ENV_FILE}" ]]; then echo "Missing ${ENV_FILE}" >&2 echo "Create it from .env.docker.production.example before starting production containers." >&2 exit 1 fi } ensure_traefik_network() { if ! docker network inspect "${TRAEFIK_NETWORK}" >/dev/null 2>&1; then echo "Creating Docker network: ${TRAEFIK_NETWORK}" docker network create "${TRAEFIK_NETWORK}" >/dev/null fi } ensure_api_uploads_volume() { if ! docker volume inspect "${API_UPLOADS_VOLUME}" >/dev/null 2>&1; then echo "Creating Docker volume: ${API_UPLOADS_VOLUME}" docker volume create "${API_UPLOADS_VOLUME}" >/dev/null fi } prod_compose() { docker compose -p "${PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PROD_COMPOSE_FILE}" "$@" } portainer_compose() { docker compose -p "${PORTAINER_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PORTAINER_COMPOSE_FILE}" "$@" } traefik_compose() { docker compose --env-file "${ENV_FILE}" -f "${TRAEFIK_COMPOSE_FILE}" "$@" } start_prod_services() { ensure_env_file ensure_api_uploads_volume local services=("$@") local needs_migrations=0 for service in "${services[@]}"; do case "${service}" in api|marketplace|dashboard|admin) needs_migrations=1 break ;; esac done if [[ ${needs_migrations} -eq 1 ]]; then prod_compose up --build -d postgres redis wait_for_healthy postgres 120 prod_compose run --rm --build migrate fi prod_compose up --build -d "$@" } require_release_image() { if [[ -z "${APP_IMAGE:-}" || -z "${APP_VERSION:-}" ]]; then echo "APP_IMAGE and APP_VERSION must be set for pull-based production deploys." >&2 exit 1 fi } login_registry_if_configured() { local configured=0 [[ -n "${REGISTRY_HOST:-}" ]] && configured=$((configured + 1)) [[ -n "${REGISTRY_USER:-}" ]] && configured=$((configured + 1)) [[ -n "${REGISTRY_PASSWORD:-}" ]] && configured=$((configured + 1)) if [[ ${configured} -eq 0 ]]; then return 0 fi if [[ ${configured} -ne 3 ]]; then echo "REGISTRY_HOST, REGISTRY_USER, and REGISTRY_PASSWORD must either all be set or all be unset." >&2 exit 1 fi echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin } pull_prod_release_images() { require_release_image prod_compose pull migrate api marketplace dashboard admin } run_prod_migrations() { prod_compose run --rm migrate } wait_for_healthy() { local service="$1" local timeout="${2:-120}" local container="${PROJECT_NAME}-${service}-1" local elapsed=0 echo "Waiting for ${service} to be healthy (timeout: ${timeout}s)..." while [[ $elapsed -lt $timeout ]]; do local status status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "missing") case "$status" in healthy) echo "${service} is healthy." return 0 ;; unhealthy) echo "ERROR: ${service} is unhealthy. Check logs with: docker logs ${container}" >&2 return 1 ;; esac sleep 5 elapsed=$((elapsed + 5)) done echo "ERROR: Timed out waiting for ${service} to become healthy." >&2 return 1 } start_portainer() { ensure_env_file ensure_traefik_network portainer_compose up -d } start_traefik() { ensure_env_file ensure_traefik_network traefik_compose up -d } stop_traefik() { ensure_env_file traefik_compose down >/dev/null 2>&1 || true } docker_volume_exists() { docker volume inspect "$1" >/dev/null 2>&1 } backup_named_volume() { local volume_name="$1" local archive_path="$2" docker run --rm \ -v "${volume_name}:/volume:ro" \ -v "$(dirname "${archive_path}"):/backup" \ "${VOLUME_BACKUP_IMAGE}" \ sh -lc "tar -czf /backup/$(basename "${archive_path}") -C /volume ." } volume_running_containers() { docker ps --filter "volume=$1" --format '{{.Names}}' } restore_named_volume() { local volume_name="$1" local archive_path="$2" local running running="$(volume_running_containers "${volume_name}")" if [[ -n "${running}" ]]; then echo "Volume ${volume_name} is currently used by running containers:" >&2 echo "${running}" >&2 echo "Stop those containers before restoring this volume." >&2 exit 1 fi docker run --rm \ -v "${volume_name}:/volume" \ -v "$(dirname "${archive_path}"):/backup" \ "${VOLUME_BACKUP_IMAGE}" \ sh -lc "find /volume -mindepth 1 -delete && tar -xzf /backup/$(basename "${archive_path}") -C /volume" }