package config import ( "errors" "os" "strconv" "strings" "time" ) const ( defaultBackupDriver = "longhorn" defaultResticImage = "restic/restic:0.16.4" defaultJobTTLSeconds = 86400 defaultListenAddr = ":8080" defaultResticSecret = "soteria-restic" defaultLonghornURL = "http://longhorn-backend.longhorn-system.svc:9500" defaultLonghornMode = "incremental" defaultAllowedGroups = "admin,maintenance" defaultMetricsRefresh = 300 * time.Second defaultPolicyEval = 300 * time.Second defaultBackupMaxAge = 24 * time.Hour defaultPolicySecret = "soteria-policies" defaultUsageSecret = "soteria-backup-usage" defaultB2ScanInterval = 15 * time.Minute defaultB2ScanTimeout = 2 * time.Minute serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" ) // Config holds the runtime settings used to build and serve Soteria. type Config struct { Namespace string SecretNamespace string BackupDriver string ResticImage string ResticRepository string ResticSecretName string ResticBackupArgs []string ResticForgetArgs []string S3Endpoint string S3Region string JobTTLSeconds int32 JobNodeSelector map[string]string WorkerServiceAccount string ListenAddr string LonghornURL string LonghornBackupMode string AuthRequired bool AllowedGroups []string AuthBearerTokens []string MetricsRefreshInterval time.Duration PolicyEvalInterval time.Duration PolicySecretName string UsageSecretName string BackupMaxAge time.Duration B2Enabled bool B2Endpoint string B2Region string B2Buckets []string B2AccessKeyID string B2SecretAccessKey string B2SecretNamespace string B2SecretName string B2AccessKeyField string B2SecretKeyField string B2EndpointField string B2ScanInterval time.Duration B2ScanTimeout time.Duration } // Load builds the runtime configuration from environment variables and cluster state. func Load() (*Config, error) { cfg := &Config{} cfg.Namespace = getenv("SOTERIA_NAMESPACE") if cfg.Namespace == "" { ns, _ := os.ReadFile(serviceNamespacePath) cfg.Namespace = strings.TrimSpace(string(ns)) } if cfg.Namespace == "" { cfg.Namespace = "soteria" } cfg.SecretNamespace = getenv("SOTERIA_SECRET_NAMESPACE") if cfg.SecretNamespace == "" { cfg.SecretNamespace = cfg.Namespace } cfg.BackupDriver = getenvDefault("SOTERIA_BACKUP_DRIVER", defaultBackupDriver) cfg.ResticImage = getenvDefault("SOTERIA_RESTIC_IMAGE", defaultResticImage) cfg.ResticRepository = getenv("SOTERIA_RESTIC_REPOSITORY") cfg.ResticSecretName = getenvDefault("SOTERIA_RESTIC_SECRET_NAME", defaultResticSecret) cfg.ResticBackupArgs = strings.Fields(getenv("SOTERIA_RESTIC_BACKUP_ARGS")) cfg.ResticForgetArgs = strings.Fields(getenv("SOTERIA_RESTIC_FORGET_ARGS")) cfg.S3Endpoint = getenv("SOTERIA_S3_ENDPOINT") cfg.S3Region = getenv("SOTERIA_S3_REGION") cfg.WorkerServiceAccount = getenv("SOTERIA_JOB_SERVICE_ACCOUNT") cfg.ListenAddr = getenvDefault("SOTERIA_LISTEN_ADDR", defaultListenAddr) cfg.JobNodeSelector = parseNodeSelector(getenv("SOTERIA_JOB_NODE_SELECTOR")) cfg.LonghornURL = getenvDefault("SOTERIA_LONGHORN_URL", defaultLonghornURL) cfg.LonghornBackupMode = getenvDefault("SOTERIA_LONGHORN_BACKUP_MODE", defaultLonghornMode) cfg.AuthRequired = getenvBool("SOTERIA_AUTH_REQUIRED") cfg.AllowedGroups = parseCSV(getenvDefault("SOTERIA_ALLOWED_GROUPS", defaultAllowedGroups)) cfg.AuthBearerTokens = parseCSV(getenv("SOTERIA_AUTH_BEARER_TOKENS")) cfg.MetricsRefreshInterval = defaultMetricsRefresh cfg.PolicyEvalInterval = defaultPolicyEval cfg.BackupMaxAge = defaultBackupMaxAge cfg.PolicySecretName = getenvDefault("SOTERIA_POLICY_SECRET_NAME", defaultPolicySecret) cfg.UsageSecretName = getenvDefault("SOTERIA_USAGE_SECRET_NAME", defaultUsageSecret) cfg.B2Enabled = getenvBool("SOTERIA_B2_ENABLED") cfg.B2Endpoint = getenv("SOTERIA_B2_ENDPOINT") cfg.B2Region = getenv("SOTERIA_B2_REGION") cfg.B2Buckets = parseCSV(getenv("SOTERIA_B2_BUCKETS")) cfg.B2AccessKeyID = getenv("SOTERIA_B2_ACCESS_KEY_ID") cfg.B2SecretAccessKey = getenv("SOTERIA_B2_SECRET_ACCESS_KEY") cfg.B2SecretNamespace = getenv("SOTERIA_B2_SECRET_NAMESPACE") cfg.B2SecretName = getenv("SOTERIA_B2_SECRET_NAME") cfg.B2AccessKeyField = getenvDefault("SOTERIA_B2_ACCESS_KEY_FIELD", "AWS_ACCESS_KEY_ID") cfg.B2SecretKeyField = getenvDefault("SOTERIA_B2_SECRET_KEY_FIELD", "AWS_SECRET_ACCESS_KEY") cfg.B2EndpointField = getenvDefault("SOTERIA_B2_ENDPOINT_FIELD", "AWS_ENDPOINTS") cfg.B2ScanInterval = defaultB2ScanInterval cfg.B2ScanTimeout = defaultB2ScanTimeout if ttl, ok := getenvInt("SOTERIA_JOB_TTL_SECONDS"); ok { cfg.JobTTLSeconds = int32(ttl) } else { cfg.JobTTLSeconds = defaultJobTTLSeconds } if seconds, ok := getenvInt("SOTERIA_METRICS_REFRESH_SECONDS"); ok { cfg.MetricsRefreshInterval = time.Duration(seconds) * time.Second } if seconds, ok := getenvInt("SOTERIA_POLICY_EVAL_SECONDS"); ok { cfg.PolicyEvalInterval = time.Duration(seconds) * time.Second } if hours, ok := getenvFloat("SOTERIA_BACKUP_MAX_AGE_HOURS"); ok { cfg.BackupMaxAge = time.Duration(hours * float64(time.Hour)) } if seconds, ok := getenvInt("SOTERIA_B2_SCAN_INTERVAL_SECONDS"); ok { cfg.B2ScanInterval = time.Duration(seconds) * time.Second } if seconds, ok := getenvInt("SOTERIA_B2_SCAN_TIMEOUT_SECONDS"); ok { cfg.B2ScanTimeout = time.Duration(seconds) * time.Second } if !cfg.B2Enabled && (cfg.B2Endpoint != "" || cfg.B2SecretName != "") { cfg.B2Enabled = true } if cfg.B2SecretName != "" && cfg.B2SecretNamespace == "" { cfg.B2SecretNamespace = cfg.Namespace } if cfg.ResticRepository == "" { if cfg.BackupDriver == "restic" { return nil, errors.New("SOTERIA_RESTIC_REPOSITORY is required for restic driver") } } if cfg.ResticSecretName == "" { if cfg.BackupDriver == "restic" { return nil, errors.New("SOTERIA_RESTIC_SECRET_NAME is required for restic driver") } } if strings.Contains(cfg.ResticRepository, "..") { if cfg.BackupDriver == "restic" { return nil, errors.New("SOTERIA_RESTIC_REPOSITORY contains invalid path segments") } } if cfg.JobNodeSelector == nil { return nil, errors.New("SOTERIA_JOB_NODE_SELECTOR is invalid; expected key=value pairs") } if cfg.BackupDriver != "longhorn" && cfg.BackupDriver != "restic" { return nil, errors.New("SOTERIA_BACKUP_DRIVER must be longhorn or restic") } if cfg.BackupDriver == "longhorn" && cfg.LonghornURL == "" { return nil, errors.New("SOTERIA_LONGHORN_URL is required for longhorn driver") } if cfg.BackupDriver == "longhorn" && cfg.LonghornBackupMode != "" { switch cfg.LonghornBackupMode { case "incremental", "full": default: return nil, errors.New("SOTERIA_LONGHORN_BACKUP_MODE must be incremental or full") } } if cfg.MetricsRefreshInterval <= 0 { return nil, errors.New("SOTERIA_METRICS_REFRESH_SECONDS must be greater than zero") } if cfg.PolicyEvalInterval <= 0 { return nil, errors.New("SOTERIA_POLICY_EVAL_SECONDS must be greater than zero") } if strings.TrimSpace(cfg.PolicySecretName) == "" { return nil, errors.New("SOTERIA_POLICY_SECRET_NAME must not be empty") } if strings.TrimSpace(cfg.UsageSecretName) == "" { return nil, errors.New("SOTERIA_USAGE_SECRET_NAME must not be empty") } if cfg.BackupMaxAge <= 0 { return nil, errors.New("SOTERIA_BACKUP_MAX_AGE_HOURS must be greater than zero") } if cfg.B2Enabled { if cfg.B2Endpoint == "" && cfg.B2SecretName == "" { return nil, errors.New("SOTERIA_B2_ENDPOINT or SOTERIA_B2_SECRET_NAME is required when B2 monitoring is enabled") } if strings.TrimSpace(cfg.B2AccessKeyField) == "" { return nil, errors.New("SOTERIA_B2_ACCESS_KEY_FIELD must not be empty") } if strings.TrimSpace(cfg.B2SecretKeyField) == "" { return nil, errors.New("SOTERIA_B2_SECRET_KEY_FIELD must not be empty") } if cfg.B2ScanInterval <= 0 { return nil, errors.New("SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero") } if cfg.B2ScanTimeout <= 0 { return nil, errors.New("SOTERIA_B2_SCAN_TIMEOUT_SECONDS must be greater than zero") } } return cfg, nil } func getenv(key string) string { return strings.TrimSpace(os.Getenv(key)) } func getenvDefault(key, value string) string { if v := getenv(key); v != "" { return v } return value } func getenvInt(key string) (int, bool) { val := getenv(key) if val == "" { return 0, false } parsed, err := strconv.Atoi(val) if err != nil { return 0, false } return parsed, true } func getenvFloat(key string) (float64, bool) { val := getenv(key) if val == "" { return 0, false } parsed, err := strconv.ParseFloat(val, 64) if err != nil { return 0, false } return parsed, true } func getenvBool(key string) bool { val := strings.ToLower(getenv(key)) switch val { case "1", "true", "yes", "on": return true default: return false } } func parseNodeSelector(raw string) map[string]string { raw = strings.TrimSpace(raw) if raw == "" { return map[string]string{} } selector := map[string]string{} parts := strings.Split(raw, ",") for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } key, value, found := strings.Cut(part, "=") if !found { return nil } key = strings.TrimSpace(key) value = strings.TrimSpace(value) if key == "" || value == "" { return nil } selector[key] = value } return selector } func parseCSV(raw string) []string { if strings.TrimSpace(raw) == "" { return nil } parts := strings.Split(raw, ",") values := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } values = append(values, part) } return values }