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
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.
473 lines
13 KiB
Bash
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"
|
|
}
|