-
-
Notifications
You must be signed in to change notification settings - Fork 169
Expand file tree
/
Copy pathconfig.go
More file actions
476 lines (414 loc) · 14.9 KB
/
config.go
File metadata and controls
476 lines (414 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
package config
import (
"fmt"
"log/slog"
"net"
"net/url"
"os"
"reflect"
"strconv"
"strings"
"time"
"github.com/getarcaneapp/arcane/backend/internal/common"
pkgutils "github.com/getarcaneapp/arcane/backend/pkg/utils"
)
type AppEnvironment string
const (
AppEnvironmentProduction AppEnvironment = "production"
AppEnvironmentDevelopment AppEnvironment = "development"
AppEnvironmentTest AppEnvironment = "test"
)
// Config holds all application configuration.
// Fields tagged with `env` will be loaded from the corresponding environment variable.
// Fields with `options:"file"` support Docker secrets via the _FILE suffix.
// Available options: file, toLower, trimTrailingSlash
type Config struct {
AppUrl string `env:"APP_URL" default:"http://localhost:3552"`
DatabaseURL string `env:"DATABASE_URL" default:"file:data/arcane.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" options:"file"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE" default:"false"`
Port string `env:"PORT" default:"3552"`
Listen string `env:"LISTEN" default:""`
TLSEnabled bool `env:"TLS_ENABLED" default:"false"`
TLSCertFile string `env:"TLS_CERT_FILE" default:""`
TLSKeyFile string `env:"TLS_KEY_FILE" default:""`
Environment AppEnvironment `env:"ENVIRONMENT" default:"production"`
JWTSecret string `env:"JWT_SECRET" default:"default-jwt-secret-change-me" options:"file"` //nolint:gosec // configuration field name is part of stable config API
JWTRefreshExpiry time.Duration `env:"JWT_REFRESH_EXPIRY" default:"168h"`
EncryptionKey string `env:"ENCRYPTION_KEY" default:"arcane-dev-key-32-characters!!!" options:"file"`
AdminStaticAPIKey string `env:"ADMIN_STATIC_API_KEY" default:"" options:"file"`
OidcEnabled bool `env:"OIDC_ENABLED" default:"false"`
OidcClientID string `env:"OIDC_CLIENT_ID" default:"" options:"file"`
OidcClientSecret string `env:"OIDC_CLIENT_SECRET" default:"" options:"file"`
OidcIssuerURL string `env:"OIDC_ISSUER_URL" default:""`
OidcScopes string `env:"OIDC_SCOPES" default:"openid email profile"`
OidcAdminClaim string `env:"OIDC_ADMIN_CLAIM" default:""`
OidcAdminValue string `env:"OIDC_ADMIN_VALUE" default:""`
OidcSkipTlsVerify bool `env:"OIDC_SKIP_TLS_VERIFY" default:"false"`
OidcAutoRedirectToProvider bool `env:"OIDC_AUTO_REDIRECT_TO_PROVIDER" default:"false"`
OidcProviderName string `env:"OIDC_PROVIDER_NAME" default:""`
OidcProviderLogoUrl string `env:"OIDC_PROVIDER_LOGO_URL" default:""`
DockerHost string `env:"DOCKER_HOST" default:"unix:///var/run/docker.sock"`
ProjectsDirectory string `env:"PROJECTS_DIRECTORY" default:"/app/data/projects"`
LogJson bool `env:"LOG_JSON" default:"false"`
LogLevel string `env:"LOG_LEVEL" default:"info" options:"toLower"`
AgentMode bool `env:"AGENT_MODE" default:"false"`
AgentToken string `env:"AGENT_TOKEN" default:"" options:"file"`
ManagerApiUrl string `env:"MANAGER_API_URL" default:""`
UpdateCheckDisabled bool `env:"UPDATE_CHECK_DISABLED" default:"false"`
UIConfigurationDisabled bool `env:"UI_CONFIGURATION_DISABLED" default:"false"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED" default:"false"`
GPUMonitoringEnabled bool `env:"GPU_MONITORING_ENABLED" default:"false"`
GPUType string `env:"GPU_TYPE" default:"auto"`
EdgeAgent bool `env:"EDGE_AGENT" default:"false"`
EdgeTransport string `env:"EDGE_TRANSPORT" default:"auto" options:"toLower"`
EdgeReconnectInterval int `env:"EDGE_RECONNECT_INTERVAL" default:"5"` // seconds
EdgeMTLSMode string `env:"EDGE_MTLS_MODE" default:"disabled" options:"toLower"`
EdgeMTLSAutoGenerate bool `env:"EDGE_MTLS_AUTO_GENERATE" default:"false"`
EdgeMTLSCAFile string `env:"EDGE_MTLS_CA_FILE" default:""`
EdgeMTLSCertFile string `env:"EDGE_MTLS_CERT_FILE" default:""`
EdgeMTLSKeyFile string `env:"EDGE_MTLS_KEY_FILE" default:""`
EdgeMTLSServerName string `env:"EDGE_MTLS_SERVER_NAME" default:""`
EdgeMTLSAssetsDir string `env:"EDGE_MTLS_ASSETS_DIR" default:""`
FilePerm os.FileMode `env:"FILE_PERM" default:"0644"`
DirPerm os.FileMode `env:"DIR_PERM" default:"0755"`
GitWorkDir string `env:"GIT_WORK_DIR" default:"data/git"`
DockerAPITimeout int `env:"DOCKER_API_TIMEOUT" default:"0"`
DockerImagePullTimeout int `env:"DOCKER_IMAGE_PULL_TIMEOUT" default:"0"`
TrivyScanTimeout int `env:"TRIVY_SCAN_TIMEOUT" default:"0"`
GitOperationTimeout int `env:"GIT_OPERATION_TIMEOUT" default:"0"`
HTTPClientTimeout int `env:"HTTP_CLIENT_TIMEOUT" default:"0"`
RegistryTimeout int `env:"REGISTRY_TIMEOUT" default:"0"`
ProxyRequestTimeout int `env:"PROXY_REQUEST_TIMEOUT" default:"0"`
BackupVolumeName string `env:"ARCANE_BACKUP_VOLUME_NAME" default:"arcane-backups"`
// Timezone for cron job scheduling. Uses IANA timezone names (e.g., "America/New_York", "Europe/London").
// "Local" uses the system's local timezone, "UTC" for Coordinated Universal Time.
Timezone string `env:"TZ" default:"Local"`
// BuildablesConfig contains feature-specific configuration that can be conditionally compiled
BuildablesConfig
}
func Load() *Config {
cfg := &Config{}
loadFromEnv(cfg)
applyOptions(cfg)
applyAgentModeDefaults(cfg)
// Set global file permissions
common.FilePerm = cfg.FilePerm
common.DirPerm = cfg.DirPerm
pkgutils.FilePerm = cfg.FilePerm
pkgutils.DirPerm = cfg.DirPerm
return cfg
}
func applyAgentModeDefaults(cfg *Config) {
if cfg.EdgeAgent {
cfg.AgentMode = true
}
}
// loadFromEnv uses reflection to load configuration from environment variables.
func loadFromEnv(cfg *Config) {
v := reflect.ValueOf(cfg).Elem()
visitConfigFields(v, func(field reflect.Value, fieldType reflect.StructField) {
envTag := fieldType.Tag.Get("env")
if envTag == "" {
return
}
defaultValue := fieldType.Tag.Get("default")
// Get the environment value directly first
envValue := trimQuotes(os.Getenv(envTag))
if envValue == "" {
envValue = defaultValue
}
setFieldValueInternal(field, fieldType, envValue)
})
}
// applyOptions processes special options for Config fields after initial load.
func applyOptions(cfg *Config) {
v := reflect.ValueOf(cfg).Elem()
visitConfigFields(v, func(field reflect.Value, fieldType reflect.StructField) {
optionsTag := fieldType.Tag.Get("options")
if optionsTag == "" {
return
}
options := strings.SplitSeq(optionsTag, ",")
for option := range options {
switch strings.TrimSpace(option) {
case "file":
resolveFileBasedEnvVariable(field, fieldType)
case "toLower":
if field.Kind() == reflect.String {
field.SetString(strings.ToLower(field.String()))
}
case "trimTrailingSlash":
if field.Kind() == reflect.String {
field.SetString(strings.TrimRight(field.String(), "/"))
}
}
}
})
}
func visitConfigFields(v reflect.Value, fn func(reflect.Value, reflect.StructField)) {
if v.Kind() == reflect.Pointer {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
if fieldType.Anonymous {
if field.Kind() == reflect.Struct {
visitConfigFields(field, fn)
continue
}
if field.Kind() == reflect.Pointer && field.Type().Elem().Kind() == reflect.Struct {
if field.IsNil() {
if field.CanSet() {
field.Set(reflect.New(field.Type().Elem()))
} else {
continue
}
}
visitConfigFields(field.Elem(), fn)
continue
}
}
fn(field, fieldType)
}
}
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) {
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
return
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
return
}
// Check both double underscore (__FILE) and single underscore (_FILE) variants
// Double underscore takes precedence
var filePath string
for _, suffix := range []string{"__FILE", "_FILE"} {
if fp := os.Getenv(envTag + suffix); fp != "" {
filePath = fp
break
}
}
if filePath == "" {
return
}
fileContent, err := os.ReadFile(filePath) //nolint:gosec // file path intentionally comes from *_FILE env vars for Docker secrets
if err != nil {
slog.Warn("Failed to read secret from file, falling back to direct env var",
"error", err)
return
}
// Log when file value overrides a direct env var
if os.Getenv(envTag) != "" {
slog.Debug("Using secret from file, overriding direct env var")
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
}
// setFieldValueInternal sets a reflect.Value from a string based on the field's type.
func setFieldValueInternal(field reflect.Value, fieldType reflect.StructField, value string) {
if !field.CanSet() {
return
}
if field.Kind() == reflect.String {
field.SetString(value)
return
}
if field.Kind() == reflect.Bool {
if b, err := strconv.ParseBool(value); err == nil {
field.SetBool(b)
}
return
}
if field.Kind() == reflect.Uint32 {
// Handle os.FileMode (which is uint32)
if i, err := strconv.ParseUint(value, 8, 32); err == nil {
field.SetUint(i)
}
return
}
if field.Kind() == reflect.Int {
if i, err := strconv.Atoi(value); err == nil {
field.SetInt(int64(i))
}
return
}
if field.Type() == reflect.TypeFor[time.Duration]() {
applyDurationDefault := func(reason string) {
envTag := fieldType.Tag.Get("env")
defaultValue := fieldType.Tag.Get("default")
if fallback, fallbackErr := time.ParseDuration(defaultValue); fallbackErr == nil {
slog.Warn("Invalid duration for config field, using tagged default", //nolint:gosec // logging invalid config input for diagnostics is intentional here.
"reason", reason,
"field", envTag,
"value", value,
"default", defaultValue)
field.SetInt(int64(fallback))
} else {
slog.Warn("Invalid duration for config field and invalid tagged default", //nolint:gosec // logging invalid config input for diagnostics is intentional here.
"reason", reason,
"field", envTag,
"value", value,
"default", defaultValue)
}
}
if d, err := time.ParseDuration(value); err == nil {
if d > 0 {
field.SetInt(int64(d))
} else {
applyDurationDefault("Non-positive duration for config field")
}
} else {
applyDurationDefault("Invalid duration for config field")
}
return
}
// Handle custom types based on underlying kind
if field.Type().ConvertibleTo(reflect.TypeFor[string]()) {
// String-based types like AppEnvironment
field.Set(reflect.ValueOf(value).Convert(field.Type()))
} else if field.Type() == reflect.TypeFor[os.FileMode]() {
// os.FileMode
if i, err := strconv.ParseUint(value, 8, 32); err == nil {
field.Set(reflect.ValueOf(os.FileMode(i)))
}
}
}
func trimQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
return s
}
func (a AppEnvironment) IsProdEnvironment() bool {
return a == AppEnvironmentProduction
}
func (a AppEnvironment) IsTestEnvironment() bool {
return a == AppEnvironmentTest
}
// ListenAddr returns the effective address for the HTTP server to bind to.
// It uses LISTEN as the host (if set) and PORT for the port.
func (c *Config) ListenAddr() string {
host := strings.TrimSpace(c.Listen)
port := c.Port
if port == "" {
port = "3552"
}
if host == "" {
return ":" + port
}
return net.JoinHostPort(host, port)
}
// GetLocation returns the timezone location for cron scheduling.
// It parses the Timezone config (TZ env var) into a *time.Location.
// Returns the system's local timezone if Timezone is "Local".
// Defaults to UTC if not set or if the timezone cannot be loaded.
func (c *Config) GetLocation() *time.Location {
tz := strings.TrimSpace(c.Timezone)
if tz == "" {
return time.UTC
}
loc, err := time.LoadLocation(tz)
if err != nil {
slog.Warn("Failed to load timezone, falling back to UTC", "timezone", tz, "error", err)
return time.UTC
}
return loc
}
// GetManagerBaseURL returns the base URL of the manager application.
// It strips any trailing slashes or /api suffix from MANAGER_API_URL.
func (c *Config) GetManagerBaseURL() string {
if c.ManagerApiUrl == "" {
return ""
}
managerURL := strings.TrimRight(c.ManagerApiUrl, "/")
managerURL = strings.TrimSuffix(managerURL, "/api")
return managerURL
}
// GetManagerGRPCAddr returns the manager gRPC address in host:port form.
func (c *Config) GetManagerGRPCAddr() string {
baseURL := c.GetManagerBaseURL()
if baseURL == "" {
return ""
}
parsed, err := url.Parse(baseURL)
if err != nil {
return ""
}
host := parsed.Hostname()
if host == "" {
return ""
}
port := parsed.Port()
if port == "" {
if strings.EqualFold(parsed.Scheme, "https") {
port = "443"
} else {
port = "80"
}
}
return net.JoinHostPort(host, port)
}
// GetAppURL returns the effective application URL.
// If in agent mode and APP_URL is not explicitly set, it returns the manager's URL.
func (c *Config) GetAppURL() string {
// If APP_URL is explicitly set to something other than the default, use it
if os.Getenv("APP_URL") != "" {
return c.AppUrl
}
// If in agent mode and we have a manager URL, use the manager URL
if c.AgentMode {
if managerBase := c.GetManagerBaseURL(); managerBase != "" {
return managerBase
}
}
return c.AppUrl
}
// MaskSensitive returns a copy of the config with sensitive fields masked.
// Useful for logging configuration without exposing secrets.
func (c *Config) MaskSensitive() map[string]any {
result := make(map[string]any)
v := reflect.ValueOf(c).Elem()
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
envTag := fieldType.Tag.Get("env")
if envTag == "" {
envTag = fieldType.Name
}
// Fields with "file" option are considered sensitive
optionsTag := fieldType.Tag.Get("options")
isSensitive := strings.Contains(optionsTag, "file")
if isSensitive {
// Mask sensitive values
strVal := fmt.Sprintf("%v", field.Interface())
if len(strVal) > 0 {
result[envTag] = "****"
} else {
result[envTag] = "(empty)"
}
} else {
result[envTag] = field.Interface()
}
}
return result
}