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

+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([]);
});
});