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,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
201277func 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.
305381func 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).
319396func 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.
349426func 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