- Werkzeug-Image wabenhain-tools (Dockerfile "AS tools", Muster wie artista-modelcamp-tools): Im App-Image fehlten tsconfig.json und Pakete wie stripe, Bestandsabgleich und Bestellfreigabe brachen mit "Cannot find module" ab. Die CI baut es neben dem App-Image. - Datenbank-Sicherung: PV auf der Storage Box (Customer/Wabenhain), der Unterordner db-backup entsteht per subPath. Vorher blieb der PVC ohne StorageClass haengen. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
332 lines
14 KiB
YAML
332 lines
14 KiB
YAML
# Taegliche Sicherung der Datenbank.
|
|
#
|
|
# WARUM: 03-postgres.yaml haelt Bestellungen, Rechnungen, Kundenkonten und
|
|
# Chargendaten in einem einzelnen PersistentVolume ohne jede Sicherung. Geht
|
|
# dieses Volume verloren (Speicherfehler, versehentliches "kubectl delete
|
|
# pvc", ein misslungenes Cluster-Update), sind saemtliche Bestellungen und
|
|
# Rechnungen unwiederbringlich weg. Rechnungen unterliegen in Deutschland
|
|
# ausserdem einer zehnjaehrigen Aufbewahrungspflicht (§ 147 AO) — ein
|
|
# Datenverlust ist hier nicht nur aergerlich, sondern ein Rechtsproblem.
|
|
#
|
|
# Diese Sicherung liegt auf derselben Storage Box wie die Datenbank. Sie
|
|
# schuetzt gegen Volume-Verlust und Bedienfehler, nicht gegen den Ausfall
|
|
# der Storage Box oder des Standorts. Fuer eine vollstaendige Absicherung die
|
|
# erzeugten .sql.gz-Dateien zusaetzlich regelmaessig aus dem Cluster
|
|
# herausziehen (Beispielbefehl siehe k8s/README.md, Abschnitt "Sicherung
|
|
# und Wiederherstellung").
|
|
#
|
|
# MYSQL STATT POSTGRES: laeuft die Installation mit DATABASE_PROVIDER=mysql
|
|
# (03-mysql.yaml), stattdessen das Image "mysql:8.4.11" verwenden und den
|
|
# pg_dump-Aufruf unten ersetzen durch:
|
|
# mysqldump -h wabenhain-mysql -u wabenhain -p"$PGPASSWORD" \
|
|
# --single-transaction --routines wabenhain | gzip -9 > "$file.partial"
|
|
# Ziel ist die Storage Box wie bei den uebrigen Wabenhain-Volumes. Das PV
|
|
# zeigt auf den vorhandenen Ordner Customer/Wabenhain; der Unterordner
|
|
# db-backup entsteht ueber subPath beim ersten Lauf.
|
|
apiVersion: v1
|
|
kind: PersistentVolume
|
|
metadata:
|
|
name: wabenhain-db-backup
|
|
spec:
|
|
capacity:
|
|
storage: 10Gi
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
persistentVolumeReclaimPolicy: Retain
|
|
storageClassName: ""
|
|
mountOptions: ["vers=3.0", "uid=70", "gid=70", "dir_mode=0700", "file_mode=0600", "noperm"]
|
|
csi:
|
|
driver: smb.csi.k8s.io
|
|
volumeHandle: wabenhain-db-backup
|
|
volumeAttributes:
|
|
source: //u652065.your-storagebox.de/backup/Server/Disks/Customer/Wabenhain
|
|
nodeStageSecretRef:
|
|
name: smb-credentials
|
|
namespace: wabenhain
|
|
claimRef:
|
|
namespace: wabenhain
|
|
name: wabenhain-db-backup
|
|
---
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: wabenhain-db-backup
|
|
namespace: wabenhain
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
storageClassName: ""
|
|
volumeName: wabenhain-db-backup
|
|
resources:
|
|
requests:
|
|
# Eine gzip-komprimierte Sicherung dieses Datenbestandes liegt im
|
|
# niedrigen Megabyte-Bereich; 10Gi tragen die 30 Tage Aufbewahrung
|
|
# unten mit grossem Puffer.
|
|
storage: 10Gi
|
|
---
|
|
apiVersion: batch/v1
|
|
kind: CronJob
|
|
metadata:
|
|
name: wabenhain-db-backup
|
|
namespace: wabenhain
|
|
spec:
|
|
# 03:15 Uhr — ausserhalb der ueblichen Bestellzeiten. Die Uhrzeit richtet
|
|
# sich nach der Zeitzone des Clusters (meist UTC).
|
|
schedule: "15 3 * * *"
|
|
# Kein zweiter Lauf, solange der vorherige noch laeuft.
|
|
concurrencyPolicy: Forbid
|
|
successfulJobsHistoryLimit: 3
|
|
# Fehlgeschlagene Laeufe laenger aufheben als erfolgreiche: nur an ihnen
|
|
# laesst sich per "kubectl logs" nachvollziehen, warum eine Sicherung
|
|
# ausblieb.
|
|
failedJobsHistoryLimit: 5
|
|
# Faellt ein Lauf aus (Knoten weg, Cluster-Wartung), gilt er nach einer
|
|
# Stunde als verpasst statt ewig nachgeholt zu werden.
|
|
startingDeadlineSeconds: 3600
|
|
jobTemplate:
|
|
spec:
|
|
backoffLimit: 2
|
|
template:
|
|
spec:
|
|
restartPolicy: OnFailure
|
|
# 70 ist die UID/GID des "postgres"-Nutzers im offiziellen
|
|
# Alpine-Image; fsGroup gibt dem Job Schreibrecht auf dem
|
|
# gemounteten Backup-Volume (gleiche Begruendung wie beim
|
|
# uploads-Volume in 05-app-deployment.yaml).
|
|
securityContext:
|
|
runAsUser: 70
|
|
runAsGroup: 70
|
|
fsGroup: 70
|
|
containers:
|
|
- name: pg-dump
|
|
# Gleiche Hauptversion wie der Server in 03-postgres.yaml —
|
|
# ein aelteres pg_dump kann eine neuere Datenbank nicht lesen.
|
|
image: postgres:16.15-alpine
|
|
env:
|
|
- name: PGPASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: wabenhain-secrets
|
|
key: DB_PASSWORD
|
|
command: ["/bin/sh", "-c"]
|
|
args:
|
|
- |
|
|
set -eu
|
|
# pipefail ist hier das Wichtigste an diesem Skript:
|
|
# ohne es liefert "pg_dump | gzip" den Exit-Code von gzip.
|
|
# gzip gelingt auch dann, wenn pg_dump vorher abgebrochen
|
|
# ist — der Job galte als erfolgreich und im Verzeichnis
|
|
# laege eine abgeschnittene, beim Wiederherstellen
|
|
# wertlose Datei. Genau so entstehen Sicherungen, die erst
|
|
# im Ernstfall als unbrauchbar auffallen.
|
|
set -o pipefail
|
|
|
|
stamp="$(date -u +%Y-%m-%d_%H%M)"
|
|
target="/backup/wabenhain-${stamp}.sql.gz"
|
|
|
|
# Erst unter einem Zwischennamen schreiben, dann
|
|
# umbenennen: ein mitten im Lauf abgebrochener Job
|
|
# hinterlaesst so keine Datei, die wie eine vollstaendige
|
|
# Sicherung aussieht.
|
|
pg_dump --host=wabenhain-postgres --username=wabenhain \
|
|
--dbname=wabenhain --clean --if-exists \
|
|
| gzip -9 > "${target}.partial"
|
|
mv "${target}.partial" "${target}"
|
|
|
|
# Rechnungsablage mitsichern (INVOICE_DIR, siehe
|
|
# 04-uploads-pvc.yaml). Die Datenbank kennt von einer
|
|
# Rechnung nur Nummer, Betrag und Pfad — das ausgelieferte
|
|
# PDF liegt ausschliesslich auf dem Volume. Ein
|
|
# Datenbank-Dump allein ersetzt den Beleg also NICHT.
|
|
invoices="/backup/rechnungen-${stamp}.tar.gz"
|
|
tar -czf "${invoices}.partial" -C /invoices .
|
|
mv "${invoices}.partial" "${invoices}"
|
|
|
|
# Angefangene Dateien frueherer Abbrueche mit aufraeumen.
|
|
find /backup -name 'wabenhain-*.sql.gz.partial' -mtime +1 -delete
|
|
find /backup -name 'rechnungen-*.tar.gz.partial' -mtime +1 -delete
|
|
|
|
# 30 Tage Aufbewahrung. Achtung: ersetzt KEINE
|
|
# langfristige Archivierung der Rechnungen (§ 147 AO,
|
|
# zehn Jahre) — dafuer die Rechnungs-PDFs bzw. regelmaessig
|
|
# ausgelagerte Sicherungen nutzen.
|
|
find /backup -name 'wabenhain-*.sql.gz' -mtime +30 -delete
|
|
find /backup -name 'rechnungen-*.tar.gz' -mtime +30 -delete
|
|
|
|
echo "Sicherung abgeschlossen:"
|
|
ls -lh /backup
|
|
resources:
|
|
requests:
|
|
cpu: "0"
|
|
memory: "0"
|
|
limits:
|
|
memory: 256Mi
|
|
volumeMounts:
|
|
- name: backup
|
|
mountPath: /backup
|
|
subPath: db-backup
|
|
# Nur lesend: dieser Job darf Belege sichern, niemals aendern.
|
|
- name: invoices
|
|
mountPath: /invoices
|
|
readOnly: true
|
|
volumes:
|
|
- name: backup
|
|
persistentVolumeClaim:
|
|
claimName: wabenhain-db-backup
|
|
# ACHTUNG bei mehr als einem Knoten: wabenhain-invoices ist
|
|
# ReadWriteOnce (04-uploads-pvc.yaml). Ein solches Volume laesst
|
|
# sich nur von Pods auf DEMSELBEN Knoten einhaengen. Landet dieser
|
|
# Sicherungs-Pod auf einem anderen Knoten als der App-Pod, bleibt
|
|
# er in "Pending" haengen — sichtbar nur, wenn man nachsieht,
|
|
# denn ein Pending-Pod meldet keinen Fehler.
|
|
#
|
|
# Auf einem Ein-Knoten-Cluster ist das kein Thema. Bei mehreren
|
|
# Knoten das PVC auf ReadWriteMany umstellen (NFS/SMB/CephFS) —
|
|
# dann greift die Einschraenkung nicht mehr.
|
|
- name: invoices
|
|
persistentVolumeClaim:
|
|
claimName: wabenhain-invoices
|
|
---
|
|
# Aufraeumlauf fuer liegengebliebene, nie bezahlte Bestellungen.
|
|
#
|
|
# WARUM: eine Bestellung entsteht VOR der Zahlung und reserviert dabei sofort
|
|
# Lagerbestand — sonst koennte zwischen Kaufentscheidung und Zahlung jemand
|
|
# anders dasselbe Glas kaufen. Zurueck kommt der Bestand normalerweise ueber
|
|
# zwei Wege: den Abbruch-Knopf bei Stripe (/checkout/cancel) und das Ereignis
|
|
# checkout.session.expired, das Stripe an den Webhook schickt.
|
|
#
|
|
# Dieser CronJob ist das Netz unter beiden. Er greift genau dann, wenn der
|
|
# Webhook nicht ankommt: das Ereignis ist im Stripe-Dashboard nicht abonniert,
|
|
# der Endpunkt war beim Zustellversuch nicht erreichbar, oder die Versuche
|
|
# sind abgelaufen. Ohne ihn bliebe der reservierte Bestand dauerhaft
|
|
# blockiert — und zwar unsichtbar, denn eine solche Bestellung sieht im
|
|
# Admin-Bereich aus wie jede andere offene.
|
|
#
|
|
# Gefragt wird immer Stripe, nie die Uhr allein: zurueckgebucht wird nur, was
|
|
# dort nachweislich abgelaufen ist (siehe scripts/release-stale-orders.ts).
|
|
#
|
|
# Der Lauf endet mit Exit-Code 1, wenn er eine Bestellung findet, die bei
|
|
# Stripe bezahlt ist, hier aber noch auf "NEU" steht. Das ist Absicht: dieser
|
|
# Fall bedeutet entgangenen Umsatz und gehoert als fehlgeschlagener Job
|
|
# sichtbar gemacht, nicht in eine Logzeile.
|
|
apiVersion: batch/v1
|
|
kind: CronJob
|
|
metadata:
|
|
name: wabenhain-release-stale-orders
|
|
namespace: wabenhain
|
|
spec:
|
|
# Alle 30 Minuten. Die Stripe-Sitzung laeuft nach 32 Minuten ab, der Lauf
|
|
# betrachtet Bestellungen ab 45 Minuten Alter — haeufiger zu pruefen bringt
|
|
# deshalb nichts, seltener laesst Ware unnoetig lange liegen.
|
|
schedule: "*/30 * * * *"
|
|
concurrencyPolicy: Forbid
|
|
successfulJobsHistoryLimit: 3
|
|
# Fehlgeschlagene Laeufe laenger aufheben: nur an ihnen laesst sich per
|
|
# "kubectl logs" nachlesen, welche Bestellung bezahlt, aber nicht
|
|
# verarbeitet ist.
|
|
failedJobsHistoryLimit: 5
|
|
startingDeadlineSeconds: 600
|
|
jobTemplate:
|
|
spec:
|
|
# Kein endloses Wiederholen: schlaegt der Lauf zweimal fehl, laeuft er
|
|
# ohnehin in 30 Minuten erneut.
|
|
backoffLimit: 2
|
|
# Nach einer Stunde abbrechen — ein haengender Lauf wuerde sonst jeden
|
|
# weiteren blockieren (concurrencyPolicy: Forbid).
|
|
activeDeadlineSeconds: 3600
|
|
template:
|
|
spec:
|
|
restartPolicy: OnFailure
|
|
# Gleiche Kennung wie der App-Container (Dockerfile: adduser --uid
|
|
# 1001 nextjs) — beide Images legen denselben Nutzer an.
|
|
securityContext:
|
|
runAsUser: 1001
|
|
runAsGroup: 1001
|
|
containers:
|
|
- name: release-stale-orders
|
|
# Werkzeug-Image (Dockerfile "AS tools") mit DEMSELBEN Tag wie
|
|
# das App-Image in 05-app-deployment.yaml. Beide zusammen
|
|
# aktualisieren: der Lauf nutzt denselben Prisma-Client wie die
|
|
# App und muss deshalb zum selben Datenbankschema passen.
|
|
image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
|
|
imagePullPolicy: IfNotPresent
|
|
# Braucht DATABASE_URL, DATABASE_PROVIDER und STRIPE_SECRET_KEY
|
|
# — die stehen bereits in denselben beiden Quellen wie fuer die
|
|
# App, deshalb hier keine eigene Liste, die auseinanderlaufen
|
|
# koennte.
|
|
envFrom:
|
|
- configMapRef:
|
|
name: wabenhain-config
|
|
- secretRef:
|
|
name: wabenhain-secrets
|
|
command: ["/bin/sh", "-c"]
|
|
args:
|
|
- |
|
|
set -eu
|
|
# prisma-provider.mjs schreibt die provider-Zeile in
|
|
# prisma/schema.prisma anhand von DATABASE_PROVIDER — ohne
|
|
# diesen Schritt laedt der Prisma-Client den falschen
|
|
# Treiber, wenn die Installation auf MySQL laeuft.
|
|
node scripts/prisma-provider.mjs
|
|
exec tsx scripts/release-stale-orders.ts
|
|
resources:
|
|
requests:
|
|
cpu: "0"
|
|
memory: "0"
|
|
limits:
|
|
# Derselbe Wert wie beim App-Container: der Lauf laedt
|
|
# denselben Prisma-Client samt Treiber.
|
|
memory: 512Mi
|
|
---
|
|
# Bestandsabgleich mit ApiCore (scripts/apicore-stock-sync.ts): sendet
|
|
# liegengebliebene Verkaeufe/Rueckbuchungen an ApiCore und gleicht alle
|
|
# verknuepften Produkte gegen den ApiCore-Restbestand ab. Sicherheitsnetz
|
|
# unter dem Sofort-Versand und dem Webhook "stock.changed" — scheitert der
|
|
# Lauf (ApiCore nicht erreichbar, Buchung abgelehnt), ist der Job rot.
|
|
apiVersion: batch/v1
|
|
kind: CronJob
|
|
metadata:
|
|
name: wabenhain-apicore-stock
|
|
namespace: wabenhain
|
|
spec:
|
|
schedule: "*/10 * * * *"
|
|
concurrencyPolicy: Forbid
|
|
successfulJobsHistoryLimit: 3
|
|
failedJobsHistoryLimit: 5
|
|
startingDeadlineSeconds: 300
|
|
jobTemplate:
|
|
spec:
|
|
backoffLimit: 1
|
|
# Ein Lauf darf den naechsten nicht blockieren.
|
|
activeDeadlineSeconds: 540
|
|
template:
|
|
spec:
|
|
restartPolicy: OnFailure
|
|
securityContext:
|
|
runAsUser: 1001
|
|
runAsGroup: 1001
|
|
containers:
|
|
- name: apicore-stock
|
|
# Werkzeug-Image mit demselben Tag wie in 05-app-deployment.yaml.
|
|
image: git.encry.de/nick.rosenloecher/wabenhain-tools:latest
|
|
imagePullPolicy: IfNotPresent
|
|
# Braucht DATABASE_URL, DATABASE_PROVIDER, APICORE_API_URL und
|
|
# APICORE_API_KEY — dieselben Quellen wie die App.
|
|
envFrom:
|
|
- configMapRef:
|
|
name: wabenhain-config
|
|
- secretRef:
|
|
name: wabenhain-secrets
|
|
command: ["/bin/sh", "-c"]
|
|
args:
|
|
- |
|
|
set -eu
|
|
node scripts/prisma-provider.mjs
|
|
exec tsx scripts/apicore-stock-sync.ts
|
|
resources:
|
|
requests:
|
|
cpu: "0"
|
|
memory: "0"
|
|
limits:
|
|
memory: 512Mi
|