Files
carmanagement/.gitlab-ci.yml
T
2026-06-04 13:17:52 -04:00

286 lines
12 KiB
YAML

stages:
- test
- build
- deploy
variables:
DOCKERFILE_PATH: Dockerfile.production
DEPLOY_ROOT: /opt/rentaldrivego
# Required: disable TLS so the docker client can reach the dind daemon
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: tcp://docker:2375
# ====================================
# TEST STAGE
# Runs on every push and merge request
# ====================================
unit_tests:
stage: test
image: node:20-bookworm
before_script:
- npm ci
- npm run db:generate
script:
- npm run type-check
- npm run test:api
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
integration_tests:
stage: test
image: node:20-bookworm
services:
- name: postgres:16-alpine
alias: postgres
variables:
POSTGRES_DB: rentaldrivego_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
- name: redis:7-alpine
alias: redis
variables:
# Used by db:deploy (Prisma migrations) and any code reading DATABASE_URL directly
DATABASE_URL: "postgresql://postgres:password@postgres:5432/rentaldrivego_test"
REDIS_URL: "redis://redis:6379"
NODE_ENV: test
before_script:
- npm ci
- npm run db:generate
# Overwrite the local .env.test so vitest integration config gets CI service hostnames
# (the file uses postgres/redis aliases, not localhost)
- |
cat > apps/api/.env.test << 'EOF'
DATABASE_URL=postgresql://postgres:password@postgres:5432/rentaldrivego_test
REDIS_URL=redis://redis:6379
JWT_SECRET=test-secret
JWT_EXPIRY=8h
NODE_ENV=test
FILE_STORAGE_ROOT=/tmp/rentaldrivego-test-storage
EOF
- npm run db:deploy
script:
- npm run test:api:integration
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# ====================================
# BUILD STAGE
# ====================================
build_image:
stage: build
image: docker:24
services:
- name: docker:24-dind
alias: docker
command: ["--tls=false"]
variables:
HEALTHCHECK_TCP_PORT: "2375"
before_script:
- |
attempts=0
until docker info >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 60 ]; then
echo "Docker daemon did not become ready after 60 seconds." >&2
echo "On self-hosted runners, docker:dind requires the runner Docker executor to run with privileged = true." >&2
exit 1
fi
echo "Waiting for Docker daemon..."
sleep 1
done
# Use one registry mode at a time:
# 1) explicit REGISTRY_* vars for external/custom registries
# 2) GitLab's built-in CI_REGISTRY_* vars for the integrated registry
- |
if [ -n "${REGISTRY_HOST:-}${REGISTRY_USER:-}${REGISTRY_PASSWORD:-}${REGISTRY_IMAGE:-}" ]; then
test -n "${REGISTRY_HOST:-}" || { echo "REGISTRY_HOST is not set. Configure all REGISTRY_* variables together."; exit 1; }
test -n "${REGISTRY_USER:-}" || { echo "REGISTRY_USER is not set. Configure all REGISTRY_* variables together."; exit 1; }
test -n "${REGISTRY_PASSWORD:-}" || { echo "REGISTRY_PASSWORD is not set. Configure all REGISTRY_* variables together."; exit 1; }
test -n "${REGISTRY_IMAGE:-}" || { echo "REGISTRY_IMAGE is not set. Configure all REGISTRY_* variables together."; exit 1; }
export IMAGE_REPOSITORY="$REGISTRY_IMAGE"
else
export REGISTRY_HOST="${CI_REGISTRY:-}"
export REGISTRY_USER="${CI_REGISTRY_USER:-}"
export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-}"
export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-}"
fi
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
- export DOCKER_IMAGE_LATEST="$IMAGE_REPOSITORY:latest"
- |
echo "--- Registry Connectivity Preflight ---"
echo "REGISTRY_HOST=$REGISTRY_HOST"
cat /etc/resolv.conf || true
if command -v getent >/dev/null 2>&1; then
getent hosts "$REGISTRY_HOST" || true
elif command -v nslookup >/dev/null 2>&1; then
nslookup "$REGISTRY_HOST" || true
else
echo "No host lookup tool available in runner image."
fi
probe_output="$(wget -S --spider --timeout=15 --tries=1 "https://$REGISTRY_HOST/v2/" 2>&1 || true)"
printf '%s\n' "$probe_output"
printf '%s\n' "$probe_output" | grep -F "401 Unauthorized" >/dev/null || {
echo "Registry preflight failed: expected HTTPS /v2/ to return 401 Unauthorized before docker login." >&2
exit 1
}
if ! printf '%s\n' "$probe_output" | grep -Fi "Docker-Distribution-Api-Version: registry/2.0" >/dev/null; then
echo "Registry preflight note: registry API header was not visible in wget output; continuing because HTTPS /v2/ returned 401." >&2
fi
- echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin
script:
- echo "--- Building Docker Image ---"
- |
for var_name in NEXT_PUBLIC_API_URL NEXT_PUBLIC_MARKETPLACE_URL NEXT_PUBLIC_DASHBOARD_URL NEXT_PUBLIC_ADMIN_URL; do
var_value="$(printenv "$var_name" || true)"
if [ -z "$var_value" ]; then
echo "$var_name is not set. Refusing to build a production image with missing public frontend URLs." >&2
exit 1
fi
case "$var_value" in
*example.com*)
echo "$var_name=$var_value still uses the placeholder example.com domain. Update the GitLab CI/CD variable before building." >&2
exit 1
;;
esac
done
# NEXT_PUBLIC_* vars are inlined into Next.js bundles at build time — must be passed as ARGs
- |
export API_INTERNAL_URL="${API_INTERNAL_URL:-http://api:4000/api/v1}"
export DASHBOARD_INTERNAL_URL="${DASHBOARD_INTERNAL_URL:-http://dashboard:3001}"
export ADMIN_INTERNAL_URL="${ADMIN_INTERNAL_URL:-http://admin:3002}"
docker build \
--build-arg API_INTERNAL_URL=$API_INTERNAL_URL \
--build-arg DASHBOARD_INTERNAL_URL=$DASHBOARD_INTERNAL_URL \
--build-arg ADMIN_INTERNAL_URL=$ADMIN_INTERNAL_URL \
--build-arg NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
--build-arg NEXT_PUBLIC_MARKETPLACE_URL=$NEXT_PUBLIC_MARKETPLACE_URL \
--build-arg NEXT_PUBLIC_DASHBOARD_URL=$NEXT_PUBLIC_DASHBOARD_URL \
--build-arg NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \
-t $DOCKER_IMAGE \
-t $DOCKER_IMAGE_LATEST \
-f $DOCKERFILE_PATH .
- echo "--- Pushing to Registry ---"
- docker push $DOCKER_IMAGE
- docker push $DOCKER_IMAGE_LATEST
rules:
# On the default branch, always attempt the image build so missing registry
# configuration fails loudly in before_script instead of skipping the job.
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
- when: never
# ====================================
# DEPLOY STAGE
# ====================================
deploy_to_vps:
stage: deploy
image: alpine:latest
needs:
- build_image
before_script:
- |
if [ -n "${REGISTRY_HOST:-}${REGISTRY_USER:-}${REGISTRY_PASSWORD:-}${REGISTRY_IMAGE:-}" ]; then
test -n "${REGISTRY_HOST:-}" || { echo "REGISTRY_HOST is not set. Configure all REGISTRY_* variables together."; exit 1; }
test -n "${REGISTRY_USER:-}" || { echo "REGISTRY_USER is not set. Configure all REGISTRY_* variables together."; exit 1; }
test -n "${REGISTRY_PASSWORD:-}" || { echo "REGISTRY_PASSWORD is not set. Configure all REGISTRY_* variables together."; exit 1; }
test -n "${REGISTRY_IMAGE:-}" || { echo "REGISTRY_IMAGE is not set. Configure all REGISTRY_* variables together."; exit 1; }
export IMAGE_REPOSITORY="$REGISTRY_IMAGE"
else
export REGISTRY_HOST="${CI_REGISTRY:-}"
export REGISTRY_USER="${CI_REGISTRY_USER:-}"
export REGISTRY_PASSWORD="${CI_REGISTRY_PASSWORD:-}"
export IMAGE_REPOSITORY="${CI_REGISTRY_IMAGE:-}"
fi
- test -n "$REGISTRY_HOST" || { echo "Registry host is not set. Configure CI_REGISTRY or REGISTRY_HOST."; exit 1; }
- test -n "$REGISTRY_USER" || { echo "Registry user is not set. Configure CI_REGISTRY_USER or REGISTRY_USER."; exit 1; }
- test -n "$REGISTRY_PASSWORD" || { echo "Registry password is not set. Configure CI_REGISTRY_PASSWORD or REGISTRY_PASSWORD."; exit 1; }
- test -n "$IMAGE_REPOSITORY" || { echo "Image repository is not set. Configure CI_REGISTRY_IMAGE or REGISTRY_IMAGE."; exit 1; }
- export DOCKER_IMAGE="$IMAGE_REPOSITORY:$CI_COMMIT_SHORT_SHA"
- apk add --no-cache openssh-client
# Load the SSH private key stored in the CI variable SSH_PRIVATE_KEY
- eval $(ssh-agent -s)
- |
key_file="$(mktemp)"
decoded_key_file="$(mktemp)"
escaped_key_file="$(mktemp)"
cleanup_key_files() {
rm -f "$key_file" "$decoded_key_file" "$escaped_key_file"
}
trap cleanup_key_files EXIT
if [ -f "${SSH_PRIVATE_KEY:-}" ]; then
tr -d '\r' < "$SSH_PRIVATE_KEY" > "$key_file"
else
printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_file"
fi
chmod 600 "$key_file"
if ! ssh-keygen -y -f "$key_file" >/dev/null 2>&1; then
if [ ! -f "${SSH_PRIVATE_KEY:-}" ]; then
printf '%b' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$escaped_key_file"
if ssh-keygen -y -f "$escaped_key_file" >/dev/null 2>&1; then
cp "$escaped_key_file" "$key_file"
elif printf '%s' "$SSH_PRIVATE_KEY" | tr -d '\r\n\t ' | base64 -d > "$decoded_key_file" 2>/dev/null; then
tr -d '\r' < "$decoded_key_file" > "$key_file"
fi
chmod 600 "$key_file"
fi
fi
ssh-keygen -y -f "$key_file" >/dev/null 2>&1 || {
echo "SSH_PRIVATE_KEY must be an unencrypted OpenSSH private key. Supported formats: GitLab file variable, raw multiline key, single-line key with literal \\n escapes, or base64-encoded key." >&2
exit 1
}
ssh-add "$key_file"
cleanup_key_files
trap - EXIT
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
# Scan and trust the VPS host key (avoids manual known_hosts management)
- ssh-keyscan -H $VPS_IP >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- echo "--- Deploying $DOCKER_IMAGE to VPS ---"
- |
REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
echo "-- Syncing deployment assets --"
ssh $VPS_USER@$VPS_IP "
set -e
mkdir -p '$DEPLOY_ROOT/scripts' '$DEPLOY_ROOT/docker/pgmanage' '$DEPLOY_ROOT/docker/registry/auth' '$DEPLOY_ROOT/dynamic'
"
scp docker-compose.production.yml docker-compose.portainer.production.yml docker-compose.registry.production.yml docker-compose.registry.local.yml traefik.yaml $VPS_USER@$VPS_IP:$DEPLOY_ROOT/
scp scripts/docker-prod-common.sh scripts/docker-prod-deploy.sh scripts/docker-prod-up-registry.sh scripts/docker-registry-local-up.sh $VPS_USER@$VPS_IP:$DEPLOY_ROOT/scripts/
scp docker/pgmanage/override.py $VPS_USER@$VPS_IP:$DEPLOY_ROOT/docker/pgmanage/
echo "-- Running deploy script on VPS --"
ssh $VPS_USER@$VPS_IP "
set -e
cd '$DEPLOY_ROOT'
APP_IMAGE='$IMAGE_REPOSITORY' \
APP_VERSION='$CI_COMMIT_SHORT_SHA' \
REGISTRY_HOST='$REGISTRY_HOST' \
REGISTRY_USER='$REGISTRY_USER' \
REGISTRY_PASSWORD=\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d) \
bash scripts/docker-prod-deploy.sh
"
rules:
# Deploy only when both registry access and VPS credentials are available.
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && ((($CI_REGISTRY && $CI_REGISTRY_USER && $CI_REGISTRY_PASSWORD && $CI_REGISTRY_IMAGE) || ($REGISTRY_HOST && $REGISTRY_USER && $REGISTRY_PASSWORD && $REGISTRY_IMAGE)) && $SSH_PRIVATE_KEY && $VPS_IP && $VPS_USER)'
when: manual
- when: never