@@ -19,13 +19,94 @@ import (
1919)
2020
2121const (
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
2728var 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.
31112func 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.
165256func ParseManifestPath (args []string ) string {
@@ -175,7 +266,6 @@ func ParseManifestPath(args []string) string {
175266}
176267
177268func 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