Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a30345e07 |
@@ -0,0 +1,87 @@
|
||||
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
|
||||
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
|
||||
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
|
||||
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
|
||||
package flag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("flag: nicht gefunden")
|
||||
|
||||
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
|
||||
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
|
||||
type Flag struct {
|
||||
Key string
|
||||
Enabled bool
|
||||
RolloutPercentage int
|
||||
TargetTenantSlugs []string
|
||||
}
|
||||
|
||||
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store {
|
||||
return &Store{pool: pool}
|
||||
}
|
||||
|
||||
func (s *Store) Set(ctx context.Context, f Flag) error {
|
||||
if f.TargetTenantSlugs == nil {
|
||||
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
|
||||
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("flag speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
|
||||
var f Flag
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT key, enabled, rollout_percentage, target_tenant_slugs
|
||||
FROM feature_flags WHERE key = $1
|
||||
`, key)
|
||||
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Flag{}, ErrNotFound
|
||||
}
|
||||
return Flag{}, fmt.Errorf("flag lesen: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
|
||||
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
|
||||
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
|
||||
// kein Feature wird versehentlich aktiv.
|
||||
func evaluate(f Flag, tenantSlug string) bool {
|
||||
if f.Enabled {
|
||||
return true
|
||||
}
|
||||
for _, target := range f.TargetTenantSlugs {
|
||||
if target == tenantSlug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if f.RolloutPercentage > 0 {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
|
||||
return int(h.Sum32()%100) < f.RolloutPercentage
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package flag
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEvaluate_GlobalEnabled(t *testing.T) {
|
||||
f := Flag{Key: "k", Enabled: true}
|
||||
if !evaluate(f, "irgendein-tenant") {
|
||||
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
|
||||
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
|
||||
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
|
||||
if !evaluate(f, "acme") {
|
||||
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
||||
}
|
||||
if evaluate(f, "globex") {
|
||||
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
|
||||
full := Flag{Key: "k", RolloutPercentage: 100}
|
||||
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
|
||||
t.Fatal("100% rollout sollte immer true liefern")
|
||||
}
|
||||
|
||||
none := Flag{Key: "k", RolloutPercentage: 0}
|
||||
if evaluate(none, "beliebiger-tenant") {
|
||||
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
|
||||
f := Flag{Key: "k", RolloutPercentage: 50}
|
||||
first := evaluate(f, "stabiler-tenant")
|
||||
for i := 0; i < 5; i++ {
|
||||
if evaluate(f, "stabiler-tenant") != first {
|
||||
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package flag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
|
||||
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
|
||||
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
|
||||
// muss (Akzeptanzkriterium 3).
|
||||
const DefaultCacheTTL = 5 * time.Second
|
||||
|
||||
type cacheEntry struct {
|
||||
flag Flag
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
|
||||
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
|
||||
type Service struct {
|
||||
store *Store
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
func NewService(store *Store, ttl time.Duration) *Service {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultCacheTTL
|
||||
}
|
||||
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
|
||||
}
|
||||
|
||||
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
|
||||
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
|
||||
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
|
||||
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
|
||||
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
|
||||
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
|
||||
f, ok := s.resolve(ctx, key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return evaluate(f, tenantSlug)
|
||||
}
|
||||
|
||||
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
|
||||
s.mu.RLock()
|
||||
entry, exists := s.cache[key]
|
||||
fresh := exists && time.Now().Before(entry.expiresAt)
|
||||
s.mu.RUnlock()
|
||||
if fresh {
|
||||
return entry.flag, true
|
||||
}
|
||||
|
||||
f, err := s.store.Get(ctx, key)
|
||||
if err != nil {
|
||||
if exists {
|
||||
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
|
||||
"flag_key", key, "error", err)
|
||||
return entry.flag, true
|
||||
}
|
||||
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
|
||||
"flag_key", key, "error", err)
|
||||
return Flag{}, false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
|
||||
s.mu.Unlock()
|
||||
return f, true
|
||||
}
|
||||
|
||||
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
|
||||
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
|
||||
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
|
||||
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
|
||||
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
|
||||
func (s *Service) Invalidate(key string) {
|
||||
s.mu.Lock()
|
||||
delete(s.cache, key)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package flag
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func setupFlagStoreTest(t *testing.T) (*Store, 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 feature_flags (
|
||||
key TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
rollout_percentage INT NOT NULL DEFAULT 0,
|
||||
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
||||
pool.Close()
|
||||
}
|
||||
return NewStore(pool), cleanup
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
|
||||
// die erwartete Auswertung.
|
||||
func TestService_TargetTenantStrategy(t *testing.T) {
|
||||
store, cleanup := setupFlagStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
svc := NewService(store, time.Hour)
|
||||
|
||||
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
|
||||
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
||||
}
|
||||
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
|
||||
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
|
||||
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
|
||||
func TestService_CacheInvalidationTiming(t *testing.T) {
|
||||
store, cleanup := setupFlagStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
const ttl = 150 * time.Millisecond
|
||||
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
svc := NewService(store, ttl)
|
||||
|
||||
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||
t.Fatal("erwartet false vor der aenderung")
|
||||
}
|
||||
|
||||
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
|
||||
// Store, ohne svc.Invalidate aufzurufen.
|
||||
changedAt := time.Now()
|
||||
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
|
||||
// Sofort danach sollte der Cache noch den alten Stand liefern.
|
||||
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
|
||||
}
|
||||
|
||||
deadline := changedAt.Add(ttl + 100*time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||
elapsed := time.Since(changedAt)
|
||||
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
|
||||
}
|
||||
|
||||
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
|
||||
store, cleanup := setupFlagStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
|
||||
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
|
||||
|
||||
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
svc.Invalidate("test_invalidate_flag")
|
||||
|
||||
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
|
||||
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
|
||||
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
|
||||
func TestService_FallsBackOnStoreFailure(t *testing.T) {
|
||||
store, cleanup := setupFlagStoreTest(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
svc := NewService(store, time.Hour)
|
||||
|
||||
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
|
||||
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||
t.Fatal("erwartet true bei funktionierender db")
|
||||
}
|
||||
|
||||
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
|
||||
if err != nil {
|
||||
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
|
||||
}
|
||||
brokenStore := NewStore(brokenPool)
|
||||
|
||||
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
|
||||
svcWithCache.mu.Lock()
|
||||
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
|
||||
flag: Flag{Key: "test_fallback_flag", Enabled: true},
|
||||
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
|
||||
}
|
||||
svcWithCache.mu.Unlock()
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
|
||||
}
|
||||
}()
|
||||
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
|
||||
}
|
||||
}()
|
||||
|
||||
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
|
||||
// default false, kein absturz.
|
||||
freshSvc := NewService(brokenStore, time.Hour)
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("IsEnabled hat gepanict: %v", r)
|
||||
}
|
||||
}()
|
||||
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
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
|
||||
// retention ist die Pruef-Schnittstelle gegen Archive RET-03/CMP-06 (TEN-08).
|
||||
// Default NoRetentionCheck{}, bis Archive angebunden ist — siehe retention.go.
|
||||
retention RetentionChecker
|
||||
}
|
||||
|
||||
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
|
||||
return &Lifecycle{registry: registry, adminPool: adminPool, retention: NoRetentionCheck{}}
|
||||
}
|
||||
|
||||
// WithRetentionChecker ersetzt den Retention-Checker (z.B. im Test durch einen
|
||||
// Fake, oder in Produktion durch den echten Archive-RET-03-Client). Gibt
|
||||
// dasselbe *Lifecycle zurueck, um Verkettung beim Aufbau zu erlauben.
|
||||
func (l *Lifecycle) WithRetentionChecker(checker RetentionChecker) *Lifecycle {
|
||||
l.retention = checker
|
||||
return l
|
||||
}
|
||||
|
||||
// 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, slug, 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, slug, dbName string }
|
||||
var candidates []due
|
||||
for rows.Next() {
|
||||
var d due
|
||||
if err := rows.Scan(&d.id, &d.slug, &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 {
|
||||
// TEN-08: vor der physischen Loeschung gegen Archive RET-03/CMP-06 pruefen.
|
||||
// Solange eine Sperre besteht, bleibt der Tenant in pending_deletion
|
||||
// ("zur Loeschung vorgemerkt, aber gesperrt") — der Grund wird
|
||||
// festgehalten (Akzeptanzkriterium 2), die naechste Sweeper-Runde
|
||||
// prueft automatisch erneut (Akzeptanzkriterium 3), ohne dass ein
|
||||
// manueller Re-Trigger noetig waere.
|
||||
result, err := l.retention.CheckTenantRetention(ctx, c.id)
|
||||
if err != nil {
|
||||
return processed, fmt.Errorf("retention-pruefung fuer tenant %q: %w", c.id, err)
|
||||
}
|
||||
if result.Blocked {
|
||||
slog.Warn("tenant-loeschung wegen aufbewahrungspflicht/legal-hold zurueckgehalten",
|
||||
"tenant_slug", c.slug, "reason", result.Reason)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE tenants SET retention_block_reason = $2, retention_checked_at = now()
|
||||
WHERE id = $1
|
||||
`, c.id, result.Reason); err != nil {
|
||||
return processed, fmt.Errorf("retention-sperrgrund fuer tenant %q speichern: %w", c.id, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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,
|
||||
retention_block_reason = NULL, retention_checked_at = now()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
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,
|
||||
retention_block_reason TEXT,
|
||||
retention_checked_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,20 +35,13 @@ 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.
|
||||
// retention_block_reason/retention_checked_at (TEN-08) aus demselben Grund
|
||||
// fuer die Admin-Einsehbarkeit des Sperrgrunds (Akzeptanzkriterium 2).
|
||||
var t Tenant
|
||||
row := r.pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at,
|
||||
retention_block_reason, retention_checked_at
|
||||
SELECT id, slug, name, db_name, db_dsn, status, created_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,
|
||||
&t.PreviousStatus, &t.DeletionScheduledAt, &t.RetentionBlockReason, &t.RetentionCheckedAt); err != nil {
|
||||
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package tenant
|
||||
|
||||
import "context"
|
||||
|
||||
// RetentionResult ist das Ergebnis einer Pruefung gegen Archive RET-03/CMP-06
|
||||
// vor einer endgueltigen Tenant-Loeschung (TEN-08).
|
||||
type RetentionResult struct {
|
||||
// Blocked ist true, solange GoBD-relevante Daten des Tenants unter
|
||||
// Aufbewahrungspflicht oder Legal Hold stehen (Akzeptanzkriterium 1).
|
||||
Blocked bool
|
||||
// Reason beschreibt Aufbewahrungsklasse/Frist oder Legal-Hold-Grund,
|
||||
// fuer Admins einsehbar (Akzeptanzkriterium 2). Nur aussagekraeftig, wenn Blocked true ist.
|
||||
Reason string
|
||||
}
|
||||
|
||||
// RetentionChecker ist die Schnittstelle zu Archive RET-03 (Loeschworkflow &
|
||||
// Aufbewahrungssperre) / CMP-06 (Vier-Augen-Freigabe fuer Loeschungen).
|
||||
// Core kennt bewusst keine Retention-Logik selbst — diese Kachel ruft nur auf,
|
||||
// siehe TEN-08 "Nicht Bestandteil dieser Kachel". Solange Archive RET-03 noch
|
||||
// nicht implementiert ist, wird ein no-op-Checker verwendet (siehe
|
||||
// NoRetentionCheck), der niemals blockiert — Core faellt damit auf das
|
||||
// TEN-04-Verhalten vor diesem Ticket zurueck, statt fehlzuschlagen.
|
||||
type RetentionChecker interface {
|
||||
CheckTenantRetention(ctx context.Context, tenantID string) (RetentionResult, error)
|
||||
}
|
||||
|
||||
// NoRetentionCheck ist der Platzhalter-Checker, solange Archive RET-03 noch
|
||||
// nicht angebunden ist — blockiert nie. Wird in Produktion durch den echten
|
||||
// HTTP-Client gegen Archive ersetzt, sobald RET-03 existiert.
|
||||
type NoRetentionCheck struct{}
|
||||
|
||||
func (NoRetentionCheck) CheckTenantRetention(context.Context, string) (RetentionResult, error) {
|
||||
return RetentionResult{Blocked: false}, nil
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeRetentionChecker simuliert Archive RET-03/CMP-06 in Tests — echte
|
||||
// Anbindung existiert noch nicht (siehe retention.go), diese Kachel ruft nur auf.
|
||||
type fakeRetentionChecker struct {
|
||||
blocked map[string]string // tenantID -> Grund
|
||||
}
|
||||
|
||||
func (f fakeRetentionChecker) CheckTenantRetention(_ context.Context, tenantID string) (RetentionResult, error) {
|
||||
if reason, ok := f.blocked[tenantID]; ok {
|
||||
return RetentionResult{Blocked: true, Reason: reason}, nil
|
||||
}
|
||||
return RetentionResult{Blocked: false}, nil
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 1: Loeschung eines Tenants mit aktiver
|
||||
// GoBD-Aufbewahrungspflicht wird abgewiesen, Grund wird protokolliert
|
||||
// (Akzeptanzkriterium 2).
|
||||
func TestLifecycle_ProcessDueDeletions_BlockedByRetention(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_retention_blocked")
|
||||
ctx := context.Background()
|
||||
|
||||
tenantBeforeSchedule, err := registry.GetBySlug(ctx, "lc_retention_blocked")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant: %v", err)
|
||||
}
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_retention_blocked", -time.Minute); err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
|
||||
lifecycle.WithRetentionChecker(fakeRetentionChecker{
|
||||
blocked: map[string]string{
|
||||
tenantBeforeSchedule.ID: "GoBD-Aufbewahrungsfrist bis 2034-01-01 (Buchungsbeleg-Klasse)",
|
||||
},
|
||||
})
|
||||
|
||||
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("process due deletions: %v", err)
|
||||
}
|
||||
if processed != 0 {
|
||||
t.Fatalf("erwartet 0 tatsaechlich verarbeitete loeschungen, habe %d", processed)
|
||||
}
|
||||
|
||||
after, err := registry.GetBySlug(ctx, "lc_retention_blocked")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach sweep: %v", err)
|
||||
}
|
||||
if after.Status != StatusPendingDeletion {
|
||||
t.Fatalf("status = %q, want pending_deletion (gesperrt, nicht geloescht)", after.Status)
|
||||
}
|
||||
if after.RetentionBlockReason == nil || *after.RetentionBlockReason == "" {
|
||||
t.Fatal("erwartet gesetzten retention_block_reason (Akzeptanzkriterium 2)")
|
||||
}
|
||||
if after.RetentionCheckedAt == nil {
|
||||
t.Fatal("erwartet gesetzten retention_checked_at")
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
|
||||
dbNameForSlug("lc_retention_blocked")).Scan(&exists); err != nil {
|
||||
t.Fatalf("pg_database pruefen: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("tenant-datenbank haette NICHT geloescht werden duerfen (retention-sperre)")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1 + Pruefung 2: Loeschung eines Tenants mit Legal Hold
|
||||
// wird ebenfalls abgewiesen — derselbe Mechanismus wie GoBD-Frist, nur anderer Grund.
|
||||
func TestLifecycle_ProcessDueDeletions_BlockedByLegalHold(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_legal_hold")
|
||||
ctx := context.Background()
|
||||
|
||||
tenant, err := registry.GetBySlug(ctx, "lc_legal_hold")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant: %v", err)
|
||||
}
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_legal_hold", -time.Minute); err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
|
||||
lifecycle.WithRetentionChecker(fakeRetentionChecker{
|
||||
blocked: map[string]string{
|
||||
tenant.ID: "Legal Hold: laufendes Gerichtsverfahren, Aktenzeichen XY-2026-042",
|
||||
},
|
||||
})
|
||||
|
||||
if _, err := lifecycle.ProcessDueDeletions(ctx); err != nil {
|
||||
t.Fatalf("process due deletions: %v", err)
|
||||
}
|
||||
|
||||
after, err := registry.GetBySlug(ctx, "lc_legal_hold")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach sweep: %v", err)
|
||||
}
|
||||
if after.Status != StatusPendingDeletion {
|
||||
t.Fatalf("status = %q, want pending_deletion", after.Status)
|
||||
}
|
||||
if after.RetentionBlockReason == nil || *after.RetentionBlockReason == "" {
|
||||
t.Fatal("erwartet gesetzten retention_block_reason")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 3: nach Aufhebung aller Sperren wird die
|
||||
// Loeschung bei der naechsten Sweep-Runde automatisch ausgefuehrt — kein
|
||||
// manueller Re-Trigger noetig, derselbe Sweeper-Aufruf greift erneut.
|
||||
func TestLifecycle_ProcessDueDeletions_ExecutesAfterRetentionCleared(t *testing.T) {
|
||||
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
|
||||
defer cleanup()
|
||||
provisionTestTenant(t, registry, adminPool, "lc_retention_cleared")
|
||||
ctx := context.Background()
|
||||
|
||||
tenant, err := registry.GetBySlug(ctx, "lc_retention_cleared")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant: %v", err)
|
||||
}
|
||||
if _, err := registry.ScheduleDeletion(ctx, "lc_retention_cleared", -time.Minute); err != nil {
|
||||
t.Fatalf("schedule deletion: %v", err)
|
||||
}
|
||||
|
||||
blockingChecker := fakeRetentionChecker{blocked: map[string]string{tenant.ID: "Aufbewahrungsfrist laeuft noch"}}
|
||||
lifecycle.WithRetentionChecker(blockingChecker)
|
||||
|
||||
if _, err := lifecycle.ProcessDueDeletions(ctx); err != nil {
|
||||
t.Fatalf("erster sweep (blockiert): %v", err)
|
||||
}
|
||||
blockedState, err := registry.GetBySlug(ctx, "lc_retention_cleared")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach erstem sweep: %v", err)
|
||||
}
|
||||
if blockedState.Status != StatusPendingDeletion {
|
||||
t.Fatalf("status nach erstem sweep = %q, want pending_deletion", blockedState.Status)
|
||||
}
|
||||
|
||||
// Sperre aufgehoben: naechster Checker blockiert nicht mehr (fakeRetentionChecker.blocked leer).
|
||||
lifecycle.WithRetentionChecker(fakeRetentionChecker{})
|
||||
|
||||
processed, err := lifecycle.ProcessDueDeletions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("zweiter sweep (unblockiert): %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("erwartet genau 1 verarbeitete loeschung im zweiten sweep, habe %d", processed)
|
||||
}
|
||||
|
||||
final, err := registry.GetBySlug(ctx, "lc_retention_cleared")
|
||||
if err != nil {
|
||||
t.Fatalf("get tenant nach zweitem sweep: %v", err)
|
||||
}
|
||||
if final.Status != StatusDeleted {
|
||||
t.Fatalf("status = %q, want deleted", final.Status)
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,6 @@ 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 {
|
||||
@@ -26,16 +22,6 @@ 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
|
||||
// RetentionBlockReason ist nur gesetzt, wenn eine faellige Loeschung wegen
|
||||
// GoBD-Aufbewahrungspflicht oder Legal Hold zurueckgehalten wurde (TEN-08,
|
||||
// siehe internal/tenant/retention.go) — fuer Admins einsehbar (Akzeptanzkriterium 2).
|
||||
RetentionBlockReason *string
|
||||
RetentionCheckedAt *time.Time
|
||||
}
|
||||
|
||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE tenants DROP COLUMN previous_status;
|
||||
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS feature_flags;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
|
||||
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
|
||||
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
|
||||
CREATE TABLE feature_flags (
|
||||
key TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
|
||||
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE tenants DROP COLUMN retention_block_reason;
|
||||
ALTER TABLE tenants DROP COLUMN retention_checked_at;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- TEN-08: Haelt fest, warum eine faellige Tenant-Loeschung zurueckgehalten wurde
|
||||
-- (GoBD-Aufbewahrungspflicht oder Legal Hold aus Archive RET-03), damit Admins
|
||||
-- den Grund einsehen koennen (Akzeptanzkriterium 2), ohne dass die Registry
|
||||
-- selbst modulspezifische Retention-Logik kennen muss — nur den Grund-Text.
|
||||
ALTER TABLE tenants ADD COLUMN retention_block_reason TEXT;
|
||||
ALTER TABLE tenants ADD COLUMN retention_checked_at TIMESTAMPTZ;
|
||||
@@ -13,7 +13,7 @@ ROLE="nexarch_test"
|
||||
|
||||
export PGPASSWORD="$PASS"
|
||||
|
||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants;"
|
||||
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
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user