Skip to content

Commit 9f67a0a

Browse files
maskarbclaude
andauthored
feat: generalize flag sync to support model flags + generic flags (#785)
Extract FlagSpec type and SyncFlags function from the model-specific SyncModelFlags. Both model manifest (models.json) and a generic flags config (flags.json) produce []FlagSpec and feed into the same sync pipeline. Tags are per-flag — model flags get scope:workspace, generic flags get whatever they specify. Mount ConfigMaps as directories (not subPath) so kubelet auto-syncs changes without pod restarts. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2e6bd5b commit 9f67a0a

10 files changed

Lines changed: 388 additions & 168 deletions

File tree

components/backend/cmd/sync_model_flags.go renamed to components/backend/cmd/sync_flags.go

Lines changed: 143 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,94 @@ import (
1919
)
2020

2121
const (
22-
defaultManifestPath = "/config/models.json"
22+
defaultManifestPath = "/config/models/models.json"
23+
defaultFlagsConfig = "/config/flags/flags.json"
2324
maxRetries = 3
2425
retryDelay = 10 * time.Second
2526
)
2627

2728
var errConflict = errors.New("flag already exists (conflict)")
2829

30+
// FlagTag represents a tag to attach to an Unleash feature flag.
31+
type FlagTag struct {
32+
Type string `json:"type"`
33+
Value string `json:"value"`
34+
}
35+
36+
// FlagSpec describes a feature flag to sync to Unleash.
37+
// All flags are created disabled with type "release" and a flexibleRollout
38+
// strategy at 0%. Tags are optional and per-flag.
39+
type FlagSpec struct {
40+
Name string `json:"name"`
41+
Description string `json:"description"`
42+
Tags []FlagTag `json:"tags,omitempty"`
43+
}
44+
45+
// FlagsConfig is the JSON structure for the generic flags config file.
46+
type FlagsConfig struct {
47+
Flags []FlagSpec `json:"flags"`
48+
}
49+
50+
// FlagsFromManifest converts a model manifest into FlagSpecs.
51+
// Skips the default model and unavailable models.
52+
func FlagsFromManifest(manifest *types.ModelManifest) []FlagSpec {
53+
var specs []FlagSpec
54+
for _, model := range manifest.Models {
55+
if model.ID == manifest.DefaultModel {
56+
continue
57+
}
58+
if !model.Available {
59+
continue
60+
}
61+
specs = append(specs, FlagSpec{
62+
Name: sanitizeLogString(fmt.Sprintf("model.%s.enabled", model.ID)),
63+
Description: sanitizeLogString(fmt.Sprintf("Enable %s (%s) for users", model.Label, model.ID)),
64+
Tags: []FlagTag{{Type: "scope", Value: "workspace"}},
65+
})
66+
}
67+
return specs
68+
}
69+
70+
// FlagsConfigPath returns the filesystem path to the generic flags config.
71+
// Defaults to defaultFlagsConfig; override via FLAGS_CONFIG_PATH env var.
72+
func FlagsConfigPath() string {
73+
if p := os.Getenv("FLAGS_CONFIG_PATH"); p != "" {
74+
return p
75+
}
76+
return defaultFlagsConfig
77+
}
78+
79+
// FlagsFromConfig loads generic flag definitions from a JSON file.
80+
// Returns nil if the file does not exist (flags config is optional).
81+
func FlagsFromConfig(path string) ([]FlagSpec, error) {
82+
data, err := os.ReadFile(path)
83+
if os.IsNotExist(err) {
84+
return nil, nil
85+
}
86+
if err != nil {
87+
return nil, fmt.Errorf("reading flags config %s: %w", path, err)
88+
}
89+
90+
var cfg FlagsConfig
91+
if err := json.Unmarshal(data, &cfg); err != nil {
92+
return nil, fmt.Errorf("parsing flags config: %w", err)
93+
}
94+
95+
// Sanitize flag names and descriptions to prevent log injection.
96+
// Model-derived names are constrained (model.<id>.enabled) but
97+
// config-file names are user-defined and unconstrained.
98+
for i := range cfg.Flags {
99+
cfg.Flags[i].Name = sanitizeLogString(cfg.Flags[i].Name)
100+
cfg.Flags[i].Description = sanitizeLogString(cfg.Flags[i].Description)
101+
for j := range cfg.Flags[i].Tags {
102+
cfg.Flags[i].Tags[j].Type = sanitizeLogString(cfg.Flags[i].Tags[j].Type)
103+
cfg.Flags[i].Tags[j].Value = sanitizeLogString(cfg.Flags[i].Tags[j].Value)
104+
}
105+
}
106+
107+
return cfg.Flags, nil
108+
}
109+
29110
// SyncModelFlagsFromFile reads a model manifest from disk and syncs flags.
30111
// Used by the sync-model-flags subcommand.
31112
func SyncModelFlagsFromFile(manifestPath string) error {
@@ -39,40 +120,39 @@ func SyncModelFlagsFromFile(manifestPath string) error {
39120
return fmt.Errorf("parsing manifest: %w", err)
40121
}
41122

42-
return SyncModelFlags(context.Background(), &manifest)
123+
return SyncFlags(context.Background(), FlagsFromManifest(&manifest))
43124
}
44125

45-
// SyncModelFlagsAsync runs SyncModelFlags in a background goroutine with
46-
// retries. Intended for use at server startup — does not block the caller.
126+
// SyncFlagsAsync runs SyncFlags in a background goroutine with retries.
127+
// Intended for use at server startup — does not block the caller.
47128
// Cancel the context to abort retries (e.g. on SIGTERM).
48-
func SyncModelFlagsAsync(ctx context.Context, manifest *types.ModelManifest) {
129+
func SyncFlagsAsync(ctx context.Context, flags []FlagSpec) {
49130
go func() {
50131
for attempt := 1; attempt <= maxRetries; attempt++ {
51-
err := SyncModelFlags(ctx, manifest)
132+
err := SyncFlags(ctx, flags)
52133
if err == nil {
53134
return
54135
}
55-
log.Printf("sync-model-flags: attempt %d/%d failed: %v", attempt, maxRetries, err)
136+
log.Printf("sync-flags: attempt %d/%d failed: %v", attempt, maxRetries, err)
56137
if attempt < maxRetries {
57138
select {
58139
case <-ctx.Done():
59-
log.Printf("sync-model-flags: cancelled, stopping retries")
140+
log.Printf("sync-flags: cancelled, stopping retries")
60141
return
61142
case <-time.After(retryDelay):
62143
}
63144
}
64145
}
65-
log.Printf("sync-model-flags: all %d attempts failed, giving up", maxRetries)
146+
log.Printf("sync-flags: all %d attempts failed, giving up", maxRetries)
66147
}()
67148
}
68149

69-
// SyncModelFlags ensures every model in the manifest has a corresponding
70-
// Unleash feature flag. Flags are created disabled with type "release"
71-
// and tagged scope:workspace so they appear in the admin UI.
150+
// SyncFlags ensures every FlagSpec has a corresponding Unleash feature flag.
151+
// Flags are created disabled with type "release" and a flexibleRollout strategy.
72152
//
73153
// Required env vars: UNLEASH_ADMIN_URL, UNLEASH_ADMIN_TOKEN
74154
// Optional env var: UNLEASH_PROJECT (default: "default")
75-
func SyncModelFlags(ctx context.Context, manifest *types.ModelManifest) error {
155+
func SyncFlags(ctx context.Context, flags []FlagSpec) error {
76156
adminURL := strings.TrimSuffix(strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_URL")), "/")
77157
adminToken := strings.TrimSpace(os.Getenv("UNLEASH_ADMIN_TOKEN"))
78158
project := strings.TrimSpace(os.Getenv("UNLEASH_PROJECT"))
@@ -86,80 +166,91 @@ func SyncModelFlags(ctx context.Context, manifest *types.ModelManifest) error {
86166
}
87167

88168
if adminURL == "" || adminToken == "" {
89-
log.Printf("sync-model-flags: UNLEASH_ADMIN_URL or UNLEASH_ADMIN_TOKEN not set, skipping")
169+
log.Printf("sync-flags: UNLEASH_ADMIN_URL or UNLEASH_ADMIN_TOKEN not set, skipping")
90170
return nil
91171
}
92172

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

95-
// Ensure the "scope" tag type exists before creating flags
96-
if err := ensureTagType(ctx, client, adminURL, "scope", "Controls flag visibility scope", adminToken); err != nil {
97-
return fmt.Errorf("ensuring scope tag type: %w", err)
98-
}
99-
100-
var created, skipped, excluded, errCount int
101-
log.Printf("Syncing Unleash flags for %d models...", len(manifest.Models))
102-
103-
for _, model := range manifest.Models {
104-
if model.ID == manifest.DefaultModel {
105-
log.Printf(" %s: default model, no flag needed", model.ID)
106-
excluded++
107-
continue
108-
}
109-
110-
if !model.Available {
111-
log.Printf(" %s: not available, skipping flag creation", model.ID)
112-
excluded++
113-
continue
175+
// Ensure all required tag types exist
176+
tagTypes := collectTagTypes(flags)
177+
for _, tt := range tagTypes {
178+
if err := ensureTagType(ctx, client, adminURL, tt, fmt.Sprintf("Tag type: %s", tt), adminToken); err != nil {
179+
return fmt.Errorf("ensuring tag type %q: %w", tt, err)
114180
}
181+
}
115182

116-
flagName := fmt.Sprintf("model.%s.enabled", model.ID)
183+
var created, skipped, errCount int
184+
log.Printf("Syncing %d Unleash flag(s)...", len(flags))
117185

118-
exists, err := flagExists(ctx, client, adminURL, project, flagName, adminToken)
186+
for _, flag := range flags {
187+
exists, err := flagExists(ctx, client, adminURL, project, flag.Name, adminToken)
119188
if err != nil {
120-
log.Printf(" ERROR checking %s: %v", flagName, err)
189+
log.Printf(" ERROR checking %s: %v", flag.Name, err)
121190
errCount++
122191
continue
123192
}
124193

125194
if exists {
126-
log.Printf(" %s: already exists, skipping", flagName)
195+
log.Printf(" %s: already exists, skipping", flag.Name)
127196
skipped++
128197
continue
129198
}
130199

131-
description := fmt.Sprintf("Enable %s (%s) for users", model.Label, model.ID)
132-
if err := createFlag(ctx, client, adminURL, project, flagName, description, adminToken); err != nil {
200+
if err := createFlag(ctx, client, adminURL, project, flag.Name, flag.Description, adminToken); err != nil {
133201
if errors.Is(err, errConflict) {
134-
log.Printf(" %s: created by another instance, skipping", flagName)
202+
log.Printf(" %s: created by another instance, skipping", flag.Name)
135203
skipped++
136204
continue
137205
}
138-
log.Printf(" ERROR creating %s: %v", flagName, err)
206+
log.Printf(" ERROR creating %s: %v", flag.Name, err)
139207
errCount++
140208
continue
141209
}
142210

143-
if err := addTag(ctx, client, adminURL, flagName, adminToken); err != nil {
144-
log.Printf(" WARNING: created %s but failed to add tag: %v", flagName, err)
211+
for _, tag := range flag.Tags {
212+
if err := addFlagTag(ctx, client, adminURL, flag.Name, tag, adminToken); err != nil {
213+
log.Printf(" WARNING: created %s but failed to add tag %s:%s: %v", flag.Name, tag.Type, tag.Value, err)
214+
}
145215
}
146216

147-
if err := addRolloutStrategy(ctx, client, adminURL, project, environment, flagName, adminToken); err != nil {
148-
log.Printf(" WARNING: created %s but failed to add rollout strategy: %v", flagName, err)
217+
if err := addRolloutStrategy(ctx, client, adminURL, project, environment, flag.Name, adminToken); err != nil {
218+
log.Printf(" WARNING: created %s but failed to add rollout strategy: %v", flag.Name, err)
149219
}
150220

151-
log.Printf(" %s: created (disabled, 0%% rollout)", flagName)
221+
log.Printf(" %s: created (disabled, 0%% rollout)", flag.Name)
152222
created++
153223
}
154224

155-
log.Printf("Summary: %d created, %d skipped, %d excluded, %d errors", created, skipped, excluded, errCount)
225+
log.Printf("Summary: %d created, %d skipped, %d errors", created, skipped, errCount)
156226

157227
if errCount > 0 {
158228
return fmt.Errorf("%d errors occurred during sync", errCount)
159229
}
160230
return nil
161231
}
162232

233+
// sanitizeLogString strips newlines and carriage returns from strings
234+
// that will be interpolated into log messages, preventing log injection.
235+
func sanitizeLogString(s string) string {
236+
return strings.ReplaceAll(strings.ReplaceAll(s, "\n", ""), "\r", "")
237+
}
238+
239+
// collectTagTypes returns the unique set of tag types across all flags.
240+
func collectTagTypes(flags []FlagSpec) []string {
241+
seen := map[string]bool{}
242+
var result []string
243+
for _, f := range flags {
244+
for _, t := range f.Tags {
245+
if !seen[t.Type] {
246+
seen[t.Type] = true
247+
result = append(result, t.Type)
248+
}
249+
}
250+
}
251+
return result
252+
}
253+
163254
// ParseManifestPath extracts --manifest-path from args, returning the path
164255
// and whether it was found. Falls back to defaultManifestPath.
165256
func ParseManifestPath(args []string) string {
@@ -175,7 +266,6 @@ func ParseManifestPath(args []string) string {
175266
}
176267

177268
func ensureTagType(ctx context.Context, client *http.Client, adminURL, name, description, token string) error {
178-
// Check if tag type exists
179269
reqURL := fmt.Sprintf("%s/api/admin/tag-types/%s", adminURL, url.PathEscape(name))
180270
resp, err := doRequest(ctx, client, "GET", reqURL, token, nil)
181271
if err != nil {
@@ -189,7 +279,6 @@ func ensureTagType(ctx context.Context, client *http.Client, adminURL, name, des
189279
return nil
190280
}
191281

192-
// Create it
193282
createURL := fmt.Sprintf("%s/api/admin/tag-types", adminURL)
194283
body, err := json.Marshal(map[string]string{
195284
"name": name,
@@ -265,11 +354,11 @@ func createFlag(ctx context.Context, client *http.Client, adminURL, project, fla
265354
}
266355
}
267356

268-
func addTag(ctx context.Context, client *http.Client, adminURL, flagName, token string) error {
357+
func addFlagTag(ctx context.Context, client *http.Client, adminURL, flagName string, tag FlagTag, token string) error {
269358
reqURL := fmt.Sprintf("%s/api/admin/features/%s/tags", adminURL, url.PathEscape(flagName))
270359
body, err := json.Marshal(map[string]string{
271-
"type": "scope",
272-
"value": "workspace",
360+
"type": tag.Type,
361+
"value": tag.Value,
273362
})
274363
if err != nil {
275364
return fmt.Errorf("marshaling tag request: %w", err)
@@ -316,8 +405,8 @@ func addRolloutStrategy(ctx context.Context, client *http.Client, adminURL, proj
316405
return nil
317406
}
318407

319-
func doRequest(ctx context.Context, client *http.Client, method, url, token string, body io.Reader) (*http.Response, error) {
320-
req, err := http.NewRequestWithContext(ctx, method, url, body)
408+
func doRequest(ctx context.Context, client *http.Client, method, reqURL, token string, body io.Reader) (*http.Response, error) {
409+
req, err := http.NewRequestWithContext(ctx, method, reqURL, body)
321410
if err != nil {
322411
return nil, err
323412
}

0 commit comments

Comments
 (0)