ci: add VPN-only Gitea deployment with isolated PostgreSQL stacks
Deploy / Test, build & deploy (push) Failing after 1m4s
Details
Deploy / Test, build & deploy (push) Failing after 1m4s
Details
This commit is contained in:
parent
d74d0a5c56
commit
df59048587
|
|
@ -0,0 +1,11 @@
|
|||
.git
|
||||
.env*
|
||||
.assessor-data
|
||||
.tmp
|
||||
artifacts
|
||||
coverage
|
||||
dist
|
||||
dist-server
|
||||
node_modules
|
||||
*.log
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue