7ff2dbb139
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
487 lines
14 KiB
Bash
Executable File
487 lines
14 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_ARCHIVE_DIR before running this script.
|
|
Example: /opt/docker-image-transfer/carmanagement-latest.tar
|
|
When IMAGE_ARCHIVE_REQUIRED=true and VPN_CONTAINER is set, a missing archive is
|
|
fetched through the VPN container before loading the release image.
|
|
|
|
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 ! should_fetch_release_archive_through_vpn; then
|
|
return 0
|
|
fi
|
|
|
|
detect_vpn_container_if_unset
|
|
|
|
if [[ -z "${VPN_CONTAINER:-}" ]]; then
|
|
echo "VPN_CONTAINER is required to fetch the image archive through VPN." >&2
|
|
usage >&2
|
|
exit 1
|
|
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}"
|
|
}
|
|
|
|
detect_vpn_container_if_unset() {
|
|
local candidates
|
|
local candidate_count
|
|
|
|
if [[ -n "${VPN_CONTAINER:-}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
candidates="$(
|
|
docker ps --format '{{.ID}} {{.Names}} {{.Image}}' 2>/dev/null \
|
|
| awk 'BEGIN { IGNORECASE = 1 } /(vpn|gluetun|wireguard|openvpn|tailscale|wg-easy)/ { print $1 }'
|
|
)"
|
|
|
|
candidate_count="$(printf '%s\n' "${candidates}" | sed '/^$/d' | wc -l | tr -d ' ')"
|
|
|
|
if [[ "${candidate_count}" == "1" ]]; then
|
|
VPN_CONTAINER="$(printf '%s\n' "${candidates}" | sed '/^$/d' | sed -n '1p')"
|
|
export VPN_CONTAINER
|
|
echo "Auto-detected VPN container: ${VPN_CONTAINER}"
|
|
fi
|
|
}
|
|
|
|
ensure_archive_source_ready() {
|
|
if [[ "${IMAGE_ARCHIVE_DISABLED:-false}" == "true" || "${IMAGE_ARCHIVE_DISABLED:-0}" == "1" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" != "true" && "${IMAGE_ARCHIVE_REQUIRED:-0}" != "1" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if release_archive_exists; then
|
|
return 0
|
|
fi
|
|
|
|
detect_vpn_container_if_unset
|
|
|
|
if [[ -n "${VPN_CONTAINER:-}" ]]; then
|
|
echo "No local image archive found; deploy will fetch it through VPN container ${VPN_CONTAINER}."
|
|
return 0
|
|
fi
|
|
|
|
echo "IMAGE_ARCHIVE_REQUIRED=true and no matching archive exists in ${IMAGE_ARCHIVE_DIR:-<unset>}." >&2
|
|
echo "Direct docker pull is disabled in this mode, and VPN_CONTAINER is not set." >&2
|
|
echo "No single VPN container could be auto-detected from common VPN names/images." >&2
|
|
echo "Find the VPN container with:" >&2
|
|
echo " docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}'" >&2
|
|
echo "Run one of:" >&2
|
|
echo " VPN_CONTAINER=<vpn-container-id-or-name> ./deploy-production.sh --fetch-through-vpn" >&2
|
|
echo " VPN_CONTAINER=<vpn-container-id-or-name> ./fetch-image-through-vpn.sh ${IMAGE_TAG}" >&2
|
|
echo "Or set VPN_CONTAINER in .env.docker.production and rerun ./deploy-production.sh." >&2
|
|
exit 1
|
|
}
|
|
|
|
release_archive_exists() {
|
|
local archive_dir="${IMAGE_ARCHIVE_DIR:-}"
|
|
|
|
if [[ -z "${archive_dir}" || ! -d "${archive_dir}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
shopt -s nullglob
|
|
local archives=(
|
|
"${archive_dir}"/*"${IMAGE_TAG}"*.tar
|
|
"${archive_dir}"/*"${IMAGE_TAG}"*.tar.gz
|
|
"${archive_dir}"/*"${IMAGE_TAG}"*.tgz
|
|
)
|
|
shopt -u nullglob
|
|
|
|
[[ ${#archives[@]} -gt 0 ]]
|
|
}
|
|
|
|
should_fetch_release_archive_through_vpn() {
|
|
if [[ "${IMAGE_ARCHIVE_DISABLED:-false}" == "true" || "${IMAGE_ARCHIVE_DISABLED:-0}" == "1" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ "${fetch_through_vpn}" -eq 1 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "${IMAGE_ARCHIVE_REQUIRED:-false}" != "true" && "${IMAGE_ARCHIVE_REQUIRED:-0}" != "1" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
if [[ -z "${VPN_CONTAINER:-}" ]]; then
|
|
return 1
|
|
fi
|
|
|
|
! release_archive_exists
|
|
}
|
|
|
|
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
|
|
ensure_archive_source_ready
|
|
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 carplace 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 carplace 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."
|