Files
carmanagement/production/deploy-production.sh
T
root e2b564aea5
Build & Deploy / Build & Push Docker Image (push) Successful in 2m51s
Test / Type Check (all packages) (push) Successful in 51s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 49s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 59s
fix prod
2026-07-01 09:18:14 -04:00

384 lines
11 KiB
Bash
Executable File

#!/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] [--image-tag <tag>] [--fetch-through-vpn] [--pull-registry]
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
Options:
--image-tag <tag> Override IMAGE_TAG from .env.docker.production for this run
--fetch-through-vpn
Fetch IMAGE_TAG into IMAGE_ARCHIVE_DIR before deployment.
Requires VPN_CONTAINER and prompts for REGISTRY_TOKEN when unset.
--pull-registry Ignore IMAGE_ARCHIVE_DIR and pull directly from the registry
EOF
}
include_pgmanage=0
skip_prune=0
fetch_through_vpn=0
while [[ $# -gt 0 ]]; do
case "$1" in
--with-pgmanage)
include_pgmanage=1
shift
;;
--skip-prune)
skip_prune=1
shift
;;
--image-tag)
if [[ -z "${2:-}" ]]; then
echo "--image-tag requires a tag value." >&2
usage >&2
exit 1
fi
IMAGE_TAG="$2"
export IMAGE_TAG
shift 2
;;
--fetch-through-vpn)
fetch_through_vpn=1
shift
;;
--pull-registry)
IMAGE_ARCHIVE_DIR=""
IMAGE_ARCHIVE_REQUIRED=false
IMAGE_ARCHIVE_DISABLED=true
export IMAGE_ARCHIVE_DIR IMAGE_ARCHIVE_REQUIRED IMAGE_ARCHIVE_DISABLED
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ "${fetch_through_vpn}" -eq 1 && ("${IMAGE_ARCHIVE_DISABLED:-false}" == "true" || "${IMAGE_ARCHIVE_DISABLED:-0}" == "1") ]]; then
echo "--fetch-through-vpn and --pull-registry cannot be used together." >&2
usage >&2
exit 1
fi
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
}
fetch_release_archive_through_vpn() {
if [[ "${fetch_through_vpn}" -ne 1 ]]; then
return 0
fi
echo "Fetching image archive through VPN container"
IMAGE_TAG="${IMAGE_TAG}" \
APP_IMAGE="${APP_IMAGE:-10.0.0.4/melabidi/carmanagement}" \
TRANSFER_DIR="${IMAGE_ARCHIVE_DIR:-/opt/docker-image-transfer}" \
"${ROOT_DIR}/fetch-image-through-vpn.sh" "${IMAGE_TAG}"
}
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 [[ "${IMAGE_ARCHIVE_DISABLED:-false}" == "true" || "${IMAGE_ARCHIVE_DISABLED:-0}" == "1" ]]; then
echo " Image source: registry pull (archive loading disabled)"
elif [[ -n "${IMAGE_ARCHIVE_DIR:-}" ]]; then
echo " Image archive dir: ${IMAGE_ARCHIVE_DIR}"
else
echo " Image source: registry pull"
fi
if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then
echo " Registry pull: disabled (IMAGE_ARCHIVE_REQUIRED=${IMAGE_ARCHIVE_REQUIRED})"
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
fetch_release_archive_through_vpn
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 homepage 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 homepage 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."