Compare commits
5 Commits
664ed3e89e
...
7a96ed3ae1
| Author | SHA1 | Date |
|---|---|---|
|
|
7a96ed3ae1 | |
|
|
c179ac1b2c | |
|
|
200a8ac381 | |
|
|
7ba69494e1 | |
|
|
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_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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -1349,6 +1349,7 @@ Aktualisierung nach dem Schritt-11-Orchestrierungsvertikalschnitt vom 2026-08-04
|
||||||
|
|
||||||
| Datum | Entscheidung | Begründung |
|
| Datum | Entscheidung | Begründung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-08-24 | Die PostgreSQL-Zieladresse der Serverlaufzeit wird bei Einzelvariablen-Konfiguration aus `ASSESSOR_POSTGRES_HOST` gelesen; ohne Wert bleibt `127.0.0.1` der lokale Standard. Eine explizite `ASSESSOR_DATABASE_URL` behaelt Vorrang. | Container teilen keinen Loopback-Namensraum. Der Compose-Dienstname `postgres` muss deshalb bis in die Backend-Verbindungszeichenfolge gelangen, ohne den bestehenden lokalen Entwicklungsbetrieb zu brechen. |
|
||||||
| 2026-08-24 | Segmente und Kacheln folgen anwendungsweit einer gemeinsamen visuellen Grammatik: flacher abgerundeter Segmentcontainer mit einheitlichem Inset und Gap; darin gleichartige abgerundete Kacheln mit feiner Kontur, dezentem gemeinsamem Schatten und fester Inhaltsbreite. Statusfarbe bleibt Akzent oder Auswahlkontur. Unvollstaendige Zeilen werden nicht gestreckt. Jede Kachel begrenzt ihren Inhalt; Titel, Werte, Labels, Zusatztexte, Medien und Aktionen duerfen weder abgeschnitten, geklemmt, ellipsiert oder verborgen werden noch die Kachelgrenze ueberschreiten oder Nachbarkacheln ueberdecken. Notwendige Umbrueche erfolgen ausschliesslich an Wortgrenzen, Zahlen werden nicht getrennt. | Ein gemeinsamer Vertrag verhindert springende Geometrie und ansichtsspezifische Sonderkarten. Die harte Inhaltsgrenze schuetzt Nachbarkacheln, waehrend vollstaendige Inhalte die fachliche Lesbarkeit erhalten. Wenn Inhalt nicht vollstaendig passt, wird er intern neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv neu angeordnet. |
|
| 2026-08-24 | Segmente und Kacheln folgen anwendungsweit einer gemeinsamen visuellen Grammatik: flacher abgerundeter Segmentcontainer mit einheitlichem Inset und Gap; darin gleichartige abgerundete Kacheln mit feiner Kontur, dezentem gemeinsamem Schatten und fester Inhaltsbreite. Statusfarbe bleibt Akzent oder Auswahlkontur. Unvollstaendige Zeilen werden nicht gestreckt. Jede Kachel begrenzt ihren Inhalt; Titel, Werte, Labels, Zusatztexte, Medien und Aktionen duerfen weder abgeschnitten, geklemmt, ellipsiert oder verborgen werden noch die Kachelgrenze ueberschreiten oder Nachbarkacheln ueberdecken. Notwendige Umbrueche erfolgen ausschliesslich an Wortgrenzen, Zahlen werden nicht getrennt. | Ein gemeinsamer Vertrag verhindert springende Geometrie und ansichtsspezifische Sonderkarten. Die harte Inhaltsgrenze schuetzt Nachbarkacheln, waehrend vollstaendige Inhalte die fachliche Lesbarkeit erhalten. Wenn Inhalt nicht vollstaendig passt, wird er intern neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv neu angeordnet. |
|
||||||
| 2026-08-24 | Die neueste Nutzerkorrektur ersetzt fuer Finding-Titel die unmittelbar folgende fruehere 18-px-Reader-Entscheidung: Trefferlistentitel bleiben auf der 16-px-Inhaltsstufe, werden aber mit Medium 500 statt Semibold gesetzt; der Titel des geoeffneten Findings verwendet eine eigene 20-px-Detailtitelrolle mit Semibold. Die Finding-Abschnittsueberschrift bleibt 18 px, regulaere Matrixkacheln bleiben 16 px. | Die Trefferliste soll ruhig und scanbar bleiben, waehrend das geoeffnete Finding als primaerer Detailkontext klar hervortreten muss. Die getrennten semantischen Rollen loesen den Kontrast ohne die 16-px-Untergrenze fuer inhaltstragenden Text zu unterschreiten. |
|
| 2026-08-24 | Die neueste Nutzerkorrektur ersetzt fuer Finding-Titel die unmittelbar folgende fruehere 18-px-Reader-Entscheidung: Trefferlistentitel bleiben auf der 16-px-Inhaltsstufe, werden aber mit Medium 500 statt Semibold gesetzt; der Titel des geoeffneten Findings verwendet eine eigene 20-px-Detailtitelrolle mit Semibold. Die Finding-Abschnittsueberschrift bleibt 18 px, regulaere Matrixkacheln bleiben 16 px. | Die Trefferliste soll ruhig und scanbar bleiben, waehrend das geoeffnete Finding als primaerer Detailkontext klar hervortreten muss. Die getrennten semantischen Rollen loesen den Kontrast ohne die 16-px-Untergrenze fuer inhaltstragenden Text zu unterschreiten. |
|
||||||
| 2026-08-24 | Finding-Titel verwenden innerhalb von Work eine kompakte semantische Hierarchie: Reader-Titel und Finding-Abschnitt nutzen H3 mit 18 px; Titel in Trefferlisten und regulaeren Matrixkacheln nutzen H4 mit 16 px. Die globale H1-/H2-Hierarchie und die eigenstaendige Pathfinder-Micro-Card bleiben unveraendert. | Lange Scannerbezeichnungen benoetigen in Listen und Kacheln sonst zu viele Zeilen und verdraengen Status sowie Metadaten. Die vorhandenen Typografietokens erhalten die Lesbarkeitsuntergrenze von 16 px fuer inhaltstragenden Text und vermeiden lokale Sondergroessen. |
|
| 2026-08-24 | Finding-Titel verwenden innerhalb von Work eine kompakte semantische Hierarchie: Reader-Titel und Finding-Abschnitt nutzen H3 mit 18 px; Titel in Trefferlisten und regulaeren Matrixkacheln nutzen H4 mit 16 px. Die globale H1-/H2-Hierarchie und die eigenstaendige Pathfinder-Micro-Card bleiben unveraendert. | Lange Scannerbezeichnungen benoetigen in Listen und Kacheln sonst zu viele Zeilen und verdraengen Status sowie Metadaten. Die vorhandenen Typografietokens erhalten die Lesbarkeitsuntergrenze von 16 px fuer inhaltstragenden Text und vermeiden lokale Sondergroessen. |
|
||||||
|
|
@ -1609,6 +1610,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
|
|
||||||
| Risiko | Gegenmaßnahme |
|
| Risiko | Gegenmaßnahme |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| Die Compose-Konfiguration benennt den PostgreSQL-Dienst korrekt, waehrend das Backend den Host ignoriert und innerhalb des App-Containers gegen `127.0.0.1` verbindet; Build und Datenbank-Health bleiben dabei gruen, aber die App wird unhealthy. | `ASSESSOR_POSTGRES_HOST` in den Repository-Umgebungsvertrag und die Verbindungszeichenfolge aufnehmen, Loopback nur als lokalen Fallback verwenden und Docker-Hostname sowie lokalen Standard durch einen reinen Unit-Test absichern. |
|
||||||
| Lokale Kartenraster koennen Kacheln wieder auf Restbreite strecken; lange Titel, Werte, Labels, Zusatztexte, Medien oder Aktionen koennen abgeschnitten, ellipsiert oder verborgen werden, aus einer Kachel herausragen, Nachbarkacheln ueberdecken oder durch aggressive Umbruchregeln mitten im Wort getrennt werden. | Gemeinsame Segment-/Kacheltokens, feste Startausrichtung und eine zuletzt geladene Kachel-Vertragsschicht verwenden. Woerter nur an Wortgrenzen umbrechen und Zahlen zusammenhalten. Bei Platzmangel Inhalt intern neu anordnen oder die Kachel verbreitern beziehungsweise responsiv neu anordnen; Clipping, Clamp, Ellipse und Truncation sind keine zulaessigen Rueckfaelle. Statische Regressionen sichern Vertrag, relevante Ansichten und Kaskadenreihenfolge. |
|
| Lokale Kartenraster koennen Kacheln wieder auf Restbreite strecken; lange Titel, Werte, Labels, Zusatztexte, Medien oder Aktionen koennen abgeschnitten, ellipsiert oder verborgen werden, aus einer Kachel herausragen, Nachbarkacheln ueberdecken oder durch aggressive Umbruchregeln mitten im Wort getrennt werden. | Gemeinsame Segment-/Kacheltokens, feste Startausrichtung und eine zuletzt geladene Kachel-Vertragsschicht verwenden. Woerter nur an Wortgrenzen umbrechen und Zahlen zusammenhalten. Bei Platzmangel Inhalt intern neu anordnen oder die Kachel verbreitern beziehungsweise responsiv neu anordnen; Clipping, Clamp, Ellipse und Truncation sind keine zulaessigen Rueckfaelle. Statische Regressionen sichern Vertrag, relevante Ansichten und Kaskadenreihenfolge. |
|
||||||
| Trefferlistentitel und geoeffneter Finding-Titel koennen trotz unterschiedlicher Informationshierarchie optisch gleich laut oder in der falschen Reihenfolge erscheinen. | Trefferliste auf 16 px/500 und geoeffneten Detailtitel auf 20 px/600 ueber eigene zentrale Tokens binden; Finding-Abschnitt und Matrixkacheln getrennt regressionspruefen. |
|
| Trefferlistentitel und geoeffneter Finding-Titel koennen trotz unterschiedlicher Informationshierarchie optisch gleich laut oder in der falschen Reihenfolge erscheinen. | Trefferliste auf 16 px/500 und geoeffneten Detailtitel auf 20 px/600 ueber eigene zentrale Tokens binden; Finding-Abschnitt und Matrixkacheln getrennt regressionspruefen. |
|
||||||
| Finding-Badges und Outcome-Texte koennen bei derselben Groesse wie regulaere Labels eine lange Titelzeile optisch ueberladen und MITRE-Zuordnungen unnoetig umbrechen. | Fuer Finding-Zustandszeilen ausschliesslich den zentralen 12-px-Badgetoken verwenden; Farbe, Glyph, Rahmen und feste Dimensionsreihenfolge bleiben als zusaetzliche Bedeutungstraeger erhalten. |
|
| Finding-Badges und Outcome-Texte koennen bei derselben Groesse wie regulaere Labels eine lange Titelzeile optisch ueberladen und MITRE-Zuordnungen unnoetig umbrechen. | Fuer Finding-Zustandszeilen ausschliesslich den zentralen 12-px-Badgetoken verwenden; Farbe, Glyph, Rahmen und feste Dimensionsreihenfolge bleiben als zusaetzliche Bedeutungstraeger erhalten. |
|
||||||
|
|
@ -1793,6 +1795,7 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
## 12. Änderungsprotokoll
|
## 12. Änderungsprotokoll
|
||||||
|
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|
| 2026-08-24 | Phase-14-Containerverbindung korrigiert: Die Repository-Laufzeit uebernimmt `ASSESSOR_POSTGRES_HOST` in die PostgreSQL-Verbindungszeichenfolge, sodass der App-Container den Compose-Dienst `postgres` statt seines eigenen Loopbacks anspricht. `ASSESSOR_DATABASE_URL` behaelt Vorrang und `127.0.0.1` bleibt der lokale Standard. Docker-Hostname und lokaler Fallback sind synthetisch regressionsgeprueft; Gesamtlauf 735 bestanden, 8 optionale PostgreSQL-Tests uebersprungen, Local-only- und Designpruefung sowie Client-/Server-Produktionsbuild erfolgreich. Keine Schema- oder Datenmigration; Phase 14 bleibt offen. |
|
||||||
| 2026-08-24 | Phase-7-Segment-/Kachelgrammatik nach Nutzerreferenz anwendungsweit umgesetzt und als Projektvorgabe verankert: gemeinsame Segment- und Kacheltokens fuer Flaeche, Kontur, Radius, Inset, Gap, Schatten, Auswahl und Breiten; flache Gruppencontainer; gleichartige Inhalts-, Aktions- und Hinzufuegekacheln; Statusfarben nur als Akzent; unvollstaendige Zeilen bleiben unstretched. Datahub, Dashboard, Sidebar, Work-TileGrid, DB Structure, Policy Analysis und statische Pathfinder-Karten verwenden den Vertrag. Die verbindliche Nutzerregel verbietet abgeschnittene, geklemmte, ellipsierte oder verborgene Titel, Werte, Labels, Zusatztexte und Aktionen. Dashboardstatuskarten wurden auf 290 beziehungsweise 210 px abgestimmt; Anzahl und Prozentwert stehen getrennt, sodass `Compliance` und Werte wie `453` vollstaendig bleiben. Das Donut-Raster nutzt auf Desktop sechs Spalten und die Matrix verteilt ihre feste Spaltenzahl ueber die volle Segmentbreite. Bei Platzmangel wird Inhalt neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv umgebrochen. Sechzehn fokussierte Layout-/Donuttests und der Gesamtlauf mit 736 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung mit 0 Rohfarben sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
| 2026-08-24 | Phase-7-Segment-/Kachelgrammatik nach Nutzerreferenz anwendungsweit umgesetzt und als Projektvorgabe verankert: gemeinsame Segment- und Kacheltokens fuer Flaeche, Kontur, Radius, Inset, Gap, Schatten, Auswahl und Breiten; flache Gruppencontainer; gleichartige Inhalts-, Aktions- und Hinzufuegekacheln; Statusfarben nur als Akzent; unvollstaendige Zeilen bleiben unstretched. Datahub, Dashboard, Sidebar, Work-TileGrid, DB Structure, Policy Analysis und statische Pathfinder-Karten verwenden den Vertrag. Die verbindliche Nutzerregel verbietet abgeschnittene, geklemmte, ellipsierte oder verborgene Titel, Werte, Labels, Zusatztexte und Aktionen. Dashboardstatuskarten wurden auf 290 beziehungsweise 210 px abgestimmt; Anzahl und Prozentwert stehen getrennt, sodass `Compliance` und Werte wie `453` vollstaendig bleiben. Das Donut-Raster nutzt auf Desktop sechs Spalten und die Matrix verteilt ihre feste Spaltenzahl ueber die volle Segmentbreite. Bei Platzmangel wird Inhalt neu angeordnet oder die Kachel verbreitert beziehungsweise responsiv umgebrochen. Sechzehn fokussierte Layout-/Donuttests und der Gesamtlauf mit 736 bestandenen sowie 8 uebersprungenen optionalen PostgreSQL-Tests, Local-only- und Designpruefung mit 0 Rohfarben sowie Client-/Server-Produktionsbuild sind erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
| 2026-08-24 | Phase-7-Finding-Titelhierarchie nach visueller Nutzerkorrektur nachgeschaerft: Trefferlistentitel bleiben 16 px, verwenden aber Medium 500 statt Semibold; der geoeffnete Finding-Titel verwendet eine neue zentrale 20-px-Detailtitelrolle mit Semibold. Finding-Abschnitt 18 px, Matrixkacheln 16 px und die 16-px-Untergrenze fuer Inhalt bleiben unveraendert. Titel brechen nur an Wortgrenzen. Drei fokussierte Typografieregressionen sowie der Gesamtlauf mit 736 bestandenen und 8 uebersprungenen Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
| 2026-08-24 | Phase-7-Finding-Titelhierarchie nach visueller Nutzerkorrektur nachgeschaerft: Trefferlistentitel bleiben 16 px, verwenden aber Medium 500 statt Semibold; der geoeffnete Finding-Titel verwendet eine neue zentrale 20-px-Detailtitelrolle mit Semibold. Finding-Abschnitt 18 px, Matrixkacheln 16 px und die 16-px-Untergrenze fuer Inhalt bleiben unveraendert. Titel brechen nur an Wortgrenzen. Drei fokussierte Typografieregressionen sowie der Gesamtlauf mit 736 bestandenen und 8 uebersprungenen Tests, Local-only-/Designpruefung und Client-/Server-Produktionsbuild erfolgreich. Keine Fachlogik-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
| 2026-08-24 | Phase-7-Finding-Labels nach Nutzerkorrektur verdichtet: Outcome (`Failed`/`Passed`), Weight, Severity, Category, Score, CIS- und MITRE-Zuordnungen verwenden in Reader und regulaeren Matrixkacheln nun einen zentralen 12-px-Badgetoken statt der bisherigen 13-px-Labelstufe. Farb-, Glyph-, Rahmen-, Reihenfolge- und Statussemantik bleiben unveraendert; inhaltstragender Text wird nicht verkleinert. Fuenf fokussierte Typografieregressionen, 736 Standardtests, Local-only-Pruefung, Designpruefer und vollstaendiger Client-/Serverbuild erfolgreich; 8 optionale PostgreSQL-Tests uebersprungen. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
| 2026-08-24 | Phase-7-Finding-Labels nach Nutzerkorrektur verdichtet: Outcome (`Failed`/`Passed`), Weight, Severity, Category, Score, CIS- und MITRE-Zuordnungen verwenden in Reader und regulaeren Matrixkacheln nun einen zentralen 12-px-Badgetoken statt der bisherigen 13-px-Labelstufe. Farb-, Glyph-, Rahmen-, Reihenfolge- und Statussemantik bleiben unveraendert; inhaltstragender Text wird nicht verkleinert. Fuenf fokussierte Typografieregressionen, 736 Standardtests, Local-only-Pruefung, Designpruefer und vollstaendiger Client-/Serverbuild erfolgreich; 8 optionale PostgreSQL-Tests uebersprungen. Keine Daten-, API-, Persistenz- oder Schemaaenderung; Phase 7 bleibt abgeschlossen. |
|
||||||
|
|
@ -2076,5 +2079,3 @@ Diese Entscheidungen werden erst getroffen, wenn die jeweilige Phase ansteht ode
|
||||||
| 2026-07-17 | Phase 1 begonnen: Format- und Fehlerfalltests erweitert, generische Fixture und Testbaseline ergänzt, Gesamtworkflow und Belastungsfälle automatisiert, leere Importe abgewiesen und englische Titelzuordnung korrigiert; Phase bleibt bis zum vollständigen Quellenkorpus in Arbeit. |
|
| 2026-07-17 | Phase 1 begonnen: Format- und Fehlerfalltests erweitert, generische Fixture und Testbaseline ergänzt, Gesamtworkflow und Belastungsfälle automatisiert, leere Importe abgewiesen und englische Titelzuordnung korrigiert; Phase bleibt bis zum vollständigen Quellenkorpus in Arbeit. |
|
||||||
| 2026-07-17 | Ersten versionierten Umsetzungsplan mit Zielarchitektur, Phasen, Abnahmekriterien, Entscheidungen und Risiken angelegt. |
|
| 2026-07-17 | Ersten versionierten Umsetzungsplan mit Zielarchitektur, Phasen, Abnahmekriterien, Entscheidungen und Risiken angelegt. |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface RepositoryEnvironment {
|
||||||
ASSESSOR_DATABASE_SSL?: string;
|
ASSESSOR_DATABASE_SSL?: string;
|
||||||
ASSESSOR_DATABASE_POOL_SIZE?: string;
|
ASSESSOR_DATABASE_POOL_SIZE?: string;
|
||||||
ASSESSOR_POSTGRES_DB?: string;
|
ASSESSOR_POSTGRES_DB?: string;
|
||||||
|
ASSESSOR_POSTGRES_HOST?: string;
|
||||||
ASSESSOR_POSTGRES_USER?: string;
|
ASSESSOR_POSTGRES_USER?: string;
|
||||||
ASSESSOR_POSTGRES_PASSWORD?: string;
|
ASSESSOR_POSTGRES_PASSWORD?: string;
|
||||||
ASSESSOR_POSTGRES_PORT?: string;
|
ASSESSOR_POSTGRES_PORT?: string;
|
||||||
|
|
@ -38,20 +39,26 @@ function sslConfiguration(value: string | undefined): PoolConfig['ssl'] {
|
||||||
throw new Error('ASSESSOR_DATABASE_SSL muss disable oder require sein.');
|
throw new Error('ASSESSOR_DATABASE_SSL muss disable oder require sein.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function repositoryConnectionString(environment: RepositoryEnvironment): string | undefined {
|
||||||
|
const explicitConnectionString = environment.ASSESSOR_DATABASE_URL?.trim();
|
||||||
|
if (explicitConnectionString) return explicitConnectionString;
|
||||||
|
const password = environment.ASSESSOR_POSTGRES_PASSWORD?.trim();
|
||||||
|
if (!password) return undefined;
|
||||||
|
const host = environment.ASSESSOR_POSTGRES_HOST?.trim() || '127.0.0.1';
|
||||||
|
return `postgresql://${encodeURIComponent(environment.ASSESSOR_POSTGRES_USER?.trim() || 'assessor')}:${encodeURIComponent(password)}@${host}:${environment.ASSESSOR_POSTGRES_PORT?.trim() || '5432'}/${encodeURIComponent(environment.ASSESSOR_POSTGRES_DB?.trim() || 'assessor')}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRepositoryRuntime(environment: RepositoryEnvironment = {
|
export async function createRepositoryRuntime(environment: RepositoryEnvironment = {
|
||||||
ASSESSOR_DATABASE_URL: process.env.ASSESSOR_DATABASE_URL,
|
ASSESSOR_DATABASE_URL: process.env.ASSESSOR_DATABASE_URL,
|
||||||
ASSESSOR_DATABASE_SSL: process.env.ASSESSOR_DATABASE_SSL,
|
ASSESSOR_DATABASE_SSL: process.env.ASSESSOR_DATABASE_SSL,
|
||||||
ASSESSOR_DATABASE_POOL_SIZE: process.env.ASSESSOR_DATABASE_POOL_SIZE,
|
ASSESSOR_DATABASE_POOL_SIZE: process.env.ASSESSOR_DATABASE_POOL_SIZE,
|
||||||
ASSESSOR_POSTGRES_DB: process.env.ASSESSOR_POSTGRES_DB,
|
ASSESSOR_POSTGRES_DB: process.env.ASSESSOR_POSTGRES_DB,
|
||||||
|
ASSESSOR_POSTGRES_HOST: process.env.ASSESSOR_POSTGRES_HOST,
|
||||||
ASSESSOR_POSTGRES_USER: process.env.ASSESSOR_POSTGRES_USER,
|
ASSESSOR_POSTGRES_USER: process.env.ASSESSOR_POSTGRES_USER,
|
||||||
ASSESSOR_POSTGRES_PASSWORD: process.env.ASSESSOR_POSTGRES_PASSWORD,
|
ASSESSOR_POSTGRES_PASSWORD: process.env.ASSESSOR_POSTGRES_PASSWORD,
|
||||||
ASSESSOR_POSTGRES_PORT: process.env.ASSESSOR_POSTGRES_PORT
|
ASSESSOR_POSTGRES_PORT: process.env.ASSESSOR_POSTGRES_PORT
|
||||||
}): Promise<RepositoryRuntime> {
|
}): Promise<RepositoryRuntime> {
|
||||||
const explicitConnectionString = environment.ASSESSOR_DATABASE_URL?.trim();
|
const connectionString = repositoryConnectionString(environment);
|
||||||
const localPassword = environment.ASSESSOR_POSTGRES_PASSWORD?.trim();
|
|
||||||
const connectionString = explicitConnectionString || (localPassword
|
|
||||||
? `postgresql://${encodeURIComponent(environment.ASSESSOR_POSTGRES_USER?.trim() || 'assessor')}:${encodeURIComponent(localPassword)}@127.0.0.1:${environment.ASSESSOR_POSTGRES_PORT?.trim() || '5432'}/${encodeURIComponent(environment.ASSESSOR_POSTGRES_DB?.trim() || 'assessor')}`
|
|
||||||
: undefined);
|
|
||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
return {
|
return {
|
||||||
repository: new LocalAssessorRepository(),
|
repository: new LocalAssessorRepository(),
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,9 @@
|
||||||
grid-template-columns: repeat(auto-fill, var(--tile-compact-width));
|
grid-template-columns: repeat(auto-fill, var(--tile-compact-width));
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1101px) {
|
||||||
|
.datahub-reference-controls {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ describe('ResizableResultTable', () => {
|
||||||
Object.defineProperty(firstColumnCells[1], 'scrollWidth', { configurable: true, value: 238 });
|
Object.defineProperty(firstColumnCells[1], 'scrollWidth', { configurable: true, value: 238 });
|
||||||
|
|
||||||
act(() => container.querySelector('.result-column-resizer')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
|
act(() => container.querySelector('.result-column-resizer')?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
|
||||||
expect((container.querySelector('col') as HTMLElement).style.width).toBe('240px');
|
expect((container.querySelector('col') as HTMLTableColElement | null)?.style.width).toBe('240px');
|
||||||
expect(container.querySelector('table')?.classList.contains('has-custom-column-widths')).toBe(true);
|
expect(container.querySelector('table')?.classList.contains('has-custom-column-widths')).toBe(true);
|
||||||
|
|
||||||
let handle = container.querySelector('.result-column-resizer') as HTMLElement;
|
let handle = container.querySelector('.result-column-resizer') as HTMLElement;
|
||||||
|
|
@ -49,7 +49,7 @@ describe('ResizableResultTable', () => {
|
||||||
act(() => handle.dispatchEvent(pointerEvent('pointerdown', 7, 100)));
|
act(() => handle.dispatchEvent(pointerEvent('pointerdown', 7, 100)));
|
||||||
handle = container.querySelector('.result-column-resizer') as HTMLElement;
|
handle = container.querySelector('.result-column-resizer') as HTMLElement;
|
||||||
act(() => handle.dispatchEvent(pointerEvent('pointermove', 7, 148)));
|
act(() => handle.dispatchEvent(pointerEvent('pointermove', 7, 148)));
|
||||||
expect((container.querySelector('col') as HTMLElement).style.width).toBe('168px');
|
expect((container.querySelector('col') as HTMLTableColElement | null)?.style.width).toBe('168px');
|
||||||
|
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
@ -66,7 +66,7 @@ describe('ResizableResultTable', () => {
|
||||||
headers.forEach(cell => { cell.getBoundingClientRect = () => ({ width: 100 } as DOMRect); });
|
headers.forEach(cell => { cell.getBoundingClientRect = () => ({ width: 100 } as DOMRect); });
|
||||||
|
|
||||||
act(() => container.querySelector('.result-column-resizer')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })));
|
act(() => container.querySelector('.result-column-resizer')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })));
|
||||||
expect((container.querySelector('col') as HTMLElement).style.width).toBe('116px');
|
expect((container.querySelector('col') as HTMLTableColElement | null)?.style.width).toBe('116px');
|
||||||
|
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -7,6 +7,7 @@ import { DatahubHistoryOverview } from '../../src/features/project-import/Datahu
|
||||||
const datahubStyles = readFileSync(new URL('../../src/styles/datahub-redesign.css', import.meta.url), 'utf8');
|
const datahubStyles = readFileSync(new URL('../../src/styles/datahub-redesign.css', import.meta.url), 'utf8');
|
||||||
const datahubTableStyles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
|
const datahubTableStyles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
|
||||||
const componentStyles = readFileSync(new URL('../../src/styles/components.css', import.meta.url), 'utf8');
|
const componentStyles = readFileSync(new URL('../../src/styles/components.css', import.meta.url), 'utf8');
|
||||||
|
const tileContractStyles = readFileSync(new URL('../../src/styles/tile-contract.css', import.meta.url), 'utf8');
|
||||||
const tokens = readFileSync(new URL('../../src/styles/ui-tokens.css', import.meta.url), 'utf8');
|
const tokens = readFileSync(new URL('../../src/styles/ui-tokens.css', import.meta.url), 'utf8');
|
||||||
const overview = readFileSync(new URL('../../src/features/project-import/DatahubHistoryOverview.tsx', import.meta.url), 'utf8');
|
const overview = readFileSync(new URL('../../src/features/project-import/DatahubHistoryOverview.tsx', import.meta.url), 'utf8');
|
||||||
const donut = readFileSync(new URL('../../src/components/DonutMetricCard/DonutMetricCard.tsx', import.meta.url), 'utf8');
|
const donut = readFileSync(new URL('../../src/components/DonutMetricCard/DonutMetricCard.tsx', import.meta.url), 'utf8');
|
||||||
|
|
@ -47,6 +48,7 @@ describe('Datahub centered reference rack layout', () => {
|
||||||
|
|
||||||
expect(componentStyles).toContain('Verbindlicher Donut-Standard: Referenz vom 2026-08-05.');
|
expect(componentStyles).toContain('Verbindlicher Donut-Standard: Referenz vom 2026-08-05.');
|
||||||
expect(componentStyles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr));');
|
expect(componentStyles).toContain('grid-template-columns: repeat(6, minmax(0, 1fr));');
|
||||||
|
expect(tileContractStyles).toMatch(/@media \(min-width: 1101px\)\s*\{\s*\.datahub-reference-controls\s*\{[^}]*grid-template-columns:\s*repeat\(6, minmax\(0, 1fr\)\);/s);
|
||||||
expect(componentStyles).toContain('padding: var(--segment-inset);');
|
expect(componentStyles).toContain('padding: var(--segment-inset);');
|
||||||
expect(componentStyles).toContain('.donut-metric-card::before');
|
expect(componentStyles).toContain('.donut-metric-card::before');
|
||||||
expect(componentStyles).toContain('border-radius: var(--tile-radius);');
|
expect(componentStyles).toContain('border-radius: var(--tile-radius);');
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ describe('Result DB timeslot scope', () => {
|
||||||
expect(appSource).toContain('scanRunIds={app.activeScanRunIds}');
|
expect(appSource).toContain('scanRunIds={app.activeScanRunIds}');
|
||||||
expect(browserSource).toContain("backendStatus !== 'connected' || !projectId || !scanRunIds?.length");
|
expect(browserSource).toContain("backendStatus !== 'connected' || !projectId || !scanRunIds?.length");
|
||||||
expect(browserSource).toContain('<h3>No timeslot selected</h3>');
|
expect(browserSource).toContain('<h3>No timeslot selected</h3>');
|
||||||
expect(browserSource).toContain('scanRunIds\n };');
|
expect(browserSource).toMatch(/scanRunIds\r?\n };/);
|
||||||
expect(browserSource).toContain('queryUnifiedResultFacets(scopedQuery)');
|
expect(browserSource).toContain('queryUnifiedResultFacets(scopedQuery)');
|
||||||
expect(browserSource).toContain('queryUnifiedResults({ ...scopedQuery, offset: 0, limit })');
|
expect(browserSource).toContain('queryUnifiedResults({ ...scopedQuery, offset: 0, limit })');
|
||||||
expect(browserSource).toContain("item.sourceKind === 'environment-observation'");
|
expect(browserSource).toContain("item.sourceKind === 'environment-observation'");
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createRepositoryRuntime } from '../../server/repository-factory';
|
import { createRepositoryRuntime, repositoryConnectionString } from '../../server/repository-factory';
|
||||||
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
|
||||||
|
|
||||||
describe('repository runtime selection', () => {
|
describe('repository runtime selection', () => {
|
||||||
|
it('uses the configured PostgreSQL host and keeps loopback as the local default', () => {
|
||||||
|
expect(repositoryConnectionString({
|
||||||
|
ASSESSOR_POSTGRES_HOST: 'postgres',
|
||||||
|
ASSESSOR_POSTGRES_DB: 'assessor_experimental',
|
||||||
|
ASSESSOR_POSTGRES_USER: 'assessor_experimental',
|
||||||
|
ASSESSOR_POSTGRES_PASSWORD: 'synthetic password'
|
||||||
|
})).toBe('postgresql://assessor_experimental:synthetic%20password@postgres:5432/assessor_experimental');
|
||||||
|
expect(repositoryConnectionString({ ASSESSOR_POSTGRES_PASSWORD: 'synthetic' }))
|
||||||
|
.toBe('postgresql://assessor:synthetic@127.0.0.1:5432/assessor');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses the explicit in-memory development mode when no database URL is configured', async () => {
|
it('uses the explicit in-memory development mode when no database URL is configured', async () => {
|
||||||
const runtime = await createRepositoryRuntime({});
|
const runtime = await createRepositoryRuntime({});
|
||||||
expect(runtime.persistence).toBe('memory');
|
expect(runtime.persistence).toBe('memory');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue