Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt. Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert: invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war ohnehin das falsche Konzept. INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR: app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen. Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung. Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn keine da ist — also bei Rechnungen aus der Zeit davor. Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und ein Wiederholungslauf aenderte daran nichts. Sicherung: der taegliche CronJob packt das Verzeichnis als rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt der Sicherungs-Pod lautlos in "Pending". Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig, aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine Umsatzsteuer aus. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
160 lines
7.1 KiB
YAML
160 lines
7.1 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
|