Skip to content

Commit e091fd1

Browse files
committed
Fix: Config This is Viper's own override > flag > env > config > default precedence
1 parent ae1b8d6 commit e091fd1

2 files changed

Lines changed: 124 additions & 209 deletions

File tree

compute-agent/internal/config/config.go

Lines changed: 124 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,26 @@ var (
9292
fs = pflag.NewFlagSet("compute-agent", pflag.ContinueOnError)
9393
)
9494

95-
// Load loads configuration with Viper + pflag
95+
// Load loads configuration.
96+
//
97+
// Precedence (highest to lowest), per key:
98+
//
99+
// 1. PERSYS_NODE_LABELS parsing (handled explicitly, see below)
100+
// 2. environment variables (PERSYS_*)
101+
// 3. config file (agent_config.yaml), if one is found
102+
// 4. built-in defaults (defaultConfig())
103+
//
104+
// This is Viper's own override > flag > env > config > default precedence.
105+
// The important bit that makes it actually work is registerDefaults: Viper's
106+
// AutomaticEnv only kicks in, during Unmarshal, for keys it already knows
107+
// about (from a config file, an explicit BindEnv, or a SetDefault). A key
108+
// with no default and no config-file entry is invisible to Unmarshal even if
109+
// the matching PERSYS_* env var is set. Registering every field's default
110+
// up front is what lets "no config file -> use env, else use default" and
111+
// "config file present -> only fill in what env didn't set" both fall out of
112+
// Viper's normal per-key resolution, instead of us re-implementing it by
113+
// hand (which is what the old cfg = defaultConfig() wholesale-replace, and
114+
// the applyMinimalDefaults zero-value merge, both did - and did buggily).
96115
func Load() (*Config, error) {
97116
v := viper.New()
98117

@@ -102,18 +121,33 @@ func Load() (*Config, error) {
102121
v.AutomaticEnv()
103122
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
104123

105-
// Handle PERSYS_NODE_LABELS specially
124+
// Register every field's default so Viper knows about the key and will
125+
// resolve it as env > config file > default, instead of silently
126+
// ignoring the env var because Unmarshal never saw the key.
127+
registerDefaults(v, defaultConfig())
128+
129+
// state_store_path historically also accepted PERSYS_STATE_PATH.
130+
if err := v.BindEnv("state_store_path", "PERSYS_STATE_PATH", "PERSYS_STATE_STORE_PATH"); err != nil {
131+
return nil, fmt.Errorf("bind state_store_path env: %w", err)
132+
}
133+
134+
// PERSYS_NODE_LABELS is a comma-separated key=value list, not something
135+
// Viper can cast on its own. Decode it by hand and Set() it - Set()
136+
// outranks every other source, which is what we want: an explicit label
137+
// list on the env should never be partially clobbered by a config file.
106138
if labelsEnv := os.Getenv("PERSYS_NODE_LABELS"); labelsEnv != "" {
107139
v.Set("node_labels", parseLabelsEnv(labelsEnv))
108140
}
109141

110-
// Bind CLI flag safely
142+
// Bind CLI flag safely (Load can be called more than once, e.g. in tests).
111143
if fs.Lookup("config") == nil {
112144
fs.String("config", "", "Path to config file")
113145
}
114-
fs.Parse(os.Args[1:])
146+
if err := fs.Parse(os.Args[1:]); err != nil && err != pflag.ErrHelp {
147+
return nil, fmt.Errorf("parse flags: %w", err)
148+
}
115149

116-
// Determine config file
150+
// Determine config file location.
117151
var configFile string
118152
if f := fs.Lookup("config").Value.String(); f != "" {
119153
configFile = f
@@ -127,61 +161,36 @@ func Load() (*Config, error) {
127161

128162
configSrc := "defaults + env"
129163

130-
// === STRICT FILE PRECEDENCE ===
131164
if configFile != "" {
165+
// An explicitly-named file must exist and parse - fail loudly if not.
132166
v.SetConfigFile(configFile)
133167
if err := v.ReadInConfig(); err != nil {
134168
return nil, fmt.Errorf("failed to read specified config file %s: %w", configFile, err)
135169
}
136170
configSrc = configFile
171+
} else if err := v.ReadInConfig(); err == nil {
172+
// No file was named, but Viper found one on the search path.
173+
configSrc = v.ConfigFileUsed()
174+
} else if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
175+
// Found a file but couldn't parse it - that's a real error.
176+
return nil, fmt.Errorf("config file error: %w", err)
137177
} else {
138-
// No explicit file → search and load gracefully
139-
if err := v.ReadInConfig(); err == nil {
140-
configSrc = v.ConfigFileUsed()
141-
} else if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
142-
return nil, fmt.Errorf("config file error: %w", err)
143-
} else {
144-
fmt.Println("ℹ️ No config file found → using ENV + defaults")
145-
}
178+
fmt.Println("ℹ️ No config file found → using ENV + defaults")
146179
}
147180

148-
// Start with empty struct so file has full control
149181
cfg := &Config{}
150-
151182
if err := v.Unmarshal(cfg); err != nil {
152183
return nil, fmt.Errorf("unmarshal config: %w", err)
153184
}
154185

155-
if configSrc == "defaults + env" {
156-
fmt.Println("loaded default config")
157-
// Explicitly bind important fields for tests
158-
v.BindEnv("grpc_port")
159-
v.BindEnv("state_store_path", "PERSYS_STATE_PATH", "PERSYS_STATE_STORE_PATH")
160-
v.BindEnv("node_region")
161-
v.BindEnv("node_env")
162-
v.BindEnv("node_labels")
163-
v.BindEnv("scheduler_addr")
164-
v.BindEnv("scheduler_insecure")
165-
v.BindEnv("docker_enabled")
166-
v.BindEnv("compose_enabled")
167-
v.BindEnv("vm_enabled")
168-
v.BindEnv("tls_enabled")
169-
v.BindEnv("vault_enabled")
170-
v.BindEnv("vault_approle_role_id")
171-
v.BindEnv("vault_approle_secret_id")
172-
v.BindEnv("vault_addr")
173-
v.BindEnv("vault_service_name")
174-
}
175-
applyMinimalDefaults(cfg)
186+
// --- Post-processing: derived fields that don't come from any source ---
176187

177-
// Post-processing
178188
cfg.SchedulerTLSEnabled = !cfg.SchedulerInsecure
179189

180190
if cfg.NodeID == "" {
181191
cfg.NodeID = generateNodeID()
182192
}
183193

184-
// Node labels: defaults + region/env
185194
if cfg.NodeLabels == nil {
186195
cfg.NodeLabels = make(map[string]string)
187196
}
@@ -193,10 +202,77 @@ func Load() (*Config, error) {
193202
}
194203

195204
fmt.Printf("✅ Config loaded from: %s | NodeID: %s\n", configSrc, cfg.NodeID)
196-
197205
return cfg, nil
198206
}
199207

208+
// registerDefaults tells Viper about every configurable key and its default
209+
// value. It must run before ReadInConfig/Unmarshal: this is what makes
210+
// AutomaticEnv actually apply per-key during Unmarshal (see the comment on
211+
// Load), and it's also what makes a partial/empty config file behave as
212+
// "fill in the blanks" rather than clobbering everything else with zero
213+
// values.
214+
func registerDefaults(v *viper.Viper, def *Config) {
215+
v.SetDefault("grpc_addr", def.GRPCAddr)
216+
v.SetDefault("grpc_port", def.GRPCPort)
217+
v.SetDefault("metrics_port", def.MetricsPort)
218+
219+
v.SetDefault("tls_enabled", def.TLSEnabled)
220+
v.SetDefault("tls_cert_path", def.TLSCertPath)
221+
v.SetDefault("tls_key_path", def.TLSKeyPath)
222+
v.SetDefault("tls_ca_path", def.TLSCAPath)
223+
224+
v.SetDefault("vault_enabled", def.VaultEnabled)
225+
v.SetDefault("vault_manager_addr", def.VaultManagerAddr)
226+
v.SetDefault("vault_addr", def.VaultAddr)
227+
v.SetDefault("vault_auth_method", def.VaultAuthMethod)
228+
v.SetDefault("vault_token", def.VaultToken)
229+
v.SetDefault("vault_approle_role_id", def.VaultAppRoleID)
230+
v.SetDefault("vault_approle_secret_id", def.VaultAppSecretID)
231+
v.SetDefault("vault_pki_mount", def.VaultPKIMount)
232+
v.SetDefault("vault_pki_role", def.VaultPKIRole)
233+
v.SetDefault("vault_cert_ttl", def.VaultCertTTL)
234+
v.SetDefault("vault_service_name", def.VaultServiceName)
235+
v.SetDefault("vault_service_domain", def.VaultServiceDomain)
236+
v.SetDefault("vault_retry_interval", def.VaultRetryInterval)
237+
238+
v.SetDefault("state_store_path", def.StateStorePath)
239+
240+
v.SetDefault("docker_enabled", def.DockerEnabled)
241+
v.SetDefault("docker_endpoint", def.DockerEndpoint)
242+
v.SetDefault("compose_enabled", def.ComposeEnabled)
243+
v.SetDefault("compose_binary", def.ComposeBinary)
244+
v.SetDefault("vm_enabled", def.VMEnabled)
245+
v.SetDefault("libvirt_uri", def.LibvirtURI)
246+
247+
v.SetDefault("storage_local_root", def.StorageLocalRoot)
248+
v.SetDefault("storage_nfs_stage_dir", def.StorageNFSStageDir)
249+
v.SetDefault("storage_nfs_server", def.StorageNFSServer)
250+
v.SetDefault("storage_nfs_export", def.StorageNFSExport)
251+
v.SetDefault("storage_nfs_options", def.StorageNFSOptions)
252+
v.SetDefault("storage_ceph_stage_dir", def.StorageCephStageDir)
253+
v.SetDefault("storage_ceph_cluster", def.StorageCephCluster)
254+
v.SetDefault("storage_ceph_pool", def.StorageCephPool)
255+
v.SetDefault("storage_ceph_user", def.StorageCephUser)
256+
v.SetDefault("storage_ceph_keyring", def.StorageCephKeyring)
257+
258+
v.SetDefault("reconcile_interval", def.ReconcileInterval)
259+
v.SetDefault("reconcile_enabled", def.ReconcileEnabled)
260+
261+
v.SetDefault("log_level", def.LogLevel)
262+
263+
v.SetDefault("node_id", def.NodeID)
264+
v.SetDefault("version", def.Version)
265+
v.SetDefault("node_region", def.NodeRegion)
266+
v.SetDefault("node_env", def.NodeEnv)
267+
v.SetDefault("node_labels", def.NodeLabels)
268+
269+
v.SetDefault("scheduler_addr", def.SchedulerAddr)
270+
v.SetDefault("scheduler_insecure", def.SchedulerInsecure)
271+
v.SetDefault("agent_grpc_endpoint", def.AgentGRPCEndpoint)
272+
273+
v.SetDefault("otlp_endpoint", def.OTELExporterEndpoint)
274+
}
275+
200276
// getConfigSearchPaths returns possible locations for agent_config.yaml
201277
func getConfigSearchPaths() []string {
202278
paths := []string{"/etc/persys"}
@@ -221,7 +297,7 @@ func defaultConfig() *Config {
221297
TLSKeyPath: "/etc/persys/certs/agent/compute-agent-key.pem",
222298
TLSCAPath: "/etc/persys/certs/agent/ca.pem",
223299

224-
VaultEnabled: false, // Changed default for test friendliness
300+
VaultEnabled: false,
225301
VaultManagerAddr: "vault-manager:50069",
226302
VaultAddr: "http://vault:8200",
227303
VaultAuthMethod: "approle",
@@ -301,7 +377,7 @@ func (c *Config) Validate() error {
301377
return nil
302378
}
303379

304-
// mergeWithDefaultLabels, parseNodeLabels, generateNodeID, parseLabelsEnv remain the same as before
380+
// mergeWithDefaultLabels adds os/arch labels if not already present.
305381
func mergeWithDefaultLabels(labels map[string]string) map[string]string {
306382
defaults := map[string]string{
307383
"os": runtime.GOOS,
@@ -315,7 +391,8 @@ func mergeWithDefaultLabels(labels map[string]string) map[string]string {
315391
return labels
316392
}
317393

318-
// parseNodeLabels merges region/env (takes precedence)
394+
// parseNodeLabels merges region/env (region/env take precedence over
395+
// whatever was already in raw, since they're the canonical fields).
319396
func parseNodeLabels(region, env string, raw map[string]string) map[string]string {
320397
if raw == nil {
321398
raw = make(map[string]string)
@@ -345,7 +422,7 @@ func getHostname() string {
345422
return "unknown"
346423
}
347424

348-
// parseLabelsEnv parses comma-separated key=value pairs, skips invalid ones
425+
// parseLabelsEnv parses comma-separated key=value pairs, skips invalid ones.
349426
func parseLabelsEnv(s string) map[string]string {
350427
labels := make(map[string]string)
351428
if s == "" {
@@ -358,9 +435,9 @@ func parseLabelsEnv(s string) map[string]string {
358435
}
359436
if idx := strings.Index(pair, "="); idx > 0 {
360437
k := strings.TrimSpace(pair[:idx])
361-
v := strings.TrimSpace(pair[idx+1:])
362-
if k != "" && v != "" {
363-
labels[k] = v
438+
val := strings.TrimSpace(pair[idx+1:])
439+
if k != "" && val != "" {
440+
labels[k] = val
364441
}
365442
}
366443
}

0 commit comments

Comments
 (0)