mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 00:43:34 +00:00
Fleets on networks numbered from nominally public IP space (issue #1522: an AD estate on 192.20.0.0/16) cannot pass the agent's local-network plaintext heuristic, and the only workaround was pointing a .internal DNS alias at the server, which bypasses the same control less visibly than a flag would. --allow-plaintext-http (PULSE_AGENT_ALLOW_PLAINTEXT_HTTP) records process-wide consent once at agent startup before any module validates a URL, covers every agent transport including the websocket command channel, warns at startup that the API token travels in cleartext, defaults closed, and is never emitted by generated install commands or settable by the server.
2246 lines
62 KiB
Go
2246 lines
62 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentupdate"
|
|
"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/rcourtman/pulse-go-rewrite/pkg/securityutil"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func TestEnableCommandsHelpUsesPatrolRemediationCopy(t *testing.T) {
|
|
source, err := os.ReadFile("main.go")
|
|
if err != nil {
|
|
t.Fatalf("read pulse-agent main.go: %v", err)
|
|
}
|
|
text := string(source)
|
|
|
|
if !strings.Contains(text, "Enable Pulse command execution for Patrol actions and Proxmox LXC Docker inventory (disabled by default)") {
|
|
t.Fatal("expected enable-commands help to describe Patrol actions and Proxmox LXC Docker inventory")
|
|
}
|
|
if strings.Contains(text, "Enable command execution for AI auto-fix") {
|
|
t.Fatal("enable-commands help must not revive AI auto-fix wording")
|
|
}
|
|
}
|
|
|
|
func TestDockerRuntimeHelpUsesDockerPodmanCopy(t *testing.T) {
|
|
source, err := os.ReadFile("main.go")
|
|
if err != nil {
|
|
t.Fatalf("read pulse-agent main.go: %v", err)
|
|
}
|
|
text := string(source)
|
|
|
|
for _, want := range []string{
|
|
"Enable Docker / Podman Agent module",
|
|
"Docker / Podman runtime: auto, docker, or podman (default: auto)",
|
|
"Force Docker / Podman runtime: docker, podman, or auto",
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected pulse-agent CLI copy %q", want)
|
|
}
|
|
}
|
|
for _, stale := range []string{
|
|
"Enable Docker Agent module",
|
|
"Enable Docker / Podman collection module",
|
|
"Container runtime: auto, docker, or podman (default: auto)",
|
|
"Force container runtime: docker, podman, or auto",
|
|
} {
|
|
if strings.Contains(text, stale) {
|
|
t.Fatalf("pulse-agent CLI copy must not expose stale generic runtime label %q", stale)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigPreservesAgentLogFile(t *testing.T) {
|
|
t.Run("environment", func(t *testing.T) {
|
|
cfg, err := loadConfig(nil, func(key string) string {
|
|
if key == "PULSE_LOG_FILE" {
|
|
return ` C:\ProgramData\Pulse\pulse-agent.log `
|
|
}
|
|
return ""
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: %v", err)
|
|
}
|
|
if cfg.LogFile != `C:\ProgramData\Pulse\pulse-agent.log` {
|
|
t.Fatalf("LogFile = %q", cfg.LogFile)
|
|
}
|
|
})
|
|
|
|
t.Run("flag overrides environment", func(t *testing.T) {
|
|
cfg, err := loadConfig([]string{"--log-file", `D:\Pulse\agent.jsonl`}, func(key string) string {
|
|
if key == "PULSE_LOG_FILE" {
|
|
return `C:\ProgramData\Pulse\pulse-agent.log`
|
|
}
|
|
return ""
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: %v", err)
|
|
}
|
|
if cfg.LogFile != `D:\Pulse\agent.jsonl` {
|
|
t.Fatalf("LogFile = %q", cfg.LogFile)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAgentFileLoggingUsesCanonicalRotatingSink(t *testing.T) {
|
|
source, err := os.ReadFile("main.go")
|
|
if err != nil {
|
|
t.Fatalf("read pulse-agent main.go: %v", err)
|
|
}
|
|
text := string(source)
|
|
for _, want := range []string{
|
|
`pulselogging.NewStandaloneLogger(pulselogging.Config{`,
|
|
`MaxSizeMB: agentLogMaxSizeMB`,
|
|
`MaxAgeDays: agentLogMaxAgeDays`,
|
|
`Compress: true`,
|
|
`Write rotating JSON logs to this file`,
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected canonical rotating agent log contract %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(text, "newLogger := zerolog.New(os.Stdout)") {
|
|
t.Fatal("remote log-level updates must not replace the configured file sink")
|
|
}
|
|
}
|
|
|
|
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 TestApplyRemoteSettingsHonorsLocalDockerOptOut(t *testing.T) {
|
|
logger := zerolog.New(io.Discard)
|
|
cfg := &Config{
|
|
EnableDocker: false,
|
|
DockerConfigured: true,
|
|
DockerExplicitlyDisabled: true,
|
|
}
|
|
|
|
applyRemoteSettings(cfg, map[string]interface{}{
|
|
"enable_docker": true,
|
|
}, &logger)
|
|
|
|
if cfg.EnableDocker {
|
|
t.Fatal("remote config must not enable Docker / Podman after a local explicit disable")
|
|
}
|
|
if !cfg.DockerConfigured {
|
|
t.Fatal("expected DockerConfigured to remain true")
|
|
}
|
|
if !cfg.DockerExplicitlyDisabled {
|
|
t.Fatal("expected DockerExplicitlyDisabled to remain true")
|
|
}
|
|
}
|
|
|
|
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 TestApplyRemoteSettingsIgnoresInvalidValues(t *testing.T) {
|
|
logger := zerolog.New(io.Discard)
|
|
cfg := &Config{
|
|
Interval: 30 * time.Second,
|
|
DockerRuntime: "docker",
|
|
}
|
|
|
|
applyRemoteSettings(cfg, map[string]interface{}{
|
|
"interval": "invalid",
|
|
"docker_runtime": "not-a-runtime",
|
|
}, &logger)
|
|
|
|
if cfg.Interval != 30*time.Second {
|
|
t.Fatalf("expected interval to remain unchanged, got %v", cfg.Interval)
|
|
}
|
|
if cfg.DockerRuntime != "docker" {
|
|
t.Fatalf("expected docker runtime to remain unchanged, got %q", cfg.DockerRuntime)
|
|
}
|
|
|
|
applyRemoteSettings(cfg, map[string]interface{}{
|
|
"interval": float64(0),
|
|
}, &logger)
|
|
|
|
if cfg.Interval != 30*time.Second {
|
|
t.Fatalf("expected non-positive numeric interval to be ignored, 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",
|
|
"PULSE_CACERT": "/etc/pulse/ca.pem",
|
|
"PULSE_SERVER_FINGERPRINT": "aabbccdd",
|
|
"PULSE_DEPLOY_SSH_USER": "pulse-deploy",
|
|
}
|
|
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")
|
|
}
|
|
if cfg.CACertPath != "/etc/pulse/ca.pem" {
|
|
t.Errorf("expected CA cert path from env, got %s", cfg.CACertPath)
|
|
}
|
|
if cfg.ServerFingerprint != "aabbccdd" {
|
|
t.Errorf("expected server fingerprint from env, got %s", cfg.ServerFingerprint)
|
|
}
|
|
if cfg.DeploySSHUser != "pulse-deploy" {
|
|
t.Errorf("expected deploy SSH user from env, got %s", cfg.DeploySSHUser)
|
|
}
|
|
})
|
|
|
|
t.Run("flag overrides", func(t *testing.T) {
|
|
cfg, err := loadConfig([]string{"-url", "http://flag.example.com", "-token", "flag-token", "-enable-host=false", "-cacert", "/tmp/custom-ca.pem", "-server-fingerprint", "1122", "-deploy-ssh-user", "pulse-deploy"}, 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")
|
|
}
|
|
if cfg.CACertPath != "/tmp/custom-ca.pem" {
|
|
t.Errorf("expected CA cert path from flag, got %s", cfg.CACertPath)
|
|
}
|
|
if cfg.ServerFingerprint != "1122" {
|
|
t.Errorf("expected server fingerprint from flag, got %s", cfg.ServerFingerprint)
|
|
}
|
|
if cfg.DeploySSHUser != "pulse-deploy" {
|
|
t.Errorf("expected deploy SSH user from flag, got %s", cfg.DeploySSHUser)
|
|
}
|
|
})
|
|
|
|
t.Run("token optional when enrollment disabled", func(t *testing.T) {
|
|
cfg, err := loadConfig([]string{"-url", "http://token-optional.example.com", "-enable-host"}, func(s string) string { return "" })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.APIToken != "" {
|
|
t.Fatalf("expected empty token for token-optional config, got %q", cfg.APIToken)
|
|
}
|
|
if cfg.Enroll {
|
|
t.Fatal("expected enrollment to be disabled")
|
|
}
|
|
})
|
|
|
|
t.Run("enrollment requires token", func(t *testing.T) {
|
|
_, err := loadConfig([]string{"-url", "http://token-required.example.com", "-enroll"}, func(s string) string { return "" })
|
|
if err == nil || !strings.Contains(err.Error(), "required for enrollment") {
|
|
t.Fatalf("expected enrollment token requirement, got %v", err)
|
|
}
|
|
})
|
|
|
|
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("non-positive interval returns error", func(t *testing.T) {
|
|
_, err := loadConfig([]string{"-token", "test-token", "-interval", "0s"}, func(s string) string { return "" })
|
|
if err == nil {
|
|
t.Fatal("expected error for non-positive interval")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid kube max pods returns error", func(t *testing.T) {
|
|
_, err := loadConfig([]string{"-token", "test-token", "-kube-max-pods", "0"}, func(s string) string { return "" })
|
|
if err == nil {
|
|
t.Fatal("expected error for non-positive kube-max-pods")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid docker runtime returns error", func(t *testing.T) {
|
|
_, err := loadConfig([]string{"-token", "test-token", "-docker-runtime", "containerd"}, func(s string) string { return "" })
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid docker runtime")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid log level returns error", func(t *testing.T) {
|
|
_, err := loadConfig([]string{"-token", "test-token", "-log-level", "invalid"}, func(s string) string { return "" })
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid log level")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid deploy ssh user returns error", func(t *testing.T) {
|
|
_, err := loadConfig([]string{"-token", "test-token", "-deploy-ssh-user", "bad user"}, func(s string) string { return "" })
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid deploy SSH user")
|
|
}
|
|
})
|
|
|
|
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_CancelDuringBackoff(t *testing.T) {
|
|
origAgent := newDockerAgent
|
|
origInitial := retryInitialDelay
|
|
origMax := retryMaxDelay
|
|
defer func() {
|
|
newDockerAgent = origAgent
|
|
retryInitialDelay = origInitial
|
|
retryMaxDelay = origMax
|
|
}()
|
|
|
|
newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) {
|
|
return nil, errors.New("not available")
|
|
}
|
|
retryInitialDelay = 5 * time.Second
|
|
retryMaxDelay = 5 * time.Second
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(20 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
start := time.Now()
|
|
logger := zerolog.New(os.Stdout)
|
|
agent := initDockerWithRetry(ctx, dockeragent.Config{}, &logger)
|
|
if agent != nil {
|
|
t.Fatalf("expected nil agent when cancelled")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
|
t.Fatalf("expected prompt cancellation during backoff, took %v", elapsed)
|
|
}
|
|
}
|
|
|
|
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_CancelDuringBackoff(t *testing.T) {
|
|
origAgent := newKubeAgent
|
|
origInitial := retryInitialDelay
|
|
origMax := retryMaxDelay
|
|
defer func() {
|
|
newKubeAgent = origAgent
|
|
retryInitialDelay = origInitial
|
|
retryMaxDelay = origMax
|
|
}()
|
|
|
|
newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) {
|
|
return nil, errors.New("not available")
|
|
}
|
|
retryInitialDelay = 5 * time.Second
|
|
retryMaxDelay = 5 * time.Second
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(20 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
start := time.Now()
|
|
logger := zerolog.New(os.Stdout)
|
|
agent := initKubernetesWithRetry(ctx, kubernetesagent.Config{}, &logger)
|
|
if agent != nil {
|
|
t.Fatalf("expected nil agent when cancelled")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
|
t.Fatalf("expected prompt cancellation during backoff, took %v", elapsed)
|
|
}
|
|
}
|
|
|
|
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("version exits cleanly", func(t *testing.T) {
|
|
ctx := context.Background()
|
|
err := run(ctx, []string{"-version"}, func(s string) string { return "" })
|
|
if err != flag.ErrHelp {
|
|
t.Fatalf("expected flag.ErrHelp for -version, got %v", err)
|
|
}
|
|
})
|
|
|
|
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 TestDockerAutoDetectHonorsExplicitDisable(t *testing.T) {
|
|
origDocker := newDockerAgent
|
|
origLook := lookPath
|
|
defer func() {
|
|
newDockerAgent = origDocker
|
|
lookPath = origLook
|
|
}()
|
|
|
|
var dockerAgentCalls int32
|
|
newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) {
|
|
atomic.AddInt32(&dockerAgentCalls, 1)
|
|
return &mockRunnableCloser{}, nil
|
|
}
|
|
lookPath = func(path string) (string, error) {
|
|
if path == "docker" {
|
|
return "/usr/bin/docker", nil
|
|
}
|
|
return "", os.ErrNotExist
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
err := run(ctx, []string{"-enable-host=false", "-enable-docker=false", "-enable-kubernetes=false", "-health-addr", ""}, func(s string) string { return "" })
|
|
if err != nil && err != context.Canceled && err != context.DeadlineExceeded {
|
|
t.Fatalf("run returned unexpected error: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&dockerAgentCalls); got != 0 {
|
|
t.Fatalf("Docker / Podman module initialized despite explicit disable, calls=%d", got)
|
|
}
|
|
}
|
|
|
|
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_PassesStateDirToUpdaterAndHostAgent(t *testing.T) {
|
|
origUpdater := newUpdater
|
|
origHost := newHostAgent
|
|
defer func() {
|
|
newUpdater = origUpdater
|
|
newHostAgent = origHost
|
|
}()
|
|
|
|
var updaterCfg agentupdate.Config
|
|
var hostCfg hostagent.Config
|
|
|
|
newUpdater = func(cfg agentupdate.Config) *agentupdate.Updater {
|
|
updaterCfg = cfg
|
|
return agentupdate.New(agentupdate.Config{
|
|
PulseURL: "https://pulse.example.com",
|
|
AgentName: cfg.AgentName,
|
|
CurrentVersion: "1.0.0",
|
|
StateDir: cfg.StateDir,
|
|
Disabled: true,
|
|
})
|
|
}
|
|
newHostAgent = func(cfg hostagent.Config) (Runnable, error) {
|
|
hostCfg = cfg
|
|
return &mockRunnable{}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
|
|
err := run(ctx, []string{
|
|
"-token", "deadbeef",
|
|
"-enable-docker=false",
|
|
"-enable-kubernetes=false",
|
|
"-state-dir", "/share/CACHEDEV1_DATA/.pulse-agent",
|
|
}, func(string) string { return "" })
|
|
if err != nil && err != context.Canceled && err != context.DeadlineExceeded {
|
|
t.Fatalf("run returned error: %v", err)
|
|
}
|
|
|
|
if updaterCfg.StateDir != "/share/CACHEDEV1_DATA/.pulse-agent" {
|
|
t.Fatalf("updater state dir = %q, want %q", updaterCfg.StateDir, "/share/CACHEDEV1_DATA/.pulse-agent")
|
|
}
|
|
if hostCfg.StateDir != "/share/CACHEDEV1_DATA/.pulse-agent" {
|
|
t.Fatalf("host agent state dir = %q, want %q", hostCfg.StateDir, "/share/CACHEDEV1_DATA/.pulse-agent")
|
|
}
|
|
}
|
|
|
|
func TestRun_AgentFailure(t *testing.T) {
|
|
origDocker := newDockerAgent
|
|
defer func() {
|
|
newDockerAgent = origDocker
|
|
}()
|
|
|
|
// Docker / Podman module 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 TestRun_PropagatesDisableCephToHostAgent(t *testing.T) {
|
|
origDocker := newDockerAgent
|
|
origKube := newKubeAgent
|
|
origHost := newHostAgent
|
|
defer func() {
|
|
newDockerAgent = origDocker
|
|
newKubeAgent = origKube
|
|
newHostAgent = origHost
|
|
}()
|
|
|
|
hostCfgCh := make(chan hostagent.Config, 1)
|
|
newHostAgent = func(cfg hostagent.Config) (Runnable, error) {
|
|
hostCfgCh <- cfg
|
|
return &mockRunnable{}, nil
|
|
}
|
|
newDockerAgent = func(cfg dockeragent.Config) (RunnableCloser, error) {
|
|
return &mockRunnableCloser{}, nil
|
|
}
|
|
newKubeAgent = func(cfg kubernetesagent.Config) (Runnable, error) {
|
|
return &mockRunnable{}, nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
|
defer cancel()
|
|
|
|
err := run(ctx, []string{
|
|
"-token", "T",
|
|
"-enable-host=true",
|
|
"-enable-docker=false",
|
|
"-enable-kubernetes=false",
|
|
"-disable-ceph=true",
|
|
"-health-addr", "127.0.0.1:0",
|
|
}, func(string) string { return "" })
|
|
if err != nil && err != context.Canceled {
|
|
t.Fatalf("run returned unexpected error: %v", err)
|
|
}
|
|
|
|
select {
|
|
case hostCfg := <-hostCfgCh:
|
|
if !hostCfg.DisableCeph {
|
|
t.Fatalf("expected DisableCeph=true on host agent config")
|
|
}
|
|
default:
|
|
t.Fatalf("host agent was not initialized")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
if cfg.DockerExplicitlyDisabled {
|
|
t.Error("DockerExplicitlyDisabled should be false when flag enables Docker")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
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")
|
|
}
|
|
if cfg.DockerExplicitlyDisabled {
|
|
t.Error("DockerExplicitlyDisabled should be false when env enables Docker")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "docker explicitly disabled by flag",
|
|
args: []string{"-token", "T", "-enable-docker=false"},
|
|
validate: func(t *testing.T, cfg Config) {
|
|
if !cfg.DockerConfigured {
|
|
t.Error("DockerConfigured should be true when Docker flag is set")
|
|
}
|
|
if !cfg.DockerExplicitlyDisabled {
|
|
t.Error("DockerExplicitlyDisabled should be true when flag disables Docker")
|
|
}
|
|
if cfg.EnableDocker {
|
|
t.Error("EnableDocker should be false when flag disables Docker")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "docker explicitly disabled by env",
|
|
args: []string{"-token", "T"},
|
|
env: map[string]string{
|
|
"PULSE_ENABLE_DOCKER": "false",
|
|
},
|
|
validate: func(t *testing.T, cfg Config) {
|
|
if !cfg.DockerConfigured {
|
|
t.Error("DockerConfigured should be true when Docker env is set")
|
|
}
|
|
if !cfg.DockerExplicitlyDisabled {
|
|
t.Error("DockerExplicitlyDisabled should be true when env disables Docker")
|
|
}
|
|
if cfg.EnableDocker {
|
|
t.Error("EnableDocker should be false when env disables Docker")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "docker env disable overridden by enabling flag",
|
|
args: []string{"-token", "T", "-enable-docker=true"},
|
|
env: map[string]string{
|
|
"PULSE_ENABLE_DOCKER": "false",
|
|
},
|
|
validate: func(t *testing.T, cfg Config) {
|
|
if !cfg.DockerConfigured {
|
|
t.Error("DockerConfigured should be true when Docker env or flag is set")
|
|
}
|
|
if cfg.DockerExplicitlyDisabled {
|
|
t.Error("DockerExplicitlyDisabled should be false when explicit flag enables Docker")
|
|
}
|
|
if !cfg.EnableDocker {
|
|
t.Error("EnableDocker should be true when flag enables Docker")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
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")
|
|
}
|
|
if cfg.DockerExplicitlyDisabled {
|
|
t.Error("DockerExplicitlyDisabled should be false when Docker is unconfigured")
|
|
}
|
|
if cfg.HealthAddr != "127.0.0.1:9191" {
|
|
t.Errorf("HealthAddr: got %q, want loopback default", cfg.HealthAddr)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "health addr can be opened explicitly",
|
|
args: []string{"-token", "T", "-health-addr", ":9191"},
|
|
validate: func(t *testing.T, cfg Config) {
|
|
if cfg.HealthAddr != ":9191" {
|
|
t.Errorf("HealthAddr: got %q, want :9191", cfg.HealthAddr)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "health addr can be disabled by env",
|
|
args: []string{"-token", "T"},
|
|
env: map[string]string{
|
|
"PULSE_HEALTH_ADDR": "off",
|
|
},
|
|
validate: func(t *testing.T, cfg Config) {
|
|
if cfg.HealthAddr != "" {
|
|
t.Errorf("HealthAddr: got %q, want disabled", cfg.HealthAddr)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
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 TestWindowsServiceRuntimeStartsHealthServer(t *testing.T) {
|
|
source, err := os.ReadFile("service_windows.go")
|
|
if err != nil {
|
|
t.Fatalf("read pulse-agent service_windows.go: %v", err)
|
|
}
|
|
text := string(source)
|
|
|
|
required := []string{
|
|
`var ready atomic.Bool`,
|
|
`runtimeStatus := newRuntimeHealth(&ready`,
|
|
`startHealthServer(ctx, ws.cfg.HealthAddr, &ready, &ws.logger, runtimeStatus)`,
|
|
`runtimeStatus.setState("host", moduleStateRunning, nil)`,
|
|
`agentUp.Set(1)`,
|
|
`defer agentUp.Set(0)`,
|
|
}
|
|
for _, want := range required {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("expected Windows service runtime to include %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestRetryLogEvent_LevelThrottling(t *testing.T) {
|
|
// Ensure debug events are not filtered by the global level
|
|
prev := zerolog.GlobalLevel()
|
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
|
t.Cleanup(func() { zerolog.SetGlobalLevel(prev) })
|
|
|
|
tests := []struct {
|
|
attempt int
|
|
wantLevel string
|
|
}{
|
|
{1, "warn"},
|
|
{5, "warn"},
|
|
{10, "warn"},
|
|
{11, "info"},
|
|
{25, "info"},
|
|
{50, "info"},
|
|
{51, "debug"},
|
|
{100, "debug"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
var buf strings.Builder
|
|
logger := zerolog.New(&buf).Level(zerolog.DebugLevel)
|
|
event := retryLogEvent(&logger, tt.attempt)
|
|
event.Msg("test")
|
|
|
|
output := buf.String()
|
|
if !strings.Contains(output, `"level":"`+tt.wantLevel+`"`) {
|
|
t.Errorf("attempt %d: expected level %q in output, got: %s", tt.attempt, tt.wantLevel, output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteDurationSettingPreservesFractionalSeconds(t *testing.T) {
|
|
got, ok := remoteDurationSetting(map[string]interface{}{"interval": 0.25}, "interval")
|
|
if !ok {
|
|
t.Fatal("remoteDurationSetting() ok = false, want true")
|
|
}
|
|
if got != 250*time.Millisecond {
|
|
t.Fatalf("remoteDurationSetting() = %s, want 250ms", got)
|
|
}
|
|
}
|
|
|
|
func TestAgentIDFilePersistence(t *testing.T) {
|
|
t.Run("read returns empty when path is empty", func(t *testing.T) {
|
|
id, err := readAgentIDFile("")
|
|
if err != nil {
|
|
t.Fatalf("expected no error for empty path, got %v", err)
|
|
}
|
|
if id != "" {
|
|
t.Errorf("expected empty id, got %q", id)
|
|
}
|
|
})
|
|
|
|
t.Run("read returns fs.ErrNotExist for missing file", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
_, err := readAgentIDFile(filepath.Join(dir, "missing"))
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
t.Fatalf("expected fs.ErrNotExist, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("write then read round-trips the ID", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "subdir", "agent-id")
|
|
const id = "1234abcd-5678-90ef-1234-567890abcdef"
|
|
|
|
if err := writeAgentIDFile(path, id); err != nil {
|
|
t.Fatalf("write failed: %v", err)
|
|
}
|
|
|
|
got, err := readAgentIDFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read failed: %v", err)
|
|
}
|
|
if got != id {
|
|
t.Errorf("round-trip mismatch: got %q, want %q", got, id)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatalf("stat: %v", err)
|
|
}
|
|
if mode := info.Mode().Perm(); runtime.GOOS != "windows" && mode != 0o600 {
|
|
t.Errorf("file permissions = %v, want 0600", mode)
|
|
}
|
|
})
|
|
|
|
t.Run("read trims whitespace", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "agent-id")
|
|
if err := os.WriteFile(path, []byte(" abc-123 \n\n"), 0o600); err != nil {
|
|
t.Fatalf("seed file: %v", err)
|
|
}
|
|
got, err := readAgentIDFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if got != "abc-123" {
|
|
t.Errorf("expected trimmed value, got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("write is a no-op when path is empty or id is empty", func(t *testing.T) {
|
|
if err := writeAgentIDFile("", "some-id"); err != nil {
|
|
t.Errorf("expected no-op for empty path, got %v", err)
|
|
}
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "agent-id")
|
|
if err := writeAgentIDFile(path, ""); err != nil {
|
|
t.Errorf("expected no-op for empty id, got %v", err)
|
|
}
|
|
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
|
|
t.Errorf("expected file not to be created, got err=%v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
type stubTypedContainerUpdater struct {
|
|
calls int
|
|
}
|
|
|
|
func (s *stubTypedContainerUpdater) TypedContainerUpdate(context.Context, string, string, string, func(string)) (agentexec.DockerContainerUpdateOutcome, error) {
|
|
s.calls++
|
|
return agentexec.DockerContainerUpdateOutcome{Success: true}, nil
|
|
}
|
|
|
|
func TestLateBoundDockerUpdaterBridgesModuleWhenItComesUp(t *testing.T) {
|
|
bridge := &lateBoundDockerUpdater{}
|
|
|
|
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil {
|
|
t.Fatal("bridge without a docker module accepted an update")
|
|
}
|
|
|
|
bridge.set(struct{}{}) // non-implementing candidates must not install
|
|
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil {
|
|
t.Fatal("bridge accepted an update after a non-implementing candidate was offered")
|
|
}
|
|
|
|
stub := &stubTypedContainerUpdater{}
|
|
bridge.set(stub)
|
|
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err != nil {
|
|
t.Fatalf("bridge with an installed module refused: %v", err)
|
|
}
|
|
if stub.calls != 1 {
|
|
t.Fatalf("expected one delegated call, got %d", stub.calls)
|
|
}
|
|
}
|
|
|
|
func TestDockerAgentImplementsTypedContainerUpdater(t *testing.T) {
|
|
// The bridge installs by structural assertion; if the Docker module's
|
|
// method signature drifts, updates silently refuse at runtime. Pin it.
|
|
var _ hostagent.DockerContainerUpdater = (*dockeragent.Agent)(nil)
|
|
}
|
|
|
|
func TestAllowPlaintextHTTPFlagParsesAndDefaultsClosed(t *testing.T) {
|
|
t.Cleanup(func() { securityutil.SetOperatorPlaintextHTTPConsent(false) })
|
|
|
|
cfg, err := loadConfig([]string{"--url", "http://192.168.1.10:7655", "--token", "t"}, func(string) string { return "" })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.AllowPlaintextHTTP {
|
|
t.Fatal("plaintext override must default to false")
|
|
}
|
|
|
|
cfg, err = loadConfig([]string{"--url", "http://192.168.1.10:7655", "--token", "t", "--allow-plaintext-http"}, func(string) string { return "" })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !cfg.AllowPlaintextHTTP {
|
|
t.Fatal("--allow-plaintext-http flag was not applied")
|
|
}
|
|
|
|
cfg, err = loadConfig([]string{"--url", "http://192.168.1.10:7655", "--token", "t"}, func(key string) string {
|
|
if key == "PULSE_AGENT_ALLOW_PLAINTEXT_HTTP" {
|
|
return "true"
|
|
}
|
|
return ""
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !cfg.AllowPlaintextHTTP {
|
|
t.Fatal("PULSE_AGENT_ALLOW_PLAINTEXT_HTTP env was not applied")
|
|
}
|
|
}
|