324 lines
8.8 KiB
Bash
Executable File
324 lines
8.8 KiB
Bash
Executable File
#!/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}"
|
|
}
|
|
|
|
validate_prod_env_file() {
|
|
local api_domain public_site_domain cors_origins pgmanage_domain portainer_domain
|
|
|
|
api_domain="$(read_env_value API_DOMAIN)"
|
|
public_site_domain="$(read_env_value PUBLIC_SITE_DOMAIN)"
|
|
cors_origins="$(read_env_value CORS_ORIGINS)"
|
|
pgmanage_domain="$(read_env_value PGMANAGE_DOMAIN)"
|
|
portainer_domain="$(read_env_value PORTAINER_DOMAIN)"
|
|
|
|
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
|
|
|
|
if [[ -n "${pgmanage_domain}" && "${pgmanage_domain}" == *example.com* ]]; then
|
|
echo "Invalid PGMANAGE_DOMAIN in ${ENV_FILE}. Set a real hostname or disable the pgmanage router." >&2
|
|
exit 1
|
|
fi
|
|
|
|
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() {
|
|
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}" "$@"
|
|
}
|
|
|
|
registry_compose() {
|
|
docker compose -p "${REGISTRY_PROJECT_NAME}" --env-file "${ENV_FILE}" -f "${REGISTRY_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
|
|
}
|
|
|
|
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"
|
|
}
|