7c1bd2d0c0
Build & Deploy / Build & Push Docker Image (push) Successful in 2m52s
Test / Type Check (all packages) (push) Successful in 50s
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 39s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m1s
495 lines
14 KiB
Bash
Executable File
495 lines
14 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"
|
|
TRAEFIK_COMPOSE_FILE="${ROOT_DIR}/traefik.yaml"
|
|
ENV_FILE="${ROOT_DIR}/.env.docker.production"
|
|
PROJECT_NAME="${DOCKER_PROD_PROJECT_NAME:-rentaldrivego-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}"
|
|
DEFAULT_ACME_EMAIL="rentaldrivego@gmail.com"
|
|
DEFAULT_API_DOMAIN="api.rentaldrivego.ma"
|
|
DEFAULT_PUBLIC_SITE_DOMAIN="rentaldrivego.ma"
|
|
DEFAULT_CORS_ORIGINS="https://rentaldrivego.ma,https://www.rentaldrivego.ma"
|
|
|
|
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" '
|
|
/^[[:space:]]*#/ || index($0, "=") == 0 {
|
|
next
|
|
}
|
|
{
|
|
name = $1
|
|
sub(/^[[:space:]]*export[[:space:]]+/, "", name)
|
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", name)
|
|
if (name == key) {
|
|
value = substr($0, index($0, "=") + 1)
|
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
|
}
|
|
}
|
|
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 \
|
|
REGISTRY_TOKEN \
|
|
REGISTRY_INSECURE \
|
|
VPN_CONTAINER
|
|
do
|
|
export_env_value_if_unset "${key}"
|
|
done
|
|
|
|
if [[ -z "${REGISTRY_PASSWORD:-}" && -n "${REGISTRY_TOKEN:-}" ]]; then
|
|
REGISTRY_PASSWORD="${REGISTRY_TOKEN}"
|
|
export REGISTRY_PASSWORD
|
|
fi
|
|
}
|
|
|
|
registry_host_name() {
|
|
local host="${REGISTRY_HOST:-}"
|
|
|
|
host="${host#http://}"
|
|
host="${host#https://}"
|
|
host="${host%%/*}"
|
|
printf '%s' "${host}"
|
|
}
|
|
|
|
ensure_registry_transport_configured() {
|
|
local insecure="${REGISTRY_INSECURE:-false}"
|
|
local host
|
|
|
|
if [[ "${insecure}" != "true" && "${insecure}" != "1" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
host="$(registry_host_name)"
|
|
if [[ -z "${host}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
REGISTRY_HOST="${host}"
|
|
export REGISTRY_HOST
|
|
|
|
if docker info 2>/dev/null | awk '/Insecure Registries:/,/Live Restore Enabled:/' | grep -Fqx " ${host}"; then
|
|
return 0
|
|
fi
|
|
|
|
cat >&2 <<EOF
|
|
Docker is not configured to pull ${host} over HTTP.
|
|
|
|
Docker image names cannot include http://. Configure the VPS Docker daemon once by adding
|
|
this entry to /etc/docker/daemon.json, merging it with any existing settings:
|
|
|
|
{
|
|
"insecure-registries": ["${host}"]
|
|
}
|
|
|
|
Then restart Docker:
|
|
|
|
sudo systemctl restart docker
|
|
|
|
Then rerun ./deploy-production.sh.
|
|
EOF
|
|
exit 1
|
|
}
|
|
|
|
validate_prod_env_file() {
|
|
local acme_email api_domain public_site_domain cors_origins 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)"
|
|
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 ACME_EMAIL=${DEFAULT_ACME_EMAIL} for Let's Encrypt." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "${api_domain}" != "${DEFAULT_API_DOMAIN}" ]]; then
|
|
echo "Invalid API_DOMAIN in ${ENV_FILE}. Set API_DOMAIN=${DEFAULT_API_DOMAIN}." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "${public_site_domain}" != "${DEFAULT_PUBLIC_SITE_DOMAIN}" ]]; then
|
|
echo "Invalid PUBLIC_SITE_DOMAIN in ${ENV_FILE}. Set PUBLIC_SITE_DOMAIN=${DEFAULT_PUBLIC_SITE_DOMAIN}." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "${cors_origins}" != "${DEFAULT_CORS_ORIGINS}" ]]; then
|
|
echo "Invalid CORS_ORIGINS in ${ENV_FILE}. Set CORS_ORIGINS=${DEFAULT_CORS_ORIGINS}." >&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
|
|
|
|
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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}" "$@"
|
|
}
|
|
|
|
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() {
|
|
local archive_disabled="${IMAGE_ARCHIVE_DISABLED:-false}"
|
|
|
|
ensure_registry_transport_configured
|
|
|
|
if [[ -z "${REGISTRY_PASSWORD:-}" && -n "${REGISTRY_TOKEN:-}" ]]; then
|
|
REGISTRY_PASSWORD="${REGISTRY_TOKEN}"
|
|
export REGISTRY_PASSWORD
|
|
fi
|
|
|
|
if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" == "true" || "${IMAGE_ARCHIVE_REQUIRED:-0}" == "1" ]]; then
|
|
if [[ "${archive_disabled}" != "true" && "${archive_disabled}" != "1" ]]; then
|
|
echo "IMAGE_ARCHIVE_REQUIRED is enabled; skipping docker login."
|
|
return 0
|
|
fi
|
|
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 or REGISTRY_TOKEN are 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
|
|
ensure_registry_transport_configured
|
|
|
|
if load_release_image_archives; then
|
|
verify_prod_release_image
|
|
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." >&2
|
|
echo "The VPS is configured for VPN/shared-folder image handoff. Create the archive first, for example:" >&2
|
|
echo " VPN_CONTAINER=<vpn-container-id-or-name> ./fetch-image-through-vpn.sh ${IMAGE_TAG}" >&2
|
|
echo "Or let deploy fetch it before starting services:" >&2
|
|
echo " VPN_CONTAINER=<vpn-container-id-or-name> ./deploy-production.sh --fetch-through-vpn" >&2
|
|
echo "You can also set VPN_CONTAINER in .env.docker.production, then run:" >&2
|
|
echo " ./deploy-production.sh" >&2
|
|
exit 1
|
|
fi
|
|
|
|
prod_compose pull migrate api homepage storefront dashboard admin
|
|
verify_prod_release_image
|
|
}
|
|
|
|
verify_prod_release_image() {
|
|
require_release_image
|
|
|
|
local image="${APP_IMAGE:-rentaldrivego/carmanagement}:${IMAGE_TAG}"
|
|
|
|
echo "Verifying production image contents: ${image}"
|
|
if ! docker image inspect "${image}" >/dev/null 2>&1; then
|
|
echo "Production image is not available locally after load/pull: ${image}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! docker run --rm --entrypoint sh "${image}" -c 'test -f /app/config/nextSecurityHeaders.js' >/dev/null 2>&1; then
|
|
echo "Production image is missing /app/config/nextSecurityHeaders.js: ${image}" >&2
|
|
echo "This image cannot start the Next.js apps because their next.config.js files require ../../config/nextSecurityHeaders." >&2
|
|
echo "Build and transfer a fresh image from the full repository, then rerun deployment." >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
load_release_image_archives() {
|
|
local archive_dir="${IMAGE_ARCHIVE_DIR:-}"
|
|
local archive
|
|
local loaded=0
|
|
|
|
if [[ "${IMAGE_ARCHIVE_DISABLED:-false}" == "true" || "${IMAGE_ARCHIVE_DISABLED:-0}" == "1" ]]; then
|
|
echo "Image archive loading is disabled; pulling from registry."
|
|
return 1
|
|
fi
|
|
|
|
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_traefik() {
|
|
ensure_env_file
|
|
ensure_traefik_network
|
|
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"
|
|
}
|