350 lines
12 KiB
Go
350 lines
12 KiB
Go
package config
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func withEnv(t *testing.T, key, value string) {
|
|
t.Helper()
|
|
t.Setenv(key, value)
|
|
}
|
|
|
|
func clearConfigEnv(t *testing.T) {
|
|
t.Helper()
|
|
keys := []string{
|
|
"SOTERIA_NAMESPACE",
|
|
"SOTERIA_SECRET_NAMESPACE",
|
|
"SOTERIA_BACKUP_DRIVER",
|
|
"SOTERIA_RESTIC_IMAGE",
|
|
"SOTERIA_RESTIC_REPOSITORY",
|
|
"SOTERIA_RESTIC_SECRET_NAME",
|
|
"SOTERIA_RESTIC_BACKUP_ARGS",
|
|
"SOTERIA_RESTIC_FORGET_ARGS",
|
|
"SOTERIA_S3_ENDPOINT",
|
|
"SOTERIA_S3_REGION",
|
|
"SOTERIA_JOB_SERVICE_ACCOUNT",
|
|
"SOTERIA_LISTEN_ADDR",
|
|
"SOTERIA_JOB_NODE_SELECTOR",
|
|
"SOTERIA_LONGHORN_URL",
|
|
"SOTERIA_LONGHORN_BACKUP_MODE",
|
|
"SOTERIA_AUTH_REQUIRED",
|
|
"SOTERIA_ALLOWED_GROUPS",
|
|
"SOTERIA_AUTH_BEARER_TOKENS",
|
|
"SOTERIA_METRICS_REFRESH_SECONDS",
|
|
"SOTERIA_POLICY_EVAL_SECONDS",
|
|
"SOTERIA_POLICY_SECRET_NAME",
|
|
"SOTERIA_USAGE_SECRET_NAME",
|
|
"SOTERIA_BACKUP_MAX_AGE_HOURS",
|
|
"SOTERIA_B2_ENABLED",
|
|
"SOTERIA_B2_ENDPOINT",
|
|
"SOTERIA_B2_REGION",
|
|
"SOTERIA_B2_BUCKETS",
|
|
"SOTERIA_B2_ACCESS_KEY_ID",
|
|
"SOTERIA_B2_SECRET_ACCESS_KEY",
|
|
"SOTERIA_B2_SECRET_NAMESPACE",
|
|
"SOTERIA_B2_SECRET_NAME",
|
|
"SOTERIA_B2_ACCESS_KEY_FIELD",
|
|
"SOTERIA_B2_SECRET_KEY_FIELD",
|
|
"SOTERIA_B2_ENDPOINT_FIELD",
|
|
"SOTERIA_B2_SCAN_INTERVAL_SECONDS",
|
|
"SOTERIA_B2_SCAN_TIMEOUT_SECONDS",
|
|
"SOTERIA_JOB_TTL_SECONDS",
|
|
}
|
|
for _, key := range keys {
|
|
withEnv(t, key, "")
|
|
}
|
|
}
|
|
|
|
func TestLoadDefaultsForLonghorn(t *testing.T) {
|
|
clearConfigEnv(t)
|
|
withEnv(t, "SOTERIA_NAMESPACE", "soteria")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
|
|
if cfg.Namespace != "soteria" || cfg.SecretNamespace != "soteria" {
|
|
t.Fatalf("unexpected namespace defaults: %#v", cfg)
|
|
}
|
|
if cfg.BackupDriver != defaultBackupDriver || cfg.ResticImage != defaultResticImage {
|
|
t.Fatalf("unexpected driver/image defaults: %#v", cfg)
|
|
}
|
|
if cfg.ResticSecretName != defaultResticSecret || cfg.ListenAddr != defaultListenAddr {
|
|
t.Fatalf("unexpected restic/listen defaults: %#v", cfg)
|
|
}
|
|
if cfg.LonghornURL != defaultLonghornURL || cfg.LonghornBackupMode != defaultLonghornMode {
|
|
t.Fatalf("unexpected longhorn defaults: %#v", cfg)
|
|
}
|
|
if cfg.JobTTLSeconds != defaultJobTTLSeconds {
|
|
t.Fatalf("expected default ttl %d, got %d", defaultJobTTLSeconds, cfg.JobTTLSeconds)
|
|
}
|
|
if cfg.MetricsRefreshInterval != defaultMetricsRefresh || cfg.PolicyEvalInterval != defaultPolicyEval {
|
|
t.Fatalf("unexpected interval defaults: %#v", cfg)
|
|
}
|
|
if cfg.BackupMaxAge != defaultBackupMaxAge {
|
|
t.Fatalf("unexpected backup max age: %#v", cfg.BackupMaxAge)
|
|
}
|
|
if cfg.PolicySecretName != defaultPolicySecret || cfg.UsageSecretName != defaultUsageSecret {
|
|
t.Fatalf("unexpected secret defaults: %#v", cfg)
|
|
}
|
|
if len(cfg.AllowedGroups) != 2 || cfg.AllowedGroups[0] != "admin" || cfg.AllowedGroups[1] != "maintenance" {
|
|
t.Fatalf("unexpected allowed groups: %#v", cfg.AllowedGroups)
|
|
}
|
|
if cfg.JobNodeSelector == nil || len(cfg.JobNodeSelector) != 0 {
|
|
t.Fatalf("expected empty node selector map, got %#v", cfg.JobNodeSelector)
|
|
}
|
|
}
|
|
|
|
func TestLoadSupportsResticAndB2Overrides(t *testing.T) {
|
|
clearConfigEnv(t)
|
|
withEnv(t, "SOTERIA_NAMESPACE", "atlas")
|
|
withEnv(t, "SOTERIA_SECRET_NAMESPACE", "vault")
|
|
withEnv(t, "SOTERIA_BACKUP_DRIVER", "restic")
|
|
withEnv(t, "SOTERIA_RESTIC_REPOSITORY", "s3:https://b2.example.invalid/atlas")
|
|
withEnv(t, "SOTERIA_RESTIC_SECRET_NAME", "restic-creds")
|
|
withEnv(t, "SOTERIA_RESTIC_BACKUP_ARGS", "--one-file-system --exclude /tmp")
|
|
withEnv(t, "SOTERIA_RESTIC_FORGET_ARGS", "--keep-last 5 --prune")
|
|
withEnv(t, "SOTERIA_S3_ENDPOINT", "https://b2.example.invalid")
|
|
withEnv(t, "SOTERIA_S3_REGION", "us-west-000")
|
|
withEnv(t, "SOTERIA_JOB_SERVICE_ACCOUNT", "soteria-worker")
|
|
withEnv(t, "SOTERIA_LISTEN_ADDR", ":9090")
|
|
withEnv(t, "SOTERIA_JOB_NODE_SELECTOR", "hardware=rpi5,role=worker")
|
|
withEnv(t, "SOTERIA_AUTH_REQUIRED", "true")
|
|
withEnv(t, "SOTERIA_ALLOWED_GROUPS", "dev,admin")
|
|
withEnv(t, "SOTERIA_AUTH_BEARER_TOKENS", "alpha,beta")
|
|
withEnv(t, "SOTERIA_METRICS_REFRESH_SECONDS", "60")
|
|
withEnv(t, "SOTERIA_POLICY_EVAL_SECONDS", "120")
|
|
withEnv(t, "SOTERIA_POLICY_SECRET_NAME", "policy-doc")
|
|
withEnv(t, "SOTERIA_USAGE_SECRET_NAME", "usage-doc")
|
|
withEnv(t, "SOTERIA_BACKUP_MAX_AGE_HOURS", "12.5")
|
|
withEnv(t, "SOTERIA_B2_ENDPOINT", "https://s3.us-west-000.backblazeb2.com")
|
|
withEnv(t, "SOTERIA_B2_REGION", "us-west-000")
|
|
withEnv(t, "SOTERIA_B2_BUCKETS", "atlas-a, atlas-b")
|
|
withEnv(t, "SOTERIA_B2_ACCESS_KEY_ID", "abc")
|
|
withEnv(t, "SOTERIA_B2_SECRET_ACCESS_KEY", "def")
|
|
withEnv(t, "SOTERIA_B2_SECRET_NAME", "b2-creds")
|
|
withEnv(t, "SOTERIA_B2_ACCESS_KEY_FIELD", "ACCESS_KEY")
|
|
withEnv(t, "SOTERIA_B2_SECRET_KEY_FIELD", "SECRET_KEY")
|
|
withEnv(t, "SOTERIA_B2_ENDPOINT_FIELD", "ENDPOINT")
|
|
withEnv(t, "SOTERIA_B2_SCAN_INTERVAL_SECONDS", "900")
|
|
withEnv(t, "SOTERIA_B2_SCAN_TIMEOUT_SECONDS", "45")
|
|
withEnv(t, "SOTERIA_JOB_TTL_SECONDS", "7200")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load config: %v", err)
|
|
}
|
|
|
|
if cfg.Namespace != "atlas" || cfg.SecretNamespace != "vault" {
|
|
t.Fatalf("unexpected namespaces: %#v", cfg)
|
|
}
|
|
if cfg.BackupDriver != "restic" || cfg.ResticRepository == "" || cfg.ResticSecretName != "restic-creds" {
|
|
t.Fatalf("unexpected restic config: %#v", cfg)
|
|
}
|
|
if strings.Join(cfg.ResticBackupArgs, " ") != "--one-file-system --exclude /tmp" {
|
|
t.Fatalf("unexpected backup args: %#v", cfg.ResticBackupArgs)
|
|
}
|
|
if strings.Join(cfg.ResticForgetArgs, " ") != "--keep-last 5 --prune" {
|
|
t.Fatalf("unexpected forget args: %#v", cfg.ResticForgetArgs)
|
|
}
|
|
if cfg.WorkerServiceAccount != "soteria-worker" || cfg.ListenAddr != ":9090" {
|
|
t.Fatalf("unexpected worker/listen config: %#v", cfg)
|
|
}
|
|
if cfg.JobNodeSelector["hardware"] != "rpi5" || cfg.JobNodeSelector["role"] != "worker" {
|
|
t.Fatalf("unexpected node selector: %#v", cfg.JobNodeSelector)
|
|
}
|
|
if !cfg.AuthRequired || len(cfg.AllowedGroups) != 2 || len(cfg.AuthBearerTokens) != 2 {
|
|
t.Fatalf("unexpected auth config: %#v", cfg)
|
|
}
|
|
if cfg.MetricsRefreshInterval != 60*time.Second || cfg.PolicyEvalInterval != 120*time.Second {
|
|
t.Fatalf("unexpected intervals: %#v", cfg)
|
|
}
|
|
if cfg.BackupMaxAge != time.Duration(12.5*float64(time.Hour)) {
|
|
t.Fatalf("unexpected backup max age: %#v", cfg.BackupMaxAge)
|
|
}
|
|
if !cfg.B2Enabled || cfg.B2SecretNamespace != "atlas" {
|
|
t.Fatalf("unexpected B2 enablement or secret namespace: %#v", cfg)
|
|
}
|
|
if cfg.B2ScanInterval != 900*time.Second || cfg.B2ScanTimeout != 45*time.Second {
|
|
t.Fatalf("unexpected B2 intervals: %#v", cfg)
|
|
}
|
|
if cfg.JobTTLSeconds != 7200 {
|
|
t.Fatalf("unexpected job ttl: %#v", cfg.JobTTLSeconds)
|
|
}
|
|
}
|
|
|
|
func TestLoadInfersB2EnablementAndSecretNamespaceDefault(t *testing.T) {
|
|
clearConfigEnv(t)
|
|
withEnv(t, "SOTERIA_NAMESPACE", "atlas")
|
|
withEnv(t, "SOTERIA_B2_SECRET_NAME", "b2-creds")
|
|
withEnv(t, "SOTERIA_B2_ACCESS_KEY_ID", "abc")
|
|
withEnv(t, "SOTERIA_B2_SECRET_ACCESS_KEY", "def")
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load config with inferred b2 enablement: %v", err)
|
|
}
|
|
if !cfg.B2Enabled {
|
|
t.Fatalf("expected B2 enablement to be inferred from secret config")
|
|
}
|
|
if cfg.B2SecretNamespace != "atlas" {
|
|
t.Fatalf("expected B2 secret namespace to default to service namespace, got %#v", cfg.B2SecretNamespace)
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsInvalidConfigurations(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
env map[string]string
|
|
substr string
|
|
}{
|
|
{
|
|
name: "restic missing repository",
|
|
env: map[string]string{
|
|
"SOTERIA_BACKUP_DRIVER": "restic",
|
|
"SOTERIA_RESTIC_REPOSITORY": "",
|
|
},
|
|
substr: "SOTERIA_RESTIC_REPOSITORY is required",
|
|
},
|
|
{
|
|
name: "invalid node selector",
|
|
env: map[string]string{
|
|
"SOTERIA_JOB_NODE_SELECTOR": "broken-selector",
|
|
},
|
|
substr: "SOTERIA_JOB_NODE_SELECTOR is invalid",
|
|
},
|
|
{
|
|
name: "restic invalid repository path",
|
|
env: map[string]string{
|
|
"SOTERIA_BACKUP_DRIVER": "restic",
|
|
"SOTERIA_RESTIC_REPOSITORY": "s3:https://repo/../bad",
|
|
"SOTERIA_RESTIC_SECRET_NAME": "restic-creds",
|
|
},
|
|
substr: "SOTERIA_RESTIC_REPOSITORY contains invalid path segments",
|
|
},
|
|
{
|
|
name: "unsupported driver",
|
|
env: map[string]string{
|
|
"SOTERIA_BACKUP_DRIVER": "zfs",
|
|
},
|
|
substr: "SOTERIA_BACKUP_DRIVER must be longhorn or restic",
|
|
},
|
|
{
|
|
name: "invalid longhorn mode",
|
|
env: map[string]string{
|
|
"SOTERIA_LONGHORN_BACKUP_MODE": "delta",
|
|
},
|
|
substr: "SOTERIA_LONGHORN_BACKUP_MODE must be incremental or full",
|
|
},
|
|
{
|
|
name: "invalid metrics interval",
|
|
env: map[string]string{
|
|
"SOTERIA_METRICS_REFRESH_SECONDS": "0",
|
|
},
|
|
substr: "SOTERIA_METRICS_REFRESH_SECONDS must be greater than zero",
|
|
},
|
|
{
|
|
name: "invalid backup max age",
|
|
env: map[string]string{
|
|
"SOTERIA_BACKUP_MAX_AGE_HOURS": "0",
|
|
},
|
|
substr: "SOTERIA_BACKUP_MAX_AGE_HOURS must be greater than zero",
|
|
},
|
|
{
|
|
name: "b2 enabled without endpoint or secret",
|
|
env: map[string]string{
|
|
"SOTERIA_B2_ENABLED": "true",
|
|
},
|
|
substr: "SOTERIA_B2_ENDPOINT or SOTERIA_B2_SECRET_NAME is required",
|
|
},
|
|
{
|
|
name: "invalid b2 scan interval",
|
|
env: map[string]string{
|
|
"SOTERIA_B2_ENABLED": "true",
|
|
"SOTERIA_B2_ENDPOINT": "https://s3.example.invalid",
|
|
"SOTERIA_B2_SCAN_INTERVAL_SECONDS": "0",
|
|
},
|
|
substr: "SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero",
|
|
},
|
|
{
|
|
name: "invalid b2 scan timeout",
|
|
env: map[string]string{
|
|
"SOTERIA_B2_ENABLED": "true",
|
|
"SOTERIA_B2_ENDPOINT": "https://s3.example.invalid",
|
|
"SOTERIA_B2_SCAN_TIMEOUT_SECONDS": "0",
|
|
},
|
|
substr: "SOTERIA_B2_SCAN_TIMEOUT_SECONDS must be greater than zero",
|
|
},
|
|
{
|
|
name: "invalid policy eval interval",
|
|
env: map[string]string{
|
|
"SOTERIA_POLICY_EVAL_SECONDS": "0",
|
|
},
|
|
substr: "SOTERIA_POLICY_EVAL_SECONDS must be greater than zero",
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
clearConfigEnv(t)
|
|
for key, value := range tc.env {
|
|
withEnv(t, key, value)
|
|
}
|
|
|
|
_, err := Load()
|
|
if err == nil || !strings.Contains(err.Error(), tc.substr) {
|
|
t.Fatalf("expected error containing %q, got %v", tc.substr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEnvAndParsingHelpers(t *testing.T) {
|
|
clearConfigEnv(t)
|
|
withEnv(t, "TEST_STRING", " value ")
|
|
withEnv(t, "TEST_INT", "42")
|
|
withEnv(t, "TEST_FLOAT", "1.5")
|
|
withEnv(t, "TEST_BOOL_TRUE", "yes")
|
|
withEnv(t, "TEST_BOOL_FALSE", "off")
|
|
|
|
if got := getenv("TEST_STRING"); got != "value" {
|
|
t.Fatalf("unexpected getenv result %q", got)
|
|
}
|
|
if got := getenvDefault("MISSING_STRING", "fallback"); got != "fallback" {
|
|
t.Fatalf("unexpected getenvDefault result %q", got)
|
|
}
|
|
if got, ok := getenvInt("TEST_INT"); !ok || got != 42 {
|
|
t.Fatalf("unexpected getenvInt result %d %v", got, ok)
|
|
}
|
|
if got, ok := getenvInt("TEST_STRING"); ok || got != 0 {
|
|
t.Fatalf("expected invalid int parse to fail, got %d %v", got, ok)
|
|
}
|
|
if got, ok := getenvFloat("TEST_FLOAT"); !ok || got != 1.5 {
|
|
t.Fatalf("unexpected getenvFloat result %f %v", got, ok)
|
|
}
|
|
if got, ok := getenvFloat("TEST_STRING"); ok || got != 0 {
|
|
t.Fatalf("expected invalid float parse to fail, got %f %v", got, ok)
|
|
}
|
|
if !getenvBool("TEST_BOOL_TRUE") || getenvBool("TEST_BOOL_FALSE") {
|
|
t.Fatalf("unexpected getenvBool results")
|
|
}
|
|
if selector := parseNodeSelector("role=worker, hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" {
|
|
t.Fatalf("unexpected selector parse: %#v", selector)
|
|
}
|
|
if selector := parseNodeSelector("role=worker, , hardware=rpi5"); selector["role"] != "worker" || selector["hardware"] != "rpi5" {
|
|
t.Fatalf("expected empty selector segments to be ignored, got %#v", selector)
|
|
}
|
|
if parseNodeSelector("role=") != nil {
|
|
t.Fatalf("expected invalid selector to return nil")
|
|
}
|
|
if values := parseCSV("one, two, , three"); len(values) != 3 || values[1] != "two" {
|
|
t.Fatalf("unexpected CSV parse: %#v", values)
|
|
}
|
|
if values := parseCSV(" "); values != nil {
|
|
t.Fatalf("expected blank CSV to return nil, got %#v", values)
|
|
}
|
|
}
|