Skip to content

Commit 5c18a66

Browse files
authored
Merge pull request #32 from persys-dev/feat/persys-compute-agent
Feat/persys compute agent
2 parents e94ba12 + e091fd1 commit 5c18a66

4 files changed

Lines changed: 271 additions & 60 deletions

File tree

compute-agent/cmd/agent/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func main() {
121121

122122
// Register Compose runtime if enabled
123123
if cfg.ComposeEnabled {
124-
composeRuntime, err := runtime.NewComposeRuntime(cfg.ComposeBinary, "", logger)
124+
composeRuntime, err := runtime.NewComposeRuntime(cfg.ComposeBinary, cfg.DockerEndpoint ,"", logger)
125125
if err != nil {
126126
logger.Warnf("Failed to initialize Compose runtime: %v", err)
127127
} else {

compute-agent/examples/client/specs/compose.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
version: "3.8"
21
services:
32
web:
43
image: nginx:1.27

compute-agent/internal/config/config.go

Lines changed: 140 additions & 49 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,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
187277
func 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.
291381
func 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).
305396
func 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.
335426
func 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

Comments
 (0)