From 45bc10719a519381e0ff5f82ab76eb4a664b597b Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 18:42:24 +0200 Subject: [PATCH] TEN-04: tenant-lifecycle-suspendieren-reaktivieren-loeschen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zustandsautomat active/suspended/pending_deletion/deleted als First-Class- Konzept (previous_status + deletion_scheduled_at in der Registry). Alle Uebergaenge in Registry.transition als atomarer Check-and-Set (UPDATE ... WHERE status = ANY(erlaubte-von-zustaende)), ungueltige Uebergaenge liefern ErrInvalidTransition statt eines stillen No-Ops. ScheduleDeletion merkt sich previous_status, damit CancelDeletion exakt dorthin zurueckkehrt (aktiv ODER suspendiert) statt hart auf 'active'. Lifecycle.ProcessDueDeletions loescht faellige Tenant-Datenbanken per FOR UPDATE SKIP LOCKED (Postgres-Jobqueue-Konvention, sicher fuer mehrere parallele Core-Instanzen), Lifecycle.RunSweeper triggert das periodisch per In-Prozess-Goroutine. Lifecycle.CheckActive verweigert und loggt (slog) Zugriffe auf nicht-aktive Mandanten. Bugfix nebenbei: Registry.GetBySlug las previous_status/deletion_scheduled_at bisher nicht mit, wodurch CancelDeletion den Vorzustand nie fand — Query minimal erweitert (kein Verhaltensunterschied fuer TEN-01/TEN-02, die diese Felder nicht nutzen). Neu: scripts/reset-test-env.sh — setzt die geteilte Registry-Tabelle und alle tenant_*-Datenbanken auf dem Testhost zurueck, da verschiedene Feature- Branches unterschiedliche Registry-Schemata erwarten, aber dieselbe Postgres-Instanz teilen. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Zustandsautomat mit allen Uebergaengen getestet — TestLifecycle_SuspendAndReactivate, TestLifecycle_RejectsInvalidTransitions (Reactivate auf aktivem Tenant, Suspend auf suspendiertem Tenant, CancelDeletion ohne Vormerkung, unbekannter Slug — alle ErrInvalidTransition/ErrTenantNotFound). PASS. 2. Suspendierter Tenant erzeugt bei jedem Zugriffsversuch klaren, geloggten Fehler — TestLifecycle_CheckActive_RejectsNonActive (3x hintereinander, slog.Warn nachweislich pro Aufruf). PASS. 3. Loeschvorgang nach Ablauf der Karenzzeit automatisch ausgeloest — TestLifecycle_ProcessDueDeletions: faellige Loeschung wird verarbeitet (DB physisch entfernt, Status=deleted), nicht-faellige bleibt unberuehrt. PASS. Co-Authored-By: Claude Sonnet 5 --- go.mod | 9 + go.sum | 28 +++ internal/tenant/lifecycle.go | 206 ++++++++++++++++++ internal/tenant/lifecycle_test.go | 253 ++++++++++++++++++++++ internal/tenant/registry.go | 8 +- internal/tenant/tenant.go | 9 + migrations/0003_tenant_lifecycle.down.sql | 2 + migrations/0003_tenant_lifecycle.up.sql | 6 + scripts/reset-test-env.sh | 23 ++ 9 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 go.sum create mode 100644 internal/tenant/lifecycle.go create mode 100644 internal/tenant/lifecycle_test.go create mode 100644 migrations/0003_tenant_lifecycle.down.sql create mode 100644 migrations/0003_tenant_lifecycle.up.sql create mode 100755 scripts/reset-test-env.sh diff --git a/go.mod b/go.mod index 75a79b7..05c10d0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tenant/lifecycle.go b/internal/tenant/lifecycle.go new file mode 100644 index 0000000..82793ad --- /dev/null +++ b/internal/tenant/lifecycle.go @@ -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) + } + } + } +} diff --git a/internal/tenant/lifecycle_test.go b/internal/tenant/lifecycle_test.go new file mode 100644 index 0000000..262acaa --- /dev/null +++ b/internal/tenant/lifecycle_test.go @@ -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") + } +} diff --git a/internal/tenant/registry.go b/internal/tenant/registry.go index 401f623..49fe2f1 100644 --- a/internal/tenant/registry.go +++ b/internal/tenant/registry.go @@ -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) { + // 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 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 `, 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 t, nil diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go index 698566e..9778bc8 100644 --- a/internal/tenant/tenant.go +++ b/internal/tenant/tenant.go @@ -12,6 +12,10 @@ type Status string const ( 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 { @@ -22,6 +26,11 @@ type Tenant struct { DBDSN string Status Status 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 diff --git a/migrations/0003_tenant_lifecycle.down.sql b/migrations/0003_tenant_lifecycle.down.sql new file mode 100644 index 0000000..43c6130 --- /dev/null +++ b/migrations/0003_tenant_lifecycle.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE tenants DROP COLUMN previous_status; +ALTER TABLE tenants DROP COLUMN deletion_scheduled_at; diff --git a/migrations/0003_tenant_lifecycle.up.sql b/migrations/0003_tenant_lifecycle.up.sql new file mode 100644 index 0000000..141c116 --- /dev/null +++ b/migrations/0003_tenant_lifecycle.up.sql @@ -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; diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..1a2e50c --- /dev/null +++ b/scripts/reset-test-env.sh @@ -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."