Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 109 additions & 3 deletions components/backend/cmd/sync_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ type FlagsConfig struct {
}

// FlagsFromManifest converts a model manifest into FlagSpecs.
// Skips default models (global and per-provider) and unavailable models.
// Only creates flags for models that are available and feature-gated.
// Skips default models (global and per-provider) as defense-in-depth.
func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec {
// Build set of all default model IDs (global + per-provider)
defaults := map[string]bool{manifest.DefaultModel: true}
Expand All @@ -58,21 +59,93 @@ func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec {

var specs []FlagSpec
for _, model := range manifest.Models {
if !model.FeatureGated {
continue
}
if defaults[model.ID] {
continue
}
if !model.Available {
continue
}
specs = append(specs, FlagSpec{
Name: sanitizeLogString(fmt.Sprintf("model.%s.enabled", model.ID)),
Name: fmt.Sprintf("model.%s.enabled", model.ID),
Description: sanitizeLogString(fmt.Sprintf("Enable %s (%s) for users", model.Label, model.ID)),
Tags: []FlagTag{{Type: "scope", Value: "workspace"}},
})
}
return specs
}

// StaleFlagsFromManifest returns Unleash flag names that should be archived
// because the corresponding model is no longer feature-gated.
func StaleFlagsFromManifest(manifest *types.ModelManifest) []string {
var stale []string
for _, model := range manifest.Models {
if model.FeatureGated {
continue
}
stale = append(stale, fmt.Sprintf("model.%s.enabled", model.ID))
}
return stale
}

// CleanupStaleFlags archives Unleash flags that are no longer needed.
// Flags that don't exist are silently skipped (already archived or never created).
//
// Required env vars: UNLEASH_ADMIN_URL, UNLEASH_ADMIN_TOKEN
// Optional env var: UNLEASH_PROJECT (default: "default")
func CleanupStaleFlags(ctx context.Context, flagNames []string) error {
if len(flagNames) == 0 {
return nil
}

adminURL := strings.TrimSuffix(strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_URL")), "/")
adminToken := strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_TOKEN"))
project := strings.TrimSpace(os.Getenv("UNLEASH_PROJECT"))
if project == "" {
project = "default"
}

if adminURL == "" || adminToken == "" {
log.Printf("cleanup-flags: UNLEASH_ADMIN_URL or UNLEASH_ADMIN_TOKEN not set, skipping")
return nil
}

client := &http.Client{Timeout: 10 * time.Second}

var archived, skipped, errCount int
log.Printf("Cleaning up %d stale Unleash flag(s)...", len(flagNames))

for _, name := range flagNames {
exists, err := flagExists(ctx, client, adminURL, project, name, adminToken)
if err != nil {
log.Printf(" ERROR checking %s: %v", name, err)
errCount++
continue
}
if !exists {
skipped++
continue
}

if err := archiveFlag(ctx, client, adminURL, project, name, adminToken); err != nil {
log.Printf(" ERROR archiving %s: %v", name, err)
errCount++
continue
}
log.Printf(" %s: archived", name)
archived++
}

log.Printf("Cleanup summary: %d archived, %d not found, %d errors", archived, skipped, errCount)

if errCount > 0 {
return fmt.Errorf("%d errors occurred during cleanup", errCount)
}
return nil
}

// FlagsConfigPath returns the filesystem path to the generic flags config.
// Defaults to defaultFlagsConfig; override via FLAGS_CONFIG_PATH env var.
func FlagsConfigPath() string {
Expand Down Expand Up @@ -126,17 +199,31 @@ func SyncModelFlagsFromFile(manifestPath string) error {
return fmt.Errorf("parsing manifest: %w", err)
}

return SyncFlags(context.Background(), FlagsFromManifest(&manifest))
ctx := context.Background()
if err := SyncFlags(ctx, FlagsFromManifest(&manifest)); err != nil {
return err
}
return CleanupStaleFlags(ctx, StaleFlagsFromManifest(&manifest))
}

// SyncFlagsAsync runs SyncFlags in a background goroutine with retries.
// Intended for use at server startup — does not block the caller.
// Cancel the context to abort retries (e.g. on SIGTERM).
func SyncFlagsAsync(ctx context.Context, flags []FlagSpec) {
SyncAndCleanupAsync(ctx, flags, nil)
}

// SyncAndCleanupAsync runs SyncFlags and CleanupStaleFlags in a background
// goroutine with retries. After a successful sync, stale flags are archived.
// Cancel the context to abort retries (e.g. on SIGTERM).
func SyncAndCleanupAsync(ctx context.Context, flags []FlagSpec, staleFlags []string) {
go func() {
for attempt := 1; attempt <= maxRetries; attempt++ {
err := SyncFlags(ctx, flags)
if err == nil {
if cErr := CleanupStaleFlags(ctx, staleFlags); cErr != nil {
log.Printf("sync-flags: cleanup failed (non-fatal): %v", cErr)
}
return
}
log.Printf("sync-flags: attempt %d/%d failed: %v", attempt, maxRetries, err)
Expand Down Expand Up @@ -360,6 +447,25 @@ func createFlag(ctx context.Context, client *http.Client, adminURL, project, fla
}
}

func archiveFlag(ctx context.Context, client *http.Client, adminURL, project, flagName, token string) error {
reqURL := fmt.Sprintf("%s/api/admin/projects/%s/features/%s", adminURL, url.PathEscape(project), url.PathEscape(flagName))
resp, err := doRequest(ctx, client, "DELETE", reqURL, token, nil)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)

switch resp.StatusCode {
case http.StatusOK, http.StatusAccepted, http.StatusNoContent:
return nil
case http.StatusNotFound:
return nil // already gone
default:
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
}

func addFlagTag(ctx context.Context, client *http.Client, adminURL, flagName string, tag FlagTag, token string) error {
reqURL := fmt.Sprintf("%s/api/admin/features/%s/tags", adminURL, url.PathEscape(flagName))
body, err := json.Marshal(map[string]string{
Expand Down
154 changes: 145 additions & 9 deletions components/backend/cmd/sync_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,27 +63,29 @@ func TestParseManifestPath(t *testing.T) {

// --- FlagsFromManifest ---

func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) {
func TestFlagsFromManifest_SkipsDefaultUnavailableAndNonGated(t *testing.T) {
manifest := &types.ModelManifest{
DefaultModel: "claude-sonnet-4-5",
ProviderDefaults: map[string]string{
"anthropic": "claude-sonnet-4-5",
"google": "gemini-2.5-flash",
},
Models: []types.ModelEntry{
{ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Provider: "anthropic", Available: true},
{ID: "claude-opus-4-6", Label: "Opus 4.6", Provider: "anthropic", Available: true},
{ID: "claude-opus-4-1", Label: "Opus 4.1", Provider: "anthropic", Available: false},
{ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google", Available: true},
{ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google", Available: true},
{ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Provider: "anthropic", Available: true, FeatureGated: false},
{ID: "claude-opus-4-6", Label: "Opus 4.6", Provider: "anthropic", Available: true, FeatureGated: true},
{ID: "claude-opus-4-1", Label: "Opus 4.1", Provider: "anthropic", Available: false, FeatureGated: true},
{ID: "claude-haiku-4-5", Label: "Haiku 4.5", Provider: "anthropic", Available: true, FeatureGated: false},
{ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google", Available: true, FeatureGated: false},
{ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google", Available: true, FeatureGated: true},
},
}

flags := FlagsFromManifest(manifest)

// Should skip: claude-sonnet-4-5 (global default + anthropic default),
// gemini-2.5-flash (google default),
// claude-opus-4-1 (unavailable)
// Should skip: claude-sonnet-4-5 (default + not gated),
// claude-opus-4-1 (unavailable),
// claude-haiku-4-5 (not gated),
// gemini-2.5-flash (default + not gated)
// Should include: claude-opus-4-6, gemini-2.5-pro
if len(flags) != 2 {
t.Fatalf("expected 2 flags, got %d: %v", len(flags), flags)
Expand All @@ -102,6 +104,9 @@ func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) {
if names["model.claude-sonnet-4-5.enabled"] {
t.Error("global default should be skipped")
}
if names["model.claude-haiku-4-5.enabled"] {
t.Error("non-gated model should be skipped")
}
if names["model.gemini-2.5-flash.enabled"] {
t.Error("provider default should be skipped")
}
Expand All @@ -115,6 +120,137 @@ func TestFlagsFromManifest_EmptyManifest(t *testing.T) {
}
}

// --- StaleFlagsFromManifest ---

func TestStaleFlagsFromManifest_ReturnsNonGatedModels(t *testing.T) {
manifest := &types.ModelManifest{
DefaultModel: "claude-sonnet-4-5",
Models: []types.ModelEntry{
{ID: "claude-sonnet-4-5", Provider: "anthropic", Available: true, FeatureGated: false},
{ID: "claude-opus-4-6", Provider: "anthropic", Available: true, FeatureGated: true},
{ID: "claude-haiku-4-5", Provider: "anthropic", Available: true, FeatureGated: false},
{ID: "gemini-2.5-pro", Provider: "google", Available: true, FeatureGated: true},
},
}

stale := StaleFlagsFromManifest(manifest)

// Non-gated models: claude-sonnet-4-5, claude-haiku-4-5
if len(stale) != 2 {
t.Fatalf("expected 2 stale flags, got %d: %v", len(stale), stale)
}
names := map[string]bool{}
for _, s := range stale {
names[s] = true
}
if !names["model.claude-sonnet-4-5.enabled"] {
t.Error("expected model.claude-sonnet-4-5.enabled in stale list")
}
if !names["model.claude-haiku-4-5.enabled"] {
t.Error("expected model.claude-haiku-4-5.enabled in stale list")
}
if names["model.claude-opus-4-6.enabled"] {
t.Error("gated model should not be in stale list")
}
}

func TestStaleFlagsFromManifest_EmptyWhenAllGated(t *testing.T) {
manifest := &types.ModelManifest{
DefaultModel: "claude-sonnet-4-5",
Models: []types.ModelEntry{
{ID: "claude-sonnet-4-5", Provider: "anthropic", Available: true, FeatureGated: true},
},
}

stale := StaleFlagsFromManifest(manifest)
if len(stale) != 0 {
t.Errorf("expected 0 stale flags, got %d", len(stale))
}
}

// --- CleanupStaleFlags ---

func TestCleanupStaleFlags_ArchivesExistingFlags(t *testing.T) {
var deleteCalled bool
var deletePath string

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"model.claude-haiku-4-5.enabled"}`))
return
}
if r.Method == "DELETE" && strings.Contains(r.URL.Path, "/features/") {
deleteCalled = true
deletePath = r.URL.Path
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

t.Setenv("UNLEASH_ADMIN_URL", server.URL)
t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token")
t.Setenv("UNLEASH_PROJECT", "default")

err := CleanupStaleFlags(context.Background(), []string{"model.claude-haiku-4-5.enabled"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !deleteCalled {
t.Error("expected DELETE to be called")
}
if !strings.Contains(deletePath, "model.claude-haiku-4-5.enabled") {
t.Errorf("expected delete path to contain flag name, got %s", deletePath)
}
}

func TestCleanupStaleFlags_SkipsNonExistentFlags(t *testing.T) {
var deleteCalled bool

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") {
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method == "DELETE" {
deleteCalled = true
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

t.Setenv("UNLEASH_ADMIN_URL", server.URL)
t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token")

err := CleanupStaleFlags(context.Background(), []string{"model.nonexistent.enabled"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if deleteCalled {
t.Error("DELETE should not be called for non-existent flags")
}
}

func TestCleanupStaleFlags_SkipsWhenEnvNotSet(t *testing.T) {
t.Setenv("UNLEASH_ADMIN_URL", "")
t.Setenv("UNLEASH_ADMIN_TOKEN", "")

err := CleanupStaleFlags(context.Background(), []string{"model.test.enabled"})
if err != nil {
t.Errorf("expected nil error when env not set, got: %v", err)
}
}

func TestCleanupStaleFlags_EmptyList(t *testing.T) {
err := CleanupStaleFlags(context.Background(), nil)
if err != nil {
t.Errorf("expected nil error for empty list, got: %v", err)
}
}

// --- FlagsFromConfig ---

func TestFlagsFromConfig_LoadsValidFile(t *testing.T) {
Expand Down
Loading
Loading