Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 b08f6a49fc TEN-07: migrations-orchestrierung-tenant-datenbanken
Orchestrator.RolloutAll wendet eine geordnete Liste von Migrationen auf JEDE
registrierte Tenant-Datenbank an (LoadMigrations liest *.up.sql aus einem
Verzeichnis nach der bestehenden 000N_name-Namenskonvention). Jeder Tenant
laeuft unabhaengig in eigener Verbindung — ein Fehlschlag bei einem Mandanten
bricht nur dessen eigenen Rollout ab (spaetere Migrationen bauen typischerweise
auf frueheren auf) und blockiert die uebrigen Tenants nicht.

schema_migrations-Tabelle pro Tenant-Datenbank (version PK, applied_at,
success, error) haelt den Stand pro Version einzeln nachvollziehbar fest.
Bereits erfolgreiche Versionen werden bei einem erneuten Rollout uebersprungen
(isAlreadySuccessful-Check vor jeder Anwendung), fehlgeschlagene werden beim
naechsten Versuch automatisch erneut probiert (kein manuelles Zuruecksetzen
noetig) — ON CONFLICT DO UPDATE haelt jeweils nur den letzten Versuch fest.

Bewusst ohne Abhaengigkeit von TEN-06 (Router): Migrations-Rollouts sind
seltene Batch-Vorgaenge, ein kurzlebiger Pool pro Tenant und Lauf reicht.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS,
go test ./... mit -p 1 noetig da mehrere Pakete die geteilte Registry-Tabelle
auf derselben Postgres-Instanz nutzen — siehe scripts/reset-test-env.sh):
1. Rollout gegen 3 Test-Tenants, einer absichtlich inkompatibel (Tabellen-
   Konflikt bei Migration 2) — TestRolloutAll_IsolatesFailurePerTenant: die
   anderen beiden erhalten beide Migrationen, der inkompatible bekommt
   Migration 1 trotzdem, scheitert nur an Migration 2, faellt nicht die
   anderen um. PASS.
2. Migrationsstand-Abfrage liefert korrekten Stand pro Tenant —
   TestStatus_ReflectsPerTenantState: Version 1 success=true, Version 2
   success=false mit Fehlertext. PASS.
3. Wiederholter Rollout fuer fehlgeschlagene Migration moeglich, ohne bereits
   erfolgreiche erneut anzuwenden — TestRolloutAll_RetryDoesNotReapplySuccessful:
   Migration 1 nutzt bewusst kein IF NOT EXISTS, ein Reapply haette den
   zweiten Lauf scheitern lassen; zweiter Lauf ist fehlerfrei und wendet nur
   die zuvor fehlgeschlagene Version an. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 19:05:47 +02:00
10 changed files with 449 additions and 537 deletions
-106
View File
@@ -1,106 +0,0 @@
// Package license implementiert Core LIC-01: Lizenzmodell je Tenant (Plan,
// Modul-Umfang, Laufzeit) und die kryptographische Pruefung signierter
// Lizenzschluessel. Feature-Flag-AUSWERTUNG zur Laufzeit (LIC-02) und die
// Verwaltungsoberflaeche (LIC-04) sind ausdruecklich nicht Teil dieses Pakets
// — hier geht es nur um Ausstellung/Validierung/Persistenz (Unleash-Vorbild:
// klare Trennung Flag-Verwaltung vs. Flag-Auswertung).
package license
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
)
var (
ErrInvalidSignature = errors.New("license: signatur ungueltig")
ErrMalformedKey = errors.New("license: lizenzschluessel hat ungueltiges format")
)
// Payload ist der signierte Lizenzinhalt (Akzeptanzkriterium 3: Plan,
// Modul-Liste, Laufzeit).
type Payload struct {
TenantSlug string `json:"tenant_slug"`
Plan string `json:"plan"`
Modules []string `json:"modules"`
IssuedAt time.Time `json:"issued_at"`
ValidUntil time.Time `json:"valid_until"`
}
// Issuer stellt signierte Lizenzschluessel aus. Haelt den PRIVATEN
// Ed25519-Schluessel — lebt in der Praxis beim Lizenzgeber, nicht im
// laufenden Core-Prozess (der nur den Validator mit dem oeffentlichen
// Schluessel braucht).
type Issuer struct {
priv ed25519.PrivateKey
}
func NewIssuer(priv ed25519.PrivateKey) *Issuer {
return &Issuer{priv: priv}
}
// Issue liefert den Lizenzschluessel im Format base64(payload-json) "." base64(signatur).
func (i *Issuer) Issue(payload Payload) (string, error) {
raw, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("payload serialisieren: %w", err)
}
sig := ed25519.Sign(i.priv, raw)
return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
// Validator prueft Lizenzschluessel gegen den OEFFENTLICHEN Ed25519-Schluessel
// — das ist alles, was der laufende Core-Prozess kennen muss.
type Validator struct {
pub ed25519.PublicKey
}
func NewValidator(pub ed25519.PublicKey) *Validator {
return &Validator{pub: pub}
}
// Parse prueft die Signatur (Akzeptanzkriterium 1 / Pruefung 1) und liefert
// bei Erfolg den entschluesselten Payload. Ein manipulierter Schluessel wird
// hier zuverlaessig erkannt, unabhaengig davon, ob die Laufzeit noch gueltig
// waere — Signaturpruefung und Ablaufpruefung sind bewusst getrennt
// (Signatur bei Einspielen, Ablauf bei jeder Nutzung, siehe Store.RequireActive).
func (v *Validator) Parse(key string) (Payload, error) {
rawPart, sigPart, ok := splitOnce(key, '.')
if !ok {
return Payload{}, ErrMalformedKey
}
raw, err := base64.RawURLEncoding.DecodeString(rawPart)
if err != nil {
return Payload{}, ErrMalformedKey
}
sig, err := base64.RawURLEncoding.DecodeString(sigPart)
if err != nil {
return Payload{}, ErrMalformedKey
}
if !ed25519.Verify(v.pub, raw, sig) {
return Payload{}, ErrInvalidSignature
}
var p Payload
if err := json.Unmarshal(raw, &p); err != nil {
// Signatur war gueltig, aber Payload nicht mehr parsebar — sollte bei
// unveraenderten Schluesseln nie vorkommen, trotzdem kein Panic.
return Payload{}, fmt.Errorf("%w: payload nicht lesbar", ErrMalformedKey)
}
return p, nil
}
func splitOnce(s string, sep byte) (before, after string, ok bool) {
for i := 0; i < len(s); i++ {
if s[i] == sep {
return s[:i], s[i+1:], true
}
}
return "", "", false
}
-106
View File
@@ -1,106 +0,0 @@
package license
import (
"crypto/ed25519"
"errors"
"testing"
"time"
)
func testKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("schluesselpaar erzeugen: %v", err)
}
return pub, priv
}
func TestIssueAndParse_RoundTrip(t *testing.T) {
pub, priv := testKeyPair(t)
issuer := NewIssuer(priv)
validator := NewValidator(pub)
payload := Payload{
TenantSlug: "acme",
Plan: "pro",
Modules: []string{"dms", "mail"},
IssuedAt: time.Now().Truncate(time.Second),
ValidUntil: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second),
}
key, err := issuer.Issue(payload)
if err != nil {
t.Fatalf("issue: %v", err)
}
got, err := validator.Parse(key)
if err != nil {
t.Fatalf("parse: %v", err)
}
if got.TenantSlug != payload.TenantSlug || got.Plan != payload.Plan || len(got.Modules) != 2 {
t.Fatalf("payload nach parse unerwartet: %+v", got)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: manipulierter Schluessel wird zuverlaessig erkannt.
func TestParse_RejectsTamperedKey(t *testing.T) {
pub, priv := testKeyPair(t)
issuer := NewIssuer(priv)
validator := NewValidator(pub)
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
if err != nil {
t.Fatalf("issue: %v", err)
}
// Ein Zeichen im signierten Teil aendern.
tampered := []byte(key)
changed := false
for i, c := range tampered {
if c != '.' {
if c == 'A' {
tampered[i] = 'B'
} else {
tampered[i] = 'A'
}
changed = true
break
}
}
if !changed {
t.Fatal("testaufbau fehlerhaft: nichts zum manipulieren gefunden")
}
if _, err := validator.Parse(string(tampered)); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
}
}
func TestParse_RejectsWrongKeyPair(t *testing.T) {
_, priv := testKeyPair(t)
otherPub, _ := testKeyPair(t)
issuer := NewIssuer(priv)
validator := NewValidator(otherPub) // falscher oeffentlicher Schluessel
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro"})
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := validator.Parse(key); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
}
}
func TestParse_RejectsMalformedKey(t *testing.T) {
pub, _ := testKeyPair(t)
validator := NewValidator(pub)
cases := []string{"", "keine-punkt-trennung", "!!!.!!!"}
for _, c := range cases {
if _, err := validator.Parse(c); !errors.Is(err, ErrMalformedKey) {
t.Fatalf("Parse(%q): erwartet ErrMalformedKey, habe %v", c, err)
}
}
}
-88
View File
@@ -1,88 +0,0 @@
package license
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrNoLicense = errors.New("license: kein lizenzdatensatz fuer diesen tenant")
ErrLicenseExpired = errors.New("license: lizenz abgelaufen")
)
// Store persistiert den Lizenzumfang je Tenant in der Control-Plane-Registry
// (siehe internal/tenant.Registry — dieselbe Datenbank, aber ein eigener,
// unabhaengiger Store, um internal/tenant nicht um lizenzfremde Belange zu
// erweitern).
type Store struct {
pool *pgxpool.Pool
validator *Validator
}
func NewStore(pool *pgxpool.Pool, validator *Validator) *Store {
return &Store{pool: pool, validator: validator}
}
// Install prueft die Signatur des Lizenzschluessels (Akzeptanzkriterium 1)
// und ersetzt den bisherigen Lizenzdatensatz des Tenants vollstaendig. Ein
// bereits abgelaufener, aber korrekt signierter Schluessel wird trotzdem
// gespeichert — der Ablauf wird erst bei der Nutzung (RequireActive)
// bewertet, nicht beim Einspielen.
func (s *Store) Install(ctx context.Context, tenantID, licenseKey string) (Payload, error) {
payload, err := s.validator.Parse(licenseKey)
if err != nil {
return Payload{}, err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO tenant_licenses (tenant_id, plan, modules, issued_at, valid_until, raw_key, installed_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (tenant_id) DO UPDATE SET
plan = $2, modules = $3, issued_at = $4, valid_until = $5, raw_key = $6, installed_at = now()
`, tenantID, payload.Plan, payload.Modules, payload.IssuedAt, payload.ValidUntil, licenseKey)
if err != nil {
return Payload{}, fmt.Errorf("lizenz speichern: %w", err)
}
return payload, nil
}
func (s *Store) get(ctx context.Context, tenantID string) (Payload, error) {
var p Payload
row := s.pool.QueryRow(ctx, `
SELECT plan, modules, issued_at, valid_until
FROM tenant_licenses WHERE tenant_id = $1
`, tenantID)
if err := row.Scan(&p.Plan, &p.Modules, &p.IssuedAt, &p.ValidUntil); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Payload{}, ErrNoLicense
}
return Payload{}, fmt.Errorf("lizenz lesen: %w", err)
}
return p, nil
}
// Status liefert den persistierten Lizenzumfang unabhaengig vom Ablauf
// (Akzeptanzkriterium 3: Plan, Modul-Liste, Laufzeit abfragbar).
func (s *Store) Status(ctx context.Context, tenantID string) (Payload, error) {
return s.get(ctx, tenantID)
}
// RequireActive liefert den Lizenzumfang NUR, wenn die Lizenz noch nicht
// abgelaufen ist — sonst ErrLicenseExpired statt eines harten Fehlers/Panics
// (Akzeptanzkriterium 2: definierter eingeschraenkter Zustand). Aufrufende
// Module (LIC-02/03) entscheiden, was "eingeschraenkt" konkret bedeutet.
func (s *Store) RequireActive(ctx context.Context, tenantID string) (Payload, error) {
p, err := s.get(ctx, tenantID)
if err != nil {
return Payload{}, err
}
if time.Now().After(p.ValidUntil) {
return Payload{}, ErrLicenseExpired
}
return p, nil
}
-177
View File
@@ -1,177 +0,0 @@
package license
import (
"context"
"crypto/ed25519"
"errors"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStoreTest(t *testing.T) (*Store, *Issuer, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_licenses (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
plan TEXT NOT NULL,
modules TEXT[] NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ NOT NULL,
raw_key TEXT NOT NULL,
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
var tenantID string
if err := pool.QueryRow(ctx, `
INSERT INTO tenants (slug, name, db_name, db_dsn)
VALUES ('lic_test_tenant', 'Lic Test', 'tenant_lic_test', 'unused')
RETURNING id
`).Scan(&tenantID); err != nil {
t.Fatalf("test-tenant anlegen: %v", err)
}
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("schluesselpaar: %v", err)
}
issuer := NewIssuer(priv)
store := NewStore(pool, NewValidator(pub))
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`, tenantID)
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
pool.Close()
}
return store, issuer, tenantID, cleanup
}
// Akzeptanzkriterium 3: Lizenzumfang persistiert und abfragbar.
func TestStore_InstallAndStatus(t *testing.T) {
store, issuer, tenantID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
payload := Payload{
TenantSlug: "lic_test_tenant",
Plan: "enterprise",
Modules: []string{"dms", "mail", "archive"},
IssuedAt: time.Now().Truncate(time.Second),
ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
}
key, err := issuer.Issue(payload)
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := store.Install(ctx, tenantID, key); err != nil {
t.Fatalf("install: %v", err)
}
status, err := store.Status(ctx, tenantID)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.Plan != "enterprise" || len(status.Modules) != 3 {
t.Fatalf("status unerwartet: %+v", status)
}
}
func TestStore_InstallRejectsInvalidSignature(t *testing.T) {
store, _, tenantID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
_, otherPriv, _ := ed25519.GenerateKey(nil)
foreignIssuer := NewIssuer(otherPriv) // signiert mit falschem schluessel
key, err := foreignIssuer.Issue(Payload{TenantSlug: "lic_test_tenant", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := store.Install(ctx, tenantID, key); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: abgelaufene Lizenz fuehrt zu definiertem
// eingeschraenktem Zustand (ErrLicenseExpired), nicht zu einem Absturz.
func TestStore_RequireActive_DetectsExpiry(t *testing.T) {
store, issuer, tenantID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
expired := Payload{
TenantSlug: "lic_test_tenant",
Plan: "pro",
Modules: []string{"dms"},
IssuedAt: time.Now().Add(-48 * time.Hour),
ValidUntil: time.Now().Add(-24 * time.Hour), // bereits abgelaufen
}
key, err := issuer.Issue(expired)
if err != nil {
t.Fatalf("issue: %v", err)
}
// Einspielen einer bereits abgelaufenen, aber korrekt signierten Lizenz
// muss funktionieren (Ablauf wird erst bei Nutzung bewertet).
if _, err := store.Install(ctx, tenantID, key); err != nil {
t.Fatalf("install sollte trotz ablauf funktionieren: %v", err)
}
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("RequireActive hat gepanict statt einen fehler zu liefern: %v", r)
}
}()
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrLicenseExpired) {
t.Fatalf("erwartet ErrLicenseExpired, habe %v", err)
}
}()
// Aber der Umfang bleibt weiterhin abfragbar (Status, im Unterschied zu RequireActive).
status, err := store.Status(ctx, tenantID)
if err != nil {
t.Fatalf("status sollte trotz ablauf funktionieren: %v", err)
}
if status.Plan != "pro" {
t.Fatalf("status unerwartet: %+v", status)
}
}
func TestStore_RequireActive_NoLicense(t *testing.T) {
store, _, tenantID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrNoLicense) {
t.Fatalf("erwartet ErrNoLicense, habe %v", err)
}
}
+211
View File
@@ -0,0 +1,211 @@
// Package migrate implementiert Core TEN-07: das automatisierte, pro Tenant
// fehlerisolierte Ausrollen von SQL-Migrationen ueber alle registrierten
// Mandanten-Datenbanken (Modell C — jede Migration muss N-mal statt einmal
// laufen, siehe TEN-01).
package migrate
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
const upSuffix = ".up.sql"
type Migration struct {
Version string
SQL string
}
// LoadMigrations liest alle *.up.sql-Dateien aus dir und sortiert sie nach
// Dateiname (die bestehende Namenskonvention 0001_..., 0002_... aus TEN-01
// sorgt fuer eine stabile Reihenfolge).
func LoadMigrations(dir string) ([]Migration, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("migrationsverzeichnis lesen: %w", err)
}
var names []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), upSuffix) {
names = append(names, e.Name())
}
}
sort.Strings(names)
migrations := make([]Migration, 0, len(names))
for _, name := range names {
content, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return nil, fmt.Errorf("migration %q lesen: %w", name, err)
}
version := strings.TrimSuffix(name, upSuffix)
migrations = append(migrations, Migration{Version: version, SQL: string(content)})
}
return migrations, nil
}
// MigrationStatus ist der pro Tenant und Version nachvollziehbare Stand
// (Akzeptanzkriterium 3): welche Version, wann zuletzt versucht, Erfolg oder
// Fehler.
type MigrationStatus struct {
Version string
AppliedAt time.Time
Success bool
Error string
}
// TenantResult fasst das Ergebnis eines Rollout-Versuchs fuer EINEN Tenant
// zusammen — wird von Orchestrator.RolloutAll pro Tenant gesammelt, damit ein
// Fehlschlag bei einem Mandanten die anderen nicht blockiert (Akzeptanzkriterium 2).
type TenantResult struct {
TenantSlug string
Applied []string // erfolgreich in diesem Lauf angewendete Versionen
FailedAt string // Version, bei der abgebrochen wurde; leer wenn kein Fehlschlag
Err error
}
// Orchestrator rollt Migrationen ueber alle in der Registry gefuehrten
// Mandanten aus. Baut bewusst NICHT auf TEN-06 (Router) auf — Migrations-
// Rollouts sind seltene Batch-Vorgaenge, kein Hot-Path, ein kurzlebiger Pool
// pro Tenant und Lauf ist hier einfacher und unabhaengig von TEN-06 testbar.
type Orchestrator struct {
registry *tenant.Registry
}
func NewOrchestrator(registry *tenant.Registry) *Orchestrator {
return &Orchestrator{registry: registry}
}
const ensureTableSQL = `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
success BOOLEAN NOT NULL,
error TEXT
);`
// RolloutAll wendet migrations auf JEDE registrierte Tenant-Datenbank an.
// Jeder Tenant laeuft unabhaengig — ein Fehlschlag bei einem Mandanten wird
// im jeweiligen TenantResult festgehalten und blockiert die uebrigen nicht
// (Akzeptanzkriterium 1 + 2).
func (o *Orchestrator) RolloutAll(ctx context.Context, migrations []Migration) ([]TenantResult, error) {
tenants, err := o.registry.List(ctx)
if err != nil {
return nil, fmt.Errorf("tenants fuer rollout auflisten: %w", err)
}
results := make([]TenantResult, 0, len(tenants))
for _, t := range tenants {
results = append(results, o.rolloutForTenant(ctx, t, migrations))
}
return results, nil
}
func (o *Orchestrator) rolloutForTenant(ctx context.Context, t tenant.Tenant, migrations []Migration) TenantResult {
result := TenantResult{TenantSlug: t.Slug}
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
result.Err = fmt.Errorf("verbindung zu tenant %q: %w", t.Slug, err)
return result
}
defer pool.Close()
if _, err := pool.Exec(ctx, ensureTableSQL); err != nil {
result.Err = fmt.Errorf("schema_migrations anlegen fuer tenant %q: %w", t.Slug, err)
return result
}
for _, m := range migrations {
alreadyApplied, err := isAlreadySuccessful(ctx, pool, m.Version)
if err != nil {
result.Err = fmt.Errorf("migrationsstand lesen fuer tenant %q, version %q: %w", t.Slug, m.Version, err)
return result
}
if alreadyApplied {
continue // Akzeptanzkriterium 3 / Pruefung 3: keine erneute Anwendung.
}
_, execErr := pool.Exec(ctx, m.SQL)
if execErr != nil {
recordAttempt(ctx, pool, m.Version, false, execErr.Error())
result.FailedAt = m.Version
result.Err = fmt.Errorf("migration %q fuer tenant %q fehlgeschlagen: %w", m.Version, t.Slug, execErr)
return result // spaetere Migrationen bauen typischerweise auf dieser auf — Abbruch NUR fuer diesen Tenant.
}
recordAttempt(ctx, pool, m.Version, true, "")
result.Applied = append(result.Applied, m.Version)
}
return result
}
func isAlreadySuccessful(ctx context.Context, pool *pgxpool.Pool, version string) (bool, error) {
var success bool
err := pool.QueryRow(ctx, `SELECT success FROM schema_migrations WHERE version = $1`, version).Scan(&success)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
return false, err
}
return success, nil
}
func recordAttempt(ctx context.Context, pool *pgxpool.Pool, version string, success bool, errMsg string) {
var errVal *string
if errMsg != "" {
errVal = &errMsg
}
_, _ = pool.Exec(ctx, `
INSERT INTO schema_migrations (version, applied_at, success, error)
VALUES ($1, now(), $2, $3)
ON CONFLICT (version) DO UPDATE SET applied_at = now(), success = $2, error = $3
`, version, success, errVal)
}
// Status liefert den Migrationsstand eines einzelnen Tenants (Akzeptanzkriterium 3).
func (o *Orchestrator) Status(ctx context.Context, tenantSlug string) ([]MigrationStatus, error) {
t, err := o.registry.GetBySlug(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("tenant %q nicht gefunden: %w", tenantSlug, err)
}
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
return nil, fmt.Errorf("verbindung zu tenant %q: %w", tenantSlug, err)
}
defer pool.Close()
rows, err := pool.Query(ctx, `
SELECT version, applied_at, success, COALESCE(error, '')
FROM schema_migrations ORDER BY version
`)
if err != nil {
return nil, fmt.Errorf("migrationsstand abfragen: %w", err)
}
defer rows.Close()
var out []MigrationStatus
for rows.Next() {
var s MigrationStatus
if err := rows.Scan(&s.Version, &s.AppliedAt, &s.Success, &s.Error); err != nil {
return nil, fmt.Errorf("migrationsstand lesen: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
+238
View File
@@ -0,0 +1,238 @@
package migrate
import (
"context"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func TestLoadMigrations_SortsByFilename(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
migrations, err := LoadMigrations(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(migrations) != 2 {
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
}
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
registry := tenant.NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
var tenants []tenant.Tenant
var slugs []string
for i := 0; i < tenantCount; i++ {
slug := fmt.Sprintf("mig_t%d", i)
slugs = append(slugs, slug)
tn, err := provisioner.Provision(ctx, slug, slug)
if err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
tenants = append(tenants, tn)
}
orchestrator := NewOrchestrator(registry)
cleanup := func() {
for _, slug := range slugs {
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
}
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
registryPool.Close()
adminPool.Close()
}
return orchestrator, tenants, adminPool, cleanup
}
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
defer cleanup()
ctx := context.Background()
migrations := []Migration{
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
}
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
// migration 0002 schlaegt dort mit "already exists" fehl.
badTenant := tenants[1]
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
if err != nil {
t.Fatalf("connect bad tenant: %v", err)
}
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
}
badPool.Close()
results, err := orchestrator.RolloutAll(ctx, migrations)
if err != nil {
t.Fatalf("rollout: %v", err)
}
if len(results) != 3 {
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
}
byslug := map[string]TenantResult{}
for _, r := range results {
byslug[r.TenantSlug] = r
}
good0 := byslug[tenants[0].Slug]
if good0.Err != nil || len(good0.Applied) != 2 {
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
}
good2 := byslug[tenants[2].Slug]
if good2.Err != nil || len(good2.Applied) != 2 {
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
}
bad := byslug[badTenant.Slug]
if bad.Err == nil {
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
}
if bad.FailedAt != "0002_demo_b" {
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
}
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
// jeden Tenant den korrekten, unabhaengigen Stand.
func TestStatus_ReflectsPerTenantState(t *testing.T) {
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
defer cleanup()
ctx := context.Background()
migrations := []Migration{
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
}
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
t.Fatalf("rollout: %v", err)
}
status, err := orchestrator.Status(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("status: %v", err)
}
if len(status) != 2 {
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
}
if !status[0].Success || status[0].Version != "0001_ok" {
t.Fatalf("status[0] unerwartet: %+v", status[0])
}
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
}
}
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
// sobald die Ursache behoben ist.
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
defer cleanup()
ctx := context.Background()
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
if err != nil {
t.Fatalf("connect: %v", err)
}
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
t.Fatalf("vorbedingung: %v", err)
}
migrations := []Migration{
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
}
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
if err != nil {
t.Fatalf("rollout 1: %v", err)
}
if firstRun[0].FailedAt != "0002_conflict" {
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
}
// Ursache beheben.
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
t.Fatalf("ursache beheben: %v", err)
}
pool.Close()
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
if err != nil {
t.Fatalf("rollout 2: %v", err)
}
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
// Applied-Eintrags fuer 0002_conflict.
if secondRun[0].Err != nil {
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
}
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
}
}
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS tenant_licenses;
-12
View File
@@ -1,12 +0,0 @@
-- Lizenzumfang pro Mandant (LIC-01, siehe core-kanban/tickets/LIC-01.md).
-- Genau ein Lizenzdatensatz pro Tenant (tenant_id PK) — ein neues Einspielen
-- ersetzt den vorherigen Datensatz vollstaendig statt eine Historie zu fuehren.
CREATE TABLE tenant_licenses (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
plan TEXT NOT NULL,
modules TEXT[] NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
valid_until TIMESTAMPTZ NOT NULL,
raw_key TEXT NOT NULL,
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1