11package controller_test
22
33import (
4+ "os"
45 "testing"
56 "time"
67
78 "github.com/buildkite/agent-stack-k8s/v2/cmd/controller"
89 "github.com/buildkite/agent-stack-k8s/v2/internal/controller/config"
910 "github.com/google/go-cmp/cmp"
10- "github.com/spf13/cobra "
11+ "github.com/stretchr/testify/assert "
1112 "github.com/stretchr/testify/require"
1213 corev1 "k8s.io/api/core/v1"
1314 "k8s.io/apimachinery/pkg/api/resource"
@@ -164,22 +165,9 @@ func TestReadAndParseConfig(t *testing.T) {
164165 },
165166 }
166167
167- // These need to be unset to as it is set in CI which pollutes the test environment
168- t .Setenv ("INTEGRATION_TEST_BUILDKITE_TOKEN" , "" )
169- t .Setenv ("IMAGE" , "" )
170- t .Setenv ("NAMESPACE" , "" )
171- t .Setenv ("AGENT_TOKEN_SECRET" , "" )
168+ cleanTestEnv (t )
172169
173- cmd := & cobra.Command {}
174- controller .AddConfigFlags (cmd )
175- v , err := controller .ReadConfigFromFileArgsAndEnv (cmd , []string {})
176- require .NoError (t , err )
177-
178- // We need to read the config file from the test
179- v .SetConfigFile ("../../examples/config.yaml" )
180- require .NoError (t , v .ReadInConfig ())
181-
182- actual , err := controller .ParseAndValidateConfig (v )
170+ actual , err := controller .BuildConfigFromArgs ([]string {"--config=../../examples/config.yaml" })
183171 require .NoError (t , err )
184172
185173 if diff := cmp .Diff (* actual , expected ); diff != "" {
@@ -189,55 +177,257 @@ func TestReadAndParseConfig(t *testing.T) {
189177
190178func TestParseAndValidateConfig_DefaultResourceClassValidation (t * testing.T ) {
191179 tests := []struct {
192- name string
193- config map [ string ] any
194- wantErr string
180+ name string
181+ configYAML string
182+ wantErr string
195183 }{
196184 {
197185 name : "default references non-existent resource class" ,
198- config : map [string ]any {
199- "agent-token-secret" : "test" ,
200- "image" : "test:latest" ,
201- "job-active-deadline-seconds" : 3600 ,
202- "namespace" : "default" ,
203- "default-resource-class-name" : "nonexistent" ,
204- "resource-classes" : map [string ]any {
205- "small" : map [string ]any {
206- "resource" : map [string ]any {
207- "requests" : map [string ]any {"cpu" : "100m" },
208- },
209- },
210- },
211- },
186+ configYAML : `
187+ default-resource-class-name: nonexistent
188+ resource-classes:
189+ small:
190+ resource:
191+ requests:
192+ cpu: "100m"
193+ ` ,
212194 wantErr : `default-resource-class-name "nonexistent" not found in resource-classes` ,
213195 },
214196 {
215197 name : "default specified but no resource classes defined" ,
216- config : map [string ]any {
217- "agent-token-secret" : "test" ,
218- "image" : "test:latest" ,
219- "job-active-deadline-seconds" : 3600 ,
220- "namespace" : "default" ,
221- "default-resource-class-name" : "small" ,
222- },
198+ configYAML : `
199+ default-resource-class-name: small
200+ ` ,
223201 wantErr : `default-resource-class-name "small" specified but no resource-classes defined` ,
224202 },
225203 }
226204
227205 for _ , tt := range tests {
228206 t .Run (tt .name , func (t * testing.T ) {
229- cmd := & cobra.Command {}
230- controller .AddConfigFlags (cmd )
231- v , err := controller .ReadConfigFromFileArgsAndEnv (cmd , []string {})
232- require .NoError (t , err )
207+ cleanTestEnv (t )
208+ configFile := createTempConfigFile (t , tt .configYAML )
233209
234- for k , val := range tt .config {
235- v .Set (k , val )
236- }
237-
238- _ , err = controller .ParseAndValidateConfig (v )
210+ _ , err := controller .BuildConfigFromArgs ([]string {"--config=" + configFile })
239211 require .Error (t , err )
240212 require .Contains (t , err .Error (), tt .wantErr )
241213 })
242214 }
243215}
216+
217+ func TestConfigPrecedence (t * testing.T ) {
218+ // Helper to build config with optional config file
219+ buildConfig := func (t * testing.T , args []string , configFile string ) (* config.Config , error ) {
220+ t .Helper ()
221+ if configFile != "" {
222+ args = append ([]string {"--config=" + configFile }, args ... )
223+ }
224+ return controller .BuildConfigFromArgs (args )
225+ }
226+
227+ t .Run ("defaults applied when nothing set" , func (t * testing.T ) {
228+ cleanTestEnv (t )
229+
230+ cfg , err := buildConfig (t , []string {}, "" )
231+ require .NoError (t , err )
232+
233+ assert .Equal (t , 25 , cfg .MaxInFlight )
234+ assert .Equal (t , "default" , cfg .Namespace )
235+ assert .Equal (t , 10 * time .Minute , cfg .JobTTL )
236+ assert .False (t , cfg .Debug )
237+ })
238+
239+ t .Run ("config file overrides defaults" , func (t * testing.T ) {
240+ cleanTestEnv (t )
241+ configFile := createTempConfigFile (t , `
242+ max-in-flight: 100
243+ namespace: from-file
244+ debug: true
245+ ` )
246+
247+ cfg , err := buildConfig (t , []string {}, configFile )
248+ require .NoError (t , err )
249+
250+ assert .Equal (t , 100 , cfg .MaxInFlight )
251+ assert .Equal (t , "from-file" , cfg .Namespace )
252+ assert .True (t , cfg .Debug )
253+ })
254+
255+ t .Run ("config file can set zero value" , func (t * testing.T ) {
256+ cleanTestEnv (t )
257+ configFile := createTempConfigFile (t , `max-in-flight: 0` )
258+
259+ cfg , err := buildConfig (t , []string {}, configFile )
260+ require .NoError (t , err )
261+
262+ assert .Equal (t , 0 , cfg .MaxInFlight )
263+ })
264+
265+ t .Run ("config file can set false" , func (t * testing.T ) {
266+ cleanTestEnv (t )
267+ configFile := createTempConfigFile (t , `debug: false` )
268+
269+ cfg , err := buildConfig (t , []string {}, configFile )
270+ require .NoError (t , err )
271+
272+ assert .False (t , cfg .Debug )
273+ })
274+
275+ t .Run ("env var overrides config file" , func (t * testing.T ) {
276+ cleanTestEnv (t )
277+ configFile := createTempConfigFile (t , `
278+ max-in-flight: 100
279+ namespace: from-file
280+ ` )
281+ t .Setenv ("MAX_IN_FLIGHT" , "50" )
282+ t .Setenv ("NAMESPACE" , "from-env" )
283+
284+ cfg , err := buildConfig (t , []string {}, configFile )
285+ require .NoError (t , err )
286+
287+ assert .Equal (t , 50 , cfg .MaxInFlight )
288+ assert .Equal (t , "from-env" , cfg .Namespace )
289+ })
290+
291+ t .Run ("env var can override config file with zero" , func (t * testing.T ) {
292+ cleanTestEnv (t )
293+ configFile := createTempConfigFile (t , `max-in-flight: 100` )
294+ t .Setenv ("MAX_IN_FLIGHT" , "0" )
295+
296+ cfg , err := buildConfig (t , []string {}, configFile )
297+ require .NoError (t , err )
298+
299+ assert .Equal (t , 0 , cfg .MaxInFlight )
300+ })
301+
302+ t .Run ("env var can override config file with false" , func (t * testing.T ) {
303+ cleanTestEnv (t )
304+ configFile := createTempConfigFile (t , `debug: true` )
305+ t .Setenv ("DEBUG" , "false" )
306+
307+ cfg , err := buildConfig (t , []string {}, configFile )
308+ require .NoError (t , err )
309+
310+ assert .False (t , cfg .Debug )
311+ })
312+
313+ t .Run ("CLI overrides env var" , func (t * testing.T ) {
314+ cleanTestEnv (t )
315+ t .Setenv ("MAX_IN_FLIGHT" , "50" )
316+ t .Setenv ("NAMESPACE" , "from-env" )
317+
318+ cfg , err := buildConfig (t , []string {
319+ "--max-in-flight=200" ,
320+ "--namespace=from-cli" ,
321+ }, "" )
322+ require .NoError (t , err )
323+
324+ assert .Equal (t , 200 , cfg .MaxInFlight )
325+ assert .Equal (t , "from-cli" , cfg .Namespace )
326+ })
327+
328+ t .Run ("CLI can override env var with zero" , func (t * testing.T ) {
329+ cleanTestEnv (t )
330+ t .Setenv ("MAX_IN_FLIGHT" , "50" )
331+
332+ cfg , err := buildConfig (t , []string {"--max-in-flight=0" }, "" )
333+ require .NoError (t , err )
334+
335+ assert .Equal (t , 0 , cfg .MaxInFlight )
336+ })
337+
338+ t .Run ("CLI can override env var with false" , func (t * testing.T ) {
339+ cleanTestEnv (t )
340+ t .Setenv ("DEBUG" , "true" )
341+
342+ cfg , err := buildConfig (t , []string {"--debug=false" }, "" )
343+ require .NoError (t , err )
344+
345+ assert .False (t , cfg .Debug )
346+ })
347+
348+ t .Run ("full precedence chain" , func (t * testing.T ) {
349+ cleanTestEnv (t )
350+ configFile := createTempConfigFile (t , `
351+ max-in-flight: 100
352+ namespace: from-file
353+ job-ttl: 5m
354+ ` )
355+ t .Setenv ("NAMESPACE" , "from-env" )
356+ t .Setenv ("JOB_TTL" , "15m" )
357+
358+ cfg , err := buildConfig (t , []string {"--namespace=from-cli" }, configFile )
359+ require .NoError (t , err )
360+
361+ // max-in-flight: only in file
362+ assert .Equal (t , 100 , cfg .MaxInFlight )
363+ // namespace: file -> env -> cli (cli wins)
364+ assert .Equal (t , "from-cli" , cfg .Namespace )
365+ // job-ttl: file -> env (env wins)
366+ assert .Equal (t , 15 * time .Minute , cfg .JobTTL )
367+ })
368+
369+ t .Run ("duration fields work correctly" , func (t * testing.T ) {
370+ cleanTestEnv (t )
371+ configFile := createTempConfigFile (t , `
372+ job-ttl: 30m
373+ poll-interval: 5s
374+ ` )
375+
376+ cfg , err := buildConfig (t , []string {}, configFile )
377+ require .NoError (t , err )
378+
379+ assert .Equal (t , 30 * time .Minute , cfg .JobTTL )
380+ assert .Equal (t , 5 * time .Second , cfg .PollInterval )
381+ })
382+
383+ t .Run ("config file with unknown field is rejected" , func (t * testing.T ) {
384+ cleanTestEnv (t )
385+ configFile := createTempConfigFile (t , `
386+ namespace: my-namespace
387+ unknown-field: some-value
388+ ` )
389+
390+ _ , err := buildConfig (t , []string {}, configFile )
391+ require .Error (t , err )
392+ assert .Contains (t , err .Error (), "unknown-field" )
393+ })
394+ }
395+
396+ // cleanTestEnv unsets environment variables that might be set in CI or .envrc
397+ // which could pollute the test environment.
398+ func cleanTestEnv (t * testing.T ) {
399+ t .Helper ()
400+ for _ , env := range []string {
401+ "BUILDKITE_TOKEN" ,
402+ "INTEGRATION_TEST_BUILDKITE_TOKEN" ,
403+ "IMAGE" ,
404+ "NAMESPACE" ,
405+ "AGENT_TOKEN_SECRET" ,
406+ "IMAGE_PULL_BACKOFF_GRACE_PERIOD" ,
407+ "CONFIG" ,
408+ "MAX_IN_FLIGHT" ,
409+ "DEBUG" ,
410+ "JOB_TTL" ,
411+ } {
412+ t .Setenv (env , "" )
413+ os .Unsetenv (env )
414+ }
415+ }
416+
417+ // createTempConfigFile creates a temporary config file with the given content
418+ // and returns its path. The file is automatically cleaned up after the test.
419+ func createTempConfigFile (t * testing.T , content string ) string {
420+ t .Helper ()
421+ f , err := os .CreateTemp ("" , "config-*.yaml" )
422+ if err != nil {
423+ t .Fatalf ("failed to create temp config file: %v" , err )
424+ }
425+ if _ , err := f .WriteString (content ); err != nil {
426+ t .Fatalf ("failed to write temp config file: %v" , err )
427+ }
428+ if err := f .Close (); err != nil {
429+ t .Fatalf ("failed to close temp config file: %v" , err )
430+ }
431+ t .Cleanup (func () { os .Remove (f .Name ()) })
432+ return f .Name ()
433+ }
0 commit comments