Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45bc10719a |
@@ -0,0 +1,206 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTenantNotFound = errors.New("tenant: nicht gefunden")
|
||||||
|
ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang")
|
||||||
|
// ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst
|
||||||
|
// EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der
|
||||||
|
// Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der
|
||||||
|
// Nicht-aktiv-Zustaende genau vorliegt.
|
||||||
|
ErrTenantNotActive = errors.New("tenant: nicht aktiv")
|
||||||
|
)
|
||||||
|
|
||||||
|
func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) {
|
||||||
|
var t Tenant
|
||||||
|
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status,
|
||||||
|
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
||||||
|
return Tenant{}, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift
|
||||||
|
// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer
|
||||||
|
// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht,
|
||||||
|
// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt"
|
||||||
|
// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen
|
||||||
|
// sprechenden Fehler liefert statt eines stillen No-Ops.
|
||||||
|
func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) {
|
||||||
|
from := make([]string, len(allowedFrom))
|
||||||
|
for i, s := range allowedFrom {
|
||||||
|
from[i] = string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := r.pool.QueryRow(ctx, `
|
||||||
|
UPDATE tenants
|
||||||
|
SET status = $2, previous_status = $3, deletion_scheduled_at = $4
|
||||||
|
WHERE slug = $1 AND status = ANY($5)
|
||||||
|
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
||||||
|
`, slug, string(to), previousStatus, deletionAt, from)
|
||||||
|
|
||||||
|
t, err := scanTenantWithLifecycle(row)
|
||||||
|
if err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, getErr := r.GetBySlug(ctx, slug)
|
||||||
|
if getErr != nil {
|
||||||
|
return Tenant{}, ErrTenantNotFound
|
||||||
|
}
|
||||||
|
return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff
|
||||||
|
// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt.
|
||||||
|
func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her
|
||||||
|
// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt
|
||||||
|
// die Rueckkehr nach StatusActive.
|
||||||
|
func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die
|
||||||
|
// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit
|
||||||
|
// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert).
|
||||||
|
func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) {
|
||||||
|
existing, err := r.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return Tenant{}, ErrTenantNotFound
|
||||||
|
}
|
||||||
|
prev := string(existing.Status)
|
||||||
|
deletionAt := time.Now().Add(grace)
|
||||||
|
return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und
|
||||||
|
// stellt exakt den zuvor gesicherten Zustand wieder her.
|
||||||
|
func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
existing, err := r.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return Tenant{}, ErrTenantNotFound
|
||||||
|
}
|
||||||
|
if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil {
|
||||||
|
return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status)
|
||||||
|
}
|
||||||
|
restoreTo := Status(*existing.PreviousStatus)
|
||||||
|
return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der
|
||||||
|
// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung
|
||||||
|
// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool
|
||||||
|
// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner.
|
||||||
|
type Lifecycle struct {
|
||||||
|
registry *Registry
|
||||||
|
adminPool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
|
||||||
|
return &Lifecycle{registry: registry, adminPool: adminPool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den
|
||||||
|
// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2).
|
||||||
|
func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error {
|
||||||
|
t, err := l.registry.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return ErrTenantNotFound
|
||||||
|
}
|
||||||
|
if t.Status != StatusActive {
|
||||||
|
slog.Warn("zugriff auf nicht-aktiven mandanten verweigert",
|
||||||
|
"tenant_slug", slug, "tenant_status", t.Status)
|
||||||
|
return ErrTenantNotActive
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit
|
||||||
|
// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED
|
||||||
|
// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe
|
||||||
|
// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel
|
||||||
|
// laufende Core-Instanzen.
|
||||||
|
func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
|
||||||
|
tx, err := l.registry.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("sweep-transaktion starten: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
rows, err := tx.Query(ctx, `
|
||||||
|
SELECT id, db_name FROM tenants
|
||||||
|
WHERE status = $1 AND deletion_scheduled_at <= now()
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
`, string(StatusPendingDeletion))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type due struct{ id, dbName string }
|
||||||
|
var candidates []due
|
||||||
|
for rows.Next() {
|
||||||
|
var d due
|
||||||
|
if err := rows.Scan(&d.id, &d.dbName); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
|
||||||
|
}
|
||||||
|
candidates = append(candidates, d)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil {
|
||||||
|
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
`, c.id, string(StatusDeleted)); err != nil {
|
||||||
|
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return 0, fmt.Errorf("sweep-transaktion committen: %w", err)
|
||||||
|
}
|
||||||
|
return processed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird —
|
||||||
|
// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention.
|
||||||
|
func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if _, err := l.ProcessDueDeletions(ctx); err != nil {
|
||||||
|
slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package tenant
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *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(),
|
||||||
|
previous_status TEXT,
|
||||||
|
deletion_scheduled_at TIMESTAMPTZ
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("registry-schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := NewRegistry(registryPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
lifecycle := NewLifecycle(registry, adminPool)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
registryPool.Close()
|
||||||
|
adminPool.Close()
|
||||||
|
}
|
||||||
|
_ = provisioner
|
||||||
|
return registry, lifecycle, adminPool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
|
||||||
|
t.Helper()
|
||||||
|
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
|
||||||
|
t.Fatalf("provision %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
||||||
|
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
|
||||||
|
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
|
||||||
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_suspend")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
suspended, err := registry.Suspend(ctx, "lc_suspend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
if suspended.Status != StatusSuspended {
|
||||||
|
t.Fatalf("status = %q, want suspended", suspended.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reactivate: %v", err)
|
||||||
|
}
|
||||||
|
if reactivated.Status != StatusActive {
|
||||||
|
t.Fatalf("status = %q, want active", reactivated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
|
||||||
|
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
|
||||||
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_invalid")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
|
||||||
|
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
|
||||||
|
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
|
||||||
|
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
|
||||||
|
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
|
||||||
|
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
|
||||||
|
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
|
||||||
|
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
|
||||||
|
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("schedule deletion: %v", err)
|
||||||
|
}
|
||||||
|
if scheduled.Status != StatusPendingDeletion {
|
||||||
|
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
|
||||||
|
}
|
||||||
|
if scheduled.DeletionScheduledAt == nil {
|
||||||
|
t.Fatal("erwartet gesetzte deletion_scheduled_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cancel deletion: %v", err)
|
||||||
|
}
|
||||||
|
if restored.Status != StatusActive {
|
||||||
|
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
|
||||||
|
// 'suspended' zurueckkehren, NICHT zu 'active'.
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
|
||||||
|
t.Fatalf("schedule deletion: %v", err)
|
||||||
|
}
|
||||||
|
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cancel deletion: %v", err)
|
||||||
|
}
|
||||||
|
if restoredSuspended.Status != StatusSuspended {
|
||||||
|
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
|
||||||
|
// Zugriffsversuch einen klaren Fehler.
|
||||||
|
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
|
||||||
|
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
|
||||||
|
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
|
||||||
|
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
|
||||||
|
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
|
||||||
|
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
|
||||||
|
// das RunSweeper periodisch aufruft).
|
||||||
|
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
|
||||||
|
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_due")
|
||||||
|
provisionTestTenant(t, registry, adminPool, "lc_not_due")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
|
||||||
|
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
|
||||||
|
t.Fatalf("schedule deletion (due): %v", err)
|
||||||
|
}
|
||||||
|
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
|
||||||
|
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
|
||||||
|
t.Fatalf("schedule deletion (not due): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("process due deletions: %v", err)
|
||||||
|
}
|
||||||
|
if processed != 1 {
|
||||||
|
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
|
||||||
|
}
|
||||||
|
|
||||||
|
due, err := registry.GetBySlug(ctx, "lc_due")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get lc_due: %v", err)
|
||||||
|
}
|
||||||
|
if due.Status != StatusDeleted {
|
||||||
|
t.Fatalf("lc_due status = %q, want deleted", due.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get lc_not_due: %v", err)
|
||||||
|
}
|
||||||
|
if notDue.Status != StatusPendingDeletion {
|
||||||
|
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
|
||||||
|
var exists bool
|
||||||
|
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||||
|
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
|
||||||
|
t.Fatalf("pg_database pruefen: %v", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,13 +35,17 @@ func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
||||||
|
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
|
||||||
|
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
||||||
|
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
|
||||||
var t Tenant
|
var t Tenant
|
||||||
row := r.pool.QueryRow(ctx, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
|
||||||
FROM tenants WHERE slug = $1
|
FROM tenants WHERE slug = $1
|
||||||
`, slug)
|
`, slug)
|
||||||
|
|
||||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt,
|
||||||
|
&t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
|
||||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||||
}
|
}
|
||||||
return t, nil
|
return t, nil
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
|
|
||||||
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
|
|
||||||
// (TEN-06 Akzeptanzkriterium 3).
|
|
||||||
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
|
|
||||||
|
|
||||||
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
|
|
||||||
// nicht existiert.
|
|
||||||
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
|
|
||||||
|
|
||||||
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
|
|
||||||
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
|
|
||||||
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
|
|
||||||
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
|
|
||||||
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
|
|
||||||
type Router struct {
|
|
||||||
registry *Registry
|
|
||||||
maxOpen int
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
order *list.List // vorne = zuletzt benutzt
|
|
||||||
items map[string]*list.Element // slug -> element mit *routerEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
type routerEntry struct {
|
|
||||||
slug string
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(registry *Registry, maxOpen int) *Router {
|
|
||||||
if maxOpen < 1 {
|
|
||||||
maxOpen = 1
|
|
||||||
}
|
|
||||||
return &Router{
|
|
||||||
registry: registry,
|
|
||||||
maxOpen: maxOpen,
|
|
||||||
order: list.New(),
|
|
||||||
items: make(map[string]*list.Element),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
|
|
||||||
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
|
|
||||||
// (Akzeptanzkriterium 2 / Pruefung 3).
|
|
||||||
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
|
|
||||||
if tenantSlug == "" {
|
|
||||||
return nil, ErrMissingTenantContext
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
if el, ok := r.items[tenantSlug]; ok {
|
|
||||||
r.order.MoveToFront(el)
|
|
||||||
pool := el.Value.(*routerEntry).pool
|
|
||||||
r.mu.Unlock()
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
|
|
||||||
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
|
|
||||||
t, err := r.registry.GetBySlug(ctx, tenantSlug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := pgxpool.New(ctx, t.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
|
|
||||||
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
|
|
||||||
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
|
|
||||||
// Verbindung wieder schliessen und die vorhandene verwenden.
|
|
||||||
if el, ok := r.items[tenantSlug]; ok {
|
|
||||||
r.order.MoveToFront(el)
|
|
||||||
existing := el.Value.(*routerEntry).pool
|
|
||||||
pool.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
|
|
||||||
r.items[tenantSlug] = el
|
|
||||||
|
|
||||||
if r.order.Len() > r.maxOpen {
|
|
||||||
r.evictOldest()
|
|
||||||
}
|
|
||||||
|
|
||||||
return pool, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
|
|
||||||
// gehaltenem r.mu aufgerufen werden.
|
|
||||||
func (r *Router) evictOldest() {
|
|
||||||
oldest := r.order.Back()
|
|
||||||
if oldest == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entry := oldest.Value.(*routerEntry)
|
|
||||||
r.order.Remove(oldest)
|
|
||||||
delete(r.items, entry.slug)
|
|
||||||
entry.pool.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
|
|
||||||
// dient Tests/Monitoring, um AC2 nachzuweisen.
|
|
||||||
func (r *Router) OpenCount() int {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
return r.order.Len()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
|
|
||||||
// Herunterfahren des Core-Prozesses.
|
|
||||||
func (r *Router) Close() {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
for el := r.order.Front(); el != nil; el = el.Next() {
|
|
||||||
el.Value.(*routerEntry).pool.Close()
|
|
||||||
}
|
|
||||||
r.order.Init()
|
|
||||||
r.items = make(map[string]*list.Element)
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, 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 := NewRegistry(registryPool)
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
var tenants []Tenant
|
|
||||||
var slugs []string
|
|
||||||
for i := 0; i < tenantCount; i++ {
|
|
||||||
slug := fmt.Sprintf("router_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)
|
|
||||||
}
|
|
||||||
|
|
||||||
router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
router.Close()
|
|
||||||
for _, slug := range slugs {
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
|
|
||||||
}
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
|
||||||
registryPool.Close()
|
|
||||||
adminPool.Close()
|
|
||||||
}
|
|
||||||
return router, tenants, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest.
|
|
||||||
func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) {
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, 2)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve: %v", err)
|
|
||||||
}
|
|
||||||
var dbName string
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil {
|
|
||||||
t.Fatalf("current_database: %v", err)
|
|
||||||
}
|
|
||||||
if dbName != tenants[0].DBName {
|
|
||||||
t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext
|
|
||||||
// wird explizit abgewiesen statt irgendeine Verbindung zu liefern.
|
|
||||||
func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) {
|
|
||||||
router, _, cleanup := newTestRouterSetup(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) {
|
|
||||||
t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err)
|
|
||||||
}
|
|
||||||
if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) {
|
|
||||||
t.Fatalf("erwartet ErrUnknownTenant, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich
|
|
||||||
// gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein
|
|
||||||
// erneuter Verbindungsaufbau.
|
|
||||||
func TestRouter_ReusesConnectionForSameTenant(t *testing.T) {
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, 1)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
first, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 1: %v", err)
|
|
||||||
}
|
|
||||||
second, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve 2: %v", err)
|
|
||||||
}
|
|
||||||
if first != second {
|
|
||||||
t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten
|
|
||||||
// als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt
|
|
||||||
// (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl.
|
|
||||||
func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) {
|
|
||||||
const tenantCount = 6
|
|
||||||
router, tenants, cleanup := newTestRouterSetup(t, tenantCount)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
for _, tn := range tenants {
|
|
||||||
if _, err := router.Resolve(ctx, tn.Slug); err != nil {
|
|
||||||
t.Fatalf("resolve %s: %v", tn.Slug, err)
|
|
||||||
}
|
|
||||||
if router.OpenCount() > 2 {
|
|
||||||
t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if router.OpenCount() != 2 {
|
|
||||||
t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar
|
|
||||||
// (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand).
|
|
||||||
pool, err := router.Resolve(ctx, tenants[0].Slug)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve nach eviction: %v", err)
|
|
||||||
}
|
|
||||||
var one int
|
|
||||||
if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil {
|
|
||||||
t.Fatalf("query nach re-resolve: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,10 @@ type Status string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StatusActive Status = "active"
|
StatusActive Status = "active"
|
||||||
|
// Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go).
|
||||||
|
StatusSuspended Status = "suspended"
|
||||||
|
StatusPendingDeletion Status = "pending_deletion"
|
||||||
|
StatusDeleted Status = "deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
@@ -22,6 +26,11 @@ type Tenant struct {
|
|||||||
DBDSN string
|
DBDSN string
|
||||||
Status Status
|
Status Status
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
// PreviousStatus und DeletionScheduledAt sind nur waehrend
|
||||||
|
// StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen
|
||||||
|
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
|
||||||
|
PreviousStatus *string
|
||||||
|
DeletionScheduledAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE tenants DROP COLUMN previous_status;
|
||||||
|
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Lebenszyklus-Zustaende fuer Mandanten (TEN-04, siehe core-kanban/tickets/TEN-04.md).
|
||||||
|
-- previous_status haelt den Zustand VOR einer Loeschvormerkung, damit
|
||||||
|
-- CancelDeletion "den vorherigen Zustand vollstaendig wiederherstellt"
|
||||||
|
-- (aktiv ODER suspendiert), statt hart auf 'active' zurueckzusetzen.
|
||||||
|
ALTER TABLE tenants ADD COLUMN previous_status TEXT;
|
||||||
|
ALTER TABLE tenants ADD COLUMN deletion_scheduled_at TIMESTAMPTZ;
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/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;"
|
||||||
|
|
||||||
|
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."
|
||||||
Reference in New Issue
Block a user