Files
Wabenhain/k8s/07-backup-cronjob.yaml
T
Nick SzaunigandClaude Opus 5.5 93b589ff04
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
feat(stock): keep shop stock in sync with ApiCore, write sales back
ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.

- Outbox apicore_stock_events, written in the same transaction as the
  status change: paid -> SALE, cancelled-after-payment / refunded return
  -> RETURN onto the batches of the sale, reopened paid order -> SALE.
  Whether a return is due is decided by the open sales in the outbox,
  so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
  wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
  ApiCore deduplicates by event id. Rejected bookings stop and show up
  on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
  reservations of unpaid orders, in a serializable transaction like the
  checkout. Triggered after bookings, by the signed ApiCore webhook
  stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
  per ApiCore article via Product.apicoreProductId, drafts created by the
  sync, existing products linkable in the product form; new category
  SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).

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

303 lines
13 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 bewusst NUR im Cluster. Sie schuetzt gegen
# Volume-Verlust und Bedienfehler, nicht gegen den Ausfall des gesamten
# Clusters oder 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"
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: wabenhain-db-backup
namespace: wabenhain
spec:
accessModes:
- ReadWriteOnce
# storageClassName: "dein-storage-class"
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: 50m
memory: 128Mi
limits:
memory: 256Mi
volumeMounts:
- name: backup
mountPath: /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) — dasselbe Image, derselbe Nutzer.
securityContext:
runAsUser: 1001
runAsGroup: 1001
containers:
- name: release-stale-orders
# DASSELBE Image und derselbe Tag wie 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: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: 50m
memory: 128Mi
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
# DASSELBE Image und derselbe Tag wie in 05-app-deployment.yaml.
image: git.encry.de/nick.rosenloecher/wabenhain: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: 50m
memory: 128Mi
limits:
memory: 512Mi