From 479ace1b887a10f1525ba7574a72812244d7ad38 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 17 Sep 2026 03:06:18 +0200 Subject: [PATCH] docs: Konfigurationsvorlagen vollstaendig und uebernahmefaehig machen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 7 +- README.md | 2 +- docker-compose.yml | 4 +- k8s/01-configmap.yaml | 114 +++++++++++++++-------- k8s/02-secret.example.yaml | 154 +++++++++++++++++++++---------- src/lib/k8s-manifest-env.test.ts | 129 ++++++++++++++++++++++++++ 6 files changed, 323 insertions(+), 87 deletions(-) create mode 100644 src/lib/k8s-manifest-env.test.ts diff --git a/.env.example b/.env.example index 302a60e..ed601f0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6eb06b8..07eaad3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml index 7c86299..4d104b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml index 6910dfd..4486b6c 100644 --- a/k8s/01-configmap.yaml +++ b/k8s/01-configmap.yaml @@ -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 " + + # --------------------------------------------------------------------- + # 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 " - # 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: "" diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml index 016fb1e..27d42e3 100644 --- a/k8s/02-secret.example.yaml +++ b/k8s/02-secret.example.yaml @@ -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='change-me@example.com' \ -# --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: "change-me@example.com" + + # 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: "CHANGE-ME@wabenhain.com" + + # 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: "" diff --git a/src/lib/k8s-manifest-env.test.ts b/src/lib/k8s-manifest-env.test.ts new file mode 100644 index 0000000..07bd938 --- /dev/null +++ b/src/lib/k8s-manifest-env.test.ts @@ -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 { + const found = new Set(); + 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 = { + 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([]); + }); +});