package config import ( "errors" "os" "strconv" "strings" "time" "k8s.io/apimachinery/pkg/api/resource" ) const ( defaultBackupDriver = "longhorn" defaultResticImage = "restic/restic:0.16.4" defaultJobTTLSeconds = 259200 defaultJobBackoff = 1 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 defaultExcludedSCs = "local-path" 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 ResticPruneAfterBackup bool ResticUnlockBeforeBackup bool S3Endpoint string S3Region string JobTTLSeconds int32 JobBackoffLimit int32 JobNodeSelector map[string]string JobCPURequest string JobCPULimit string JobMemoryRequest string JobMemoryLimit string ExcludedStorageClasses []string ExcludedPVCs []string WorkerServiceAccount string ListenAddr string LonghornURL string LonghornBackupMode string AuthRequired bool AllowedGroups []string AuthBearerTokens []string MetricsRefreshInterval time.Duration PolicyEvalInterval time.Duration PolicyBackupsPerCycle int 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.ResticPruneAfterBackup = getenvBool("SOTERIA_RESTIC_PRUNE_AFTER_BACKUP") cfg.ResticUnlockBeforeBackup = getenvBool("SOTERIA_RESTIC_UNLOCK_BEFORE_BACKUP") 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.JobCPURequest = getenv("SOTERIA_JOB_CPU_REQUEST") cfg.JobCPULimit = getenv("SOTERIA_JOB_CPU_LIMIT") cfg.JobMemoryRequest = getenv("SOTERIA_JOB_MEMORY_REQUEST") cfg.JobMemoryLimit = getenv("SOTERIA_JOB_MEMORY_LIMIT") cfg.ExcludedStorageClasses = parseCSV(getenvDefault("SOTERIA_EXCLUDED_STORAGE_CLASSES", defaultExcludedSCs)) cfg.ExcludedPVCs = parseCSV(getenv("SOTERIA_EXCLUDED_PVCS")) 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 retries, ok := getenvInt("SOTERIA_JOB_BACKOFF_LIMIT"); ok { cfg.JobBackoffLimit = int32(retries) } else { cfg.JobBackoffLimit = defaultJobBackoff } 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 limit, ok := getenvInt("SOTERIA_POLICY_BACKUPS_PER_CYCLE"); ok { cfg.PolicyBackupsPerCycle = limit } 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.JobTTLSeconds <= 0 { return nil, errors.New("SOTERIA_JOB_TTL_SECONDS must be greater than zero") } if cfg.JobBackoffLimit < 0 { return nil, errors.New("SOTERIA_JOB_BACKOFF_LIMIT must be greater than or equal to zero") } if err := validateQuantity("SOTERIA_JOB_CPU_REQUEST", cfg.JobCPURequest); err != nil { return nil, err } if err := validateQuantity("SOTERIA_JOB_CPU_LIMIT", cfg.JobCPULimit); err != nil { return nil, err } if err := validateQuantity("SOTERIA_JOB_MEMORY_REQUEST", cfg.JobMemoryRequest); err != nil { return nil, err } if err := validateQuantity("SOTERIA_JOB_MEMORY_LIMIT", cfg.JobMemoryLimit); err != nil { return nil, err } 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 cfg.PolicyBackupsPerCycle < 0 { return nil, errors.New("SOTERIA_POLICY_BACKUPS_PER_CYCLE must be greater than or equal to 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 validateQuantity(key, raw string) error { if strings.TrimSpace(raw) == "" { return nil } if _, err := resource.ParseQuantity(raw); err != nil { return errors.New(key + " must be a valid Kubernetes resource quantity") } return nil } 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 }