env_reachability_test.go 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. //go:build unit
  2. package config
  3. import (
  4. "reflect"
  5. "sort"
  6. "strings"
  7. "testing"
  8. "github.com/spf13/viper"
  9. )
  10. // collectMapstructureKeys walks a config struct and returns every dotted key
  11. // viper would need in order to populate it.
  12. func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]string) {
  13. for i := 0; i < t.NumField(); i++ {
  14. field := t.Field(i)
  15. if field.PkgPath != "" {
  16. continue // unexported
  17. }
  18. tag := field.Tag.Get("mapstructure")
  19. name, _, _ := strings.Cut(tag, ",")
  20. if name == "-" {
  21. continue
  22. }
  23. if name == "" {
  24. name = strings.ToLower(field.Name)
  25. }
  26. key := name
  27. if prefix != "" {
  28. key = prefix + "." + name
  29. }
  30. ft := field.Type
  31. for ft.Kind() == reflect.Ptr {
  32. ft = ft.Elem()
  33. }
  34. if ft.Kind() == reflect.Struct {
  35. collectMapstructureKeys(ft, key, out)
  36. continue
  37. }
  38. if ft.Kind() == reflect.Map {
  39. // A map cannot be expressed in a single environment variable, so it
  40. // is out of scope here — such settings need a config file either way.
  41. continue
  42. }
  43. if ft.Kind() == reflect.Slice {
  44. elem := ft.Elem()
  45. for elem.Kind() == reflect.Ptr {
  46. elem = elem.Elem()
  47. }
  48. if elem.Kind() == reflect.Struct {
  49. // AutomaticEnv exposes one string value. Viper's string-to-slice
  50. // hook can populate scalar slices, but it cannot decode a string
  51. // into []struct. Registering a default would turn silent ignore
  52. // into a startup unmarshal error, so structured slices remain
  53. // config-file-only just like maps.
  54. continue
  55. }
  56. }
  57. out[strings.ToLower(key)] = ft.String()
  58. }
  59. }
  60. // TestConfigKeysAreEnvReachable is the systemic guard behind the image_storage
  61. // bug: viper.Unmarshal only decodes keys returned by AllKeys(), which unions
  62. // SetDefault keys, config-file keys and explicit BindEnv keys. AutomaticEnv can
  63. // override a key already in that union but never introduces one, and the
  64. // viper_bind_struct escape hatch is compiled out (we build with -tags embed).
  65. //
  66. // So a Config field with no registered default is unreachable by environment
  67. // variable whenever the deployment has no config.yaml containing it — the
  68. // operator sets the variable, the loader discards it, and the feature behaves
  69. // as if it were never configured. That is exactly how image_storage credentials
  70. // were lost, silently disabling async image tasks for env-driven deployments.
  71. //
  72. // When this fails, register a zero-valued default in setEnvReachableDefaults
  73. // for each reported scalar key. Maps and slices of structs are config-file-only.
  74. func TestConfigKeysAreEnvReachable(t *testing.T) {
  75. bound := map[string]string{}
  76. collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
  77. viper.Reset()
  78. t.Cleanup(viper.Reset)
  79. setDefaults()
  80. registered := map[string]struct{}{}
  81. for _, key := range viper.AllKeys() {
  82. registered[key] = struct{}{}
  83. }
  84. var unreachable []string
  85. for key, kind := range bound {
  86. if _, ok := registered[key]; !ok {
  87. unreachable = append(unreachable, key+" ("+kind+")")
  88. }
  89. }
  90. sort.Strings(unreachable)
  91. if len(unreachable) > 0 {
  92. t.Fatalf("%d config keys have no default registered, so their environment variables are silently ignored:\n %s",
  93. len(unreachable), strings.Join(unreachable, "\n "))
  94. }
  95. }