package main import ( "context" "errors" "flag" "io" "net/http" "net/http/httptest" "os" "reflect" "strings" "sync/atomic" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" "github.com/rcourtman/pulse-go-rewrite/internal/hostagent" "github.com/rcourtman/pulse-go-rewrite/internal/kubernetesagent" "github.com/rs/zerolog" ) func TestGatherTags(t *testing.T) { tests := []struct { name string env string flags []string expected []string }{ // Empty inputs { name: "empty env and flags returns empty slice", env: "", flags: nil, expected: []string{}, }, { name: "empty env and empty flags returns empty slice", env: "", flags: []string{}, expected: []string{}, }, // Environment only { name: "single env tag", env: "prod", flags: nil, expected: []string{"prod"}, }, { name: "multiple env tags comma separated", env: "prod,us-west", flags: nil, expected: []string{"prod", "us-west"}, }, { name: "env tags with whitespace trimmed", env: " prod , us-west ", flags: nil, expected: []string{"prod", "us-west"}, }, { name: "env empty items filtered", env: "prod,,us-west,", flags: nil, expected: []string{"prod", "us-west"}, }, { name: "env whitespace-only items filtered", env: "prod, ,us-west", flags: nil, expected: []string{"prod", "us-west"}, }, // Flags only { name: "single flag tag", env: "", flags: []string{"staging"}, expected: []string{"staging"}, }, { name: "multiple flag tags", env: "", flags: []string{"staging", "eu-central"}, expected: []string{"staging", "eu-central"}, }, { name: "flag tags with whitespace trimmed", env: "", flags: []string{" staging ", " eu-central "}, expected: []string{"staging", "eu-central"}, }, { name: "flag empty items filtered", env: "", flags: []string{"staging", "", "eu-central"}, expected: []string{"staging", "eu-central"}, }, { name: "flag whitespace-only items filtered", env: "", flags: []string{"staging", " ", "eu-central"}, expected: []string{"staging", "eu-central"}, }, // Both env and flags (env first, then flags) { name: "env tags come before flags", env: "prod", flags: []string{"app1"}, expected: []string{"prod", "app1"}, }, { name: "multiple env and multiple flags", env: "prod,us-west", flags: []string{"app1", "critical"}, expected: []string{"prod", "us-west", "app1", "critical"}, }, { name: "duplicates preserved (no dedup)", env: "prod,prod", flags: []string{"prod"}, expected: []string{"prod", "prod", "prod"}, }, // Edge cases { name: "only commas in env", env: ",,,", flags: nil, expected: []string{}, }, { name: "single comma", env: ",", flags: nil, expected: []string{}, }, { name: "env with tabs", env: "\tprod\t,\tstaging\t", flags: nil, expected: []string{"prod", "staging"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := gatherTags(tt.env, tt.flags) if !reflect.DeepEqual(got, tt.expected) { t.Fatalf("expected %v, got %v", tt.expected, got) } }) } } func TestGatherCSV(t *testing.T) { tests := []struct { name string env string flags []string expected []string }{ {"empty", "", nil, []string{}}, {"env only", "a,b", nil, []string{"a", "b"}}, {"env trims", " a , b ", nil, []string{"a", "b"}}, {"flags only", "", []string{"x", " y "}, []string{"x", "y"}}, {"both", "a", []string{"b"}, []string{"a", "b"}}, {"filters empties", "a,,", []string{"", "b", " "}, []string{"a", "b"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := gatherCSV(tc.env, tc.flags) if !reflect.DeepEqual(got, tc.expected) { t.Fatalf("expected %v, got %v", tc.expected, got) } }) } } func TestApplyRemoteSettings(t *testing.T) { originalLevel := zerolog.GlobalLevel() defer zerolog.SetGlobalLevel(originalLevel) logger := zerolog.New(io.Discard).Level(zerolog.InfoLevel) cfg := &Config{ Interval: time.Second, Logger: &logger, } settings := map[string]interface{}{ "enable_host": true, "enable_docker": true, "enable_kubernetes": true, "enable_proxmox": true, "proxmox_type": "Auto", "docker_runtime": "PoDmAn", "log_level": "debug", "interval": "45s", "disable_auto_update": true, "disable_docker_update_checks": true, "kube_include_all_pods": true, "kube_include_all_deployments": true, "report_ip": "10.0.0.1", "disable_ceph": true, "unknown_key_should_be_ignored": true, } applyRemoteSettings(cfg, settings, &logger) if !cfg.EnableHost || !cfg.EnableDocker || !cfg.EnableKubernetes || !cfg.EnableProxmox { t.Fatalf("expected module flags enabled, got host=%v docker=%v kube=%v proxmox=%v", cfg.EnableHost, cfg.EnableDocker, cfg.EnableKubernetes, cfg.EnableProxmox) } if !cfg.DockerConfigured { t.Fatalf("expected DockerConfigured to be true") } if cfg.ProxmoxType != "" { t.Fatalf("expected proxmox type to normalize to empty for auto, got %q", cfg.ProxmoxType) } if cfg.DockerRuntime != "podman" { t.Fatalf("expected docker runtime to be normalized, got %q", cfg.DockerRuntime) } if cfg.LogLevel != zerolog.DebugLevel { t.Fatalf("expected log level debug, got %v", cfg.LogLevel) } if cfg.Logger == nil { t.Fatalf("expected logger to be updated") } if cfg.Interval != 45*time.Second { t.Fatalf("expected interval 45s, got %v", cfg.Interval) } if !cfg.DisableAutoUpdate || !cfg.DisableDockerUpdateChecks { t.Fatalf("expected auto-update disables to be true") } if !cfg.KubeIncludeAllPods || !cfg.KubeIncludeAllDeployments { t.Fatalf("expected kube include flags to be true") } if cfg.ReportIP != "10.0.0.1" || !cfg.DisableCeph { t.Fatalf("unexpected report ip / disable ceph: %q %v", cfg.ReportIP, cfg.DisableCeph) } } func TestApplyRemoteSettingsIntervalFloat(t *testing.T) { logger := zerolog.New(io.Discard) cfg := &Config{} applyRemoteSettings(cfg, map[string]interface{}{ "interval": float64(12), }, &logger) if cfg.Interval != 12*time.Second { t.Fatalf("expected interval 12s, got %v", cfg.Interval) } } func TestDefaultInt(t *testing.T) { tests := []struct { name string value string fallback int expected int }{ {"empty uses fallback", "", 5, 5}, {"whitespace uses fallback", " ", 5, 5}, {"valid int", "12", 5, 12}, {"invalid uses fallback", "nope", 5, 5}, {"leading whitespace", " 7", 5, 7}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := defaultInt(tc.value, tc.fallback) if got != tc.expected { t.Fatalf("expected %d, got %d", tc.expected, got) } }) } } func TestParseLogLevel(t *testing.T) { tests := []struct { name string input string wantLevel zerolog.Level wantErr bool }{ // Valid levels { name: "debug level", input: "debug", wantLevel: zerolog.DebugLevel, }, { name: "info level", input: "info", wantLevel: zerolog.InfoLevel, }, { name: "warn level", input: "warn", wantLevel: zerolog.WarnLevel, }, { name: "error level", input: "error", wantLevel: zerolog.ErrorLevel, }, { name: "trace level (accepted in unified agent)", input: "trace", wantLevel: zerolog.TraceLevel, }, { name: "fatal level (accepted in unified agent)", input: "fatal", wantLevel: zerolog.FatalLevel, }, { name: "panic level (accepted in unified agent)", input: "panic", wantLevel: zerolog.PanicLevel, }, // Case insensitivity { name: "uppercase DEBUG", input: "DEBUG", wantLevel: zerolog.DebugLevel, }, { name: "mixed case Info", input: "Info", wantLevel: zerolog.InfoLevel, }, { name: "uppercase WARN", input: "WARN", wantLevel: zerolog.WarnLevel, }, { name: "uppercase ERROR", input: "ERROR", wantLevel: zerolog.ErrorLevel, }, { name: "uppercase TRACE", input: "TRACE", wantLevel: zerolog.TraceLevel, }, // Whitespace handling { name: "leading whitespace", input: " debug", wantLevel: zerolog.DebugLevel, }, { name: "trailing whitespace", input: "warn ", wantLevel: zerolog.WarnLevel, }, { name: "both whitespace", input: " error ", wantLevel: zerolog.ErrorLevel, }, { name: "tabs", input: "\tinfo\t", wantLevel: zerolog.InfoLevel, }, // Empty string defaults to info { name: "empty string defaults to info", input: "", wantLevel: zerolog.InfoLevel, }, { name: "whitespace only defaults to info", input: " ", wantLevel: zerolog.InfoLevel, }, { name: "tabs only defaults to info", input: "\t\t", wantLevel: zerolog.InfoLevel, }, // Numeric levels (zerolog supports these) { name: "numeric -1 maps to trace level", input: "-1", wantLevel: zerolog.TraceLevel, }, { name: "numeric 0 maps to debug level", input: "0", wantLevel: zerolog.DebugLevel, }, { name: "numeric 1 maps to info level", input: "1", wantLevel: zerolog.InfoLevel, }, { name: "numeric 2 maps to warn level", input: "2", wantLevel: zerolog.WarnLevel, }, { name: "numeric 3 maps to error level", input: "3", wantLevel: zerolog.ErrorLevel, }, { name: "numeric 4 maps to fatal level", input: "4", wantLevel: zerolog.FatalLevel, }, { name: "numeric 5 maps to panic level", input: "5", wantLevel: zerolog.PanicLevel, }, // Invalid levels { name: "invalid level returns error", input: "invalid", wantLevel: zerolog.NoLevel, // zerolog.ParseLevel returns NoLevel on error wantErr: true, }, { name: "typo returns error", input: "debuf", wantLevel: zerolog.NoLevel, wantErr: true, }, { name: "verbose returns error", input: "verbose", wantLevel: zerolog.NoLevel, wantErr: true, }, { name: "numeric out of range accepted (zerolog accepts any int)", input: "99", wantLevel: zerolog.Level(99), wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { level, err := parseLogLevel(tt.input) if tt.wantErr { if err == nil { t.Fatalf("expected error, got nil") } } else { if err != nil { t.Fatalf("unexpected error: %v", err) } } if level != tt.wantLevel { t.Fatalf("expected level %v, got %v", tt.wantLevel, level) } }) } } func TestDefaultLogLevel(t *testing.T) { tests := []struct { name string envValue string expected string }{ // Empty returns "info" { name: "empty string returns info", envValue: "", expected: "info", }, { name: "whitespace only returns info", envValue: " ", expected: "info", }, { name: "tabs only returns info", envValue: "\t\t", expected: "info", }, { name: "newline only returns info", envValue: "\n", expected: "info", }, // Non-empty returns as-is (no validation) { name: "debug returns debug", envValue: "debug", expected: "debug", }, { name: "error returns error", envValue: "error", expected: "error", }, { name: "trace returns trace", envValue: "trace", expected: "trace", }, { name: "invalid value passed through", envValue: "invalid", expected: "invalid", }, { name: "mixed case passed through", envValue: "DEBUG", expected: "DEBUG", }, { name: "value with surrounding whitespace NOT trimmed", envValue: " debug ", expected: " debug ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := defaultLogLevel(tt.envValue) if got != tt.expected { t.Fatalf("expected %q, got %q", tt.expected, got) } }) } } func TestMultiValue(t *testing.T) { t.Run("String joins with comma", func(t *testing.T) { mv := multiValue{"a", "b", "c"} if got := mv.String(); got != "a,b,c" { t.Fatalf("expected %q, got %q", "a,b,c", got) } }) t.Run("String empty slice returns empty string", func(t *testing.T) { mv := multiValue{} if got := mv.String(); got != "" { t.Fatalf("expected %q, got %q", "", got) } }) t.Run("String single item no comma", func(t *testing.T) { mv := multiValue{"single"} if got := mv.String(); got != "single" { t.Fatalf("expected %q, got %q", "single", got) } }) t.Run("Set appends values", func(t *testing.T) { mv := multiValue{} if err := mv.Set("first"); err != nil { t.Fatalf("unexpected error: %v", err) } if err := mv.Set("second"); err != nil { t.Fatalf("unexpected error: %v", err) } if err := mv.Set("third"); err != nil { t.Fatalf("unexpected error: %v", err) } expected := multiValue{"first", "second", "third"} if !reflect.DeepEqual(mv, expected) { t.Fatalf("expected %v, got %v", expected, mv) } }) t.Run("Set preserves empty strings", func(t *testing.T) { mv := multiValue{} _ = mv.Set("") _ = mv.Set("value") _ = mv.Set("") if len(mv) != 3 { t.Fatalf("expected 3 items, got %d", len(mv)) } }) t.Run("Set always returns nil error", func(t *testing.T) { mv := multiValue{} // Set always returns nil, testing various inputs inputs := []string{"", "normal", "with spaces", "special!@#$%", "unicode日本語"} for _, input := range inputs { if err := mv.Set(input); err != nil { t.Fatalf("expected nil error for input %q, got %v", input, err) } } }) } func TestResolveEnableCommands(t *testing.T) { tests := []struct { name string enableFlag bool disableFlag bool envEnable string envDisable string expected bool }{ {"flag enable takes priority", true, false, "false", "false", true}, {"flag enable takes priority over disable flag", true, true, "false", "false", true}, {"flag disable (deprecated) returns false", false, true, "true", "false", false}, {"env enable true returns true", false, false, "true", "false", true}, {"env enable false returns false", false, false, "false", "false", false}, {"env disable (deprecated) false returns true", false, false, "", "false", true}, {"env disable (deprecated) true returns false", false, false, "", "true", false}, {"default returns false", false, false, "", "", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := resolveEnableCommands(tc.enableFlag, tc.disableFlag, tc.envEnable, tc.envDisable) if got != tc.expected { t.Fatalf("expected %v, got %v", tc.expected, got) } }) } } func TestResolveToken(t *testing.T) { fakeReadFile := func(path string) ([]byte, error) { if path == "/var/lib/pulse-agent/token" { return []byte("default-token"), nil } if path == "valid-file" { return []byte("file-token"), nil } return nil, os.ErrNotExist } tests := []struct { name string tokenFlag string tokenFileFlag string envToken string expected string }{ {"flag priority", "flag-token", "valid-file", "env-token", "flag-token"}, {"file priority", "", "valid-file", "env-token", "file-token"}, {"env priority", "", "", "env-token", "env-token"}, {"default file priority", "", "", "", "default-token"}, {"missing returns empty", "", "", "", "default-token"}, // Wait, default-token will be returned if nothing else is provided } // Update the test cases to avoid the default file if we want to test empty fakeReadFileNoDefault := func(path string) ([]byte, error) { if path == "valid-file" { return []byte("file-token"), nil } return nil, os.ErrNotExist } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := resolveTokenInternal(tc.tokenFlag, tc.tokenFileFlag, tc.envToken, fakeReadFile) if got != tc.expected { t.Fatalf("%s: expected %q, got %q", tc.name, tc.expected, got) } }) } t.Run("truly empty", func(t *testing.T) { got := resolveTokenInternal("", "", "", fakeReadFileNoDefault) if got != "" { t.Fatalf("expected empty, got %q", got) } }) } func TestCleanupDockerAgent(t *testing.T) { t.Run("nil agent does nothing", func(t *testing.T) { cleanupDockerAgent(nil, &zerolog.Logger{}) }) // Testing with a real agent might be hard without a docker daemon. // But we can at least test the nil case. } func TestHealthHandler(t *testing.T) { var ready atomic.Bool handler := healthHandler(&ready) ts := httptest.NewServer(handler) defer ts.Close() // Test /healthz resp, err := http.Get(ts.URL + "/healthz") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } // Test /readyz (not ready) resp, err = http.Get(ts.URL + "/readyz") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusServiceUnavailable { t.Errorf("expected 503, got %d", resp.StatusCode) } // Test /readyz (ready) ready.Store(true) resp, err = http.Get(ts.URL + "/readyz") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } // Test /metrics resp, err = http.Get(ts.URL + "/metrics") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Errorf("expected 200, got %d", resp.StatusCode) } } func TestStartHealthServer(t *testing.T) { var ready atomic.Bool logger := zerolog.New(os.Stdout) ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Use port 0 to get a random available port startHealthServer(ctx, "127.0.0.1:0", &ready, &logger) // Since startHealthServer runs in background and doesn't return the listener, // it's a bit hard to know the port. But we can at least exercise the code. // For better testing, startHealthServer should probably return something or take a listener. } func TestLoadConfig(t *testing.T) { t.Run("defaults", func(t *testing.T) { cfg, err := loadConfig([]string{"-token", "test-token"}, func(s string) string { return "" }) if err != nil { t.Fatal(err) } if cfg.PulseURL != "http://localhost:7655" { t.Errorf("expected default URL, got %s", cfg.PulseURL) } if cfg.EnableHost != true { t.Errorf("expected host enabled by default") } }) t.Run("env overrides", func(t *testing.T) { env := map[string]string{ "PULSE_URL": "http://pulse.example.com", "PULSE_TOKEN": "my-token", "PULSE_ENABLE_HOST": "false", "PULSE_ENABLE_DOCKER": "true", } cfg, err := loadConfig([]string{}, func(s string) string { return env[s] }) if err != nil { t.Fatal(err) } if cfg.PulseURL != "http://pulse.example.com" { t.Errorf("expected env URL, got %s", cfg.PulseURL) } if cfg.APIToken != "my-token" { t.Errorf("expected env token, got %s", cfg.APIToken) } if cfg.EnableHost != false { t.Errorf("expected host disabled by env") } if cfg.EnableDocker != true { t.Errorf("expected docker enabled by env") } }) t.Run("flag overrides", func(t *testing.T) { cfg, err := loadConfig([]string{"-url", "http://flag.example.com", "-token", "flag-token", "-enable-host=false"}, func(s string) string { return "" }) if err != nil { t.Fatal(err) } if cfg.PulseURL != "http://flag.example.com" { t.Errorf("expected flag URL, got %s", cfg.PulseURL) } if cfg.APIToken != "flag-token" { t.Errorf("expected flag token, got %s", cfg.APIToken) } if cfg.EnableHost != false { t.Errorf("expected host disabled by flag") } }) t.Run("invalid interval flag", func(t *testing.T) { _, err := loadConfig([]string{"-interval", "invalid"}, func(s string) string { return "" }) if err == nil { t.Fatal("expected error for invalid interval") } }) t.Run("show version", func(t *testing.T) { _, err := loadConfig([]string{"-version"}, func(s string) string { return "" }) if err != flag.ErrHelp { t.Errorf("expected flag.ErrHelp for -version, got %v", err) } }) t.Run("self test", func(t *testing.T) { cfg, err := loadConfig([]string{"-self-test"}, func(s string) string { return "" }) if err != nil { t.Fatal(err) } if !cfg.SelfTest { t.Errorf("expected SelfTest to be true") } }) t.Run("tags and csv", func(t *testing.T) { cfg, err := loadConfig([]string{"-token", "T", "-tag", "t1", "-tag", "t2", "-disk-exclude", "d1"}, func(s string) string { if s == "PULSE_TAGS" { return "e1,e2" } return "" }) if err != nil { t.Fatal(err) } expectedTags := []string{"e1", "e2", "t1", "t2"} if !reflect.DeepEqual(cfg.Tags, expectedTags) { t.Errorf("expected tags %v, got %v", expectedTags, cfg.Tags) } expectedDisk := []string{"d1"} if !reflect.DeepEqual(cfg.DiskExclude, expectedDisk) { t.Errorf("expected disk exclude %v, got %v", expectedDisk, cfg.DiskExclude) } }) } func TestInitDockerWithRetry_Cancel(t *testing.T) { orig := newDockerAgent defer func() { newDockerAgent = orig }() newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { return nil, errors.New("not available") } ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately logger := zerolog.New(os.Stdout) cfg := dockeragent.Config{} agent := initDockerWithRetry(ctx, cfg, &logger) if agent != nil { t.Errorf("expected nil agent when cancelled") } } func TestInitDockerWithRetry_Success(t *testing.T) { orig := newDockerAgent defer func() { newDockerAgent = orig }() // First call fails, second succeeds calls := 0 newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { calls++ if calls == 1 { return nil, errors.New("not yet") } return &dockeragent.Agent{}, nil } // Mock time.After to be fast if possible? No, we can it in the function but we can't easily mock time.After. // However, we can use a very small delay if we refactored it to take intervals. // For now, let's just test success on first try or skip the retry delay. t.Run("success on first try", func(t *testing.T) { calls = 1 // will succeed on next call (which is first in this run) newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { return &dockeragent.Agent{}, nil } ctx := context.Background() logger := zerolog.New(os.Stdout) agent := initDockerWithRetry(ctx, dockeragent.Config{}, &logger) if agent == nil { t.Fatal("expected agent, got nil") } }) } func TestInitKubernetesWithRetry_Cancel(t *testing.T) { orig := newKubeAgent defer func() { newKubeAgent = orig }() newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { return nil, errors.New("not available") } ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately logger := zerolog.New(os.Stdout) cfg := kubernetesagent.Config{} agent := initKubernetesWithRetry(ctx, cfg, &logger) if agent != nil { t.Errorf("expected nil agent when cancelled") } } func TestInitKubernetesWithRetry_Success(t *testing.T) { orig := newKubeAgent defer func() { newKubeAgent = orig }() t.Run("success on first try", func(t *testing.T) { newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { return &kubernetesagent.Agent{}, nil } ctx := context.Background() logger := zerolog.New(os.Stdout) agent := initKubernetesWithRetry(ctx, kubernetesagent.Config{}, &logger) if agent == nil { t.Fatal("expected agent, got nil") } }) } func TestRun(t *testing.T) { // Mock agents to avoid actual initialization origDocker := newDockerAgent origKube := newKubeAgent defer func() { newDockerAgent = origDocker newKubeAgent = origKube }() newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { return &dockeragent.Agent{}, nil } newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { return &kubernetesagent.Agent{}, nil } t.Run("self-test", func(t *testing.T) { ctx := context.Background() err := run(ctx, []string{"-self-test"}, func(s string) string { return "" }) if err != nil { t.Fatal(err) } }) t.Run("invalid config", func(t *testing.T) { ctx := context.Background() err := run(ctx, []string{"-interval", "invalid"}, func(s string) string { return "" }) if err == nil { t.Fatal("expected error for invalid config") } }) t.Run("basic run", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) // Cancel after a short time go func() { time.Sleep(200 * time.Millisecond) cancel() }() // Use minimal config, no agents err := run(ctx, []string{"-token", "T", "-enable-host=false", "-enable-docker=false", "-enable-kubernetes=false", "-health-addr", "127.0.0.1:0"}, func(s string) string { return "" }) if err != nil && err != context.Canceled { t.Errorf("expected nil or context.Canceled, got %v", err) } }) t.Run("full run with mocks", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { return nil, errors.New("disabled for test") } newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { return nil, errors.New("disabled for test") } // hostagent.New will still fail because of token scope or some other thing if not careful newHostAgent = func(cfg hostagent.Config) (Runnable, error) { return nil, errors.New("disabled for test") } go func() { time.Sleep(200 * time.Millisecond) cancel() }() // Enable everything, but they will fail to init and log warnings, which is fine for coverage of run's branches err := run(ctx, []string{"-token", "T", "-enable-host", "-enable-docker", "-enable-kubernetes", "-health-addr", "127.0.0.1:0"}, func(s string) string { return "" }) if err != nil && err != context.Canceled && !strings.Contains(err.Error(), "disabled for test") { t.Errorf("expected nil or context.Canceled or disabled for test, got %v", err) } }) t.Run("auto-detect docker", func(t *testing.T) { origLook := lookPath defer func() { lookPath = origLook }() lookPath = func(path string) (string, error) { if path == "docker" { return "/usr/bin/docker", nil } return "", os.ErrNotExist } ctx, cancel := context.WithCancel(context.Background()) cancel() run(ctx, []string{"-token", "T", "-enable-host=false"}, func(s string) string { return "" }) }) t.Run("auto-detect podman", func(t *testing.T) { origLook := lookPath defer func() { lookPath = origLook }() lookPath = func(path string) (string, error) { if path == "podman" { return "/usr/bin/podman", nil } return "", os.ErrNotExist } ctx, cancel := context.WithCancel(context.Background()) cancel() run(ctx, []string{"-token", "T", "-enable-host=false"}, func(s string) string { return "" }) }) t.Run("goroutine error", func(t *testing.T) { origHost := newHostAgent defer func() { newHostAgent = origHost }() newHostAgent = func(cfg hostagent.Config) (Runnable, error) { // We need a non-nil agent that returns an error from Run // This is hard without a real mock, but we can try to return an agent and have it fail. // Actually, if we return a "real" agent with a bad URL, it might fail. return &hostagent.Agent{}, nil } // Wait, if I use a real hostagent, it might panic if uninitialized. // Let's skip the goroutine error for now or find a better way. }) } func TestCleanupDockerAgent_Nil(t *testing.T) { cleanupDockerAgent(nil, nil) } type mockCloser struct { err error } func (m *mockCloser) Close() error { return m.err } func (m *mockCloser) Run(ctx context.Context) error { return nil } func TestCleanupDockerAgent_Error(t *testing.T) { logger := zerolog.New(os.Stdout) mock := &mockCloser{err: errors.New("close error")} // Should log warning but not panic cleanupDockerAgent(mock, &logger) } func TestInitDockerWithRetry_Failure(t *testing.T) { orig := newDockerAgent defer func() { newDockerAgent = orig }() // Always fail newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { return nil, errors.New("fail") } // Override delays to be super fast origInitial := retryInitialDelay origMax := retryMaxDelay retryInitialDelay = 1 * time.Millisecond retryMaxDelay = 2 * time.Millisecond defer func() { retryInitialDelay = origInitial retryMaxDelay = origMax }() ctx, cancel := context.WithCancel(context.Background()) // Let it run for a bit then cancel go func() { time.Sleep(10 * time.Millisecond) cancel() }() logger := zerolog.New(os.Stdout) agent := initDockerWithRetry(ctx, dockeragent.Config{}, &logger) if agent != nil { t.Errorf("expected nil agent") } } // Mock agents for TestRun type mockRunnable struct { started chan struct{} err error } func (m *mockRunnable) Run(ctx context.Context) error { if m.started != nil { close(m.started) } if m.err != nil { return m.err } <-ctx.Done() return nil } type mockRunnableCloser struct { mockRunnable } func (m *mockRunnableCloser) Close() error { return nil } func TestRun_Success(t *testing.T) { origDocker := newDockerAgent origKube := newKubeAgent origHost := newHostAgent defer func() { newDockerAgent = origDocker newKubeAgent = origKube newHostAgent = origHost }() // Setup mocks that signal startup and wait for context newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { return &mockRunnableCloser{mockRunnable: mockRunnable{started: make(chan struct{})}}, nil } newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { return &mockRunnable{started: make(chan struct{})}, nil } newHostAgent = func(cfg hostagent.Config) (Runnable, error) { return &mockRunnable{started: make(chan struct{})}, nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // Run in a separate goroutine so we can wait for it errCh := make(chan error) go func() { // Enable all agents errCh <- run(ctx, []string{ "-token", "T", "-enable-host=true", "-enable-docker=true", "-enable-kubernetes=true", "-health-addr", ":0", // Random port }, func(s string) string { return "" }) }() // Wait for run to finish (which should happen on context cancel) select { case err := <-errCh: if err != nil { t.Errorf("expected nil error, got %v", err) } case <-time.After(3 * time.Second): t.Fatal("timeout waiting for run to finish") } } func TestRun_AgentFailure(t *testing.T) { origDocker := newDockerAgent defer func() { newDockerAgent = origDocker }() // Docker agent fails immediately after start newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { return &mockRunnableCloser{mockRunnable: mockRunnable{ started: make(chan struct{}), err: errors.New("simulated failure"), }}, nil } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() err := run(ctx, []string{"-token", "T", "-enable-docker=true", "-enable-host=false"}, func(s string) string { return "" }) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "simulated failure") { t.Errorf("expected 'simulated failure', got %v", err) } } func TestLoadConfig_Comprehensive(t *testing.T) { tests := []struct { name string args []string env map[string]string validate func(t *testing.T, cfg Config) }{ { name: "all flags", args: []string{ "-token", "F", "-enable-host=false", "-enable-docker=true", "-enable-kubernetes=true", "-enable-proxmox=true", "-proxmox-type", "pbs", "-disable-auto-update=true", "-disable-docker-update-checks=true", "-docker-runtime", "podman", "-enable-commands=true", "-kubeconfig", "/tmp/kube", "-kube-context", "ctx", "-kube-max-pods", "50", "-kube-include-all-pods=true", "-kube-include-all-deployments=true", "-report-ip", "1.2.3.4", }, validate: func(t *testing.T, cfg Config) { if cfg.EnableHost { t.Error("EnableHost should be false") } if !cfg.EnableDocker { t.Error("EnableDocker should be true") } if !cfg.EnableKubernetes { t.Error("EnableKubernetes should be true") } if !cfg.EnableProxmox { t.Error("EnableProxmox should be true") } if cfg.ProxmoxType != "pbs" { t.Errorf("ProxmoxType: got %s, want pbs", cfg.ProxmoxType) } if !cfg.DisableAutoUpdate { t.Error("DisableAutoUpdate should be true") } if !cfg.DisableDockerUpdateChecks { t.Error("DisableDockerUpdateChecks should be true") } if cfg.DockerRuntime != "podman" { t.Errorf("DockerRuntime: got %s, want podman", cfg.DockerRuntime) } if !cfg.EnableCommands { t.Error("EnableCommands should be true") } if cfg.KubeconfigPath != "/tmp/kube" { t.Errorf("KubeconfigPath: got %s, want /tmp/kube", cfg.KubeconfigPath) } if cfg.KubeContext != "ctx" { t.Errorf("KubeContext: got %s, want ctx", cfg.KubeContext) } if cfg.KubeMaxPods != 50 { t.Errorf("KubeMaxPods: got %d, want 50", cfg.KubeMaxPods) } if !cfg.KubeIncludeAllPods { t.Error("KubeIncludeAllPods should be true") } if !cfg.KubeIncludeAllDeployments { t.Error("KubeIncludeAllDeployments should be true") } if cfg.ReportIP != "1.2.3.4" { t.Errorf("ReportIP: got %s, want 1.2.3.4", cfg.ReportIP) } if !cfg.DockerConfigured { t.Error("DockerConfigured should be true when flag is set") } }, }, { name: "env vars", env: map[string]string{ "PULSE_TOKEN": "E", "PULSE_ENABLE_HOST": "false", "PULSE_ENABLE_DOCKER": "true", "PULSE_ENABLE_KUBERNETES": "true", "PULSE_ENABLE_PROXMOX": "true", "PULSE_PROXMOX_TYPE": "pve", "PULSE_DISABLE_AUTO_UPDATE": "true", "PULSE_DISABLE_DOCKER_UPDATE_CHECKS": "true", "PULSE_DOCKER_RUNTIME": "docker", "PULSE_ENABLE_COMMANDS": "true", "PULSE_KUBECONFIG": "/env/kube", "PULSE_KUBE_CONTEXT": "env-ctx", "PULSE_KUBE_MAX_PODS": "100", "PULSE_KUBE_INCLUDE_ALL_POD_FILES": "true", // Note: var name matches loadConfig implementation "PULSE_KUBE_INCLUDE_ALL_DEPLOYMENTS": "true", "PULSE_REPORT_IP": "5.6.7.8", }, validate: func(t *testing.T, cfg Config) { if cfg.EnableHost { t.Error("EnableHost should be false") } if !cfg.EnableDocker { t.Error("EnableDocker should be true") } if cfg.ProxmoxType != "pve" { t.Errorf("ProxmoxType: got %s, want pve", cfg.ProxmoxType) } if cfg.ReportIP != "5.6.7.8" { t.Errorf("ReportIP: got %s, want 5.6.7.8", cfg.ReportIP) } if !cfg.DockerConfigured { t.Error("DockerConfigured should be true when env is set") } }, }, { name: "docker not configured", args: []string{"-token", "T"}, validate: func(t *testing.T, cfg Config) { if cfg.DockerConfigured { t.Error("DockerConfigured should be false when not set") } if cfg.EnableDocker { t.Error("EnableDocker should be false by default") } }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { getenv := func(key string) string { if tc.env == nil { return "" } return tc.env[key] } cfg, err := loadConfig(tc.args, getenv) if err != nil { t.Fatalf("unexpected error: %v", err) } tc.validate(t, cfg) }) } } func TestStartHealthServer_Error(t *testing.T) { var ready atomic.Bool logger := zerolog.New(os.Stdout) ctx, cancel := context.WithCancel(context.Background()) defer cancel() // Use invalid port to force error (logs warning, doesn't panic) // We just want to exercise the code path startHealthServer(ctx, "invalid-address", &ready, &logger) // Give it a moment to try starting time.Sleep(50 * time.Millisecond) } func TestInitKubernetesWithRetry_Failure(t *testing.T) { orig := newKubeAgent defer func() { newKubeAgent = orig }() // Always fail newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { return nil, errors.New("fail") } // Override delays to be super fast origInitial := retryInitialDelay origMax := retryMaxDelay retryInitialDelay = 1 * time.Millisecond retryMaxDelay = 2 * time.Millisecond defer func() { retryInitialDelay = origInitial retryMaxDelay = origMax }() ctx, cancel := context.WithCancel(context.Background()) // Let it run for a bit then cancel go func() { time.Sleep(10 * time.Millisecond) cancel() }() logger := zerolog.New(os.Stdout) agent := initKubernetesWithRetry(ctx, kubernetesagent.Config{}, &logger) if agent != nil { t.Errorf("expected nil agent") } } func TestRun_WindowsServiceError(t *testing.T) { orig := runAsWindowsServiceFunc defer func() { runAsWindowsServiceFunc = orig }() runAsWindowsServiceFunc = func(cfg Config, logger zerolog.Logger) (bool, error) { return false, errors.New("service error") } ctx := context.Background() err := run(ctx, []string{"-token", "T"}, func(s string) string { return "" }) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "service error") { t.Errorf("expected 'service error', got %v", err) } } func TestRun_DockerRetry(t *testing.T) { origDocker := newDockerAgent defer func() { newDockerAgent = origDocker }() // First call fails, second succeeds calls := 0 newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) { calls++ if calls == 1 { return nil, errors.New("not available yet") } return &mockRunnableCloser{mockRunnable: mockRunnable{started: make(chan struct{})}}, nil } // Speed up retry origInitial := retryInitialDelay retryInitialDelay = 1 * time.Millisecond defer func() { retryInitialDelay = origInitial }() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() errCh := make(chan error) go func() { errCh <- run(ctx, []string{"-token", "T", "-enable-docker=true", "-enable-host=false"}, func(s string) string { return "" }) }() select { case err := <-errCh: if err != nil { t.Errorf("expected nil error, got %v", err) } case <-time.After(3 * time.Second): t.Fatal("timeout waiting for run") } if calls < 2 { t.Errorf("expected at least 2 calls to newDockerAgent, got %d", calls) } } func TestRunAsWindowsServiceStub(t *testing.T) { res, err := runAsWindowsService(Config{}, zerolog.New(os.Stdout)) if err != nil { t.Errorf("expected nil error, got %v", err) } if res != false { t.Error("expected false") } } func TestRun_KubeRetry(t *testing.T) { origKube := newKubeAgent defer func() { newKubeAgent = origKube }() // First call fails, second succeeds calls := 0 newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) { calls++ if calls == 1 { return nil, errors.New("not available yet") } return &mockRunnable{started: make(chan struct{})}, nil } // Speed up retry origInitial := retryInitialDelay retryInitialDelay = 1 * time.Millisecond defer func() { retryInitialDelay = origInitial }() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() errCh := make(chan error) go func() { // Only enable kubernetes errCh <- run(ctx, []string{"-token", "T", "-enable-kubernetes=true", "-enable-host=false", "-enable-docker=false"}, func(s string) string { return "" }) }() select { case err := <-errCh: if err != nil { t.Errorf("expected nil error, got %v", err) } case <-time.After(3 * time.Second): t.Fatal("timeout waiting for run") } if calls < 2 { t.Errorf("expected at least 2 calls to newKubeAgent, got %d", calls) } }