Files
carmanagement/scripts/docker-prod-common.sh
T
root 0394f0ea2a
Build & Deploy / Build & Push Docker Image (push) Successful in 2m53s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Successful in 46s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Successful in 40s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 58s
Update production registry pull over VPN
Configure production defaults to pull images directly from the VPN registry at 10.0.0.4, with the VPS reachable at 10.0.0.1, and disable archive handoff by default.
2026-07-02 11:36:35 -04:00

473 lines
13 KiB
Bash

#!/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"
REGISTRY_COMPOSE_FILE="${ROOT_DIR}/docker-compose.registry.production.yml"
TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml"
ENV_FILE="${ROOT_DIR}/.env.docker.production"
DYNAMIC_DIR="${ROOT_DIR}/dynamic"
REGISTRY_DYNAMIC_FILE="${DYNAMIC_DIR}/registry-upstream.yml"
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-prod}"
PORTAINER_PROJECT_NAME="${DOCKER_PORTAINER_PROJECT_NAME:-rentaldrivego-portainer-prod}"
REGISTRY_PROJECT_NAME="${DOCKER_REGISTRY_PROJECT_NAME:-rentaldrivego-registry-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}"
REGISTRY_AUTH_DIR="${ROOT_DIR}/docker/registry/auth"
REGISTRY_AUTH_FILE="${REGISTRY_AUTH_DIR}/htpasswd"
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
validate_prod_env_file
}
read_env_value() {
local key="$1"
local value
value="$(
awk -F= -v key="$key" '
$1 == key {
value = substr($0, index($0, "=") + 1)
}
END {
if (value != "") {
print value
}
}
' "${ENV_FILE}"
)"
value="${value%$'\r'}"
value="${value#\"}"
value="${value%\"}"
printf '%s' "${value}"
}
export_env_value_if_unset() {
local key="$1"
local value
if [[ -n "${!key:-}" ]]; then
return 0
fi
value="$(read_env_value "${key}")"
if [[ -n "${value}" ]]; then
export "${key}=${value}"
fi
}
export_compose_env_from_file() {
local line key value
while IFS= read -r line || [[ -n "${line}" ]]; do
line="${line%$'\r'}"
[[ -z "${line//[[:space:]]/}" || "${line}" =~ ^[[:space:]]*# ]] && continue
line="${line#"${line%%[![:space:]]*}"}"
line="${line#export }"
[[ "${line}" == *"="* ]] || continue
key="${line%%=*}"
value="${line#*=}"
key="${key//[[:space:]]/}"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
if [[ "${value}" == \"*\" && "${value}" == *\" ]]; then
value="${value#\"}"
value="${value%\"}"
elif [[ "${value}" == \'*\' && "${value}" == *\' ]]; then
value="${value#\'}"
value="${value%\'}"
fi
if [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
export "${key}=${value}"
fi
done < "${ENV_FILE}"
}
load_prod_env_exports() {
local key
for key in \
ACME_EMAIL \
APP_IMAGE \
APP_VERSION \
IMAGE_TAG \
IMAGE_ARCHIVE_DIR \
IMAGE_ARCHIVE_REQUIRED \
REGISTRY_HOST \
REGISTRY_USER \
REGISTRY_PASSWORD
do
export_env_value_if_unset "${key}"
done
}
validate_prod_env_file() {
local acme_email api_domain public_site_domain cors_origins portainer_domain postgres_password redis_password jwt_secret
acme_email="$(read_env_value ACME_EMAIL)"
api_domain="$(read_env_value API_DOMAIN)"
public_site_domain="$(read_env_value PUBLIC_SITE_DOMAIN)"
cors_origins="$(read_env_value CORS_ORIGINS)"
portainer_domain="$(read_env_value PORTAINER_DOMAIN)"
postgres_password="$(read_env_value POSTGRES_PASSWORD)"
redis_password="$(read_env_value REDIS_PASSWORD)"
jwt_secret="$(read_env_value JWT_SECRET)"
if [[ -z "${acme_email}" || "${acme_email}" != *@* || "${acme_email}" == *example.com* ]]; then
echo "Invalid ACME_EMAIL in ${ENV_FILE}. Set it to a real contact email for Let's Encrypt, not example.com." >&2
exit 1
fi
if [[ -z "${api_domain}" || "${api_domain}" == *example.com* ]]; then
echo "Invalid API_DOMAIN in ${ENV_FILE}. Set it to the real production API hostname, not example.com." >&2
exit 1
fi
if [[ -z "${public_site_domain}" || "${public_site_domain}" == *example.com* ]]; then
echo "Invalid PUBLIC_SITE_DOMAIN in ${ENV_FILE}. Set it to the real production site hostname, not example.com." >&2
exit 1
fi
if [[ -z "${cors_origins}" || "${cors_origins}" == *example.com* ]]; then
echo "Invalid CORS_ORIGINS in ${ENV_FILE}. Replace example.com origins with the real production domains." >&2
exit 1
fi
for pair in "POSTGRES_PASSWORD:${postgres_password}" "REDIS_PASSWORD:${redis_password}" "JWT_SECRET:${jwt_secret}"; do
key="${pair%%:*}"
value="${pair#*:}"
if [[ -z "${value}" || "${value}" == replace-with-* || "${value}" == *changeme* || "${value}" == *change-me* ]]; then
echo "Invalid ${key} in ${ENV_FILE}. Set a real production secret." >&2
exit 1
fi
done
if [[ -n "${portainer_domain}" && "${portainer_domain}" == *example.com* ]]; then
echo "Invalid PORTAINER_DOMAIN in ${ENV_FILE}. Set a real hostname or disable the portainer router." >&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
}
ensure_dynamic_dir() {
mkdir -p "${DYNAMIC_DIR}"
}
prod_compose() {
export_compose_env_from_file
APP_ENV_FILE="${ENV_FILE}" DOCKER_PROD_API_UPLOADS_VOLUME="${API_UPLOADS_VOLUME}" docker compose -p "${PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PROD_COMPOSE_FILE}" "$@"
}
portainer_compose() {
export_compose_env_from_file
docker compose -p "${PORTAINER_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${PORTAINER_COMPOSE_FILE}" "$@"
}
registry_compose() {
export_compose_env_from_file
docker compose -p "${REGISTRY_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${REGISTRY_COMPOSE_FILE}" "$@"
}
traefik_compose() {
export_compose_env_from_file
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|storefront|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() {
IMAGE_TAG="${IMAGE_TAG:-${APP_VERSION:-}}"
export IMAGE_TAG
if [[ -z "${IMAGE_TAG:-}" ]]; then
echo "IMAGE_TAG must be set for production deploys." >&2
exit 1
fi
}
login_registry_if_configured() {
if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then
echo "IMAGE_ARCHIVE_REQUIRED is enabled; skipping docker login."
return 0
fi
if [[ -n "${IMAGE_ARCHIVE_DIR:-}" && -d "${IMAGE_ARCHIVE_DIR}" ]]; then
shopt -s nullglob
local archives=(
"${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tar
"${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tar.gz
"${IMAGE_ARCHIVE_DIR}"/*"${IMAGE_TAG:-latest}"*.tgz
)
shopt -u nullglob
if [[ ${#archives[@]} -gt 0 && -z "${REGISTRY_USER:-}" && -z "${REGISTRY_PASSWORD:-}" ]]; then
echo "Found image archive in IMAGE_ARCHIVE_DIR; skipping registry login."
return 0
fi
fi
if [[ -z "${REGISTRY_USER:-}" && -z "${REGISTRY_PASSWORD:-}" ]]; then
if [[ -n "${REGISTRY_HOST:-}" ]]; then
echo "REGISTRY_HOST is set but no registry credentials are configured; skipping docker login."
fi
return 0
fi
if [[ -z "${REGISTRY_HOST:-}" || -z "${REGISTRY_USER:-}" || -z "${REGISTRY_PASSWORD:-}" ]]; then
echo "REGISTRY_HOST, REGISTRY_USER, and REGISTRY_PASSWORD are all required when registry credentials are configured." >&2
exit 1
fi
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin
}
pull_prod_release_images() {
require_release_image
if load_release_image_archives; then
return 0
fi
if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then
echo "IMAGE_ARCHIVE_REQUIRED is enabled, but no archive for IMAGE_TAG=${IMAGE_TAG} was found in ${IMAGE_ARCHIVE_DIR:-<unset>}." >&2
echo "Direct docker pull is disabled in this mode. Create a shared archive first, for example:" >&2
echo " docker save ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG} -o ${IMAGE_ARCHIVE_DIR:-./image-archives}/carmanagement-${IMAGE_TAG}.tar" >&2
exit 1
fi
prod_compose pull migrate api storefront dashboard admin
}
load_release_image_archives() {
local archive_dir="${IMAGE_ARCHIVE_DIR:-}"
local archive
local loaded=0
if [[ -z "${archive_dir}" ]]; then
return 1
fi
if [[ ! -d "${archive_dir}" ]]; then
echo "IMAGE_ARCHIVE_DIR is set but does not exist: ${archive_dir}" >&2
exit 1
fi
shopt -s nullglob
for archive in \
"${archive_dir}"/*"${IMAGE_TAG}"*.tar \
"${archive_dir}"/*"${IMAGE_TAG}"*.tar.gz \
"${archive_dir}"/*"${IMAGE_TAG}"*.tgz
do
echo "Loading Docker image archive: ${archive}"
docker load -i "${archive}"
loaded=1
done
shopt -u nullglob
if [[ ${loaded} -eq 0 ]]; then
echo "No Docker image archive found for IMAGE_TAG=${IMAGE_TAG} in ${archive_dir}."
return 1
fi
echo "Loaded image archive(s) for ${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}; skipping docker pull."
return 0
}
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
}
render_registry_proxy_config() {
ensure_dynamic_dir
if [[ -z "${REGISTRY_DOMAIN:-}" && -z "${REGISTRY_UPSTREAM_URL:-}" ]]; then
rm -f "${REGISTRY_DYNAMIC_FILE}"
return 0
fi
if [[ -z "${REGISTRY_DOMAIN:-}" || -z "${REGISTRY_UPSTREAM_URL:-}" ]]; then
echo "REGISTRY_DOMAIN and REGISTRY_UPSTREAM_URL must either both be set or both be unset." >&2
exit 1
fi
cat > "${REGISTRY_DYNAMIC_FILE}" <<EOF
http:
routers:
registry:
rule: Host(\`${REGISTRY_DOMAIN}\`)
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: registry-upstream
services:
registry-upstream:
loadBalancer:
servers:
- url: "${REGISTRY_UPSTREAM_URL}"
EOF
}
ensure_registry_auth_file() {
mkdir -p "${REGISTRY_AUTH_DIR}"
if [[ -n "${REGISTRY_USER:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
docker run --rm --entrypoint htpasswd httpd:2-alpine -Bbn "${REGISTRY_USER}" "${REGISTRY_PASSWORD}" > "${REGISTRY_AUTH_FILE}"
chmod 600 "${REGISTRY_AUTH_FILE}"
return 0
fi
if [[ -f "${REGISTRY_AUTH_FILE}" ]]; then
return 0
fi
echo "Set REGISTRY_USER and REGISTRY_PASSWORD in .env.docker.production or create ${REGISTRY_AUTH_FILE} manually." >&2
exit 1
}
start_registry() {
ensure_env_file
ensure_traefik_network
ensure_registry_auth_file
registry_compose up -d
}
start_traefik() {
ensure_env_file
ensure_traefik_network
render_registry_proxy_config
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"
}