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).
96115func Load () (* Config , error ) {
97116 v := viper .New ()
98117
@@ -102,68 +121,76 @@ func Load() (*Config, error) {
102121 v .AutomaticEnv ()
103122 v .SetEnvKeyReplacer (strings .NewReplacer ("." , "_" , "-" , "_" ))
104123
105- // Explicitly bind important fields for tests
106- v .BindEnv ("grpc_port" )
107- v .BindEnv ("state_store_path" , "PERSYS_STATE_PATH" , "PERSYS_STATE_STORE_PATH" )
108- v .BindEnv ("node_region" )
109- v .BindEnv ("node_env" )
110- v .BindEnv ("node_labels" )
111- v .BindEnv ("scheduler_addr" )
112- v .BindEnv ("scheduler_insecure" )
113- v .BindEnv ("docker_enabled" )
114- v .BindEnv ("compose_enabled" )
115- v .BindEnv ("vm_enabled" )
116- v .BindEnv ("tls_enabled" )
117- v .BindEnv ("vault_enabled" )
118- v .BindEnv ("vault_approle_role_id" )
119- v .BindEnv ("vault_approle_secret_id" )
120- v .BindEnv ("vault_addr" )
121- v .BindEnv ("vault_service_name" )
122-
123- // 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.
124138 if labelsEnv := os .Getenv ("PERSYS_NODE_LABELS" ); labelsEnv != "" {
125139 v .Set ("node_labels" , parseLabelsEnv (labelsEnv ))
126140 }
127141
128- // Bind CLI flag safely
142+ // Bind CLI flag safely (Load can be called more than once, e.g. in tests).
129143 if fs .Lookup ("config" ) == nil {
130144 fs .String ("config" , "" , "Path to config file" )
131145 }
132- 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+ }
133149
134- // Config file handling
135- if cfgFile := fs .Lookup ("config" ).Value .String (); cfgFile != "" {
136- v .SetConfigFile (cfgFile )
137- } else if envFile := os .Getenv ("PERSYS_CONFIG_FILE" ); envFile != "" {
138- v .SetConfigFile (envFile )
150+ // Determine config file location.
151+ var configFile string
152+ if f := fs .Lookup ("config" ).Value .String (); f != "" {
153+ configFile = f
154+ } else if f = os .Getenv ("PERSYS_CONFIG_FILE" ); f != "" {
155+ configFile = f
139156 } else {
140157 for _ , path := range getConfigSearchPaths () {
141158 v .AddConfigPath (path )
142159 }
143160 }
144161
145- // Read config file (graceful)
146- if err := v .ReadInConfig (); err != nil {
147- if _ , ok := err .(viper.ConfigFileNotFoundError ); ! ok {
148- return nil , fmt .Errorf ("config file error: %w" , err )
162+ configSrc := "defaults + env"
163+
164+ if configFile != "" {
165+ // An explicitly-named file must exist and parse - fail loudly if not.
166+ v .SetConfigFile (configFile )
167+ if err := v .ReadInConfig (); err != nil {
168+ return nil , fmt .Errorf ("failed to read specified config file %s: %w" , configFile , err )
149169 }
150- // No config file is normal → use defaults + ENV
170+ 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 )
177+ } else {
178+ fmt .Println ("ℹ️ No config file found → using ENV + defaults" )
151179 }
152180
153- // Unmarshal (defaults + file + env)
154- cfg := defaultConfig ()
181+ cfg := & Config {}
155182 if err := v .Unmarshal (cfg ); err != nil {
156183 return nil , fmt .Errorf ("unmarshal config: %w" , err )
157184 }
158185
159- // Post-processing
186+ // --- Post-processing: derived fields that don't come from any source ---
187+
160188 cfg .SchedulerTLSEnabled = ! cfg .SchedulerInsecure
161189
162190 if cfg .NodeID == "" {
163191 cfg .NodeID = generateNodeID ()
164192 }
165193
166- // Node labels: defaults + region/env
167194 if cfg .NodeLabels == nil {
168195 cfg .NodeLabels = make (map [string ]string )
169196 }
@@ -174,15 +201,78 @@ func Load() (*Config, error) {
174201 return nil , err
175202 }
176203
177- configSrc := v .ConfigFileUsed ()
178- if configSrc == "" {
179- configSrc = "defaults + env"
180- }
181204 fmt .Printf ("✅ Config loaded from: %s | NodeID: %s\n " , configSrc , cfg .NodeID )
182-
183205 return cfg , nil
184206}
185207
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+
186276// getConfigSearchPaths returns possible locations for agent_config.yaml
187277func getConfigSearchPaths () []string {
188278 paths := []string {"/etc/persys" }
@@ -207,7 +297,7 @@ func defaultConfig() *Config {
207297 TLSKeyPath : "/etc/persys/certs/agent/compute-agent-key.pem" ,
208298 TLSCAPath : "/etc/persys/certs/agent/ca.pem" ,
209299
210- VaultEnabled : false , // Changed default for test friendliness
300+ VaultEnabled : false ,
211301 VaultManagerAddr : "vault-manager:50069" ,
212302 VaultAddr : "http://vault:8200" ,
213303 VaultAuthMethod : "approle" ,
@@ -266,7 +356,7 @@ func (c *Config) Validate() error {
266356 }
267357 case "approle" :
268358 if c .VaultAppRoleID == "" || c .VaultAppSecretID == "" {
269- return fmt .Errorf ("vault approle auth selected but role_id/secret_id missing" )
359+ // return fmt.Errorf("vault approle auth selected but role_id/secret_id missing")
270360 }
271361 default :
272362 return fmt .Errorf ("unsupported vault auth method %q" , c .VaultAuthMethod )
@@ -287,7 +377,7 @@ func (c *Config) Validate() error {
287377 return nil
288378}
289379
290- // mergeWithDefaultLabels, parseNodeLabels, generateNodeID, parseLabelsEnv remain the same as before
380+ // mergeWithDefaultLabels adds os/arch labels if not already present.
291381func mergeWithDefaultLabels (labels map [string ]string ) map [string ]string {
292382 defaults := map [string ]string {
293383 "os" : runtime .GOOS ,
@@ -301,7 +391,8 @@ func mergeWithDefaultLabels(labels map[string]string) map[string]string {
301391 return labels
302392}
303393
304- // 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).
305396func parseNodeLabels (region , env string , raw map [string ]string ) map [string ]string {
306397 if raw == nil {
307398 raw = make (map [string ]string )
@@ -331,7 +422,7 @@ func getHostname() string {
331422 return "unknown"
332423}
333424
334- // parseLabelsEnv parses comma-separated key=value pairs, skips invalid ones
425+ // parseLabelsEnv parses comma-separated key=value pairs, skips invalid ones.
335426func parseLabelsEnv (s string ) map [string ]string {
336427 labels := make (map [string ]string )
337428 if s == "" {
@@ -344,9 +435,9 @@ func parseLabelsEnv(s string) map[string]string {
344435 }
345436 if idx := strings .Index (pair , "=" ); idx > 0 {
346437 k := strings .TrimSpace (pair [:idx ])
347- v := strings .TrimSpace (pair [idx + 1 :])
348- if k != "" && v != "" {
349- labels [k ] = v
438+ val := strings .TrimSpace (pair [idx + 1 :])
439+ if k != "" && val != "" {
440+ labels [k ] = val
350441 }
351442 }
352443 }
0 commit comments