2026-01-31 03:34:34 -03:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"os"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
2026-04-12 11:09:49 -03:00
|
|
|
"time"
|
2026-01-31 03:34:34 -03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2026-04-12 11:09:49 -03:00
|
|
|
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
|
2026-04-12 14:32:39 -03:00
|
|
|
defaultPolicyEval = 300 * time.Second
|
2026-04-12 11:09:49 -03:00
|
|
|
defaultBackupMaxAge = 24 * time.Hour
|
2026-04-12 14:32:39 -03:00
|
|
|
defaultPolicySecret = "soteria-policies"
|
2026-04-12 19:45:23 -03:00
|
|
|
defaultB2ScanInterval = 15 * time.Minute
|
|
|
|
|
defaultB2ScanTimeout = 2 * time.Minute
|
2026-04-12 11:09:49 -03:00
|
|
|
serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
|
2026-01-31 03:34:34 -03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Config struct {
|
2026-04-12 11:09:49 -03:00
|
|
|
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
|
2026-04-12 14:32:39 -03:00
|
|
|
PolicyEvalInterval time.Duration
|
|
|
|
|
PolicySecretName string
|
2026-04-12 11:09:49 -03:00
|
|
|
BackupMaxAge time.Duration
|
2026-04-12 19:45:23 -03:00
|
|
|
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
|
2026-01-31 03:34:34 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:25:19 -03:00
|
|
|
cfg.BackupDriver = getenvDefault("SOTERIA_BACKUP_DRIVER", defaultBackupDriver)
|
2026-01-31 03:34:34 -03:00
|
|
|
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)
|
2026-02-06 04:08:23 -03:00
|
|
|
cfg.JobNodeSelector = parseNodeSelector(getenv("SOTERIA_JOB_NODE_SELECTOR"))
|
2026-02-06 18:25:19 -03:00
|
|
|
cfg.LonghornURL = getenvDefault("SOTERIA_LONGHORN_URL", defaultLonghornURL)
|
|
|
|
|
cfg.LonghornBackupMode = getenvDefault("SOTERIA_LONGHORN_BACKUP_MODE", defaultLonghornMode)
|
2026-04-12 11:09:49 -03:00
|
|
|
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
|
2026-04-12 14:32:39 -03:00
|
|
|
cfg.PolicyEvalInterval = defaultPolicyEval
|
2026-04-12 11:09:49 -03:00
|
|
|
cfg.BackupMaxAge = defaultBackupMaxAge
|
2026-04-12 14:32:39 -03:00
|
|
|
cfg.PolicySecretName = getenvDefault("SOTERIA_POLICY_SECRET_NAME", defaultPolicySecret)
|
2026-04-12 19:45:23 -03:00
|
|
|
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
|
2026-01-31 03:34:34 -03:00
|
|
|
|
|
|
|
|
if ttl, ok := getenvInt("SOTERIA_JOB_TTL_SECONDS"); ok {
|
|
|
|
|
cfg.JobTTLSeconds = int32(ttl)
|
|
|
|
|
} else {
|
|
|
|
|
cfg.JobTTLSeconds = defaultJobTTLSeconds
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 11:09:49 -03:00
|
|
|
if seconds, ok := getenvInt("SOTERIA_METRICS_REFRESH_SECONDS"); ok {
|
|
|
|
|
cfg.MetricsRefreshInterval = time.Duration(seconds) * time.Second
|
|
|
|
|
}
|
2026-04-12 14:32:39 -03:00
|
|
|
if seconds, ok := getenvInt("SOTERIA_POLICY_EVAL_SECONDS"); ok {
|
|
|
|
|
cfg.PolicyEvalInterval = time.Duration(seconds) * time.Second
|
|
|
|
|
}
|
2026-04-12 11:09:49 -03:00
|
|
|
if hours, ok := getenvFloat("SOTERIA_BACKUP_MAX_AGE_HOURS"); ok {
|
|
|
|
|
cfg.BackupMaxAge = time.Duration(hours * float64(time.Hour))
|
|
|
|
|
}
|
2026-04-12 19:45:23 -03:00
|
|
|
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
|
|
|
|
|
}
|
2026-04-12 11:09:49 -03:00
|
|
|
|
2026-01-31 03:34:34 -03:00
|
|
|
if cfg.ResticRepository == "" {
|
2026-02-06 18:25:19 -03:00
|
|
|
if cfg.BackupDriver == "restic" {
|
|
|
|
|
return nil, errors.New("SOTERIA_RESTIC_REPOSITORY is required for restic driver")
|
|
|
|
|
}
|
2026-01-31 03:34:34 -03:00
|
|
|
}
|
|
|
|
|
if cfg.ResticSecretName == "" {
|
2026-02-06 18:25:19 -03:00
|
|
|
if cfg.BackupDriver == "restic" {
|
|
|
|
|
return nil, errors.New("SOTERIA_RESTIC_SECRET_NAME is required for restic driver")
|
|
|
|
|
}
|
2026-01-31 03:34:34 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.Contains(cfg.ResticRepository, "..") {
|
2026-02-06 18:25:19 -03:00
|
|
|
if cfg.BackupDriver == "restic" {
|
|
|
|
|
return nil, errors.New("SOTERIA_RESTIC_REPOSITORY contains invalid path segments")
|
|
|
|
|
}
|
2026-01-31 03:34:34 -03:00
|
|
|
}
|
2026-02-06 04:08:23 -03:00
|
|
|
if cfg.JobNodeSelector == nil {
|
|
|
|
|
return nil, errors.New("SOTERIA_JOB_NODE_SELECTOR is invalid; expected key=value pairs")
|
|
|
|
|
}
|
2026-02-06 18:25:19 -03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-12 11:09:49 -03:00
|
|
|
if cfg.MetricsRefreshInterval <= 0 {
|
|
|
|
|
return nil, errors.New("SOTERIA_METRICS_REFRESH_SECONDS must be greater than zero")
|
|
|
|
|
}
|
2026-04-12 14:32:39 -03:00
|
|
|
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")
|
|
|
|
|
}
|
2026-04-12 11:09:49 -03:00
|
|
|
if cfg.BackupMaxAge <= 0 {
|
|
|
|
|
return nil, errors.New("SOTERIA_BACKUP_MAX_AGE_HOURS must be greater than zero")
|
|
|
|
|
}
|
2026-04-12 19:45:23 -03:00
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-31 03:34:34 -03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-02-06 04:08:23 -03:00
|
|
|
|
2026-04-12 11:09:49 -03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 04:08:23 -03:00
|
|
|
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
|
|
|
|
|
}
|
2026-04-12 11:09:49 -03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|