Files
Nick SzaunigandClaude Opus 5.5 8a85bfaed4
Docker-Images bauen und veroeffentlichen / lint (push) Successful in 1m30s
Docker-Images bauen und veroeffentlichen / typecheck (push) Successful in 1m48s
Docker-Images bauen und veroeffentlichen / test (push) Successful in 1m33s
Docker-Images bauen und veroeffentlichen / build-and-push (push) Successful in 7m20s
docs: Doku-Abgleich mit origin/main (Stand 2026-10-16)
README mit Projektstand (Links auf Issues #33, #32), fehlenden Befehlen und
CI-Gate. BETRIEB: sha-gepinnte Manifeste, DB-Sicherung, Rollout per set image,
Label-Selector im pg_dump-Befehl, Storage Box. k8s/README: Schritt 09,
Warnung vor delete namespace. TESTS, OFFENE-PUNKTE, ARCHITEKTUR (ADR-8, ADR-9)
aktualisiert; Kommentare in .env.example und Worker-CronJob korrigiert.

Refs #31

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 07:57:43 +02:00

126 lines
12 KiB
Markdown

# Betrieb auf RKE2
## 1. Images bauen
**Automatisch (Gitea Actions):** `.gitea/workflows/docker-build.yml` läuft bei jedem Push auf `main`, bei Tags `v*` oder manuell per „Run workflow“. Zuerst prüfen drei Jobs auf dem isolierten Runner (`ci-isolated`, ohne Docker-Socket) den Code: `lint` (inklusive `pnpm audit --prod --audit-level high`), `typecheck` und `test` (mit PostgreSQL 18.6 als Service). Nur wenn alle drei grün sind, baut `build-and-push` auf dem Runner `docker-builder` beide Images und pusht sie in die Gitea-Registry:
- `main` ergibt die Tags `latest` und `sha-<kurz>`
- `v0.1.0` ergibt zusätzlich den Tag `0.1.0`
Einmalig einrichten:
- ein Personal Access Token mit Scope `package` als Repo-Secret `REGISTRY_TOKEN` hinterlegen (Repo-Einstellungen → Actions → Secrets)
- Runner mit den Labels `ci-isolated` (Prüfungen) und `docker-builder` (Image-Build) bereitstellen; im EnCry-Cluster sind beide vorhanden
Die Manifeste in `k8s/` sind auf einen CI-Build gepinnt (`sha-<commit>`, Platzhalter `sha-REPLACE_ME` im Repo), nicht auf `latest`. Ausgerollt wird mit `kubectl set image`, siehe Abschnitt 3 und [k8s/README.md](../k8s/README.md).
Scheitert `build-and-push` beim Push mit `500 … cannot allocate memory`, lag das bisher an der Gitea-Registry, wenn gleichzeitig ein anderes Repo pushte. Dann den Lauf neu starten, sobald der andere Build fertig ist.
**Manuell:**
```bash
docker build --target runner -t git.encry.de/nick.rosenloecher/artista-modelcamp:latest .
docker build --target tools -t git.encry.de/nick.rosenloecher/artista-modelcamp-tools:latest .
docker push … # beide Images, immer mit identischem Tag
```
Der Build braucht Internet für `next/font` (Schriften werden eingebettet), aber weder Datenbank noch Secrets.
## 2. Einmalig einrichten
**Was ausgerollt wird:** StatefulSet PostgreSQL, Services (App, Postgres), Ingress, App-Deployment (mit Migration als initContainer), zwei SMB-Volumes (Postgres-Daten, Medien) und zusätzlich:
- der Worker-CronJob, nötig für Reservierungsfreigabe, Abgleich der Folgeraten und Mail-Wiederholung
- ConfigMap
- zwei NetworkPolicies (Schutz der Rate Limits und der Datenbank)
- der Sicherungs-CronJob `artista-db-backup` (täglicher `pg_dump`, Abschnitt 7)
**Schritt für Schritt zum Kopieren: [k8s/README.md](../k8s/README.md).** Die Manifeste liegen flach in `k8s/` (`00-namespace` bis `09-db-backup-cronjob`, Namensschema wie bei Wabenhain) und werden in dieser Reihenfolge einzeln angewendet. Die Secrets kommen aus `k8s/02-secret.yaml.example` und werden nicht eingecheckt. Pod Security: Erzwungen wird `baseline`, damit der Zertifikats-Solver von cert-manager starten kann. `restricted` wird nur gemeldet, die eigenen Pods erfüllen es.
Im EnCry-Cluster liegen die SMB-Freigaben auf der Hetzner Storage Box, nicht auf einem lokalen NAS; „NAS“ meint im Folgenden diese Freigabe. Im Namespace `artista` läuft außerdem Artista-Telefonie (eigenes Repo `Artista-TeleTracking`). Sie nutzt eine eigene Datenbank `telefonie` in derselben PostgreSQL-Instanz `artista-modelcamp-postgres`. Wer die Instanz oder den Namespace anfasst, trifft beide Anwendungen.
Zu PostgreSQL:
- **PostgreSQL auf SMB ist eine bewusste Ausnahme.** SMB garantiert Sperren und fsync nicht in jedem Fall wie ein lokales Dateisystem. Bei einem NAS- oder Netzwerkausfall während Schreibvorgängen kann die Datenbank beschädigt werden.
- **Gemildert** wird das durch `hard`, `cache=strict`, SMB-Verschlüsselung (`seal`) und ein 120 Sekunden langes, sauberes Herunterfahren. Die Rechte setzen die `mountOptions`, weil `fsGroup` auf SMB nicht wirkt: Postgres `uid=70`, `0700`; Medien `uid=1001`, nur lesbar.
- **Sicherung:** Eine Kopie der Freigabe ist für die laufende Datenbank nur mit **atomaren Snapshots** (Btrfs/ZFS) konsistent. Deshalb zieht der CronJob `artista-db-backup` jede Nacht einen `pg_dump` (Abschnitt 7).
- **Nach SMB-Unterbrechungen** melden lang laufende Postgres-Prozesse gelegentlich `could not seek to end of file … Bad file descriptor`. Abhilfe: `kubectl -n artista rollout restart sts/artista-modelcamp-postgres` (fährt sauber herunter).
## 3. Update ausrollen
App, Migration und Worker immer mit demselben Tag `sha-<commit>` aus der CI ausrollen (genauer Befehl in [k8s/README.md](../k8s/README.md), Abschnitt „Bei einem neuen Image“):
```bash
TAG=sha-<commit>
kubectl -n artista set image deployment/artista-modelcamp-app migrate=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG app=git.encry.de/nick.rosenloecher/artista-modelcamp:$TAG
kubectl -n artista set image cronjob/artista-modelcamp-worker worker=git.encry.de/nick.rosenloecher/artista-modelcamp-tools:$TAG
kubectl -n artista rollout status deploy/artista-modelcamp-app
kubectl -n artista logs deploy/artista-modelcamp-app -c migrate # Migrationsausgabe
```
Ein `rollout restart` allein holt kein neues Image, weil die Manifeste auf einen festen Tag zeigen.
Jeder neue App-Pod migriert per initContainer zuerst die Datenbank. Scheitert das, startet der Pod nicht, und die alten Pods laufen weiter.
Migrationen sind vorwärtskompatibel zu schreiben (erst Spalten ergänzen, später entfernen), damit alte und neue Pods während des Rollouts parallel laufen können.
## 4. Stripe
- **Schlüssel:** Im Test `sk_test_…`, live `sk_live_…` (nur mit `APP_ENV=production`). Empfohlen ist ein eingeschränkter Schlüssel mit Schreibrechten auf Customers, Checkout Sessions, PaymentIntents, PaymentMethods und Refunds.
- **Webhook-Endpunkt:** `https://<domain>/api/stripe/webhook`. Diese Events abonnieren:
`checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`, `invoice.paid`, `invoice.payment_failed`, `invoice.payment_action_required`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`.
Das Signing Secret gehört als `STRIPE_WEBHOOK_SECRET` ins Secret. Test- und Live-Endpunkt haben getrennte Secrets.
- **Zahlungsarten** (Dashboard → Zahlungsmethoden): Für Ratenzahlung nur wiederverwendbare Methoden aktivieren (Karten, gegebenenfalls SEPA-Lastschrift). Checkout blendet bei Ratenzahlung ohnehin nur Methoden ein, die `setup_future_usage` unterstützen.
- **Folgeraten (Stripe Billing):** Stripe zieht die Raten 2 und 3 über einen Subscription Schedule ein (docs/ARCHITEKTUR.md, ADR-3). Im Dashboard unter Billing → Einstellungen festlegen:
- **Revenue recovery → Retries:** Smart Retries an (z. B. 4 Versuche innerhalb von 2 Wochen). Nach dem letzten Versuch: Abonnement kündigen. Die Buchung erscheint dann als „Prüfung nötig“.
- **E-Mails an Kunden:** „Zahlung fehlgeschlagen“ und „Bestätigung für 3-D Secure erforderlich“ einschalten, damit Stripe selbst mahnt und Bestätigungslinks schickt. Die App schickt zusätzlich eine eigene deutschsprachige Mail mit Link zur Statusseite.
- Der eingeschränkte Schlüssel braucht zusätzlich Schreibrechte für Products, Subscription Schedules, Subscriptions und Invoices.
- **Test lokal:** `stripe listen --forward-to localhost:3000/api/stripe/webhook`
- **Sicherheitsnetz:** Der Worker gleicht bei abgelaufenen Reservierungen und hängenden Einzügen immer mit Stripe ab. Ein verlorener Webhook führt so nicht zu verlorenen Zahlungen.
## 5. E-Mail
`MAIL_TRANSPORT=smtp` mit `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER` und `SMTP_PASSWORD`. Den Absender (`MAIL_FROM`) per SPF, DKIM und DMARC für die Absenderdomain freischalten. `ADMIN_NOTIFY_EMAIL` erhält Hinweise zu „Prüfung nötig“. Nicht zustellbare Mails zeigt das Admin-Dashboard. Der Worker versucht jede Mail bis zu achtmal.
## 6. Worker
Der CronJob `artista-modelcamp-worker` läuft alle 10 Minuten und kümmert sich um:
- abgelaufene Reservierungen, vorher mit Rückfrage bei Stripe
- hängende erste Zahlungen (SEPA)
- fehlende Stripe-Ratenpläne anlegen (z. B. nach Bestätigung einer geprüften Buchung)
- Abgleich überfälliger Raten mit den Stripe-Rechnungen (verlorene Webhooks)
- Ratenpläne stornierter Buchungen beenden, falls das beim Storno scheiterte
- das Trennen der Zahlungsmethode nach der letzten Rate
- den E-Mail-Versand
- das Aufräumen der Rate Limits
Ein fehlgeschlagener Lauf erscheint als fehlgeschlagener Job (`kubectl -n artista get jobs`).
## 7. Sicherung und Wiederherstellung
- **Täglicher Dump im Cluster:** Der CronJob `artista-db-backup` (`k8s/09-db-backup-cronjob.yaml`) läuft um 02:25 Uhr (Zeitzone des Clusters) und schreibt `pg_dump -Fc` der Datenbank `artista` in die PVC `artista-db-backup` (StorageClass `smb-backup`, Ordner `Backups/artista/` auf der Storage Box). Aufbewahrung: 14 Tage täglich (`daily/`), 8 Wochen sonntags (`weekly/`). Die Datenbank `telefonie` derselben Instanz ist darin nicht enthalten. Stand prüfen: `kubectl -n artista get cronjob artista-db-backup` und `kubectl -n artista get jobs | grep artista-db-backup`.
- **Logischer Dump von Hand** (zusätzlich vor Updates und vor dem Live-Verkauf):
```bash
POD=$(kubectl -n artista get pod -l app=artista-modelcamp-postgres -o name | head -1)
kubectl -n artista exec $POD -- pg_dump -U artista -d artista --format=custom > artista-$(date +%F).dump
```
- **Wiederherstellen:**
1. App stoppen: `kubectl -n artista scale deploy/artista-modelcamp-app --replicas=0` und den Worker-CronJob pausieren: `kubectl -n artista patch cronjob artista-modelcamp-worker -p '{"spec":{"suspend":true}}'`
2. Einspielen: `kubectl -n artista exec -i $POD -- pg_restore -U artista -d artista --clean --if-exists < artista-YYYY-MM-DD.dump`
3. App wieder hochskalieren (der initContainer migriert dabei), Worker fortsetzen.
4. Abgleich: Zahlungen, die nach dem Sicherungszeitpunkt eingingen, im Stripe-Dashboard prüfen. Stripe stellt Webhooks bis zu drei Tage erneut zu (Dashboard → Webhooks → „Erneut senden“).
- **Medien:** liegen im SMB-Ordner `modelcamp-media`; gesichert sind sie nur über die Sicherung der Freigabe selbst.
- **Wiederherstellung üben:** mindestens einmal vor dem Live-Verkauf in einen Test-Namespace. Das ist noch nicht geschehen.
## 8. Stornierung und Erstattung
Die Software trifft keine Storno-Entscheidung, sie setzt sie nur nachvollziehbar um.
1. **Stornieren** (Admin → Buchung → „Stornieren“, Grund ist Pflicht): Offene Raten werden beendet, Platz und Gutschein freigegeben, die gespeicherte Zahlungsmethode getrennt und offene Checkout-Sessions geschlossen. Danach wird nichts mehr abgebucht.
2. **Erstatten** (je bezahlter Rate, Betrag frei wählbar, höchstens bis zum gezahlten Betrag): Welcher Betrag erstattet wird, bestimmen die freigegebenen Stornobedingungen. Die Erstattung wird bei Stripe ausgelöst, der Zahlungsstatus aktualisiert sich per Webhook (`charge.refunded`). Ein doppeltes Abschicken wird über einen Idempotenzschlüssel abgefangen.
3. **Prüfung nötig** entsteht bei verspäteter Zahlung ohne freien Platz, Doppelzahlung, Rückbuchung oder endgültig fehlgeschlagener Rate. Dann entweder „Buchung bestätigen“ (nur mit freiem Platz, sonst vorher Kapazität erhöhen) oder stornieren und erstatten.
4. **Dokumentation:** Alle Schritte stehen im Protokoll der Buchung (AuditLog) mit handelnder Person.
## 9. Medien
Dateien (jpg, png, webp, avif, mp4, webm) direkt in den SMB-Ordner `modelcamp-media` legen, z. B. über den Windows-Explorer (`\\nas…\artista\modelcamp-media`). Die App sieht sie sofort unter `/media/<dateiname>`. Im Pod ist das Volume schreibgeschützt eingehängt.
Danach in `src/content/site.ts` eintragen. Hotlinking fremder Bilder ist nicht vorgesehen: Nur Bilder mit geklärter Nutzungsberechtigung ablegen. Die Unterkunftsbilder liegen als lokale Kopie in `public/images/unterkunft/` und sind Teil des Container-Images.