ci: add VPN-only Gitea deployment with isolated PostgreSQL stacks
Deploy / Test, build & deploy (push) Failing after 1m4s Details

This commit is contained in:
Binh 2026-08-24 15:21:10 +02:00
parent d74d0a5c56
commit df59048587
10 changed files with 577 additions and 0 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.git
.env*
.assessor-data
.tmp
artifacts
coverage
dist
dist-server
node_modules
*.log

View File

@ -10,3 +10,16 @@ ASSESSOR_GPO_JOB_LEASE_MS=60000
ASSESSOR_GPO_JOB_RETRY_MS=5000 ASSESSOR_GPO_JOB_RETRY_MS=5000
ASSESSOR_GPO_JOB_POLL_MS=1000 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

25
.gitattributes vendored Normal file
View File

@ -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

201
.gitea/workflows/deploy.yml Normal file
View File

@ -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

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

29
Dockerfile Normal file
View File

@ -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"]

View File

@ -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;
}

56
docker-compose.deploy.yml Normal file
View File

@ -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:

108
docs/DEPLOYMENT.md Normal file
View File

@ -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
<compose-project>/<UTC timestamp>/postgres.dump
<compose-project>/<UTC timestamp>/artifacts.tar.gz
<compose-project>/<UTC timestamp>/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.

View File

@ -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);
});
});