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 # ==================================== api_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' carplace_tests: stage: test image: node:20-bookworm before_script: - npm ci - npm run build --workspace @rentaldrivego/types script: - npm run test:carplace rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' admin_tests: stage: test image: node:20-bookworm before_script: - npm ci script: - npm run test:admin rules: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' dashboard_tests: stage: test image: node:20-bookworm before_script: - npm ci - npm run build --workspace @rentaldrivego/types script: - npm run test:dashboard 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 # 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 script: - npm run db:generate - npm run db:deploy - npx prisma db push --schema packages/database/prisma/schema.prisma - 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