From df59048587ef360fb18838c5ac44d9e5328ece63 Mon Sep 17 00:00:00 2001 From: Binh Date: Mon, 24 Aug 2026 15:21:10 +0200 Subject: [PATCH] ci: add VPN-only Gitea deployment with isolated PostgreSQL stacks --- .dockerignore | 11 ++ .env.postgres.example | 13 ++ .gitattributes | 25 +++ .gitea/workflows/deploy.yml | 201 +++++++++++++++++++++++ CLAUDE.md | 1 + Dockerfile | 29 ++++ deploy/nginx/assessor.conf | 58 +++++++ docker-compose.deploy.yml | 56 +++++++ docs/DEPLOYMENT.md | 108 ++++++++++++ tests/config/deployment-workflow.test.ts | 75 +++++++++ 10 files changed, 577 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .gitea/workflows/deploy.yml create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 deploy/nginx/assessor.conf create mode 100644 docker-compose.deploy.yml create mode 100644 docs/DEPLOYMENT.md create mode 100644 tests/config/deployment-workflow.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f464b3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.env* +.assessor-data +.tmp +artifacts +coverage +dist +dist-server +node_modules +*.log + diff --git a/.env.postgres.example b/.env.postgres.example index 13ffe01..7f1aaba 100644 --- a/.env.postgres.example +++ b/.env.postgres.example @@ -10,3 +10,16 @@ ASSESSOR_GPO_JOB_LEASE_MS=60000 ASSESSOR_GPO_JOB_RETRY_MS=5000 ASSESSOR_GPO_JOB_POLL_MS=1000 +# Optional AI Overview. Leave ASSESSOR_AI_MODE unset to keep outbound AI disabled. +# ASSESSOR_AI_MODE=local +# ASSESSOR_AI_BASE_URL=http://127.0.0.1:11434/v1/ +# ASSESSOR_AI_MODEL=local-model-name +# ASSESSOR_AI_API_KEY= +# ASSESSOR_AI_TIMEOUT_MS=120000 +# ASSESSOR_AI_MAX_PROMPT_CHARS=12000 +# ASSESSOR_AI_MAX_CHUNK_CHARS=60000 +# ASSESSOR_AI_MAX_OUTPUT_CHARS=24000 +# ASSESSOR_AI_MAX_OUTPUT_TOKENS=3000 +# ASSESSOR_AI_JOB_POLL_MS=1000 +# ASSESSOR_AI_JOB_LEASE_MS=300000 + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d897b3d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +* text=auto + +*.css text eol=lf +*.html text eol=lf +*.js text eol=lf +*.json text eol=lf +*.md text eol=lf +*.mjs text eol=lf +*.sql text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.xml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +*.gif binary +*.ico binary +*.jpeg binary +*.jpg binary +*.pdf binary +*.png binary +*.woff binary +*.woff2 binary +*.xlsx binary +*.zip binary diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..b18e836 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,201 @@ +# Parallel deployment of the new ASSESSOR application. +# main -> stable (10.0.0.154:4173) +# experimental -> experimental (10.0.0.154:4174) +# +# The existing HTTPS production deployment is not managed by this workflow. +# Both new environments use separate Compose projects, databases and artifact +# volumes. Their complete environment files come from different Gitea secrets. +name: Deploy + +on: + push: + branches: + - main + - experimental + +jobs: + deploy: + name: Test, build & deploy + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Configure stable environment + if: github.ref_name == 'main' + env: + ASSESSOR_ENV_FILE: ${{ secrets.PROD_ENV_FILE }} + run: | + set -eu + if [ -z "$ASSESSOR_ENV_FILE" ]; then + echo "::error::PROD_ENV_FILE is empty or not configured." + exit 1 + fi + printf '%s\n' "$ASSESSOR_ENV_FILE" > .env + chmod 600 .env + for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD; do + if ! grep -q "^${key}=." .env; then + echo "::error::PROD_ENV_FILE does not contain a value for ${key}." + exit 1 + fi + done + echo "ASSESSOR_ENVIRONMENT=stable" >> "$GITHUB_ENV" + echo "ASSESSOR_STACK=assessor_prod" >> "$GITHUB_ENV" + echo "ASSESSOR_PUBLISH_PORT=4173" >> "$GITHUB_ENV" + echo "ASSESSOR_BIND=10.0.0.154" >> "$GITHUB_ENV" + echo "Stable configuration validated; secret values were not printed." + + - name: Configure experimental environment + if: github.ref_name == 'experimental' + env: + ASSESSOR_ENV_FILE: ${{ secrets.STAGING_ENV_FILE }} + run: | + set -eu + if [ -z "$ASSESSOR_ENV_FILE" ]; then + echo "::error::STAGING_ENV_FILE is empty or not configured." + exit 1 + fi + printf '%s\n' "$ASSESSOR_ENV_FILE" > .env + chmod 600 .env + for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD; do + if ! grep -q "^${key}=." .env; then + echo "::error::STAGING_ENV_FILE does not contain a value for ${key}." + exit 1 + fi + done + echo "ASSESSOR_ENVIRONMENT=experimental" >> "$GITHUB_ENV" + echo "ASSESSOR_STACK=assessor_experimental" >> "$GITHUB_ENV" + echo "ASSESSOR_PUBLISH_PORT=4174" >> "$GITHUB_ENV" + echo "ASSESSOR_BIND=10.0.0.154" >> "$GITHUB_ENV" + echo "Experimental configuration validated; secret values were not printed." + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test + + - name: Build + run: npm run build + + # Gitea currently ignores the GitHub-compatible concurrency key. An + # atomic Docker container name therefore serializes each target stack on + # the shared Docker host. --rm releases a crashed job after two hours. + - name: Acquire environment deployment lock + run: | + set -eu + lock_name="${ASSESSOR_STACK}_deployment_lock" + attempt=0 + until docker run -d --rm \ + --name "$lock_name" \ + --label "de.assessor.deployment-lock=$ASSESSOR_STACK" \ + postgres:16-alpine sleep 7200 >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 180 ]; then + echo "::error::Timed out waiting for the $ASSESSOR_STACK deployment lock." + exit 1 + fi + sleep 10 + done + echo "ASSESSOR_LOCK_ACQUIRED=true" >> "$GITHUB_ENV" + echo "Environment=$ASSESSOR_ENVIRONMENT deployment-lock=acquired" + + - name: Validate Compose configuration + run: docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml config --quiet + + - name: Check deployment isolation + run: | + set -eu + published_containers="$(docker ps --filter "publish=$ASSESSOR_PUBLISH_PORT" --format '{{.ID}}')" + for container_id in $published_containers; do + project="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$container_id")" + if [ "$project" != "$ASSESSOR_STACK" ]; then + echo "::error::Port $ASSESSOR_PUBLISH_PORT is already published outside $ASSESSOR_STACK." + exit 1 + fi + done + + services="$(docker ps -a --filter "label=com.docker.compose.project=$ASSESSOR_STACK" --format '{{.Label "com.docker.compose.service"}}' | sort -u)" + for service in $services; do + case "$service" in + app|postgres) ;; + *) + echo "::error::Compose project $ASSESSOR_STACK contains an unexpected service." + exit 1 + ;; + esac + done + echo "Environment=$ASSESSOR_ENVIRONMENT stack=$ASSESSOR_STACK port=$ASSESSOR_PUBLISH_PORT isolation=ok" + + - name: Back up existing environment + run: | + set -euo pipefail + postgres_id="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps -a -q postgres)" + if [ -z "$postgres_id" ]; then + echo "Environment=$ASSESSOR_ENVIRONMENT backup=not-required-first-deployment" + exit 0 + fi + if [ "$(docker inspect --format '{{.State.Running}}' "$postgres_id")" != "true" ]; then + echo "::error::Existing $ASSESSOR_STACK database is not running; deployment stopped before migration." + exit 1 + fi + + backup_volume=assessor_deployment_backups + backup_stamp="$(date -u +%Y%m%dT%H%M%SZ)" + backup_path="$ASSESSOR_STACK/$backup_stamp" + docker volume create "$backup_volume" >/dev/null + docker run --rm -v "$backup_volume:/backups" postgres:16-alpine \ + mkdir -p "/backups/$backup_path" + + docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml exec -T postgres \ + sh -c 'pg_dump --format=custom --no-owner --username="$POSTGRES_USER" --dbname="$POSTGRES_DB"' \ + | docker run --rm -i -v "$backup_volume:/backups" postgres:16-alpine \ + sh -c "cat > '/backups/$backup_path/postgres.dump'" + + artifact_volume="$(docker volume ls \ + --filter "label=com.docker.compose.project=$ASSESSOR_STACK" \ + --filter "label=com.docker.compose.volume=assessor-artifacts" \ + --format '{{.Name}}')" + if [ -z "$artifact_volume" ]; then + echo "::error::Existing $ASSESSOR_STACK artifact volume was not found." + exit 1 + fi + docker run --rm \ + -v "$artifact_volume:/artifacts:ro" \ + -v "$backup_volume:/backups" \ + postgres:16-alpine \ + tar -C /artifacts -czf "/backups/$backup_path/artifacts.tar.gz" . + docker run --rm -v "$backup_volume:/backups" postgres:16-alpine \ + sh -c "cd '/backups/$backup_path' && sha256sum postgres.dump artifacts.tar.gz > SHA256SUMS" + echo "Environment=$ASSESSOR_ENVIRONMENT backup=created timestamp=$backup_stamp" + + - name: Deploy + run: | + docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml up -d --build --remove-orphans \ + --wait --wait-timeout 180 + published_port="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml port app 4173)" + echo "Environment=$ASSESSOR_ENVIRONMENT stack=$ASSESSOR_STACK published=$published_port state=started" + + # A containerized Actions runner does not share the Docker host's loopback + # namespace, so readiness is checked inside the application container. + - name: Check application health + run: | + docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml exec -T app \ + node -e "fetch('http://127.0.0.1:4173/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + echo "Environment=$ASSESSOR_ENVIRONMENT health=ok" + + - name: Show content-free diagnostics on failure + if: failure() + run: | + docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps || true + app_id="$(docker compose --env-file .env -p "$ASSESSOR_STACK" -f docker-compose.deploy.yml ps -a -q app 2>/dev/null || true)" + if [ -n "$app_id" ]; then + docker inspect --format 'app={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$app_id" || true + fi + + - name: Release environment deployment lock + if: always() + run: | + if [ "${ASSESSOR_LOCK_ACQUIRED:-false}" = "true" ]; then + docker rm -f "${ASSESSOR_STACK}_deployment_lock" >/dev/null 2>&1 || true + fi diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eef4bd2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d9ece7e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:22-bookworm-slim AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:22-bookworm-slim AS runtime + +ENV NODE_ENV=production +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --chown=node:node --from=build /app/dist ./dist +COPY --chown=node:node --from=build /app/dist-server ./dist-server + +RUN mkdir -p /var/lib/assessor/artifacts \ + && chown -R node:node /var/lib/assessor + +USER node + +EXPOSE 4173 + +CMD ["node", "dist-server/server.mjs"] diff --git a/deploy/nginx/assessor.conf b/deploy/nginx/assessor.conf new file mode 100644 index 0000000..4e326c9 --- /dev/null +++ b/deploy/nginx/assessor.conf @@ -0,0 +1,58 @@ +# nginx-Reverse-Proxy-vhosts für ASSESSOR. +# +# In den bestehenden Proxy einfügen (z. B. /etc/nginx/conf.d/assessor.conf oder +# ein sites-available-Symlink), die ssl_certificate-Pfade an das Zertifikat +# anpassen, das assessor.soc-ops.de bereits terminiert, dann: +# nginx -t && systemctl reload nginx +# +# WICHTIG: proxy_set_header Host $host ist zwingend. ASSESSOR lehnt jeden +# Schreibzugriff ab (403 cross_origin_rejected), wenn der weitergeleitete Host +# nicht zum Browser-Origin passt. Den Host-Header nicht fest setzen oder entfernen. + +# ---- Produktion: Branch main -> 127.0.0.1:4173 ---- +server { + listen 443 ssl; + server_name assessor.soc-ops.de; + + ssl_certificate /etc/ssl/assessor/assessor.crt; # an vorhandenes Zertifikat anpassen + ssl_certificate_key /etc/ssl/assessor/assessor.key; # an vorhandenen Schlüssel anpassen + + # Uploads akzeptieren Dateien bis 100 MiB (≈140 MiB base64); Reserve lassen. + client_max_body_size 200m; + + location / { + proxy_pass http://127.0.0.1:4173; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } +} + +# ---- Staging: Branch experimental -> 127.0.0.1:4174 ---- +server { + listen 443 ssl; + server_name staging.assessor.soc-ops.de; + + ssl_certificate /etc/ssl/assessor/assessor.crt; # SAN- oder Wildcard-Zertifikat, das staging.* abdeckt + ssl_certificate_key /etc/ssl/assessor/assessor.key; + + client_max_body_size 200m; + + location / { + proxy_pass http://127.0.0.1:4174; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } +} + +# Optional: einfaches HTTP auf HTTPS umleiten für beide Hosts. +server { + listen 80; + server_name assessor.soc-ops.de staging.assessor.soc-ops.de; + return 301 https://$host$request_uri; +} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..ca0b672 --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,56 @@ +# Shared stable/experimental deployment definition. +# The workflow always supplies a distinct project name and VPN-only host port: +# assessor_prod -> 10.0.0.154:4173 +# assessor_experimental -> 10.0.0.154:4174 +# Compose scopes both named volumes by project, so the environments never share +# PostgreSQL or artifacts. PostgreSQL is intentionally not published to the host. +name: ${ASSESSOR_STACK:-assessor_prod} + +services: + app: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + environment: + ASSESSOR_HOST: 0.0.0.0 + ASSESSOR_PORT: "4173" + ASSESSOR_ARTIFACT_ROOT: /var/lib/assessor/artifacts + ASSESSOR_POSTGRES_HOST: postgres + ASSESSOR_POSTGRES_PORT: "5432" + ASSESSOR_POSTGRES_DB: ${ASSESSOR_POSTGRES_DB:?set ASSESSOR_POSTGRES_DB} + ASSESSOR_POSTGRES_USER: ${ASSESSOR_POSTGRES_USER:?set ASSESSOR_POSTGRES_USER} + ASSESSOR_POSTGRES_PASSWORD: ${ASSESSOR_POSTGRES_PASSWORD:?set ASSESSOR_POSTGRES_PASSWORD} + ASSESSOR_DATABASE_SSL: disable + ports: + - "${ASSESSOR_BIND:?set ASSESSOR_BIND}:${ASSESSOR_PUBLISH_PORT:?set ASSESSOR_PUBLISH_PORT}:4173" + volumes: + - assessor-artifacts:/var/lib/assessor/artifacts + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4173/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 40s + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${ASSESSOR_POSTGRES_DB:?set ASSESSOR_POSTGRES_DB} + POSTGRES_USER: ${ASSESSOR_POSTGRES_USER:?set ASSESSOR_POSTGRES_USER} + POSTGRES_PASSWORD: ${ASSESSOR_POSTGRES_PASSWORD:?set ASSESSOR_POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 12 + volumes: + - assessor-postgres-data:/var/lib/postgresql/data + +volumes: + assessor-artifacts: + assessor-postgres-data: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..049644c --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,108 @@ +# Parallel deployment + +## Environments + +The Gitea workflow deploys two permanent, isolated environments on the target +server. The old HTTPS installation is outside this workflow and remains +unchanged until a separately approved cutover. + +| Branch | Compose project | VPN address | Secret | +|---|---|---|---| +| `main` | `assessor_prod` | `http://10.0.0.154:4173` | `PROD_ENV_FILE` | +| `experimental` | `assessor_experimental` | `http://10.0.0.154:4174` | `STAGING_ENV_FILE` | + +`assessor_prod` already exists on the server. Keep its current `PROD_ENV_FILE` +database name, user and password unchanged so the existing PostgreSQL volume +remains accessible. Do not rotate only the secret; password rotation requires a +coordinated PostgreSQL role change. + +`STAGING_ENV_FILE` contains a complete environment file with credentials that +are distinct from stable: + +```text +ASSESSOR_POSTGRES_DB=assessor_experimental +ASSESSOR_POSTGRES_USER=assessor_experimental +ASSESSOR_POSTGRES_PASSWORD=replace-with-a-long-random-experimental-password +``` + +Do not reuse the stable database name, user, or password for experimental. +Secret values must never be printed into an Actions log. + +## Mandatory server inventory + +Before the first push-triggered deployment, connect to the target server over +SSH and perform a read-only inventory. Record only technical identifiers; do +not inspect database rows, uploaded files, application pages, or report text. + +```sh +docker version +docker compose version +docker ps -a --format '{{.Names}}\t{{.Image}}\t{{.Ports}}\t{{.Label "com.docker.compose.project"}}' +docker volume ls +docker network ls +docker ps --filter publish=4173 --format '{{.Names}}\t{{.Ports}}' +docker ps --filter publish=4174 --format '{{.Names}}\t{{.Ports}}' +``` + +Also identify the existing reverse proxy and firewall policy without changing +them. Confirm that `10.0.0.154` is a host address, port 4174 is unused, the +runner uses this Docker daemon, and `assessor_experimental` does not belong to +an unrelated stack. Port 4173 and project `assessor_prod` are intentionally +adopted as stable; only its expected `app` and `postgres` services may exist. +Any other conflict blocks deployment and is never resolved by stopping or +deleting the existing resource. + +## Deployment behavior + +Pushes to a branch are serialized per environment with an atomic lock container +on the shared Docker host. This is necessary because Gitea currently ignores the +GitHub-compatible workflow `concurrency` key. A normal job removes its lock; a +crashed job's `--rm` lock expires after two hours. The workflow installs +dependencies, runs the complete test suite and production build, validates the +resolved Compose configuration, checks port/project isolation, backs up an +existing target environment, then builds and starts only that environment. + +Compose project scoping produces independent PostgreSQL and artifact volumes. +PostgreSQL has no host-published port. `restart: unless-stopped` preserves both +environments across Docker daemon and server restarts. + +Feature promotion is a merge from `experimental` to `main`. Stable is rebuilt +from the merged commit; databases and artifacts are never copied between the +environments. + +## Backups and restore checks + +Before every update of an existing environment, the workflow writes a custom +PostgreSQL dump, an artifact archive and SHA-256 checksums to the external +Docker volume `assessor_deployment_backups`: + +```text +//postgres.dump +//artifacts.tar.gz +//SHA256SUMS +``` + +The workflow never deletes backups. Retention and off-host copying require a +separate approved operations policy. The backup volume must be included in the +server's protected backup system; a volume on the same Docker host is not a +complete disaster-recovery copy. + +Restore tests must use an isolated temporary Compose project and synthetic data. +Verify `SHA256SUMS`, restore `postgres.dump` with `pg_restore --exit-on-error` +into a fresh database, extract the artifact archive into a fresh artifact +volume, and confirm only content-free counts and health state. Never restore an +experimental backup into stable or a stable backup into experimental. Never +run `docker compose down --volumes` for either permanent environment. + +## Acceptance and later cutover + +After deployment, verify both in-container health checks and VPN access. Import +different synthetic fixtures into stable and experimental, redeploy each branch, +and confirm persistence and isolation. Confirm the old HTTPS site remains +available without reading its domain content. + +Changing `assessor.soc-ops.de` is not part of the deployment workflow. After +explicit production acceptance, validate the existing proxy configuration, +preserve its old upstream, point it to stable, reload it, and verify TLS and +health. Rollback restores the saved upstream; the archived old stack and its +data must remain recoverable until a later retention decision. diff --git a/tests/config/deployment-workflow.test.ts b/tests/config/deployment-workflow.test.ts new file mode 100644 index 0000000..3c1dd0f --- /dev/null +++ b/tests/config/deployment-workflow.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const workflow = readFileSync(new URL('../../.gitea/workflows/deploy.yml', import.meta.url), 'utf8'); +const compose = readFileSync(new URL('../../docker-compose.deploy.yml', import.meta.url), 'utf8'); + +describe('deployment workflow', () => { + it('maps main and experimental to isolated stacks and VPN ports', () => { + expect(workflow).toContain('ASSESSOR_ENV_FILE: ${{ secrets.PROD_ENV_FILE }}'); + expect(workflow).toContain('ASSESSOR_ENV_FILE: ${{ secrets.STAGING_ENV_FILE }}'); + expect(workflow).toContain('ASSESSOR_STACK=assessor_prod'); + expect(workflow).toContain('ASSESSOR_STACK=assessor_experimental'); + expect(workflow).toContain('ASSESSOR_PUBLISH_PORT=4173'); + expect(workflow).toContain('ASSESSOR_PUBLISH_PORT=4174'); + expect(workflow.match(/ASSESSOR_BIND=10\.0\.0\.154/g)).toHaveLength(2); + expect(workflow).not.toContain('assessor.soc-ops.de'); + }); + + it('validates all database settings without printing secret values', () => { + expect(workflow.match(/if \[ -z "\$ASSESSOR_ENV_FILE" \]/g)).toHaveLength(2); + expect(workflow.match(/for key in ASSESSOR_POSTGRES_DB ASSESSOR_POSTGRES_USER ASSESSOR_POSTGRES_PASSWORD/g)).toHaveLength(2); + expect(workflow).toContain('secret values were not printed.'); + expect(workflow).not.toMatch(/echo "\$ASSESSOR_ENV_FILE"/); + expect(workflow).not.toContain('printf \'%s\\n\' "${{ secrets.'); + }); + + it('tests, builds, validates and serializes each branch deployment', () => { + expect(workflow).toContain('run: npm test'); + expect(workflow).toContain('run: npm run build'); + expect(workflow).toMatch(/docker compose[^\n]*config --quiet/); + expect(workflow).toContain('publish=$ASSESSOR_PUBLISH_PORT'); + expect(workflow).toContain('project="$(docker inspect'); + expect(workflow).toContain('if [ "$project" != "$ASSESSOR_STACK" ]'); + expect(workflow).toContain('name: Acquire environment deployment lock'); + expect(workflow).toContain('name: Release environment deployment lock'); + expect(workflow).toContain('${ASSESSOR_STACK}_deployment_lock'); + expect(workflow).toContain('ASSESSOR_LOCK_ACQUIRED=true'); + expect(workflow).toContain('if: always()'); + expect(workflow).not.toMatch(/^concurrency:/m); + }); + + it('creates separate pre-deployment database and artifact backups', () => { + expect(workflow).toContain('backup_volume=assessor_deployment_backups'); + expect(workflow).toContain('pg_dump --format=custom --no-owner'); + expect(workflow).toContain('artifacts.tar.gz'); + expect(workflow).toContain('SHA256SUMS'); + expect(workflow).not.toContain('down --volumes'); + expect(workflow).not.toContain('docker image prune'); + expect(workflow).not.toMatch(/docker compose[^\n]*logs/); + }); + + it('checks application health inside the app container', () => { + expect(workflow).toMatch(/docker compose[^\n]*exec -T app/); + expect(workflow).toContain("fetch('http://127.0.0.1:4173/api/v1/health')"); + expect(workflow).not.toMatch(/curl[^\n]*127\.0\.0\.1:4173/); + }); +}); + +describe('deployment Compose stack', () => { + it('requires an explicit bind, port and database configuration', () => { + expect(compose).toContain('${ASSESSOR_BIND:?set ASSESSOR_BIND}'); + expect(compose).toContain('${ASSESSOR_PUBLISH_PORT:?set ASSESSOR_PUBLISH_PORT}'); + expect(compose).toContain('${ASSESSOR_POSTGRES_DB:?set ASSESSOR_POSTGRES_DB}'); + expect(compose).toContain('${ASSESSOR_POSTGRES_USER:?set ASSESSOR_POSTGRES_USER}'); + expect(compose).toContain('${ASSESSOR_POSTGRES_PASSWORD:?set ASSESSOR_POSTGRES_PASSWORD}'); + }); + + it('keeps PostgreSQL internal and scopes persistent volumes through Compose', () => { + const postgresSection = compose.slice(compose.indexOf(' postgres:')); + expect(postgresSection).not.toMatch(/\n ports:/); + expect(compose).toContain('assessor-artifacts:/var/lib/assessor/artifacts'); + expect(compose).toContain('assessor-postgres-data:/var/lib/postgresql/data'); + expect(compose.match(/restart: unless-stopped/g)).toHaveLength(2); + }); +});