diff --git a/components/backend/cmd/sync_model_flags.go b/components/backend/cmd/sync_flags.go similarity index 57% rename from components/backend/cmd/sync_model_flags.go rename to components/backend/cmd/sync_flags.go index 18eea8539..e96e83edf 100644 --- a/components/backend/cmd/sync_model_flags.go +++ b/components/backend/cmd/sync_flags.go @@ -19,13 +19,94 @@ import ( ) const ( - defaultManifestPath = "/config/models.json" + defaultManifestPath = "/config/models/models.json" + defaultFlagsConfig = "/config/flags/flags.json" maxRetries = 3 retryDelay = 10 * time.Second ) var errConflict = errors.New("flag already exists (conflict)") +// FlagTag represents a tag to attach to an Unleash feature flag. +type FlagTag struct { + Type string `json:"type"` + Value string `json:"value"` +} + +// FlagSpec describes a feature flag to sync to Unleash. +// All flags are created disabled with type "release" and a flexibleRollout +// strategy at 0%. Tags are optional and per-flag. +type FlagSpec struct { + Name string `json:"name"` + Description string `json:"description"` + Tags []FlagTag `json:"tags,omitempty"` +} + +// FlagsConfig is the JSON structure for the generic flags config file. +type FlagsConfig struct { + Flags []FlagSpec `json:"flags"` +} + +// FlagsFromManifest converts a model manifest into FlagSpecs. +// Skips the default model and unavailable models. +func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec { + var specs []FlagSpec + for _, model := range manifest.Models { + if model.ID == manifest.DefaultModel { + continue + } + if !model.Available { + continue + } + specs = append(specs, FlagSpec{ + Name: sanitizeLogString(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 +} + +// FlagsConfigPath returns the filesystem path to the generic flags config. +// Defaults to defaultFlagsConfig; override via FLAGS_CONFIG_PATH env var. +func FlagsConfigPath() string { + if p := os.Getenv("FLAGS_CONFIG_PATH"); p != "" { + return p + } + return defaultFlagsConfig +} + +// FlagsFromConfig loads generic flag definitions from a JSON file. +// Returns nil if the file does not exist (flags config is optional). +func FlagsFromConfig(path string) ([]FlagSpec, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("reading flags config %s: %w", path, err) + } + + var cfg FlagsConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing flags config: %w", err) + } + + // Sanitize flag names and descriptions to prevent log injection. + // Model-derived names are constrained (model..enabled) but + // config-file names are user-defined and unconstrained. + for i := range cfg.Flags { + cfg.Flags[i].Name = sanitizeLogString(cfg.Flags[i].Name) + cfg.Flags[i].Description = sanitizeLogString(cfg.Flags[i].Description) + for j := range cfg.Flags[i].Tags { + cfg.Flags[i].Tags[j].Type = sanitizeLogString(cfg.Flags[i].Tags[j].Type) + cfg.Flags[i].Tags[j].Value = sanitizeLogString(cfg.Flags[i].Tags[j].Value) + } + } + + return cfg.Flags, nil +} + // SyncModelFlagsFromFile reads a model manifest from disk and syncs flags. // Used by the sync-model-flags subcommand. func SyncModelFlagsFromFile(manifestPath string) error { @@ -39,40 +120,39 @@ func SyncModelFlagsFromFile(manifestPath string) error { return fmt.Errorf("parsing manifest: %w", err) } - return SyncModelFlags(context.Background(), &manifest) + return SyncFlags(context.Background(), FlagsFromManifest(&manifest)) } -// SyncModelFlagsAsync runs SyncModelFlags in a background goroutine with -// retries. Intended for use at server startup — does not block the caller. +// 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 SyncModelFlagsAsync(ctx context.Context, manifest *types.ModelManifest) { +func SyncFlagsAsync(ctx context.Context, flags []FlagSpec) { go func() { for attempt := 1; attempt <= maxRetries; attempt++ { - err := SyncModelFlags(ctx, manifest) + err := SyncFlags(ctx, flags) if err == nil { return } - log.Printf("sync-model-flags: attempt %d/%d failed: %v", attempt, maxRetries, err) + log.Printf("sync-flags: attempt %d/%d failed: %v", attempt, maxRetries, err) if attempt < maxRetries { select { case <-ctx.Done(): - log.Printf("sync-model-flags: cancelled, stopping retries") + log.Printf("sync-flags: cancelled, stopping retries") return case <-time.After(retryDelay): } } } - log.Printf("sync-model-flags: all %d attempts failed, giving up", maxRetries) + log.Printf("sync-flags: all %d attempts failed, giving up", maxRetries) }() } -// SyncModelFlags ensures every model in the manifest has a corresponding -// Unleash feature flag. Flags are created disabled with type "release" -// and tagged scope:workspace so they appear in the admin UI. +// SyncFlags ensures every FlagSpec has a corresponding Unleash feature flag. +// Flags are created disabled with type "release" and a flexibleRollout strategy. // // Required env vars: UNLEASH_ADMIN_URL, UNLEASH_ADMIN_TOKEN // Optional env var: UNLEASH_PROJECT (default: "default") -func SyncModelFlags(ctx context.Context, manifest *types.ModelManifest) error { +func SyncFlags(ctx context.Context, flags []FlagSpec) error { 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")) @@ -86,73 +166,63 @@ func SyncModelFlags(ctx context.Context, manifest *types.ModelManifest) error { } if adminURL == "" || adminToken == "" { - log.Printf("sync-model-flags: UNLEASH_ADMIN_URL or UNLEASH_ADMIN_TOKEN not set, skipping") + log.Printf("sync-flags: UNLEASH_ADMIN_URL or UNLEASH_ADMIN_TOKEN not set, skipping") return nil } client := &http.Client{Timeout: 10 * time.Second} - // Ensure the "scope" tag type exists before creating flags - if err := ensureTagType(ctx, client, adminURL, "scope", "Controls flag visibility scope", adminToken); err != nil { - return fmt.Errorf("ensuring scope tag type: %w", err) - } - - var created, skipped, excluded, errCount int - log.Printf("Syncing Unleash flags for %d models...", len(manifest.Models)) - - for _, model := range manifest.Models { - if model.ID == manifest.DefaultModel { - log.Printf(" %s: default model, no flag needed", model.ID) - excluded++ - continue - } - - if !model.Available { - log.Printf(" %s: not available, skipping flag creation", model.ID) - excluded++ - continue + // Ensure all required tag types exist + tagTypes := collectTagTypes(flags) + for _, tt := range tagTypes { + if err := ensureTagType(ctx, client, adminURL, tt, fmt.Sprintf("Tag type: %s", tt), adminToken); err != nil { + return fmt.Errorf("ensuring tag type %q: %w", tt, err) } + } - flagName := fmt.Sprintf("model.%s.enabled", model.ID) + var created, skipped, errCount int + log.Printf("Syncing %d Unleash flag(s)...", len(flags)) - exists, err := flagExists(ctx, client, adminURL, project, flagName, adminToken) + for _, flag := range flags { + exists, err := flagExists(ctx, client, adminURL, project, flag.Name, adminToken) if err != nil { - log.Printf(" ERROR checking %s: %v", flagName, err) + log.Printf(" ERROR checking %s: %v", flag.Name, err) errCount++ continue } if exists { - log.Printf(" %s: already exists, skipping", flagName) + log.Printf(" %s: already exists, skipping", flag.Name) skipped++ continue } - description := fmt.Sprintf("Enable %s (%s) for users", model.Label, model.ID) - if err := createFlag(ctx, client, adminURL, project, flagName, description, adminToken); err != nil { + if err := createFlag(ctx, client, adminURL, project, flag.Name, flag.Description, adminToken); err != nil { if errors.Is(err, errConflict) { - log.Printf(" %s: created by another instance, skipping", flagName) + log.Printf(" %s: created by another instance, skipping", flag.Name) skipped++ continue } - log.Printf(" ERROR creating %s: %v", flagName, err) + log.Printf(" ERROR creating %s: %v", flag.Name, err) errCount++ continue } - if err := addTag(ctx, client, adminURL, flagName, adminToken); err != nil { - log.Printf(" WARNING: created %s but failed to add tag: %v", flagName, err) + for _, tag := range flag.Tags { + if err := addFlagTag(ctx, client, adminURL, flag.Name, tag, adminToken); err != nil { + log.Printf(" WARNING: created %s but failed to add tag %s:%s: %v", flag.Name, tag.Type, tag.Value, err) + } } - if err := addRolloutStrategy(ctx, client, adminURL, project, environment, flagName, adminToken); err != nil { - log.Printf(" WARNING: created %s but failed to add rollout strategy: %v", flagName, err) + if err := addRolloutStrategy(ctx, client, adminURL, project, environment, flag.Name, adminToken); err != nil { + log.Printf(" WARNING: created %s but failed to add rollout strategy: %v", flag.Name, err) } - log.Printf(" %s: created (disabled, 0%% rollout)", flagName) + log.Printf(" %s: created (disabled, 0%% rollout)", flag.Name) created++ } - log.Printf("Summary: %d created, %d skipped, %d excluded, %d errors", created, skipped, excluded, errCount) + log.Printf("Summary: %d created, %d skipped, %d errors", created, skipped, errCount) if errCount > 0 { return fmt.Errorf("%d errors occurred during sync", errCount) @@ -160,6 +230,27 @@ func SyncModelFlags(ctx context.Context, manifest *types.ModelManifest) error { return nil } +// sanitizeLogString strips newlines and carriage returns from strings +// that will be interpolated into log messages, preventing log injection. +func sanitizeLogString(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", "") +} + +// collectTagTypes returns the unique set of tag types across all flags. +func collectTagTypes(flags []FlagSpec) []string { + seen := map[string]bool{} + var result []string + for _, f := range flags { + for _, t := range f.Tags { + if !seen[t.Type] { + seen[t.Type] = true + result = append(result, t.Type) + } + } + } + return result +} + // ParseManifestPath extracts --manifest-path from args, returning the path // and whether it was found. Falls back to defaultManifestPath. func ParseManifestPath(args []string) string { @@ -175,7 +266,6 @@ func ParseManifestPath(args []string) string { } func ensureTagType(ctx context.Context, client *http.Client, adminURL, name, description, token string) error { - // Check if tag type exists reqURL := fmt.Sprintf("%s/api/admin/tag-types/%s", adminURL, url.PathEscape(name)) resp, err := doRequest(ctx, client, "GET", reqURL, token, nil) if err != nil { @@ -189,7 +279,6 @@ func ensureTagType(ctx context.Context, client *http.Client, adminURL, name, des return nil } - // Create it createURL := fmt.Sprintf("%s/api/admin/tag-types", adminURL) body, err := json.Marshal(map[string]string{ "name": name, @@ -265,11 +354,11 @@ func createFlag(ctx context.Context, client *http.Client, adminURL, project, fla } } -func addTag(ctx context.Context, client *http.Client, adminURL, flagName, token string) error { +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{ - "type": "scope", - "value": "workspace", + "type": tag.Type, + "value": tag.Value, }) if err != nil { return fmt.Errorf("marshaling tag request: %w", err) @@ -316,8 +405,8 @@ func addRolloutStrategy(ctx context.Context, client *http.Client, adminURL, proj return nil } -func doRequest(ctx context.Context, client *http.Client, method, url, token string, body io.Reader) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, method, url, body) +func doRequest(ctx context.Context, client *http.Client, method, reqURL, token string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) if err != nil { return nil, err } diff --git a/components/backend/cmd/sync_model_flags_test.go b/components/backend/cmd/sync_flags_test.go similarity index 56% rename from components/backend/cmd/sync_model_flags_test.go rename to components/backend/cmd/sync_flags_test.go index 10e187d2e..fa92238d1 100644 --- a/components/backend/cmd/sync_model_flags_test.go +++ b/components/backend/cmd/sync_flags_test.go @@ -61,121 +61,149 @@ func TestParseManifestPath(t *testing.T) { } } -func TestSyncModelFlags_SkipsWhenEnvNotSet(t *testing.T) { - // Ensure env vars are not set - t.Setenv("UNLEASH_ADMIN_URL", "") - t.Setenv("UNLEASH_ADMIN_TOKEN", "") +// --- FlagsFromManifest --- +func TestFlagsFromManifest_SkipsDefaultAndUnavailable(t *testing.T) { manifest := &types.ModelManifest{ DefaultModel: "claude-sonnet-4-5", Models: []types.ModelEntry{ {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Available: true}, {ID: "claude-opus-4-6", Label: "Opus 4.6", Available: true}, + {ID: "claude-opus-4-1", Label: "Opus 4.1", Available: false}, }, } - err := SyncModelFlags(context.Background(), manifest) - if err != nil { - t.Errorf("expected nil error when env not set, got: %v", err) + flags := FlagsFromManifest(manifest) + if len(flags) != 1 { + t.Fatalf("expected 1 flag, got %d: %v", len(flags), flags) + } + if flags[0].Name != "model.claude-opus-4-6.enabled" { + t.Errorf("expected model.claude-opus-4-6.enabled, got %s", flags[0].Name) + } + if len(flags[0].Tags) != 1 || flags[0].Tags[0].Type != "scope" || flags[0].Tags[0].Value != "workspace" { + t.Errorf("expected scope:workspace tag, got %v", flags[0].Tags) } } -func TestSyncModelFlags_ExcludesDefaultModel(t *testing.T) { - var flagChecks []string - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Tag type check - if strings.Contains(r.URL.Path, "/tag-types/") { - w.WriteHeader(http.StatusOK) - return - } - // Feature existence check - if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") { - parts := strings.Split(r.URL.Path, "/features/") - if len(parts) > 1 { - flagChecks = append(flagChecks, parts[1]) - } - w.WriteHeader(http.StatusOK) // flag already exists - return - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() +func TestFlagsFromManifest_EmptyManifest(t *testing.T) { + manifest := &types.ModelManifest{DefaultModel: "x", Models: nil} + flags := FlagsFromManifest(manifest) + if len(flags) != 0 { + t.Errorf("expected 0 flags, got %d", len(flags)) + } +} - t.Setenv("UNLEASH_ADMIN_URL", server.URL) - t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token") - t.Setenv("UNLEASH_PROJECT", "default") +// --- FlagsFromConfig --- - manifest := &types.ModelManifest{ - DefaultModel: "claude-sonnet-4-5", - Models: []types.ModelEntry{ - {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Available: true}, - {ID: "claude-opus-4-6", Label: "Opus 4.6", Available: true}, +func TestFlagsFromConfig_LoadsValidFile(t *testing.T) { + config := FlagsConfig{ + Flags: []FlagSpec{ + {Name: "framework.langgraph.enabled", Description: "Enable LangGraph"}, + {Name: "feature.dark-mode", Description: "Dark mode", Tags: []FlagTag{{Type: "scope", Value: "workspace"}}}, }, } + data, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } - err := SyncModelFlags(context.Background(), manifest) + path := filepath.Join(t.TempDir(), "flags.json") + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + flags, err := FlagsFromConfig(path) if err != nil { t.Fatalf("unexpected error: %v", err) } + if len(flags) != 2 { + t.Fatalf("expected 2 flags, got %d", len(flags)) + } + if flags[0].Name != "framework.langgraph.enabled" { + t.Errorf("expected framework.langgraph.enabled, got %s", flags[0].Name) + } +} - // Only opus should have been checked — sonnet is the default - if len(flagChecks) != 1 { - t.Fatalf("expected 1 flag check, got %d: %v", len(flagChecks), flagChecks) +func TestFlagsFromConfig_MissingFileReturnsNil(t *testing.T) { + flags, err := FlagsFromConfig("/nonexistent/flags.json") + if err != nil { + t.Fatalf("missing file should not error, got: %v", err) } - if flagChecks[0] != "model.claude-opus-4-6.enabled" { - t.Errorf("expected flag check for model.claude-opus-4-6.enabled, got %s", flagChecks[0]) + if flags != nil { + t.Errorf("expected nil, got %v", flags) } } -func TestSyncModelFlags_ExcludesUnavailableModels(t *testing.T) { - var flagChecks []string +func TestFlagsFromConfig_SanitizesNewlines(t *testing.T) { + raw := `{ + "flags": [{ + "name": "flag.with\nnewline", + "description": "desc\rwith\r\nCRLF", + "tags": [{"type": "scope\n", "value": "work\rspace"}] + }] + }` + + path := filepath.Join(t.TempDir(), "flags.json") + if err := os.WriteFile(path, []byte(raw), 0644); err != nil { + t.Fatal(err) + } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/tag-types/") { - w.WriteHeader(http.StatusOK) - return - } - if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") { - parts := strings.Split(r.URL.Path, "/features/") - if len(parts) > 1 { - flagChecks = append(flagChecks, parts[1]) - } - w.WriteHeader(http.StatusOK) - return - } - w.WriteHeader(http.StatusOK) - })) - defer server.Close() + flags, err := FlagsFromConfig(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(flags) != 1 { + t.Fatalf("expected 1 flag, got %d", len(flags)) + } - t.Setenv("UNLEASH_ADMIN_URL", server.URL) - t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token") - t.Setenv("UNLEASH_PROJECT", "default") + f := flags[0] + if strings.ContainsAny(f.Name, "\n\r") { + t.Errorf("name should not contain newlines, got %q", f.Name) + } + if strings.ContainsAny(f.Description, "\n\r") { + t.Errorf("description should not contain newlines, got %q", f.Description) + } + if strings.ContainsAny(f.Tags[0].Type, "\n\r") { + t.Errorf("tag type should not contain newlines, got %q", f.Tags[0].Type) + } + if strings.ContainsAny(f.Tags[0].Value, "\n\r") { + t.Errorf("tag value should not contain newlines, got %q", f.Tags[0].Value) + } +} - manifest := &types.ModelManifest{ +func TestFlagsFromConfig_InvalidJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "flags.json") + if err := os.WriteFile(path, []byte("{bad"), 0644); err != nil { + t.Fatal(err) + } + + _, err := FlagsFromConfig(path) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +// --- SyncFlags --- + +func TestSyncFlags_SkipsWhenEnvNotSet(t *testing.T) { + t.Setenv("UNLEASH_ADMIN_URL", "") + t.Setenv("UNLEASH_ADMIN_TOKEN", "") + + flags := FlagsFromManifest(&types.ModelManifest{ DefaultModel: "claude-sonnet-4-5", Models: []types.ModelEntry{ {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Available: true}, {ID: "claude-opus-4-6", Label: "Opus 4.6", Available: true}, - {ID: "claude-opus-4-1", Label: "Opus 4.1", Available: false}, }, - } + }) - err := SyncModelFlags(context.Background(), manifest) + err := SyncFlags(context.Background(), flags) if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Only opus-4-6 should be checked (sonnet is default, opus-4-1 is unavailable) - if len(flagChecks) != 1 { - t.Fatalf("expected 1 flag check, got %d: %v", len(flagChecks), flagChecks) - } - if flagChecks[0] != "model.claude-opus-4-6.enabled" { - t.Errorf("expected flag check for model.claude-opus-4-6.enabled, got %s", flagChecks[0]) + t.Errorf("expected nil error when env not set, got: %v", err) } } -func TestSyncModelFlags_CreatesNewFlag(t *testing.T) { +func TestSyncFlags_CreatesNewFlag(t *testing.T) { var ( createCalled bool tagCalled bool @@ -188,25 +216,21 @@ func TestSyncModelFlags_CreatesNewFlag(t *testing.T) { w.WriteHeader(http.StatusOK) return } - // Feature existence check — return 404 so it gets created if r.Method == "GET" && strings.Contains(r.URL.Path, "/features/") { w.WriteHeader(http.StatusNotFound) return } - // Feature creation if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/features") { createCalled = true json.NewDecoder(r.Body).Decode(&createBody) w.WriteHeader(http.StatusCreated) return } - // Tag addition if r.Method == "POST" && strings.Contains(r.URL.Path, "/tags") { tagCalled = true w.WriteHeader(http.StatusCreated) return } - // Strategy addition if r.Method == "POST" && strings.Contains(r.URL.Path, "/strategies") { strategyCalled = true w.WriteHeader(http.StatusCreated) @@ -221,15 +245,15 @@ func TestSyncModelFlags_CreatesNewFlag(t *testing.T) { t.Setenv("UNLEASH_PROJECT", "default") t.Setenv("UNLEASH_ENVIRONMENT", "development") - manifest := &types.ModelManifest{ - DefaultModel: "claude-sonnet-4-5", - Models: []types.ModelEntry{ - {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Available: true}, - {ID: "claude-opus-4-6", Label: "Opus 4.6", Available: true}, + flags := []FlagSpec{ + { + Name: "model.claude-opus-4-6.enabled", + Description: "Enable Opus 4.6", + Tags: []FlagTag{{Type: "scope", Value: "workspace"}}, }, } - err := SyncModelFlags(context.Background(), manifest) + err := SyncFlags(context.Background(), flags) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -244,7 +268,6 @@ func TestSyncModelFlags_CreatesNewFlag(t *testing.T) { t.Error("expected strategy API call") } - // Verify the flag was created with correct properties if createBody["name"] != "model.claude-opus-4-6.enabled" { t.Errorf("expected flag name model.claude-opus-4-6.enabled, got %v", createBody["name"]) } @@ -256,7 +279,49 @@ func TestSyncModelFlags_CreatesNewFlag(t *testing.T) { } } -func TestSyncModelFlags_HandlesConflict(t *testing.T) { +func TestSyncFlags_NoTagsSkipsTagCall(t *testing.T) { + tagCalled := false + + 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 == "POST" && strings.HasSuffix(r.URL.Path, "/features") { + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == "POST" && strings.Contains(r.URL.Path, "/tags") { + tagCalled = true + w.WriteHeader(http.StatusCreated) + return + } + if r.Method == "POST" && strings.Contains(r.URL.Path, "/strategies") { + w.WriteHeader(http.StatusCreated) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv("UNLEASH_ADMIN_URL", server.URL) + t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token") + + flags := []FlagSpec{ + {Name: "framework.xyz.enabled", Description: "XYZ framework"}, + } + + err := SyncFlags(context.Background(), flags) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tagCalled { + t.Error("tag API should not be called for flags with no tags") + } +} + +func TestSyncFlags_HandlesConflict(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/tag-types/") { w.WriteHeader(http.StatusOK) @@ -267,7 +332,7 @@ func TestSyncModelFlags_HandlesConflict(t *testing.T) { return } if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/features") { - w.WriteHeader(http.StatusConflict) // another instance created it + w.WriteHeader(http.StatusConflict) return } w.WriteHeader(http.StatusOK) @@ -277,21 +342,17 @@ func TestSyncModelFlags_HandlesConflict(t *testing.T) { t.Setenv("UNLEASH_ADMIN_URL", server.URL) t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token") - manifest := &types.ModelManifest{ - DefaultModel: "claude-sonnet-4-5", - Models: []types.ModelEntry{ - {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Available: true}, - {ID: "claude-opus-4-6", Label: "Opus 4.6", Available: true}, - }, + flags := []FlagSpec{ + {Name: "test.flag", Description: "test", Tags: []FlagTag{{Type: "scope", Value: "workspace"}}}, } - err := SyncModelFlags(context.Background(), manifest) + err := SyncFlags(context.Background(), flags) if err != nil { t.Errorf("conflict should not cause error, got: %v", err) } } -func TestSyncModelFlags_ReturnsErrorOnCreateFailure(t *testing.T) { +func TestSyncFlags_ReturnsErrorOnCreateFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/tag-types/") { w.WriteHeader(http.StatusOK) @@ -313,15 +374,11 @@ func TestSyncModelFlags_ReturnsErrorOnCreateFailure(t *testing.T) { t.Setenv("UNLEASH_ADMIN_URL", server.URL) t.Setenv("UNLEASH_ADMIN_TOKEN", "test-token") - manifest := &types.ModelManifest{ - DefaultModel: "claude-sonnet-4-5", - Models: []types.ModelEntry{ - {ID: "claude-sonnet-4-5", Label: "Sonnet 4.5", Available: true}, - {ID: "claude-opus-4-6", Label: "Opus 4.6", Available: true}, - }, + flags := []FlagSpec{ + {Name: "test.flag", Description: "test", Tags: []FlagTag{{Type: "scope", Value: "workspace"}}}, } - err := SyncModelFlags(context.Background(), manifest) + err := SyncFlags(context.Background(), flags) if err == nil { t.Error("expected error on create failure") } @@ -331,7 +388,6 @@ func TestSyncModelFlags_ReturnsErrorOnCreateFailure(t *testing.T) { } func TestSyncModelFlagsFromFile(t *testing.T) { - // Ensure Unleash env vars are not set so sync is a no-op t.Setenv("UNLEASH_ADMIN_URL", "") t.Setenv("UNLEASH_ADMIN_TOKEN", "") @@ -382,3 +438,29 @@ func TestSyncModelFlagsFromFile_InvalidJSON(t *testing.T) { t.Errorf("expected parsing error, got: %v", err) } } + +// --- collectTagTypes --- + +func TestCollectTagTypes(t *testing.T) { + flags := []FlagSpec{ + {Name: "a", Tags: []FlagTag{{Type: "scope", Value: "workspace"}}}, + {Name: "b", Tags: []FlagTag{{Type: "scope", Value: "global"}, {Type: "env", Value: "prod"}}}, + {Name: "c"}, + } + + tagTypes := collectTagTypes(flags) + if len(tagTypes) != 2 { + t.Fatalf("expected 2 unique tag types, got %d: %v", len(tagTypes), tagTypes) + } + + found := map[string]bool{} + for _, tt := range tagTypes { + found[tt] = true + } + if !found["scope"] { + t.Error("expected 'scope' in tag types") + } + if !found["env"] { + t.Error("expected 'env' in tag types") + } +} diff --git a/components/backend/handlers/models.go b/components/backend/handlers/models.go index 3f437439f..4b90b9765 100644 --- a/components/backend/handlers/models.go +++ b/components/backend/handlers/models.go @@ -23,7 +23,7 @@ var cachedManifest atomic.Pointer[types.ModelManifest] const ( // DefaultManifestPath is where the ambient-models ConfigMap is mounted. - DefaultManifestPath = "/config/models.json" + DefaultManifestPath = "/config/models/models.json" ) // ManifestPath returns the filesystem path to the models manifest. diff --git a/components/backend/main.go b/components/backend/main.go index ad9afa76c..092a458df 100644 --- a/components/backend/main.go +++ b/components/backend/main.go @@ -80,16 +80,28 @@ func main() { // Optional: Unleash feature flags (when UNLEASH_URL and UNLEASH_CLIENT_KEY are set) featureflags.Init() - // Sync model flags to Unleash in the background (best-effort, non-blocking). - // Uses handlers.LoadManifest which reads the mounted models.json file. + // Sync feature flags to Unleash in the background (best-effort, non-blocking). + // Collects flags from two sources: + // 1. Model manifest (models.json) — model-specific flags with scope:workspace tag + // 2. Generic flags config (flags.json) — arbitrary flags with custom tags // The context is cancelled on SIGTERM/SIGINT so in-flight retries abort // during graceful shutdown rather than delaying termination. syncCtx, syncCancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer syncCancel() + + var allFlags []cmd.FlagSpec if manifest, err := handlers.LoadManifest(handlers.ManifestPath()); err != nil { - log.Printf("WARNING: cannot sync model flags: %v", err) + log.Printf("WARNING: cannot load model manifest for flag sync: %v", err) + } else { + allFlags = append(allFlags, cmd.FlagsFromManifest(manifest)...) + } + if extraFlags, err := cmd.FlagsFromConfig(cmd.FlagsConfigPath()); err != nil { + log.Printf("WARNING: cannot load flags config: %v", err) } else { - cmd.SyncModelFlagsAsync(syncCtx, manifest) + allFlags = append(allFlags, extraFlags...) + } + if len(allFlags) > 0 { + cmd.SyncFlagsAsync(syncCtx, allFlags) } // Initialize git package diff --git a/components/manifests/base/backend-deployment.yaml b/components/manifests/base/backend-deployment.yaml index b864380a2..ea76a3bb3 100644 --- a/components/manifests/base/backend-deployment.yaml +++ b/components/manifests/base/backend-deployment.yaml @@ -194,7 +194,11 @@ spec: readOnly: true # Model manifest (mounted ConfigMap — kubelet auto-syncs changes) - name: model-manifest - mountPath: /config + mountPath: /config/models + readOnly: true + # Feature flags config (optional — kubelet auto-syncs changes) + - name: flags-config + mountPath: /config/flags readOnly: true volumes: - name: backend-state @@ -212,6 +216,11 @@ spec: configMap: name: ambient-models optional: true # Don't fail if ConfigMap not yet created + # Feature flags config (generic flags synced to Unleash on startup) + - name: flags-config + configMap: + name: ambient-flags + optional: true # Don't fail if no generic flags defined --- apiVersion: v1 diff --git a/components/manifests/base/flags.json b/components/manifests/base/flags.json new file mode 100644 index 000000000..b033cd873 --- /dev/null +++ b/components/manifests/base/flags.json @@ -0,0 +1,3 @@ +{ + "flags": [] +} diff --git a/components/manifests/base/flags.json.example b/components/manifests/base/flags.json.example new file mode 100644 index 000000000..4a957997b --- /dev/null +++ b/components/manifests/base/flags.json.example @@ -0,0 +1,19 @@ +{ + "flags": [ + { + "name": "framework.example.enabled", + "description": "Enable Example framework - available in UI", + "tags": [ + { + "type": "scope", + "value": "workspace" + } + ] + }, + { + "name": "framework.example-not.enabled", + "description": "Enable Example framework - not available in UI", + "tags": [] + } + ] +} diff --git a/components/manifests/base/kustomization.yaml b/components/manifests/base/kustomization.yaml index 6e93378fe..c8891c784 100644 --- a/components/manifests/base/kustomization.yaml +++ b/components/manifests/base/kustomization.yaml @@ -22,12 +22,18 @@ resources: - unleash-deployment.yaml # Model manifest ConfigMap (single source of truth for available models) +# Feature flags ConfigMap (generic flags synced to Unleash on startup) configMapGenerator: - name: ambient-models files: - models.json options: disableNameSuffixHash: true +- name: ambient-flags + files: + - flags.json + options: + disableNameSuffixHash: true # Default images (can be overridden by overlays) images: diff --git a/components/manifests/base/operator-deployment.yaml b/components/manifests/base/operator-deployment.yaml index ed13b6ecd..bc22d05d7 100644 --- a/components/manifests/base/operator-deployment.yaml +++ b/components/manifests/base/operator-deployment.yaml @@ -125,7 +125,7 @@ spec: volumeMounts: # Model manifest (mounted ConfigMap — kubelet auto-syncs changes) - name: model-manifest - mountPath: /config + mountPath: /config/models readOnly: true resources: requests: diff --git a/components/operator/internal/models/models.go b/components/operator/internal/models/models.go index c2d54d820..86ccf65b2 100644 --- a/components/operator/internal/models/models.go +++ b/components/operator/internal/models/models.go @@ -10,7 +10,7 @@ import ( const ( // DefaultManifestPath is where the ambient-models ConfigMap is mounted. - DefaultManifestPath = "/config/models.json" + DefaultManifestPath = "/config/models/models.json" ) // ManifestPath returns the filesystem path to the models manifest.