feat(quick-260909-dgj): Migrationsverbindung von Laufzeitverbindung trennen (Task 2/4)
- apps/api/scripts/migrate-and-start.sh: TESSERA_MIGRATE_DATABASE_URL fuer den Migrationsschritt, DATABASE_URL bleibt unveraendert fuer den Laufzeitschritt; leer/nicht gesetzt = bisheriges Verhalten - Dockerfile kopiert scripts/ und ruft das Skript als CMD auf; exec statt &&-Verkettung, damit Signale den Node-Prozess erreichen - docker-compose.yml/.prod.yml reichen TESSERA_MIGRATE_DATABASE_URL durch (leerer Vorgabewert); docker-compose.dev.yml/.ci.yml unveraendert - .env.example erklaert beide Variablen mit Platzhaltern, Umstellung bleibt auskommentiert - 5 Tests in start-script.spec.ts (rot vor dem Skript, jetzt gruen) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
@@ -2,6 +2,15 @@ DB_PASSWORD=your_db_password_here
|
|||||||
DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera
|
DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# DATABASE_URL ist die Verbindung, mit der die laufende Anwendung arbeitet.
|
||||||
|
# TESSERA_MIGRATE_DATABASE_URL ist die separate Verbindung, mit der beim
|
||||||
|
# Containerstart NUR "prisma migrate deploy" laeuft. Bleibt sie leer, laeuft
|
||||||
|
# alles wie bisher — beide Schritte nutzen DATABASE_URL. Die getrennte
|
||||||
|
# Belegung ist erst sinnvoll, wenn docs/mandantentrennung-datenbankrolle.md
|
||||||
|
# vollstaendig abgearbeitet ist; vorher wuerde eine ungeprüfte Umstellung die
|
||||||
|
# Anwendung von ihren eigenen Daten aussperren.
|
||||||
|
# TESSERA_MIGRATE_DATABASE_URL=postgresql://tessera:your_db_password_here@db:5432/tessera
|
||||||
|
|
||||||
# Encrypts stored credentials (LDAP bind password, calendar and mailbox logins).
|
# Encrypts stored credentials (LDAP bind password, calendar and mailbox logins).
|
||||||
# Required - the stack refuses to start without it.
|
# Required - the stack refuses to start without it.
|
||||||
# Generate one with: openssl rand -hex 32
|
# Generate one with: openssl rand -hex 32
|
||||||
|
|||||||
+2
-1
@@ -33,6 +33,7 @@ COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
|||||||
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma ./node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma
|
COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma ./node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3/node_modules/.prisma
|
||||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||||
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
||||||
|
COPY apps/api/scripts ./apps/api/scripts
|
||||||
USER nestjs
|
USER nestjs
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["sh", "-c", "apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma && node apps/api/dist/main.js"]
|
CMD ["sh", "apps/api/scripts/migrate-and-start.sh"]
|
||||||
|
|||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# WINDOWS #18 — trennt die Verbindung des Migrationsschritts von der
|
||||||
|
# Verbindung des Laufzeitschritts (T-DGJ-03).
|
||||||
|
#
|
||||||
|
# `prisma migrate deploy` braucht die Rechte des Tabelleneigentuemers, um DDL
|
||||||
|
# auszufuehren. Die Anwendung soll genau diese Rechte NICHT haben — sonst
|
||||||
|
# waere die Rollentrennung aus Migration 20260909130000_rls_app_role wieder
|
||||||
|
# verloren. Ohne getrennte Verbindungen fuer Migration und Laufzeit ist eine
|
||||||
|
# Rollentrennung deshalb nicht moeglich.
|
||||||
|
#
|
||||||
|
# Vorgabe (ohne gesetztes TESSERA_MIGRATE_DATABASE_URL): beide Schritte
|
||||||
|
# nutzen DATABASE_URL — exakt der bisherige Ablauf, unveraendert fuer
|
||||||
|
# lokale Entwicklung, CI und den Server, bis
|
||||||
|
# docs/mandantentrennung-datenbankrolle.md abgearbeitet ist.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
echo "FEHLER: DATABASE_URL ist nicht gesetzt." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In sh ersetzt ${VAR:-fallback} auch eine gesetzte, aber leere Variable
|
||||||
|
# durch den Fallback — Compose reicht nicht belegte Variablen als leere
|
||||||
|
# Zeichenketten weiter, das soll wie "nicht gesetzt" behandelt werden.
|
||||||
|
MIGRATE_URL="${TESSERA_MIGRATE_DATABASE_URL:-$DATABASE_URL}"
|
||||||
|
|
||||||
|
if [ -n "$TESSERA_MIGRATE_DATABASE_URL" ]; then
|
||||||
|
MIGRATE_SOURCE="TESSERA_MIGRATE_DATABASE_URL"
|
||||||
|
else
|
||||||
|
MIGRATE_SOURCE="DATABASE_URL"
|
||||||
|
fi
|
||||||
|
RUNTIME_SOURCE="DATABASE_URL"
|
||||||
|
|
||||||
|
if [ "$1" = "--print-plan" ]; then
|
||||||
|
# Nur Variablennamen ausgeben, niemals Werte — diese Betriebsart existiert
|
||||||
|
# allein, damit die CI das Verhalten ohne Datenbank pruefen kann.
|
||||||
|
echo "Migrationsschritt verwendet: $MIGRATE_SOURCE"
|
||||||
|
echo "Laufzeitschritt verwendet: $RUNTIME_SOURCE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# DATABASE_URL nur fuer diesen einen Aufruf auf die Migrationsverbindung
|
||||||
|
# setzen (vorangestellte Zuweisung, kein export) — der nachfolgende exec
|
||||||
|
# erbt die unveraenderte DATABASE_URL aus der Umgebung.
|
||||||
|
DATABASE_URL="$MIGRATE_URL" apps/api/node_modules/.bin/prisma migrate deploy --schema apps/api/prisma/schema.prisma
|
||||||
|
|
||||||
|
# exec statt Aufruf, damit Signale (z. B. SIGTERM bei docker stop) den
|
||||||
|
# Node-Prozess erreichen. Die bisherige CMD-Zeile hatte dieses Problem
|
||||||
|
# ebenfalls und loeste es nicht; hier wird es nebenbei mitbehoben.
|
||||||
|
exec node apps/api/dist/main.js
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft apps/api/scripts/migrate-and-start.sh ausschliesslich ueber die
|
||||||
|
* Ausgabebetriebsart --print-plan — es wird weder Prisma noch Node
|
||||||
|
* aufgerufen, keine Datenbankverbindung aufgebaut (Task 2, WINDOWS #18).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCRIPT_PATH = join(__dirname, '../../scripts/migrate-and-start.sh');
|
||||||
|
const SECRET_MARKER = 'tessera-test-secret-marker-9f3c1e';
|
||||||
|
|
||||||
|
function runScript(env: Record<string, string>): { stdout: string; status: number } {
|
||||||
|
try {
|
||||||
|
const stdout = execFileSync('sh', [SCRIPT_PATH, '--print-plan'], {
|
||||||
|
env,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
return { stdout, status: 0 };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { stdout: (err.stdout ?? '').toString(), status: err.status ?? 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('migrate-and-start.sh --print-plan', () => {
|
||||||
|
it('Test 1 (Rueckwaertsvertraeglichkeit): ohne TESSERA_MIGRATE_DATABASE_URL melden beide Schritte DATABASE_URL', () => {
|
||||||
|
const { stdout, status } = runScript({
|
||||||
|
PATH: process.env.PATH ?? '',
|
||||||
|
DATABASE_URL: `postgresql://x:${SECRET_MARKER}@db:5432/tessera`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(0);
|
||||||
|
expect(stdout).toContain('DATABASE_URL');
|
||||||
|
// Kein Wert, nur der Variablenname darf erscheinen
|
||||||
|
expect(stdout).not.toContain(SECRET_MARKER);
|
||||||
|
// beide Zeilen (Migration + Laufzeit) nennen DATABASE_URL als Quelle
|
||||||
|
const dbUrlMentions = (stdout.match(/DATABASE_URL/g) ?? []).length;
|
||||||
|
expect(dbUrlMentions).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2 (Trennung): mit gesetztem TESSERA_MIGRATE_DATABASE_URL nennt der Migrationsschritt die Migrationsvariable, der Laufzeitschritt bleibt bei DATABASE_URL', () => {
|
||||||
|
const { stdout, status } = runScript({
|
||||||
|
PATH: process.env.PATH ?? '',
|
||||||
|
DATABASE_URL: `postgresql://app:${SECRET_MARKER}@db:5432/tessera`,
|
||||||
|
TESSERA_MIGRATE_DATABASE_URL: `postgresql://owner:${SECRET_MARKER}@db:5432/tessera`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(0);
|
||||||
|
expect(stdout).toContain('TESSERA_MIGRATE_DATABASE_URL');
|
||||||
|
expect(stdout).toContain('DATABASE_URL');
|
||||||
|
expect(stdout).not.toContain(SECRET_MARKER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3 (leer zaehlt als nicht gesetzt): TESSERA_MIGRATE_DATABASE_URL="" verhaelt sich wie Test 1', () => {
|
||||||
|
const { stdout, status } = runScript({
|
||||||
|
PATH: process.env.PATH ?? '',
|
||||||
|
DATABASE_URL: `postgresql://x:${SECRET_MARKER}@db:5432/tessera`,
|
||||||
|
TESSERA_MIGRATE_DATABASE_URL: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(0);
|
||||||
|
expect(stdout).not.toContain('TESSERA_MIGRATE_DATABASE_URL wird verwendet');
|
||||||
|
const dbUrlMentions = (stdout.match(/DATABASE_URL/g) ?? []).length;
|
||||||
|
expect(dbUrlMentions).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4 (kein Geheimnisabfluss): keiner der beiden Verbindungswerte erscheint in der Ausgabe', () => {
|
||||||
|
const { stdout } = runScript({
|
||||||
|
PATH: process.env.PATH ?? '',
|
||||||
|
DATABASE_URL: `postgresql://app:${SECRET_MARKER}@db:5432/tessera`,
|
||||||
|
TESSERA_MIGRATE_DATABASE_URL: `postgresql://owner:${SECRET_MARKER}b@db:5432/tessera`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stdout).not.toContain(SECRET_MARKER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5 (fehlende Angabe): ohne DATABASE_URL bricht das Skript mit Ende-Code ungleich 0 ab und nennt den Variablennamen', () => {
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let status = 0;
|
||||||
|
try {
|
||||||
|
stdout = execFileSync('sh', [SCRIPT_PATH, '--print-plan'], {
|
||||||
|
env: { PATH: process.env.PATH ?? '' },
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
status = err.status ?? 1;
|
||||||
|
stdout = (err.stdout ?? '').toString();
|
||||||
|
stderr = (err.stderr ?? '').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(status).not.toBe(0);
|
||||||
|
expect(stdout + stderr).toContain('DATABASE_URL');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
TESSERA_MIGRATE_DATABASE_URL: ${TESSERA_MIGRATE_DATABASE_URL:-}
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
|
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
|
||||||
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL}
|
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql://tessera:tessera_dev@db:5432/tessera}
|
DATABASE_URL: ${DATABASE_URL:-postgresql://tessera:tessera_dev@db:5432/tessera}
|
||||||
|
TESSERA_MIGRATE_DATABASE_URL: ${TESSERA_MIGRATE_DATABASE_URL:-}
|
||||||
JWT_SECRET: ${JWT_SECRET:-tessera-dev-jwt-secret-change-in-production}
|
JWT_SECRET: ${JWT_SECRET:-tessera-dev-jwt-secret-change-in-production}
|
||||||
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
|
TESSERA_ADMIN_USER: ${TESSERA_ADMIN_USER:-admin}
|
||||||
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL:-admin@tessera.local}
|
TESSERA_ADMIN_EMAIL: ${TESSERA_ADMIN_EMAIL:-admin@tessera.local}
|
||||||
|
|||||||
Reference in New Issue
Block a user