soteria/internal/config/config.go

300 lines
9.3 KiB
Go

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"
defaultB2ScanInterval = 15 * time.Minute
defaultB2ScanTimeout = 2 * time.Minute
serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
)
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
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
}
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.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 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
}