docs: Konfigurationsvorlagen vollstaendig und uebernahmefaehig machen

01-configmap.yaml und 02-secret.example.yaml sind jetzt so geschrieben, dass
sie sich als Produktivkonfiguration uebernehmen lassen: in drei Abschnitte
gegliedert (ANPASSEN / Vorgaben / optional), jeder Wert mit der Angabe, was
ohne ihn nicht funktioniert. Anzupassen sind genau zwei Werte in der
ConfigMap und die mit "CHANGE-ME" markierten im Secret; alles Uebrige traegt
eine brauchbare Vorgabe oder darf leer bleiben.

Beide Dateien decken zusammen jede Umgebungsvariable ab, die die Anwendung
liest, und jeder Schluessel steht in genau einer der beiden. Bisher war das
Handarbeit und driftete: GOOGLE_PLACES_API_KEY lag in der unverschluesselten
ConfigMap, APICORE_API_URL unnoetig im Secret.

Neu ist deshalb src/lib/k8s-manifest-env.test.ts. Der Test liest die
process.env-Zugriffe unter src/ und die Schluessel der beiden Manifeste und
prueft vier Zusagen: keine gelesene Variable fehlt, kein Schluessel steht
doppelt (sonst entschiede allein die Reihenfolge der envFrom-Eintraege,
welcher Wert gewinnt), kein Schluessel ist tot, und die committete
Secret-Vorlage enthaelt keine ausgefuellten Werte.

Beim ersten Lauf hat der Test zwei Dinge gefunden. ADMIN_EMAIL und
ADMIN_PASSWORD liest nicht die App, sondern prisma/seed.ts — das ist
legitim und steht jetzt begruendet in einer Ausnahmeliste, zusammen mit
DB_PASSWORD fuer das Datenbank-StatefulSet. STRIPE_PUBLISHABLE_KEY dagegen
wird von keiner einzigen Code-Stelle gelesen: der Checkout laeuft
vollstaendig serverseitig ueber eine Stripe-Checkout-Session. Der Schluessel
ist aus Secret-Vorlage, .env.example, docker-compose.yml und README
entfernt, mit Hinweis, wofuer man ihn braeuchte.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-17 03:06:18 +02:00
1 parent 990a16e654
commit 479ace1b88
6 files changed
+323 -87

No files matched your search

+6 -1
View File
@@ -21,8 +21,13 @@ MYSQL_PASSWORD=change-me
MYSQL_DATABASE=wabenhain
# Stripe (Test-Keys aus dem Stripe-Dashboard, https://dashboard.stripe.com/test/apikeys)
#
# Nur der geheime Schluessel wird gebraucht: der Checkout laeuft vollstaendig
# serverseitig ueber eine Stripe-Checkout-Session (src/app/api/checkout/), der
# Browser wird lediglich weitergeleitet. Einen veroeffentlichbaren Schluessel
# (pk_...) braeuchte erst eine clientseitige Einbindung wie Stripe Elements —
# solange es die nicht gibt, waere er Konfiguration, die nichts tut.
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
# Webhook-Secret aus `stripe listen` bzw. dem Stripe-Dashboard-Webhook-Endpoint
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
+1 -1
View File
@@ -142,7 +142,7 @@ Nur `db:push`/`db:migrate`/`db:deploy`/`db:studio` (Befehle, die tatsächlich ge
## Stripe
1. Test-Keys aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`, `STRIPE_PUBLISHABLE_KEY`).
1. Test-Key aus dem [Stripe-Dashboard](https://dashboard.stripe.com/test/apikeys) in `.env` eintragen (`STRIPE_SECRET_KEY`). Einen veröffentlichbaren `pk_`-Schlüssel braucht der Shop nicht: der Checkout läuft vollständig serverseitig über eine Stripe-Checkout-Session, der Browser wird nur weitergeleitet.
2. Für Webhooks lokal: `stripe listen --forward-to localhost:3000/api/checkout/webhook` und das ausgegebene Secret als `STRIPE_WEBHOOK_SECRET` eintragen.
3. Ohne konfigurierte Stripe-Keys bleibt der Checkout funktionsfähig: Die Bestellung wird angelegt, der Nutzer landet mit Hinweis auf der Erfolgsseite, die Bestellung kann im Admin-Bereich manuell nachbearbeitet werden.
+3 -1
View File
@@ -104,8 +104,10 @@ services:
# MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306).
DATABASE_PROVIDER: ${DATABASE_PROVIDER:-postgresql}
DATABASE_URL: ${DATABASE_URL}
# Nur der geheime Schluessel: der Checkout laeuft vollstaendig
# serverseitig, ein veroeffentlichbarer pk_-Schluessel wird nirgends
# gelesen (siehe .env.example).
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
# Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an,
# siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst
+78 -36
View File
@@ -1,46 +1,88 @@
# Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Keys) gehoeren
# in 02-secret.yaml (aus 02-secret.example.yaml kopiert), NIE hierher.
# Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Schluessel)
# gehoeren in 02-secret.yaml, NIE hierher: eine ConfigMap ist unverschluesselt
# und in der Cluster-Oberflaeche im Klartext lesbar.
#
# ANPASSEN vor dem ersten Ausrollen: NEXT_PUBLIC_SITE_URL und SMTP_FROM. Alle
# uebrigen Werte sind sinnvolle Vorgaben und koennen so bleiben; die leeren
# ("") gehoeren zu optionalen Funktionen, die dann einfach ruhen.
#
# Zusammen mit 02-secret.yaml deckt diese Datei JEDE Umgebungsvariable ab, die
# die Anwendung liest. Die Vollstaendigkeit wird automatisch geprueft, siehe
# src/lib/k8s-manifest-env.test.ts — kommt im Code eine neue Variable dazu,
# faellt das beim naechsten "pnpm test" auf, nicht erst im Betrieb.
apiVersion: v1
kind: ConfigMap
metadata:
name: wabenhain-config
namespace: wabenhain
data:
# "postgresql" oder "mysql" — muss zum Service passen, den du unter
# 03-postgres.yaml/03-mysql.yaml tatsaechlich ausrollst, UND zum
# DATABASE_URL-Host in 02-secret.yaml. Ein einzelnes Image traegt beide
# ---------------------------------------------------------------------
# ANPASSEN
# ---------------------------------------------------------------------
# Oeffentliche Adresse des Shops. Einzige Quelle fuer Sitemap,
# Canonical-Tags, JSON-LD und JEDEN Link in JEDER E-Mail (src/lib/url.ts).
# Muss zum "host" in 06-ingress.yaml passen — mit https, ohne
# abschliessenden Schraegstrich.
NEXT_PUBLIC_SITE_URL: "https://wabenhain.com"
# Absenderadresse aller Transaktions-E-Mails. Sollte auf einer eigenen
# Domain liegen, sonst landen die Mails regelmaessig im Spam-Ordner.
SMTP_FROM: "Wabenhain <[email protected]>"
# ---------------------------------------------------------------------
# VORGABEN — koennen so bleiben
# ---------------------------------------------------------------------
NODE_ENV: "production"
# "postgresql" oder "mysql" — muss zu dem Datenbank-Manifest passen, das du
# tatsaechlich ausrollst (03-postgres.yaml bzw. 03-mysql.yaml), UND zum
# Schema in DATABASE_URL im Secret. Ein einzelnes Image traegt beide
# Prisma-Client-Varianten, siehe src/lib/prisma.ts.
DATABASE_PROVIDER: "postgresql"
NODE_ENV: "production"
# Oeffentliche URL des Shops (fuer absolute Links in E-Mails/Sitemaps/CSP).
# Muss zum "host" in 06-ingress.yaml passen, sonst zeigen Sitemap,
# Canonical-Tags und jeder Link in jeder Bestellbestaetigung ins Leere.
NEXT_PUBLIC_SITE_URL: "https://wabenhain.com"
# Port des SMTP-Anbieters. 587 ist STARTTLS (der Normalfall), 465 waere
# implizites TLS.
SMTP_PORT: "587"
SMTP_FROM: "Wabenhain <[email protected]>"
# Optional, siehe README-Abschnitte "Google-Rezensionen"/"CAPTCHA" — leer
# lassen, wenn nicht genutzt, die App faellt automatisch zurueck.
#
# Nur die Place-ID steht hier: sie ist oeffentlich, sie steckt in jeder
# Google-Maps-Adresse des Eintrags. Der zugehoerige GOOGLE_PLACES_API_KEY
# ist dagegen ein abrechenbarer Zugangsschluessel und liegt im Secret —
# eine ConfigMap ist unverschluesselt und in der Cluster-Oberflaeche im
# Klartext lesbar.
GOOGLE_PLACE_ID: ""
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
# Adresse der ApiCore-API fuer den Stammdaten-Sync (Ernten, Produkte,
# Chargen — siehe src/lib/apicore-client.ts). Nur die URL; die Zugangsdaten
# des Sync-Kontos liegen im Secret.
APICORE_API_URL: ""
# Threads fuer das Umrechnen hochgeladener Videos. Nicht gesetzt bedeutet 2.
# NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit dieses Pods
# mit dem Node-Prozess, und jeder zusaetzliche Thread haelt eigene
# Bildpuffer (bei 4K rund 12 MB je Bild). Siehe src/lib/video-transcode.ts.
VIDEO_TRANSCODE_THREADS: "2"
# "sandbox" bis DHL-Versandmarken getestet sind, danach "production".
DHL_ENVIRONMENT: "sandbox"
# Verzeichnis fuer hochgeladene Bilder (Inhalte-/Produkt-/Erntenfotos,
# siehe app/api/admin/upload/route.ts) — MUSS exakt dem mountPath des
# "uploads"-Volumes in 05-app-deployment.yaml entsprechen. Die App selbst
# weiss nicht, was fuer ein Volume dort gemountet ist; siehe .env.example.
# Verzeichnis fuer hochgeladene Bilder und Videos. MUSS exakt dem mountPath
# des "uploads"-Volumes in 05-app-deployment.yaml entsprechen — die App
# weiss nicht, was dort gemountet ist, sie schreibt nur in diesen Pfad.
# Fehlt der Wert, schreibt die App nach "./uploads" relativ zum
# Arbeitsverzeichnis: das trifft im Image zufaellig denselben Ort, ist aber
# nirgends zugesichert. Deshalb hier ausdruecklich setzen.
UPLOAD_DIR: "/app/uploads"
# Threads fuer das Umrechnen hochgeladener Videos (Admin -> Inhalte ->
# Hero-Video). NICHT ohne Not erhoehen: ffmpeg teilt sich das Speicherlimit
# dieses Pods mit dem Node-Prozess, und jeder zusaetzliche Thread haelt
# eigene Bildpuffer — bei 4K-Material rund 12 MB je Bild. Zu viele Threads
# bringen den Pod beim Video-Upload um. Siehe src/lib/video-transcode.ts.
VIDEO_TRANSCODE_THREADS: "2"
# "sandbox", bis die DHL-Versandmarken getestet sind, danach "production".
# Wirkt nur, wenn die DHL-Zugangsdaten im Secret gesetzt sind.
DHL_ENVIRONMENT: "sandbox"
# ---------------------------------------------------------------------
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
# ---------------------------------------------------------------------
# Oeffentlicher Teil von Cloudflare Turnstile (CAPTCHA). Der geheime
# Gegenpart TURNSTILE_SECRET_KEY steht im Secret — beide setzen oder beide
# leer lassen. Leer = kein CAPTCHA-Widget, die Formulare bleiben nutzbar.
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
# Google-Eintrag, aus dem die echten Rezensionen auf der Startseite kommen.
# Die Place-ID ist oeffentlich (sie steckt in jeder Google-Maps-Adresse des
# Eintrags) und darf deshalb hier stehen; der abrechenbare
# GOOGLE_PLACES_API_KEY liegt im Secret. Leer = statische Beispiel-Stimmen.
GOOGLE_PLACE_ID: ""
# Adresse der ApiCore-API fuer den Stammdaten-Sync (Ernten, Produkte,
# Chargen — siehe src/lib/apicore-client.ts), mit dem globalen Praefix
# "api", z.B. "https://apicore.example.org/api". Nur die Adresse; die
# Zugangsdaten des Sync-Kontos liegen im Secret. Leer = der Sync-Knopf im
# Admin meldet einen Konfigurationsfehler.
APICORE_API_URL: ""
+106 -48
View File
@@ -1,19 +1,17 @@
# Vorlage OHNE echte Geheimnisse — analog zu .env.example. Kopieren nach
# 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), Werte
# ausfuellen, dann "kubectl apply -f k8s/02-secret.yaml".
# Geheime Konfiguration. Vorlage OHNE echte Werte — kopieren nach
# 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), ausfuellen,
# dann "kubectl apply -f k8s/02-secret.yaml".
#
# Alternative ohne Datei mit Klartext-Secrets auf der Platte:
# kubectl create secret generic wabenhain-secrets -n wabenhain \
# --from-literal=DATABASE_URL='postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public' \
# --from-literal=DB_PASSWORD='CHANGE-ME' \
# --from-literal=SESSION_SECRET="$(openssl rand -base64 32)" \
# --from-literal=ADMIN_EMAIL='[email protected]' \
# --from-literal=ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -d '=+/' | head -c 28)" \
# --from-literal=STRIPE_SECRET_KEY='' \
# --from-literal=STRIPE_PUBLISHABLE_KEY='' \
# --from-literal=STRIPE_WEBHOOK_SECRET='' \
# --from-literal=SMTP_HOST='' --from-literal=SMTP_USER='' --from-literal=SMTP_PASSWORD='' \
# --from-literal=TURNSTILE_SECRET_KEY=''
# ANPASSEN: jeder Wert, der "CHANGE-ME" enthaelt. Die leeren Werte ("")
# gehoeren zu optionalen Funktionen und koennen leer bleiben — die App faellt
# dann nachvollziehbar zurueck (siehe Kommentar an der jeweiligen Stelle) und
# stuerzt nicht ab. Nichts hier darf FEHLEN: ein fehlender und ein leerer
# Schluessel sind fuer die App zwar dasselbe, aber ein vollstaendiges Secret
# macht sofort sichtbar, welche Funktionen es ueberhaupt gibt.
#
# Gegenprobe nach dem Ausrollen: "pnpm preflight" meldet jeden Wert, der fuer
# den Live-Betrieb fehlt, als Blocker — mit Begruendung, was ohne ihn kaputt
# geht.
apiVersion: v1
kind: Secret
metadata:
@@ -21,55 +19,115 @@ metadata:
namespace: wabenhain
type: Opaque
stringData:
# ---------------------------------------------------------------------
# PFLICHT — ohne diese Werte ist kein Live-Betrieb moeglich
# ---------------------------------------------------------------------
# Host "wabenhain-postgres" bzw. "wabenhain-mysql" passend zum Service aus
# 03-postgres.yaml/03-mysql.yaml waehlen; Port 5432 (Postgres) bzw. 3306
# (MySQL). Muss zu DATABASE_PROVIDER in 01-configmap.yaml passen.
# WICHTIG: das Passwort hier MUSS URL-encodiert sein (Sonderzeichen wie
# @ : / % # ? & sonst ungueltig bzw. falsch geparst) — DB_PASSWORD unten
# bleibt dagegen roh/unencodiert. Encodieren z.B. mit:
#
# WICHTIG: das Passwort MUSS hier URL-encodiert sein — Sonderzeichen wie
# @ : / % # ? & werden sonst als Trennzeichen der URL gelesen, und die
# Verbindung schlaegt mit einer irrefuehrenden Meldung fehl. DB_PASSWORD
# weiter unten bleibt dagegen roh. Encodieren mit:
# node -e "console.log(encodeURIComponent('DEIN-PASSWORT'))"
DATABASE_URL: "postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public"
# Dasselbe Passwort wie oben in DATABASE_URL — wird vom Postgres-/MySQL-
# StatefulSet (je nachdem, welches du anwendest) zum Anlegen des
# Datenbank-Nutzers verwendet.
# Dasselbe Passwort wie in DATABASE_URL, aber UNENCODIERT. Das
# Datenbank-StatefulSet legt damit den Datenbanknutzer an (03-postgres.yaml
# bzw. 03-mysql.yaml), und die Sicherung in 07-backup-cronjob.yaml meldet
# sich damit an. Die App selbst liest diesen Wert nicht — sie kennt nur
# DATABASE_URL.
DB_PASSWORD: "CHANGE-ME"
# Mindestens 32 Zeichen, z.B.: openssl rand -base64 32
SESSION_SECRET: "CHANGE-ME-min-32-zeichen-zufaelliger-string"
# Unvorhersehbar waehlen (nicht "admin@..."), siehe README "Admin-Zugang" —
# sonst koennte sich jemand die erwartete Adresse vorregistrieren.
ADMIN_EMAIL: "[email protected]"
# Signiert die Login-Cookies. Mindestens 32 Zeichen, unvorhersehbar.
# Erzeugen mit: openssl rand -base64 32
# Wird dieser Wert bekannt, ist jede Sitzung faelschbar, auch die eines
# Admins. Ihn zu aendern meldet alle Kundinnen und Kunden ab, mehr nicht.
SESSION_SECRET: "CHANGE-ME-mindestens-32-zeichen-zufaellig"
# Erstes Admin-Konto, angelegt beim Start durch "prisma db seed".
# Unvorhersehbare Adresse waehlen, NICHT "admin@..." oder "info@...":
# sonst kann sich jemand die erwartete Adresse vorher selbst registrieren
# und das Konto belegen (siehe README "Admin-Zugang").
ADMIN_EMAIL: "[email protected]"
# Mindestens 12 Zeichen. Dieses Konto darf Rueckerstattungen ausloesen und
# alle Kundendaten einsehen. Erzeugen mit:
# openssl rand -base64 24 | tr -d '=+/' | head -c 28
ADMIN_PASSWORD: "CHANGE-ME"
STRIPE_SECRET_KEY: ""
STRIPE_PUBLISHABLE_KEY: ""
STRIPE_WEBHOOK_SECRET: ""
SMTP_HOST: ""
SMTP_USER: ""
SMTP_PASSWORD: ""
# Stripe im LIVE-Modus (sk_live_...). Mit einem sk_test-Schluessel sehen
# Bestellungen erfolgreich aus, ohne dass Geld fliesst.
STRIPE_SECRET_KEY: "CHANGE-ME"
# Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben
# auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.
# Stammt aus dem Webhook-Endpunkt im Stripe-Dashboard, der auf
# https://wabenhain.com/api/checkout/webhook zeigt (whsec_...).
STRIPE_WEBHOOK_SECRET: "CHANGE-ME"
# Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (src/lib/mail.ts):
# keine Bestellbestaetigung, keine Versandmeldung, keine
# Passwort-Zuruecksetzung. Funktioniert mit jedem SMTP-Anbieter; Port und
# Absenderadresse stehen als SMTP_PORT/SMTP_FROM in 01-configmap.yaml.
SMTP_HOST: "CHANGE-ME"
SMTP_USER: "CHANGE-ME"
SMTP_PASSWORD: "CHANGE-ME"
# ---------------------------------------------------------------------
# EMPFOHLEN — der Shop laeuft auch ohne, aber ungeschuetzt
# ---------------------------------------------------------------------
# Cloudflare Turnstile (CAPTCHA) fuer Registrierung, Newsletter und
# Firmenanfrage. Leer = kein CAPTCHA; die Formulare bleiben nutzbar, aber
# bei einem oeffentlichen Shop sammeln sich darueber schnell Bot-Eintraege.
# Der oeffentliche Gegenpart NEXT_PUBLIC_TURNSTILE_SITE_KEY steht in
# 01-configmap.yaml — beide setzen oder beide leer lassen.
TURNSTILE_SECRET_KEY: ""
# ---------------------------------------------------------------------
# OPTIONAL — leer lassen, solange die Funktion nicht genutzt wird
# ---------------------------------------------------------------------
# Google-Places-API fuer echte Google-Rezensionen auf der Startseite.
# Leer = die Startseite zeigt die statischen Beispiel-Stimmen.
#
# Der Schluessel gehoert hierher und NICHT in 01-configmap.yaml: er ist
# abrechenbar, und eine ConfigMap ist unverschluesselt und in der
# Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige GOOGLE_PLACE_ID
# ist dagegen oeffentlich und steht dort.
GOOGLE_PLACES_API_KEY: ""
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
# "ApiCore-Synchronisierung"). Leer = der Sync-Knopf meldet einen
# Konfigurationsfehler, statt still nichts zu tun.
#
# ApiCore kennt keinen API-Key-Mechanismus, nur E-Mail/Passwort-Login —
# deshalb hier ein eigenes, MFA-FREIES Service-Konto eintragen, nicht einen
# echten Admin-Zugang. Noetige Leserechte: harvests:read, products:read,
# extractions:read, bottling:read und optional apiaries:read. Die Adresse
# APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml.
APICORE_SYNC_EMAIL: ""
APICORE_SYNC_PASSWORD: ""
# DHL-Versandmarken (Admin -> Bestellung -> "DHL-Versandmarke"), siehe
# .env.example fuer die vollstaendige Einrichtungsanleitung. Leer lassen,
# bis vorhanden — der Button zeigt dann "noch nicht konfiguriert" statt
# zu crashen.
# .env.example fuer die vollstaendige Einrichtung. Leer = der Knopf meldet
# "noch nicht konfiguriert", der Rest des Shops ist unberuehrt. Ob Sandbox
# oder Echtbetrieb, steuert DHL_ENVIRONMENT in 01-configmap.yaml.
DHL_API_KEY: ""
DHL_API_SECRET: ""
DHL_GKP_USERNAME: ""
DHL_GKP_PASSWORD: ""
DHL_ACCOUNT_NUMBER: ""
# Entra-ID-SSO ("Mit Microsoft anmelden"), siehe .env.example fuer die
# Einrichtung in Azure. ENTRA_REDIRECT_URI muss auf die echte, oeffentliche
# Domain zeigen: https://wabenhain.com/api/auth/entra/callback
# Einrichtung in Azure. Leer = der Anmeldeknopf erscheint gar nicht erst.
# ENTRA_REDIRECT_URI muss exakt der in Azure hinterlegten Adresse
# entsprechen und auf die echte Domain zeigen:
# https://wabenhain.com/api/auth/entra/callback
ENTRA_TENANT_ID: ""
ENTRA_CLIENT_ID: ""
ENTRA_CLIENT_SECRET: ""
ENTRA_REDIRECT_URI: ""
# Abrechenbarer Zugangsschluessel fuer die Google-Places-API (echte
# Google-Rezensionen auf der Startseite). Gehoert hierher und NICHT in
# 01-configmap.yaml: eine ConfigMap ist unverschluesselt und in der
# Cluster-Oberflaeche im Klartext lesbar. Die zugehoerige, oeffentliche
# GOOGLE_PLACE_ID steht dort.
GOOGLE_PLACES_API_KEY: ""
# Zugangsdaten des ApiCore-Sync-Kontos (Admin -> Ernten bzw. Produkte ->
# "ApiCore-Synchronisierung"), siehe .env.example. Die zugehoerige Adresse
# APICORE_API_URL ist nicht geheim und steht in 01-configmap.yaml.
APICORE_SYNC_EMAIL: ""
APICORE_SYNC_PASSWORD: ""
+129
View File
@@ -0,0 +1,129 @@
import { describe, test, expect } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/**
* Haelt die Kubernetes-Manifeste und den Code in Deckung.
*
* Die Manifeste unter k8s/ sind ausdruecklich dafuer gedacht, mit moeglichst
* wenig Anpassung als Produktivkonfiguration uebernommen zu werden. Das
* traegt nur, solange sie VOLLSTAENDIG sind. Ohne diese Pruefung faellt eine
* im Code neu hinzugekommene Umgebungsvariable erst im Betrieb auf, und zwar
* als stiller Rueckfall auf einen Standardwert statt als Fehler: zur Laufzeit
* ist eine fehlende Variable nicht von einer absichtlich leeren zu
* unterscheiden.
*
* Bewusst KEIN YAML-Parser: das Projekt hat keinen als Abhaengigkeit, und
* fuer diesen Zweck genuegt es, die Schluesselzeilen der flachen
* data:/stringData:-Bloecke zu lesen. Die Manifeste sind von Hand gepflegt
* und halten dieses einfache Format ein.
*/
const REPO_ROOT = process.cwd();
const CONFIGMAP = path.join(REPO_ROOT, "k8s", "01-configmap.yaml");
const SECRET = path.join(REPO_ROOT, "k8s", "02-secret.example.yaml");
const SRC = path.join(REPO_ROOT, "src");
/**
* Schluessel eines Manifests: genau zwei Leerzeichen eingerueckt, beginnend
* mit einem Grossbuchstaben. Kommentarzeilen (" # ...") und Metadatenfelder
* (" name: ...") fallen dadurch heraus, ohne Sonderfaelle.
*/
function manifestKeys(file: string): string[] {
return readFileSync(file, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^ {2}([A-Z][A-Z0-9_]*):/.exec(line);
return match ? [match[1]] : [];
});
}
/** Alle process.env-Zugriffe unterhalb von src/ — das ist die Laufzeit der App. */
function envNamesUsedInCode(dir: string): Set<string> {
const found = new Set<string>();
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
for (const name of envNamesUsedInCode(full)) found.add(name);
continue;
}
if (!/\.tsx?$/.test(entry.name) || entry.name.endsWith(".test.ts")) continue;
for (const match of readFileSync(full, "utf8").matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g)) {
found.add(match[1]);
}
}
return found;
}
/**
* Schluessel, die in den Manifesten stehen duerfen, ohne dass die App sie
* liest. Wer hier etwas ergaenzt, muss benennen koennen, WER den Wert sonst
* liest — sonst sammelt sich Konfiguration an, die nichts mehr tut.
*/
const USED_OUTSIDE_THE_APP: Record<string, string> = {
DB_PASSWORD:
"Datenbank-StatefulSet (03-postgres.yaml/03-mysql.yaml) und Sicherung (07-backup-cronjob.yaml); die App kennt nur DATABASE_URL.",
ADMIN_EMAIL: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.",
ADMIN_PASSWORD: "prisma/seed.ts — legt beim Start das erste Admin-Konto an.",
};
describe("Kubernetes-Manifeste und Code", () => {
test("die Manifeste liefern jede Variable, die die App liest", () => {
// Arrange
const provided = new Set([...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]);
// Act
const missing = [...envNamesUsedInCode(SRC)].filter((name) => !provided.has(name)).sort();
// Assert
expect(missing, `In k8s/ fehlen: ${missing.join(", ")}`).toEqual([]);
});
test("kein Schluessel steht doppelt in ConfigMap und Secret", () => {
// Arrange
const config = manifestKeys(CONFIGMAP);
const secret = new Set(manifestKeys(SECRET));
// Act
const inBoth = config.filter((name) => secret.has(name)).sort();
// Assert: stuenden sie in beiden, entschiede allein die Reihenfolge der
// envFrom-Eintraege, welcher Wert gewinnt — eine Falle beim Suchen.
expect(inBoth, `Doppelt vergeben: ${inBoth.join(", ")}`).toEqual([]);
});
test("kein Schluessel in den Manifesten, den niemand liest", () => {
// Arrange
const used = envNamesUsedInCode(SRC);
// Act
const unused = [...manifestKeys(CONFIGMAP), ...manifestKeys(SECRET)]
.filter((name) => !used.has(name) && !(name in USED_OUTSIDE_THE_APP))
.sort();
// Assert: toter Konfigurationsballast ist irrefuehrend — er sieht aus,
// als liesse sich damit etwas einstellen.
expect(unused, `Wird nirgends gelesen: ${unused.join(", ")}`).toEqual([]);
});
test("die Secret-Vorlage enthaelt keine ausgefuellten Werte", () => {
// Arrange
const secret = readFileSync(SECRET, "utf8");
// Act
const filledIn = secret
.split("\n")
.flatMap((line) => {
const match = /^ {2}([A-Z][A-Z0-9_]*): "(.*)"$/.exec(line);
if (!match) return [];
const [, key, value] = match;
const isPlaceholder = value === "" || value.includes("CHANGE-ME");
return isPlaceholder ? [] : [key];
})
.sort();
// Assert: die Vorlage wird committet — ein echter Wert waere damit im
// Repository. Platzhalter sind leer oder tragen "CHANGE-ME".
expect(filledIn, `Sieht nach echten Werten aus: ${filledIn.join(", ")}`).toEqual([]);
});
});