diff --git a/.gitignore b/.gitignore index d0ce196..235b03b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,14 @@ dist/ next-env.d.ts *.tsbuildinfo +# Eigene Anpassungen der Installation. +# +# Docker Compose liest diese Datei automatisch zusaetzlich zur +# docker-compose.yml. Sie ist der vorgesehene Ort fuer Aenderungen, die ein +# Update ueberleben sollen: update.sh prueft nur versionierte Dateien +# (git status --untracked-files=no) und laesst sie deshalb in Ruhe. +docker-compose.override.yml + # Umgebung - enthaelt Schluessel, gehoert NIE ins Repo .env .env.local diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 11b3b8f..465fe94 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -34,6 +34,7 @@ durchgeht — dann weißt du im Fehlerfall genau, wo es hakt. - [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen) - [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten) - [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen) +- [Wo deine Daten liegen](#wo-deine-daten-liegen) - [Aktualisieren](#aktualisieren) - [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt) - [Abnahmeliste](#abnahmeliste) @@ -375,22 +376,37 @@ nichts kaputtmacht. Der Objektspeicher braucht einen Ablagebereich für die Dokumente: ```bash -set -a && source .env && set +a - -docker compose exec minio mc alias set lokal \ - http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY" - -docker compose exec minio mc mb lokal/betreuungs-cockpit +docker compose exec minio sh -c ' + mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && + mc mb "lokal/$S3_BUCKET" 2>/dev/null || true + mc ls lokal +' ``` -### Kontrolle +Es muss eine Zeile mit dem Namen deines Ablagebereichs erscheinen — +standardmäßig `betreuungs-cockpit/`. -```bash -docker compose exec minio mc ls lokal -# [...] betreuungs-cockpit/ -``` +> **Warum der Befehl so umständlich aussieht.** Er holt die Zugangsdaten +> aus den Variablen, die im MinIO-Behälter ohnehin stehen. Das hat drei +> Gründe, und jeder davon hat schon einmal Zeit gekostet: +> +> 1. **Kein Passwort in der Befehlszeile** — es stünde sonst in der +> Verlaufsdatei deiner Shell. +> 2. **Kein `source .env`** — exportierte Werte haben Vorrang vor der +> `.env`-Datei und überstimmen danach *jedes* `docker compose up`. Wer +> später die `.env` ändert und neu startet, sieht keine Wirkung und +> sucht den Fehler überall, nur nicht in der eigenen Shell. +> 3. **Der Alias überlebt kein Neuerstellen.** `mc alias set` schreibt +> nach `/root/.mc` **im Behälter**. Nach jedem `docker compose up -d +> minio` ist er weg, und `mc ls lokal` liefert dann kommentarlos nichts +> — man hält seine Dokumente für verschwunden. Deshalb setzt jeder +> Aufruf den Alias selbst. +> +> Der Bucket-Name kommt aus `$S3_BUCKET` und ist nicht fest verdrahtet: +> Sonst zeigen Anwendung und Ablagebereich aneinander vorbei, sobald +> jemand den Namen ändert. -> **Serverseitige Verschluesselung?** Brauchst du nicht. Die Anwendung +> **Serverseitige Verschlüsselung?** Brauchst du nicht. Die Anwendung > verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit > `ENC_KEY_V1`, der den Anbieter nie erreicht. Das mitgelieferte MinIO > läuft ohne KMS und würde jede Ablage ablehnen, sobald `S3_SSE` in der @@ -913,6 +929,105 @@ Denk an die letzten Pflichten: --- +## Wo deine Daten liegen + +Drei Orte, und keiner davon ist ein Ordner, den du einfach öffnest. + +| Was | Wo | In der täglichen Sicherung? | +|---|---|---| +| **Dokumente** (Bescheide, Schreiben, Belege) | Docker-Volume `minio-daten` | ja | +| **Datenbank** (Akte, Fristen, Buchungen) | Docker-Volume `postgres-daten` | ja | +| **Sicherungen** | `./backups` neben der `docker-compose.yml` | — | + +### Den Pfad auf der Platte finden + +```bash +docker volume inspect betreuungs-cockpit_minio-daten --format '{{.Mountpoint}}' +# /var/lib/docker/volumes/betreuungs-cockpit_minio-daten/_data +``` + +Heißt dein Projektverzeichnis anders, heißen auch die Volumes anders — +Docker stellt den Verzeichnisnamen voran: + +```bash +docker volume ls | grep -E "minio-daten|postgres-daten" +``` + +> **Dort hineinzusehen bringt nichts.** MinIO legt jedes Dokument als +> eigenes Verzeichnis mit Metadatei und Datenteilen ab, und der Inhalt ist +> verschlüsselt. Du fändest dort keine anklickbaren PDFs, sondern +> Fragmente. Wer die Dokumente sehen will, nimmt die Anwendung. + +### Nachsehen, was abgelegt ist + +```bash +docker compose exec minio sh -c ' + mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && + mc ls --recursive "lokal/$S3_BUCKET" +' +``` + +Je Dokument eine Zeile unter `betreute//dokumente/.pdf`. + +### Prüfen, ob Akte und Speicher übereinstimmen + +Die wichtigste Kontrolle: Zu **jedem** Eintrag in der Akte muss eine Datei +gehören. Fehlt sie, belegt eine Prüfsumme ein Dokument, das es nicht mehr +gibt — gegenüber dem Betreuungsgericht der schlechteste Zustand. + +```bash +docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c "SELECT \"objektSchluessel\" FROM dokument WHERE \"geloeschtAm\" IS NULL ORDER BY 1;"' | sort > /tmp/erwartet.txt + +docker compose exec minio sh -c ' + mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null && + mc ls --recursive "lokal/$S3_BUCKET" +' | awk '{print $NF}' | sort > /tmp/vorhanden.txt + +echo "In der Akte: $(wc -l < /tmp/erwartet.txt) Im Speicher: $(wc -l < /tmp/vorhanden.txt)" +echo "--- Fehlt im Speicher (Handlungsbedarf): ---" +comm -23 /tmp/erwartet.txt /tmp/vorhanden.txt +echo "--- Verwaist im Speicher (harmlos): ---" +comm -13 /tmp/erwartet.txt /tmp/vorhanden.txt +``` + +Beide Zahlen gleich und beide Listen leer — dann passt es. + +Ein Eintrag unter *„Fehlt im Speicher"* ist ein Akteneintrag ohne Datei. +Er entsteht, wenn eine Ablage scheitert. Solche Einträge löschen und das +Dokument neu anlegen. + +### Ohne welche Schlüssel nichts davon lesbar ist + +| Schlüssel | Ohne ihn | +|---|---| +| `ENC_KEY_V1` | Diagnosen, IBANs, Notizen und alle Dokumente bleiben unlesbar — auch aus jeder Sicherung | +| `SICHERUNG_PASSWORT` | keine Sicherung lässt sich öffnen | +| `MINIO_KMS_SECRET_KEY` | nur falls gesetzt: MinIO gibt die Objekte nicht mehr heraus | + +Alle gehören an die zwei Orte aus Schritt 11, und keiner davon ist dieser +Server. + +### Eigene Anpassungen, die ein Update überleben + +Änderungen an der `docker-compose.yml` legt `scripts/update.sh` bei jedem +Update per `git stash` beiseite — sie sind versioniert. Wer etwas dauerhaft +ändern will, legt stattdessen eine **`docker-compose.override.yml`** +daneben: + +```yaml +services: + app: + environment: + EIGENER_WERT: ${EIGENER_WERT:-} +``` + +Docker Compose liest sie automatisch mit. `update.sh` prüft nur +versionierte Dateien (`git status --untracked-files=no`) und lässt sie +deshalb unangetastet. + +--- + + ## Aktualisieren ```bash diff --git a/tests/anleitung.test.ts b/tests/anleitung.test.ts new file mode 100644 index 0000000..4b8665a --- /dev/null +++ b/tests/anleitung.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +/** + * Die Installationsanleitung muss ausführbar sein, nicht nur lesbar. + * + * Anlass ist eine Fehlersuche vom 31.08.2026. Drei Stellen der Anleitung + * haben dabei Zeit gekostet — keine davon war falsch formuliert, alle drei + * waren schlicht nicht durchdacht: + * + * 1. `mc alias set` wurde einmalig gesetzt. Der Alias liegt in `/root/.mc` + * **im Behälter** und ist nach jedem `docker compose up -d minio` weg. + * `mc ls lokal` liefert dann kommentarlos nichts — man hält seine + * Dokumente für verschwunden. + * 2. Der Bucket-Name stand fest im Befehl, die Anwendung liest ihn aus + * `S3_BUCKET`. Weichen sie ab, zeigen beide aneinander vorbei. + * 3. `set -a && source .env` exportiert alle Werte in die Shell — und die + * haben danach Vorrang vor der `.env`-Datei. Wer später etwas ändert + * und neu startet, sieht keine Wirkung. + * + * Diese Prüfungen lesen den Text. Sie können die Anleitung nicht + * ausführen, aber sie schlagen an, sobald jemand in eine der Fallen + * zurückfällt. + */ + +const anleitung = readFileSync('docs/INSTALLATION.md', 'utf8') + +/** Alle Zeilen mit einem mc-Aufruf, ohne Fließtext und Zitate. */ +function mcZeilen(): string[] { + return anleitung + .split('\n') + .filter((zeile) => /\bmc (ls|mb|mirror|cp|rm|stat)\b/.test(zeile)) + .filter((zeile) => !zeile.trim().startsWith('>')) +} + +describe('Installationsanleitung', () => { + it('setzt bei jedem mc-Aufruf den Alias selbst', () => { + // Sonst funktioniert der Befehl nur, solange der Behaelter nicht neu + // erstellt wurde - und das sagt einem niemand. + const abschnitte = anleitung.split('```') + const ohneAlias = abschnitte + .filter((block) => /\bmc (ls|mb|mirror|cp|rm|stat)\b/.test(block)) + .filter((block) => !block.includes('mc alias set')) + + expect( + ohneAlias.length, + 'Ein mc-Befehl ohne "mc alias set" im selben Block läuft nur, solange ' + + 'der MinIO-Behälter nicht neu erstellt wurde.', + ).toBe(0) + }) + + it('verdrahtet den Namen des Ablagebereichs nicht fest', () => { + const festVerdrahtet = mcZeilen().filter((zeile) => /lokal\/betreuungs-cockpit/.test(zeile)) + + expect( + festVerdrahtet, + 'Der Name gehört aus $S3_BUCKET - sonst zeigen Anwendung und ' + + 'Ablagebereich aneinander vorbei, sobald jemand ihn ändert.', + ).toEqual([]) + }) + + it('empfiehlt kein "source .env" mehr', () => { + // Exportierte Werte ueberstimmen danach jedes "docker compose up". + const zeilen = anleitung + .split('\n') + .filter((zeile) => !zeile.trim().startsWith('>')) + .filter((zeile) => !zeile.trim().startsWith('#')) + .filter((zeile) => /source \.env/.test(zeile)) + + expect(zeilen).toEqual([]) + }) + + it('erklärt trotzdem, warum es nicht empfohlen wird', () => { + // Weglassen allein hilft nicht: Wer den Kniff von woanders kennt, + // benutzt ihn weiter. + expect(anleitung).toMatch(/Vorrang vor der/) + }) + + it('sagt, wo die Daten liegen', () => { + expect(anleitung).toMatch(/## Wo deine Daten liegen/) + expect(anleitung).toMatch(/docker volume inspect/) + expect(anleitung).toMatch(/minio-daten/) + expect(anleitung).toMatch(/postgres-daten/) + }) + + it('zeigt, wie man Akte und Speicher abgleicht', () => { + // Die wichtigste Kontrolle: Zu jedem Akteneintrag muss eine Datei + // gehoeren, sonst belegt eine Pruefsumme ein Dokument, das es nicht + // mehr gibt. + expect(anleitung).toMatch(/objektSchluessel/) + expect(anleitung).toMatch(/comm -23/) + }) + + it('nennt die Schlüssel, ohne die nichts mehr lesbar ist', () => { + const abschnitt = anleitung.slice(anleitung.indexOf('## Wo deine Daten liegen')) + for (const schluessel of ['ENC_KEY_V1', 'SICHERUNG_PASSWORT', 'MINIO_KMS_SECRET_KEY']) { + expect(abschnitt, `${schluessel} fehlt in der Aufzählung`).toContain(schluessel) + } + }) + + it('weist docker-compose.override.yml als Ort für eigene Anpassungen aus', () => { + expect(anleitung).toMatch(/docker-compose\.override\.yml/) + expect(readFileSync('.gitignore', 'utf8')).toMatch(/docker-compose\.override\.yml/) + }) + + it('behauptet nicht mehr, die Dokumente seien ungesichert', () => { + // Stand bis v1.11.1 so drin und stimmt seit der Sicherung nicht mehr. + expect(anleitung).not.toMatch(/Was das Backup nicht enthält:.*Dateien im Objektspeicher/s) + }) +})