package config import ( "context" "testing" "time" "tangled.org/core/spindle/quota" ) func TestLoadAllowsUnconfiguredMicroVMEngine(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_MICROVM_PIPELINES_IMAGE_DIR", "") cfg, err := Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.MicroVMPipelines.ImageDir != "" { t.Fatalf("image directory = %q, want empty", cfg.MicroVMPipelines.ImageDir) } if cfg.Mill.DrainTimeout != 20*time.Minute { t.Fatalf("drain timeout = %s, want 20m", cfg.Mill.DrainTimeout) } } func TestLoadRequiresOwnerForCoordinatorRoles(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "") for _, role := range []string{"standalone", "mill"} { t.Run(role, func(t *testing.T) { t.Setenv("SPINDLE_ROLE", role) if _, err := Load(context.Background()); err == nil { t.Fatalf("Load accepted SPINDLE_ROLE=%s without an owner", role) } }) } } func TestLoadAllowsExecutorWithoutOwner(t *testing.T) { t.Setenv("SPINDLE_ROLE", "executor") t.Setenv("SPINDLE_SERVER_HOSTNAME", "worker-1") t.Setenv("SPINDLE_MILL_URL", "wss://spindle.example.com/mill") t.Setenv("SPINDLE_MILL_SHARED_SECRET", "secret") cfg, err := Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.Server.Owner != "" { t.Fatalf("owner = %q, want empty", cfg.Server.Owner) } } func TestLoadRejectsNonPositiveDrainTimeout(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") for _, timeout := range []string{"0s", "-1s"} { t.Run(timeout, func(t *testing.T) { t.Setenv("SPINDLE_MILL_DRAIN_TIMEOUT", timeout) if _, err := Load(context.Background()); err == nil { t.Fatalf("Load accepted drain timeout %s", timeout) } }) } } func TestLoadRejectsInvalidScheduleLimits(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") for _, tc := range []struct{ name, key, value string }{ {"disabled interval", "SPINDLE_SCHEDULE_MINIMUM_INTERVAL", "0s"}, {"sub-minute interval", "SPINDLE_SCHEDULE_MINIMUM_INTERVAL", "30s"}, {"fractional minute", "SPINDLE_SCHEDULE_MINIMUM_INTERVAL", "90s"}, {"no workers", "SPINDLE_SCHEDULE_CONCURRENCY", "0"}, {"negative workers", "SPINDLE_SCHEDULE_CONCURRENCY", "-1"}, } { t.Run(tc.name, func(t *testing.T) { t.Setenv(tc.key, tc.value) if _, err := Load(context.Background()); err == nil { t.Fatalf("Load accepted %s=%s", tc.key, tc.value) } }) } } func TestLoadRequiresJumpHostKey(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_ROLE", "mill") t.Setenv("SPINDLE_MILL_JUMP_LISTEN_ADDR", "0.0.0.0:22") if _, err := Load(context.Background()); err == nil { t.Fatal("Load accepted a jump listener without a host key path") } } func TestLoadRejectsJumpListenerOutsideMill(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_ROLE", "standalone") t.Setenv("SPINDLE_MILL_JUMP_LISTEN_ADDR", "0.0.0.0:22") t.Setenv("SPINDLE_MILL_JUMP_HOST_KEY_PATH", "/tmp/jump-host-key") if _, err := Load(context.Background()); err == nil { t.Fatal("Load accepted a mill jump listener in standalone mode") } } func TestLoadValidatesMaxJumpConnections(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_ROLE", "mill") t.Setenv("SPINDLE_MILL_JUMP_LISTEN_ADDR", "0.0.0.0:22") t.Setenv("SPINDLE_MILL_JUMP_HOST_KEY_PATH", "/tmp/jump-host-key") t.Setenv("SPINDLE_MILL_MAX_JUMP_CONNECTIONS", "0") if _, err := Load(context.Background()); err == nil { t.Fatal("Load accepted a non-positive jump connection limit") } t.Setenv("SPINDLE_MILL_MAX_JUMP_CONNECTIONS", "17") cfg, err := Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.Mill.MaxJumpConnections != 17 { t.Fatalf("max jump connections = %d, want 17", cfg.Mill.MaxJumpConnections) } } func TestLoadMetricsListenAddr(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") cfg, err := Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.Server.MetricsListenAddr != "127.0.0.1:9091" { t.Errorf("default MetricsListenAddr = %q, want 127.0.0.1:9091", cfg.Server.MetricsListenAddr) } t.Setenv("SPINDLE_SERVER_METRICS_LISTEN_ADDR", "0.0.0.0:9999") cfg, err = Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.Server.MetricsListenAddr != "0.0.0.0:9999" { t.Errorf("overridden MetricsListenAddr = %q, want 0.0.0.0:9999", cfg.Server.MetricsListenAddr) } } func TestLoadTracingConfig(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_TRACING_ENDPOINT", "tempo:4318") t.Setenv("SPINDLE_TRACING_SERVICE_NAME", "spindle-test") t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "0.5") t.Setenv("SPINDLE_TRACING_INSECURE", "true") cfg, err := Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.Tracing.Endpoint != "tempo:4318" { t.Errorf("Endpoint = %q, want tempo:4318", cfg.Tracing.Endpoint) } if cfg.Tracing.ServiceName != "spindle-test" { t.Errorf("ServiceName = %q, want spindle-test", cfg.Tracing.ServiceName) } if cfg.Tracing.SampleRatio != 0.5 { t.Errorf("SampleRatio = %f, want 0.5", cfg.Tracing.SampleRatio) } if !cfg.Tracing.Insecure { t.Errorf("Insecure = %t, want true", cfg.Tracing.Insecure) } } func TestLoadTracingConfigValidation(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "1.5") if _, err := Load(context.Background()); err == nil { t.Fatal("expected error for SampleRatio > 1") } t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "-0.1") if _, err := Load(context.Background()); err == nil { t.Fatal("expected error for SampleRatio < 0") } t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "NaN") if _, err := Load(context.Background()); err == nil { t.Fatal("expected error for NaN SampleRatio") } } func TestLoadQuotaLimitsValidation(t *testing.T) { tests := []struct { name string envs map[string]string wantErr bool }{ { name: "valid defaults", envs: map[string]string{ "SPINDLE_QUOTA_USER_CACHE_STORAGE_MIB": "0", "SPINDLE_QUOTA_USER_WORKFLOWS": "0", "SPINDLE_QUOTA_USER_VCPUS": "0", "SPINDLE_QUOTA_USER_MEMORY_MIB": "0", "SPINDLE_QUOTA_USER_DISK_MIB": "0", "SPINDLE_QUOTA_REPO_CACHE_STORAGE_MIB": "0", "SPINDLE_QUOTA_REPO_WORKFLOWS": "0", "SPINDLE_QUOTA_REPO_VCPUS": "0", "SPINDLE_QUOTA_REPO_MEMORY_MIB": "0", "SPINDLE_QUOTA_REPO_DISK_MIB": "0", }, wantErr: false, }, { name: "valid positive values", envs: map[string]string{ "SPINDLE_QUOTA_USER_CACHE_STORAGE_MIB": "100", "SPINDLE_QUOTA_USER_WORKFLOWS": "5", "SPINDLE_QUOTA_USER_VCPUS": "4", "SPINDLE_QUOTA_USER_MEMORY_MIB": "4096", "SPINDLE_QUOTA_USER_DISK_MIB": "10240", }, wantErr: false, }, { name: "negative user cache storage", envs: map[string]string{ "SPINDLE_QUOTA_USER_CACHE_STORAGE_MIB": "-1", }, wantErr: true, }, { name: "negative user workflows", envs: map[string]string{ "SPINDLE_QUOTA_USER_WORKFLOWS": "-1", }, wantErr: true, }, { name: "negative user vcpus", envs: map[string]string{ "SPINDLE_QUOTA_USER_VCPUS": "-1", }, wantErr: true, }, { name: "negative user memory", envs: map[string]string{ "SPINDLE_QUOTA_USER_MEMORY_MIB": "-1", }, wantErr: true, }, { name: "negative user disk", envs: map[string]string{ "SPINDLE_QUOTA_USER_DISK_MIB": "-1", }, wantErr: true, }, { name: "negative repo cache storage", envs: map[string]string{ "SPINDLE_QUOTA_REPO_CACHE_STORAGE_MIB": "-1", }, wantErr: true, }, { name: "negative repo workflows", envs: map[string]string{ "SPINDLE_QUOTA_REPO_WORKFLOWS": "-1", }, wantErr: true, }, { name: "negative repo vcpus", envs: map[string]string{ "SPINDLE_QUOTA_REPO_VCPUS": "-1", }, wantErr: true, }, { name: "negative repo memory", envs: map[string]string{ "SPINDLE_QUOTA_REPO_MEMORY_MIB": "-1", }, wantErr: true, }, { name: "negative repo disk", envs: map[string]string{ "SPINDLE_QUOTA_REPO_DISK_MIB": "-1", }, wantErr: true, }, { name: "user cache storage overflow", envs: map[string]string{ "SPINDLE_QUOTA_USER_CACHE_STORAGE_MIB": "9223372036854775807", }, wantErr: true, }, { name: "repo cache storage overflow", envs: map[string]string{ "SPINDLE_QUOTA_REPO_CACHE_STORAGE_MIB": "9223372036854775807", }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("SPINDLE_QUOTA_USER_CACHE_STORAGE_MIB", "") t.Setenv("SPINDLE_QUOTA_USER_WORKFLOWS", "") t.Setenv("SPINDLE_QUOTA_USER_VCPUS", "") t.Setenv("SPINDLE_QUOTA_USER_MEMORY_MIB", "") t.Setenv("SPINDLE_QUOTA_USER_DISK_MIB", "") t.Setenv("SPINDLE_QUOTA_REPO_CACHE_STORAGE_MIB", "") t.Setenv("SPINDLE_QUOTA_REPO_WORKFLOWS", "") t.Setenv("SPINDLE_QUOTA_REPO_VCPUS", "") t.Setenv("SPINDLE_QUOTA_REPO_MEMORY_MIB", "") t.Setenv("SPINDLE_QUOTA_REPO_DISK_MIB", "") t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_TRACING_SAMPLE_RATIO", "1.0") for k, v := range tt.envs { t.Setenv(k, v) } _, err := Load(context.Background()) if (err != nil) != tt.wantErr { t.Errorf("Load() error = %v, wantErr = %v", err, tt.wantErr) } }) } } func TestLoadLoggingConfig(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_LOGGING_ENDPOINT", "loki:4318") t.Setenv("SPINDLE_LOGGING_SERVICE_NAME", "spindle-log-test") t.Setenv("SPINDLE_LOGGING_INSECURE", "true") cfg, err := Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.Logging.Endpoint != "loki:4318" { t.Errorf("Endpoint = %q, want loki:4318", cfg.Logging.Endpoint) } if cfg.Logging.ServiceName != "spindle-log-test" { t.Errorf("ServiceName = %q, want spindle-log-test", cfg.Logging.ServiceName) } if !cfg.Logging.Insecure { t.Errorf("Insecure = %t, want true", cfg.Logging.Insecure) } } func TestLoadLoggingConfigValidation(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_LOGGING_ENDPOINT", "loki:4318") t.Setenv("SPINDLE_LOGGING_SERVICE_NAME", "") if _, err := Load(context.Background()); err == nil { t.Fatal("expected error for empty ServiceName when logging endpoint is configured") } } func TestLoadQuotaDefaultsDoesNotRequireServerConfiguration(t *testing.T) { t.Setenv("SPINDLE_QUOTA_USER_WORKFLOWS", "3") defaults, err := LoadQuotaDefaults(context.Background()) if err != nil { t.Fatal(err) } if got := defaults[quota.ScopeUser][quota.ResourceWorkflows]; got != 3 { t.Fatalf("user workflow limit = %d, want 3", got) } } func TestLoadQuotaDefaultsRejectsNegativeLimits(t *testing.T) { t.Setenv("SPINDLE_QUOTA_REPO_VCPUS", "-1") if _, err := LoadQuotaDefaults(context.Background()); err == nil { t.Fatal("negative quota limit accepted") } } func TestLoadCacheStoreID(t *testing.T) { t.Setenv("SPINDLE_SERVER_HOSTNAME", "spindle.example.com") t.Setenv("SPINDLE_SERVER_OWNER", "did:web:spindle.example.com") t.Setenv("SPINDLE_CACHE_STORE_ID", "shared-cache") cfg, err := Load(context.Background()) if err != nil { t.Fatal(err) } if cfg.Cache.StoreID != "shared-cache" { t.Fatalf("cache store ID = %q, want shared-cache", cfg.Cache.StoreID) } }