88 lines
2.6 KiB
Go
88 lines
2.6 KiB
Go
// 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
|
|
}
|