Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2337841249 |
@@ -44,88 +44,3 @@ Keine Commits in dieser Session.
|
|||||||
Keine Änderungen ermittelbar.
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
## 2026-08-27 17:26 – 17:28 (1m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:28 – 17:29 (1m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:31 – 17:31 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:36 – 17:36 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:36 – 17:37 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- .gitignore | 2 ++
|
|
||||||
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
|
|
||||||
- go.mod | 5 +++++
|
|
||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
|
||||||
- internal/db/db.go | 11 +++++++++++
|
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
+3
-18
@@ -7,7 +7,6 @@ import (
|
|||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -16,30 +15,16 @@ func main() {
|
|||||||
log.Fatalf("config: %v", err)
|
log.Fatalf("config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
|
||||||
|
|
||||||
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("registry db: %v", err)
|
log.Fatalf("db: %v", err)
|
||||||
}
|
}
|
||||||
defer registryPool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("admin db: %v", err)
|
|
||||||
}
|
|
||||||
defer adminPool.Close()
|
|
||||||
|
|
||||||
registry := tenant.NewRegistry(registryPool)
|
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
|
||||||
|
|
||||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||||
|
|||||||
@@ -3,12 +3,3 @@ module gitea.perlbach24.de/scripte/nexarch
|
|||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.6.0
|
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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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=
|
|
||||||
@@ -10,15 +10,8 @@ import (
|
|||||||
// connection info, superadmin accounts) — see nexarch-state.json
|
// connection info, superadmin accounts) — see nexarch-state.json
|
||||||
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
|
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
|
|
||||||
RegistryDSN string
|
RegistryDSN string
|
||||||
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
|
|
||||||
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
|
|
||||||
AdminDSN string
|
|
||||||
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
|
||||||
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
|
||||||
TenantDSNTemplate string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -27,25 +20,10 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
|
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
|
|
||||||
if dsnTemplate == "" {
|
|
||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Config{
|
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
|
||||||
ListenAddr: addr,
|
|
||||||
RegistryDSN: dsn,
|
|
||||||
AdminDSN: adminDSN,
|
|
||||||
TenantDSNTemplate: dsnTemplate,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
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,45 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
|
||||||
// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding).
|
|
||||||
// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von
|
|
||||||
// TEN-01 und werden dort nachgezogen.
|
|
||||||
type Handler struct {
|
|
||||||
provisioner *Provisioner
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(p *Provisioner) *Handler {
|
|
||||||
return &Handler{provisioner: p}
|
|
||||||
}
|
|
||||||
|
|
||||||
type createTenantRequest struct {
|
|
||||||
Slug string `json:"slug"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createTenantRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name)
|
|
||||||
if err != nil {
|
|
||||||
if err == ErrInvalidSlug {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(t)
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
|
||||||
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
|
||||||
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
|
||||||
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
|
|
||||||
type Provisioner struct {
|
|
||||||
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
|
|
||||||
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
|
|
||||||
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
|
|
||||||
adminPool *pgxpool.Pool
|
|
||||||
registry *Registry
|
|
||||||
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
|
||||||
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
|
|
||||||
dsnTemplate string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
|
|
||||||
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
|
|
||||||
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
|
|
||||||
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
|
|
||||||
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
|
|
||||||
if err := ValidateSlug(slug); err != nil {
|
|
||||||
return Tenant{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
dbName := dbNameForSlug(slug)
|
|
||||||
|
|
||||||
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
|
||||||
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
|
||||||
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
|
||||||
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t := Tenant{
|
|
||||||
Slug: slug,
|
|
||||||
Name: name,
|
|
||||||
DBName: dbName,
|
|
||||||
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
|
|
||||||
Status: StatusActive,
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := p.registry.pool.Begin(ctx)
|
|
||||||
if err != nil {
|
|
||||||
p.rollbackDatabase(ctx, dbName)
|
|
||||||
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := p.registry.insertTx(ctx, tx, t)
|
|
||||||
if err != nil {
|
|
||||||
_ = tx.Rollback(ctx)
|
|
||||||
p.rollbackDatabase(ctx, dbName)
|
|
||||||
return Tenant{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
|
||||||
p.rollbackDatabase(ctx, dbName)
|
|
||||||
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
|
|
||||||
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
|
|
||||||
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
|
||||||
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
|
|
||||||
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
|
|
||||||
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
|
|
||||||
//
|
|
||||||
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
|
|
||||||
//
|
|
||||||
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
|
|
||||||
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
defer adminPool.Close()
|
|
||||||
|
|
||||||
registryPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("registry pool: %v", err)
|
|
||||||
}
|
|
||||||
defer registryPool.Close()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
|
||||||
registry := NewRegistry(registryPool)
|
|
||||||
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
|
|
||||||
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
|
|
||||||
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
|
|
||||||
})
|
|
||||||
|
|
||||||
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision acme: %v", err)
|
|
||||||
}
|
|
||||||
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("provision globex: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tenantA.DBName == tenantB.DBName {
|
|
||||||
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
|
|
||||||
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
|
|
||||||
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
|
|
||||||
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect tenant a: %v", err)
|
|
||||||
}
|
|
||||||
defer poolA.Close()
|
|
||||||
|
|
||||||
var globexVisible bool
|
|
||||||
err = poolA.QueryRow(ctx, `
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
|
|
||||||
)
|
|
||||||
`, tenantB.DBName).Scan(&globexVisible)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pruefung tenant-trennung: %v", err)
|
|
||||||
}
|
|
||||||
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
|
|
||||||
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
|
|
||||||
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
|
|
||||||
var currentDB string
|
|
||||||
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(¤tDB); err != nil {
|
|
||||||
t.Fatalf("current_database: %v", err)
|
|
||||||
}
|
|
||||||
if currentDB != tenantA.DBName {
|
|
||||||
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
|
|
||||||
}
|
|
||||||
if currentDB == tenantB.DBName {
|
|
||||||
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank.
|
|
||||||
// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) —
|
|
||||||
// niemals Geschaeftsdaten eines Mandanten.
|
|
||||||
type Registry struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRegistry(pool *pgxpool.Pool) *Registry {
|
|
||||||
return &Registry{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion,
|
|
||||||
// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann.
|
|
||||||
func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
|
|
||||||
row := tx.QueryRow(ctx, `
|
|
||||||
INSERT INTO tenants (slug, name, db_name, db_dsn, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING id, created_at
|
|
||||||
`, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
|
|
||||||
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
|
||||||
var t Tenant
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
|
||||||
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); err != nil {
|
|
||||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
|
||||||
rows, err := r.pool.Query(ctx, `
|
|
||||||
SELECT id, slug, name, db_name, db_dsn, status, created_at
|
|
||||||
FROM tenants ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tenants auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []Tenant
|
|
||||||
for rows.Next() {
|
|
||||||
var t Tenant
|
|
||||||
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("tenant lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, t)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// Package tenant implements Core TEN-01: die Control-Plane-Registry und die
|
|
||||||
// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C).
|
|
||||||
package tenant
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Status string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusActive Status = "active"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Tenant struct {
|
|
||||||
ID string
|
|
||||||
Slug string
|
|
||||||
Name string
|
|
||||||
DBName string
|
|
||||||
DBDSN string
|
|
||||||
Status Status
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
|
||||||
// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird.
|
|
||||||
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`)
|
|
||||||
|
|
||||||
var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)")
|
|
||||||
|
|
||||||
func ValidateSlug(slug string) error {
|
|
||||||
if !slugPattern.MatchString(slug) {
|
|
||||||
return ErrInvalidSlug
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func dbNameForSlug(slug string) string {
|
|
||||||
return "tenant_" + slug
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package tenant
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateSlug(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
slug string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"acme", false},
|
|
||||||
{"acme_gmbh", false},
|
|
||||||
{"a1", false},
|
|
||||||
{"", true},
|
|
||||||
{"a", true},
|
|
||||||
{"1acme", true},
|
|
||||||
{"Acme", true},
|
|
||||||
{"acme-gmbh", true},
|
|
||||||
{"acme;drop table tenants", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
err := ValidateSlug(c.slug)
|
|
||||||
if (err != nil) != c.wantErr {
|
|
||||||
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDBNameForSlug(t *testing.T) {
|
|
||||||
got := dbNameForSlug("acme")
|
|
||||||
want := "tenant_acme"
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("dbNameForSlug() = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS tenants;
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Control-plane registry: tenant list + connection info (Modell C).
|
||||||
|
-- Core TEN-01 (siehe core-kanban).
|
||||||
|
CREATE TABLE tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
db_dsn TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- Control-plane registry: Tenant-Liste + Verbindungsinformationen (Modell C).
|
|
||||||
-- Enthaelt AUSSCHLIESSLICH Tenant-Metadaten, keine Geschaeftsdaten eines Mandanten.
|
|
||||||
-- Core TEN-01 (siehe core-kanban/tickets/TEN-01.md).
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
CREATE TABLE 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()
|
|
||||||
);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS feature_flags;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
-- 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,23 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
|
||||||
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
|
||||||
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
|
||||||
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
|
||||||
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
|
||||||
ROLE="nexarch_test"
|
|
||||||
|
|
||||||
export PGPASSWORD="$PASS"
|
|
||||||
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
|
|
||||||
|
|
||||||
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
|
||||||
for db in $dbs; do
|
|
||||||
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
|
||||||
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
|
||||||
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
|
||||||
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
|
||||||
#
|
|
||||||
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
|
|
||||||
|
|
||||||
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
|
|
||||||
|
|
||||||
echo "== go build =="
|
|
||||||
go build ./...
|
|
||||||
|
|
||||||
echo "== go vet =="
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
echo "== go test (-p 1) =="
|
|
||||||
go test ./... -p 1 -count=1
|
|
||||||
Reference in New Issue
Block a user