195530e576
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
451 lines
16 KiB
Go
451 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestConfigSourceLoadsStableYAMLFields(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configFile := filepath.Join(dir, "config.yaml")
|
|
contents := `server:
|
|
trustedProxies:
|
|
- 10.0.0.0/8
|
|
tasks:
|
|
preparationTimeout: 17m
|
|
credentials:
|
|
file: /run/secrets/credentials
|
|
limits:
|
|
ghMaxConcurrent: 11
|
|
gitMaxConcurrent: 13
|
|
lspMaxConnections: 21
|
|
messageQueue:
|
|
maxPerSession: 14
|
|
agentctl:
|
|
idleTimeout: 2h
|
|
idleReaperInterval: 3m
|
|
notificationQueueCapacity: 2048
|
|
planning:
|
|
coalesceWindowMs: 2400
|
|
office:
|
|
schedulerTickMs: 7000
|
|
maxConcurrentInstance: 9
|
|
maxConcurrentWorkspace: 5
|
|
workspaceBudgetPerHour: 121
|
|
routineBudgetPerHour: 22
|
|
promotionAgeMinutes: 16
|
|
maxCausationDepth: 9
|
|
selfTriggerAllowance: 4
|
|
gateFailureThreshold: 5
|
|
observability:
|
|
otlpEndpoint: https://otel.example.test/v1/traces
|
|
launcher:
|
|
webPort: 37430
|
|
healthTimeoutMs: 12345
|
|
noBrowser: true
|
|
`
|
|
if err := os.WriteFile(configFile, []byte(contents), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
cfg, err := LoadWithPath(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
|
|
if got := nestedField(t, cfg, "Server", "TrustedProxies"); !reflect.DeepEqual(got.Interface(), []string{"10.0.0.0/8"}) {
|
|
t.Fatalf("server.trustedProxies = %#v", got.Interface())
|
|
}
|
|
if got := nestedField(t, cfg, "Tasks", "PreparationTimeout"); got.Interface() != 17*time.Minute {
|
|
t.Fatalf("tasks.preparationTimeout = %#v", got.Interface())
|
|
}
|
|
if got := nestedField(t, cfg, "Credentials", "File"); got.String() != "/run/secrets/credentials" {
|
|
t.Fatalf("credentials.file = %q", got.String())
|
|
}
|
|
if got := nestedField(t, cfg, "Limits", "GHMaxConcurrent"); got.Int() != 11 {
|
|
t.Fatalf("limits.ghMaxConcurrent = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Limits", "GitMaxConcurrent"); got.Int() != 13 {
|
|
t.Fatalf("limits.gitMaxConcurrent = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Limits", "LSPMaxConnections"); got.Int() != 21 {
|
|
t.Fatalf("limits.lspMaxConnections = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "MessageQueue", "MaxPerSession"); got.Int() != 14 {
|
|
t.Fatalf("messageQueue.maxPerSession = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Agentctl", "IdleTimeout"); got.Interface() != 2*time.Hour {
|
|
t.Fatalf("agentctl.idleTimeout = %#v", got.Interface())
|
|
}
|
|
if got := nestedField(t, cfg, "Agentctl", "IdleReaperInterval"); got.Interface() != 3*time.Minute {
|
|
t.Fatalf("agentctl.idleReaperInterval = %#v", got.Interface())
|
|
}
|
|
if got := nestedField(t, cfg, "Agentctl", "NotificationQueueCapacity"); got.Int() != 2048 {
|
|
t.Fatalf("agentctl.notificationQueueCapacity = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Planning", "CoalesceWindowMs"); got.Int() != 2400 {
|
|
t.Fatalf("planning.coalesceWindowMs = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "SchedulerTickMs"); got.Int() != 7000 {
|
|
t.Fatalf("office.schedulerTickMs = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "MaxConcurrentInstance"); got.Int() != 9 {
|
|
t.Fatalf("office.maxConcurrentInstance = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "MaxConcurrentWorkspace"); got.Int() != 5 {
|
|
t.Fatalf("office.maxConcurrentWorkspace = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "WorkspaceBudgetPerHour"); got.Int() != 121 {
|
|
t.Fatalf("office.workspaceBudgetPerHour = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "RoutineBudgetPerHour"); got.Int() != 22 {
|
|
t.Fatalf("office.routineBudgetPerHour = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "PromotionAgeMinutes"); got.Int() != 16 {
|
|
t.Fatalf("office.promotionAgeMinutes = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "MaxCausationDepth"); got.Int() != 9 {
|
|
t.Fatalf("office.maxCausationDepth = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "SelfTriggerAllowance"); got.Int() != 4 {
|
|
t.Fatalf("office.selfTriggerAllowance = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Office", "GateFailureThreshold"); got.Int() != 5 {
|
|
t.Fatalf("office.gateFailureThreshold = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Observability", "OTLPEndpoint"); got.String() != "https://otel.example.test/v1/traces" {
|
|
t.Fatalf("observability.otlpEndpoint = %q", got.String())
|
|
}
|
|
if got := nestedField(t, cfg, "Launcher", "WebPort"); got.Int() != 37430 {
|
|
t.Fatalf("launcher.webPort = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Launcher", "HealthTimeoutMs"); got.Int() != 12345 {
|
|
t.Fatalf("launcher.healthTimeoutMs = %d", got.Int())
|
|
}
|
|
if got := nestedField(t, cfg, "Launcher", "NoBrowser"); !got.Bool() {
|
|
t.Fatal("launcher.noBrowser = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigDiscovery(t *testing.T) {
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
homeDir := t.TempDir()
|
|
t.Chdir(t.TempDir())
|
|
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40123\n"), 0o600); err != nil {
|
|
t.Fatalf("write home config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Server.Port != 40123 {
|
|
t.Fatalf("server.port = %d, want 40123", cfg.Server.Port)
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigCannotRelocateItself(t *testing.T) {
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
homeDir := t.TempDir()
|
|
t.Chdir(t.TempDir())
|
|
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("homeDir: /different\n"), 0o600); err != nil {
|
|
t.Fatalf("write home config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
|
|
|
_, err := Load()
|
|
if err == nil || !strings.Contains(err.Error(), "homeDir") || !strings.Contains(err.Error(), homeDir) {
|
|
t.Fatalf("Load error = %v, want a homeDir relocation error naming %s", err, homeDir)
|
|
}
|
|
}
|
|
|
|
func TestConfigSourceMetadataReportsSelectedFileAndYAMLValues(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("server:\n port: 40124\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_SERVER_PORT", "40125")
|
|
|
|
cfg, err := LoadWithPath(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
source := reflect.ValueOf(cfg).Elem().FieldByName("Source")
|
|
if !source.IsValid() {
|
|
t.Fatal("Config.Source is missing")
|
|
}
|
|
file := source.FieldByName("FilePath")
|
|
if !file.IsValid() || file.String() != filepath.Join(dir, "config.yaml") {
|
|
t.Fatalf("selected config file = %#v, want %s", file.Interface(), filepath.Join(dir, "config.yaml"))
|
|
}
|
|
values := source.FieldByName("Values")
|
|
if !values.IsValid() {
|
|
t.Fatal("Config.Source.Values is missing")
|
|
}
|
|
got := values.MapIndex(reflect.ValueOf("server.port"))
|
|
if !got.IsValid() || got.String() != "environment" {
|
|
t.Fatalf("server.port source = %#v, want environment", got.Interface())
|
|
}
|
|
}
|
|
|
|
func TestConfigPrecedenceEnvironmentOverYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("messageQueue:\n maxPerSession: 4\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_QUEUE_MAX_PER_SESSION", "9")
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
|
|
cfg, err := LoadWithPath(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
if cfg.MessageQueue.MaxPerSession != 9 {
|
|
t.Fatalf("messageQueue.maxPerSession = %d, want 9", cfg.MessageQueue.MaxPerSession)
|
|
}
|
|
if got := cfg.SourceFor("messageQueue.maxPerSession"); got != SourceEnvironment {
|
|
t.Fatalf("messageQueue.maxPerSession source = %q, want %q", got, SourceEnvironment)
|
|
}
|
|
}
|
|
|
|
func TestEmptyCredentialsEnvironmentClearsYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("credentials:\n file: /run/secrets/credentials\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_CREDENTIALS_FILE", "")
|
|
|
|
cfg, err := LoadWithPath(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
if cfg.Credentials.File != "" {
|
|
t.Fatalf("credentials.file = %q, want empty environment override", cfg.Credentials.File)
|
|
}
|
|
if got := cfg.SourceFor("credentials.file"); got != SourceEnvironment {
|
|
t.Fatalf("credentials.file source = %q, want environment", got)
|
|
}
|
|
}
|
|
|
|
func TestRepositoryDiscoveryRootsEnvironmentUsesCommaSeparatedValues(t *testing.T) {
|
|
t.Setenv("KANDEV_REPOSITORYDISCOVERY_ROOTS", "/one,/two")
|
|
|
|
cfg, err := LoadWithPath(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(cfg.RepositoryDiscovery.Roots, []string{"/one", "/two"}) {
|
|
t.Fatalf("repositoryDiscovery.roots = %#v, want comma-separated paths", cfg.RepositoryDiscovery.Roots)
|
|
}
|
|
}
|
|
|
|
func TestInvalidYAMLValueNamesSelectedFileAndKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configFile := filepath.Join(dir, "config.yaml")
|
|
if err := os.WriteFile(configFile, []byte("tasks:\n preparationTimeout: not-a-duration\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
|
|
_, err := LoadWithPath(dir)
|
|
if err == nil || !strings.Contains(err.Error(), configFile) || !strings.Contains(err.Error(), "preparationTimeout") {
|
|
t.Fatalf("Load error = %v, want selected file and preparationTimeout", err)
|
|
}
|
|
}
|
|
|
|
func TestSecretConfigPermissionsWarningOmitsValue(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix permission warning is not applicable")
|
|
}
|
|
dir := t.TempDir()
|
|
configFile := filepath.Join(dir, "config.yaml")
|
|
secret := "do-not-log-this-secret"
|
|
if err := os.WriteFile(configFile, []byte("auth:\n jwtSecret: "+secret+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
|
|
cfg, err := LoadWithPath(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
if len(cfg.Source.Warnings) != 1 || !strings.Contains(cfg.Source.Warnings[0], configFile) {
|
|
t.Fatalf("warnings = %#v, want one warning naming %s", cfg.Source.Warnings, configFile)
|
|
}
|
|
if strings.Contains(cfg.Source.Warnings[0], secret) {
|
|
t.Fatalf("warning exposed secret: %q", cfg.Source.Warnings[0])
|
|
}
|
|
}
|
|
|
|
func TestInvalidYAMLRangeNamesSettingAndFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configFile := filepath.Join(dir, "config.yaml")
|
|
if err := os.WriteFile(configFile, []byte("limits:\n ghMaxConcurrent: 0\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
|
|
_, err := LoadWithPath(dir)
|
|
if err == nil || !strings.Contains(err.Error(), configFile) || !strings.Contains(err.Error(), "ghMaxConcurrent") {
|
|
t.Fatalf("Load error = %v, want selected file and ghMaxConcurrent", err)
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigDiscoveryOrderUsesOneFirstExistingFile(t *testing.T) {
|
|
workingDir := t.TempDir()
|
|
homeDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(workingDir, "config.yaml"), []byte("server:\n port: 40126\n"), 0o600); err != nil {
|
|
t.Fatalf("write working-directory config: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("server:\n port: 40127\n"), 0o600); err != nil {
|
|
t.Fatalf("write home config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
t.Chdir(workingDir)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Server.Port != 40126 || cfg.Source.FilePath != filepath.Join(workingDir, "config.yaml") {
|
|
t.Fatalf("selected config = %q, port = %d; want working-directory file and port 40126", cfg.Source.FilePath, cfg.Server.Port)
|
|
}
|
|
}
|
|
|
|
func TestHomeConfigInvalidFirstDoesNotFallThrough(t *testing.T) {
|
|
workingDir := t.TempDir()
|
|
homeDir := t.TempDir()
|
|
configFile := filepath.Join(workingDir, "config.yaml")
|
|
if err := os.WriteFile(configFile, []byte("limits:\n ghMaxConcurrent: 0\n"), 0o600); err != nil {
|
|
t.Fatalf("write invalid config: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(homeDir, "config.yaml"), []byte("limits:\n ghMaxConcurrent: 22\n"), 0o600); err != nil {
|
|
t.Fatalf("write home config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_HOME_DIR", homeDir)
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
t.Chdir(workingDir)
|
|
|
|
_, err := Load()
|
|
if err == nil || !strings.Contains(err.Error(), configFile) {
|
|
t.Fatalf("Load error = %v, want invalid first file and no fallback", err)
|
|
}
|
|
}
|
|
|
|
// @covers AC-EXECUTORS-SSH-REACHABILITY-001.14
|
|
//
|
|
// The catalog's own fallback-and-bounds logic only ever falls back to the
|
|
// default (60): the 0-disables/15-3600-clamp behavior belongs to the
|
|
// reachability package's own ClampInterval, not this layer, per the design's
|
|
// split between catalog-level normalization and package-level clamping.
|
|
func TestExecutorsSSHReachabilityIntervalSecondsEnvironment(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
env string
|
|
wantValue int
|
|
wantSource SettingSource
|
|
}{
|
|
{"absent uses default", "", 60, SourceDefault},
|
|
{"valid value passes through unclamped", "45", 45, SourceEnvironment},
|
|
{"zero passes through as the disable sentinel", "0", 0, SourceEnvironment},
|
|
{"negative falls back to the default", "-5", 60, SourceDefault},
|
|
{"unparsable falls back to the default", "not-a-number", 60, SourceDefault},
|
|
{"a value outside 15-3600 still passes through unclamped here", "9999", 9999, SourceEnvironment},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.env != "" {
|
|
t.Setenv("KANDEV_EXECUTORS_SSHREACHABILITYINTERVALSECONDS", tt.env)
|
|
} else {
|
|
t.Setenv("KANDEV_EXECUTORS_SSHREACHABILITYINTERVALSECONDS", "")
|
|
}
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
|
|
cfg, err := LoadWithPath(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
if cfg.Executors.SSHReachabilityIntervalSeconds != tt.wantValue {
|
|
t.Fatalf("executors.sshReachabilityIntervalSeconds = %d, want %d", cfg.Executors.SSHReachabilityIntervalSeconds, tt.wantValue)
|
|
}
|
|
if got := cfg.SourceFor("executors.sshReachabilityIntervalSeconds"); got != tt.wantSource {
|
|
t.Fatalf("executors.sshReachabilityIntervalSeconds source = %q, want %q", got, tt.wantSource)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// @covers AC-EXECUTORS-SSH-REACHABILITY-001.14
|
|
//
|
|
// A YAML value is not passed through the environment's fallback-and-bounds
|
|
// logic at all — it decodes straight into the typed field, negative values
|
|
// included, leaving ClampInterval as the sole place that normalizes it.
|
|
func TestExecutorsSSHReachabilityIntervalSecondsYAMLPassesThroughUnclamped(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("executors:\n sshReachabilityIntervalSeconds: -5\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
t.Setenv("KANDEV_EXECUTORS_SSHREACHABILITYINTERVALSECONDS", "")
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
|
|
cfg, err := LoadWithPath(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadWithPath: %v", err)
|
|
}
|
|
if cfg.Executors.SSHReachabilityIntervalSeconds != -5 {
|
|
t.Fatalf("executors.sshReachabilityIntervalSeconds = %d, want -5 (unclamped)", cfg.Executors.SSHReachabilityIntervalSeconds)
|
|
}
|
|
if got := cfg.SourceFor("executors.sshReachabilityIntervalSeconds"); got != SourceConfiguration {
|
|
t.Fatalf("executors.sshReachabilityIntervalSeconds source = %q, want %q", got, SourceConfiguration)
|
|
}
|
|
}
|
|
|
|
func nestedField(t *testing.T, value any, names ...string) reflect.Value {
|
|
t.Helper()
|
|
current := reflect.ValueOf(value)
|
|
if current.Kind() == reflect.Pointer {
|
|
current = current.Elem()
|
|
}
|
|
for _, name := range names {
|
|
if current.Kind() != reflect.Struct {
|
|
t.Fatalf("cannot find %s in %s", name, current.Type())
|
|
}
|
|
current = current.FieldByName(name)
|
|
if !current.IsValid() {
|
|
t.Fatalf("configuration field %s is missing", strings.Join(names, "."))
|
|
}
|
|
}
|
|
return current
|
|
}
|
|
|
|
func TestHomeConfigDefaultDotDirectory(t *testing.T) {
|
|
userHome := t.TempDir()
|
|
defaultHome := filepath.Join(userHome, ".kandev")
|
|
if err := os.MkdirAll(defaultHome, 0o700); err != nil {
|
|
t.Fatalf("create default home: %v", err)
|
|
}
|
|
configFile := filepath.Join(defaultHome, "config.yaml")
|
|
if err := os.WriteFile(configFile, []byte("server:\n port: 40128\n"), 0o600); err != nil {
|
|
t.Fatalf("write default home config: %v", err)
|
|
}
|
|
t.Setenv("HOME", userHome)
|
|
t.Setenv("KANDEV_HOME_DIR", "")
|
|
t.Setenv("KANDEV_SERVER_PORT", "")
|
|
t.Chdir(t.TempDir())
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Server.Port != 40128 || cfg.Source.FilePath != configFile {
|
|
t.Fatalf("selected config = %q, port = %d; want %s and port 40128", cfg.Source.FilePath, cfg.Server.Port, configFile)
|
|
}
|
|
}
|