#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${ROOT_DIR}" source "${ROOT_DIR}/scripts/docker-prod-common.sh" usage() { cat <<'EOF' Usage: ./deploy-production.sh [--with-pgmanage] [--skip-prune] What it does: 1. Validates .env.docker.production 2. Loads IMAGE_TAG, APP_IMAGE, registry, and archive settings from the env file 3. Loads a matching Docker image archive from IMAGE_ARCHIVE_DIR when present 4. Otherwise logs in to the registry if credentials are configured, then pulls 5. Reuses healthy Traefik, Postgres, and Redis containers when present 6. Waits for health checks and prints container status VPN/shared-folder image flow: Put an archive matching IMAGE_TAG in ./image-archives before running this script. Example: ./image-archives/carmanagement-latest.tar EOF } include_pgmanage=0 skip_prune=0 while [[ $# -gt 0 ]]; do case "$1" in --with-pgmanage) include_pgmanage=1 shift ;; --skip-prune) skip_prune=1 shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown option: $1" >&2 usage >&2 exit 1 ;; esac done require_command() { local command_name="$1" if ! command -v "${command_name}" >/dev/null 2>&1; then echo "Missing required command: ${command_name}" >&2 exit 1 fi } normalize_archive_dir() { if [[ -z "${IMAGE_ARCHIVE_DIR:-}" ]]; then return 0 fi case "${IMAGE_ARCHIVE_DIR}" in /*) ;; *) IMAGE_ARCHIVE_DIR="${ROOT_DIR}/${IMAGE_ARCHIVE_DIR#./}" export IMAGE_ARCHIVE_DIR ;; esac } print_release_summary() { echo "Production deployment" echo " Image: ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}" echo " Env: ${ENV_FILE}" echo " Compose project: ${PROJECT_NAME}" if [[ -n "${IMAGE_ARCHIVE_DIR:-}" ]]; then echo " Image archive dir: ${IMAGE_ARCHIVE_DIR}" fi } container_status() { local container_name="$1" docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_name}" 2>/dev/null || echo "missing" } compose_service_status() { local compose_name="$1" local service_name="$2" local container_id case "${compose_name}" in prod) container_id="$(prod_compose ps -q "${service_name}" 2>/dev/null || true)" ;; traefik) container_id="$(traefik_compose ps -q "${service_name}" 2>/dev/null || true)" ;; *) echo "missing" return 0 ;; esac if [[ -z "${container_id}" ]]; then echo "missing" return 0 fi docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "${container_id}" 2>/dev/null || echo "missing" } first_container_id() { docker ps -q "$@" 2>/dev/null | sed -n '1p' } ensure_container_on_traefik_network() { local container_id="$1" local networks networks="$(docker inspect --format='{{range $name, $_ := .NetworkSettings.Networks}}{{printf "%s " $name}}{{end}}' "${container_id}" 2>/dev/null || true)" case " ${networks} " in *" ${TRAEFIK_NETWORK} "*) ;; *) echo "Connecting existing Traefik container ${container_id} to ${TRAEFIK_NETWORK}." docker network connect "${TRAEFIK_NETWORK}" "${container_id}" 2>/dev/null || true ;; esac } find_running_traefik_container() { local container_id container_id="$(first_container_id --filter name=traefik)" if [[ -n "${container_id}" ]]; then printf '%s' "${container_id}" return 0 fi container_id="$(first_container_id --filter ancestor=traefik --filter status=running)" if [[ -n "${container_id}" ]]; then printf '%s' "${container_id}" return 0 fi container_id="$(first_container_id --filter ancestor=traefik:latest --filter status=running)" if [[ -n "${container_id}" ]]; then printf '%s' "${container_id}" return 0 fi } ensure_ports_available_for_traefik() { local container_id local port_listener container_id="$(first_container_id --filter publish=80 --filter status=running)" if [[ -n "${container_id}" ]]; then echo "Port 80 is already used by container ${container_id}, and it does not look like Traefik." >&2 echo "Stop that container or set up Traefik outside this deployment before rerunning." >&2 exit 1 fi port_listener="$(sudo ss -H -ltnp 'sport = :80' 2>/dev/null || ss -H -ltnp 'sport = :80' 2>/dev/null || true)" if [[ -n "${port_listener}" ]]; then echo "Port 80 is already used by a host process:" >&2 echo "${port_listener}" >&2 echo "Stop that process before starting Traefik, or manage Traefik outside this deployment." >&2 exit 1 fi container_id="$(first_container_id --filter publish=443 --filter status=running)" if [[ -n "${container_id}" ]]; then echo "Port 443 is already used by container ${container_id}, and it does not look like Traefik." >&2 echo "Stop that container or set up Traefik outside this deployment before rerunning." >&2 exit 1 fi port_listener="$(sudo ss -H -ltnp 'sport = :443' 2>/dev/null || ss -H -ltnp 'sport = :443' 2>/dev/null || true)" if [[ -n "${port_listener}" ]]; then echo "Port 443 is already used by a host process:" >&2 echo "${port_listener}" >&2 echo "Stop that process before starting Traefik, or manage Traefik outside this deployment." >&2 exit 1 fi } ensure_traefik_ready() { local existing_container_id local status status="$(compose_service_status traefik traefik)" case "${status}" in running|healthy) echo "Traefik is already ${status}; reusing existing container." ;; missing|exited|dead|created|restarting|removing|paused|unhealthy) existing_container_id="$(find_running_traefik_container)" if [[ -n "${existing_container_id}" ]]; then echo "Found existing Traefik container ${existing_container_id}; reusing it." ensure_container_on_traefik_network "${existing_container_id}" return 0 fi ensure_ports_available_for_traefik echo "Starting Traefik (status: ${status})" start_traefik ;; *) existing_container_id="$(find_running_traefik_container)" if [[ -n "${existing_container_id}" ]]; then echo "Found existing Traefik container ${existing_container_id}; reusing it." ensure_container_on_traefik_network "${existing_container_id}" return 0 fi ensure_ports_available_for_traefik echo "Starting Traefik (status: ${status})" start_traefik ;; esac } ensure_postgres_ready() { local status status="$(compose_service_status prod postgres)" case "${status}" in healthy) echo "Postgres is already healthy; reusing existing container." ;; running) echo "Postgres is running; waiting for health check." wait_for_healthy postgres 120 ;; missing|exited|dead|created|restarting|removing|paused|unhealthy) echo "Starting Postgres (status: ${status})" prod_compose up -d postgres wait_for_healthy postgres 120 ;; *) echo "Starting Postgres (status: ${status})" prod_compose up -d postgres wait_for_healthy postgres 120 ;; esac } ensure_redis_ready() { local status status="$(compose_service_status prod redis)" case "${status}" in healthy) echo "Redis is already healthy; reusing existing container." ;; running) echo "Redis is running; waiting for health check." wait_for_healthy redis 120 ;; missing|exited|dead|created|restarting|removing|paused|unhealthy) echo "Starting Redis (status: ${status})" prod_compose up -d redis wait_for_healthy redis 120 ;; *) echo "Starting Redis (status: ${status})" prod_compose up -d redis wait_for_healthy redis 120 ;; esac } require_command docker ensure_env_file load_prod_env_exports require_release_image normalize_archive_dir print_release_summary ensure_traefik_network ensure_api_uploads_volume login_registry_if_configured echo "Preparing release image" pull_prod_release_images echo "Checking shared infrastructure" ensure_traefik_ready ensure_postgres_ready ensure_redis_ready echo "Running database migrations" run_prod_migrations echo "Starting application services" app_services=(api storefront dashboard admin) if [[ "${include_pgmanage}" -eq 1 ]]; then prod_compose --profile private-tools up -d "${app_services[@]}" pgmanage else prod_compose up -d "${app_services[@]}" fi wait_for_healthy api 180 wait_for_healthy storefront 180 wait_for_healthy dashboard 180 wait_for_healthy admin 180 if [[ "${skip_prune}" -eq 0 ]]; then echo "Pruning dangling images" docker image prune -f >/dev/null fi echo "Final container status" prod_compose ps echo "Production release ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG} is up and healthy."