Pulse/internal/servicediscovery/deep_scanner_test.go
2026-03-18 16:06:30 +00:00

473 lines
15 KiB
Go

package servicediscovery
import (
"bytes"
"context"
"strings"
"sync"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type stubExecutor struct {
mu sync.Mutex
commands []string
payloads []ExecuteCommandPayload // Track full payloads for testing
agents []ConnectedAgent
}
func (s *stubExecutor) ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) {
s.mu.Lock()
s.commands = append(s.commands, cmd.Command)
s.payloads = append(s.payloads, cmd)
s.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.Contains(cmd.Command, "docker ps -a") {
return &CommandResultPayload{
RequestID: cmd.RequestID,
Success: false,
Error: "boom",
}, nil
}
return &CommandResultPayload{
RequestID: cmd.RequestID,
Success: true,
Stdout: cmd.Command,
Duration: 5,
}, nil
}
func (s *stubExecutor) GetConnectedAgents() []ConnectedAgent {
return s.agents
}
func (s *stubExecutor) IsAgentConnected(agentID string) bool {
for _, agent := range s.agents {
if agent.AgentID == agentID {
return true
}
}
return false
}
type outputExecutor struct{}
func (outputExecutor) ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) {
switch {
case strings.Contains(cmd.Command, "docker ps -a"):
return &CommandResultPayload{Success: true, Stdout: "out", Stderr: "err"}, nil
case strings.Contains(cmd.Command, "docker images"):
return &CommandResultPayload{Success: true, Stderr: "err-only"}, nil
default:
return &CommandResultPayload{Success: true}, nil
}
}
func (outputExecutor) GetConnectedAgents() []ConnectedAgent {
return []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}}
}
func (outputExecutor) IsAgentConnected(string) bool { return true }
type errorExecutor struct{}
func (errorExecutor) ExecuteCommand(ctx context.Context, agentID string, cmd ExecuteCommandPayload) (*CommandResultPayload, error) {
return nil, context.DeadlineExceeded
}
func (errorExecutor) GetConnectedAgents() []ConnectedAgent {
return []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}}
}
func (errorExecutor) IsAgentConnected(string) bool { return true }
func TestDeepScanner_Scan_NestedDockerCommands(t *testing.T) {
exec := &stubExecutor{
agents: []ConnectedAgent{
{AgentID: "host1", Hostname: "host1", ConnectedAt: time.Now()},
},
}
scanner := NewDeepScanner(exec)
result, err := scanner.Scan(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDockerVM,
ResourceID: "101:web",
TargetID: "host1",
Hostname: "host1",
})
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if len(result.CommandOutputs) == 0 {
t.Fatalf("expected command outputs")
}
if _, ok := result.Errors["docker_containers"]; !ok {
t.Fatalf("expected docker_containers error, got %#v", result.Errors)
}
exec.mu.Lock()
defer exec.mu.Unlock()
// Verify the payload fields are set correctly for nested Docker:
// - Command should contain "docker exec" (buildCommand adds this)
// - TargetType should be "vm" (agent wraps with qm guest exec)
// - TargetID should be "101" (extracted from "101:web")
foundCorrectPayload := false
for _, payload := range exec.payloads {
hasDockerExec := strings.Contains(payload.Command, "docker exec")
hasContainerName := strings.Contains(payload.Command, "web")
correctTargetType := payload.TargetType == "vm"
correctTargetID := payload.TargetID == "101"
if hasDockerExec && hasContainerName && correctTargetType && correctTargetID {
foundCorrectPayload = true
break
}
}
if !foundCorrectPayload {
t.Fatalf("expected nested docker payload with docker exec, TargetType=vm, TargetID=101, got payloads: %+v", exec.payloads)
}
}
func TestDeepScanner_FindAgentAndTargetType(t *testing.T) {
exec := &stubExecutor{
agents: []ConnectedAgent{
{AgentID: "a1", Hostname: "node1"},
{AgentID: "a2", Hostname: "node2"},
},
}
scanner := NewDeepScanner(exec)
if got := scanner.findAgentForTarget("a2", ""); got != "a2" {
t.Fatalf("expected direct agent match, got %s", got)
}
if got := scanner.findAgentForTarget("node1", "node1"); got != "a1" {
t.Fatalf("expected hostname match, got %s", got)
}
exec.agents = []ConnectedAgent{{AgentID: "solo", Hostname: "only"}}
if got := scanner.findAgentForTarget("missing", "missing"); got != "solo" {
t.Fatalf("expected single agent fallback, got %s", got)
}
exec.agents = nil
if got := scanner.findAgentForTarget("missing", "missing"); got != "" {
t.Fatalf("expected no agent, got %s", got)
}
if scanner.getTargetType(ResourceTypeSystemContainer) != "container" {
t.Fatalf("unexpected target type for lxc")
}
if scanner.getTargetType(ResourceTypeVM) != "vm" {
t.Fatalf("unexpected target type for vm")
}
if scanner.getTargetType(ResourceTypeDocker) != "agent" {
t.Fatalf("unexpected target type for docker")
}
if scanner.getTargetType(ResourceTypeAgent) != "agent" {
t.Fatalf("unexpected target type for host")
}
}
func TestSplitResourceID(t *testing.T) {
parts := splitResourceID("101:web:extra")
if len(parts) != 3 || parts[0] != "101" || parts[1] != "web" || parts[2] != "extra" {
t.Fatalf("unexpected parts: %#v", parts)
}
}
func TestDeepScanner_GetTargetTypeAndID(t *testing.T) {
scanner := NewDeepScanner(&stubExecutor{})
// Test getTargetType
tests := []struct {
resourceType ResourceType
wantType string
}{
{ResourceTypeSystemContainer, "container"},
{ResourceTypeVM, "vm"},
{ResourceTypeDocker, "agent"},
{ResourceTypeDockerSystemContainer, "container"}, // Docker inside LXC runs via pct exec
{ResourceTypeDockerVM, "vm"}, // Docker inside VM runs via qm guest exec
{ResourceTypeAgent, "agent"},
{ResourceType("unknown"), "agent"},
}
for _, tt := range tests {
if got := scanner.getTargetType(tt.resourceType); got != tt.wantType {
t.Errorf("getTargetType(%s) = %s, want %s", tt.resourceType, got, tt.wantType)
}
}
// Test getTargetID
idTests := []struct {
resourceType ResourceType
resourceID string
wantID string
}{
{ResourceTypeSystemContainer, "101", "101"},
{ResourceTypeVM, "102", "102"},
{ResourceTypeDocker, "web", "web"},
{ResourceTypeDockerSystemContainer, "201:nginx", "201"}, // Extract vmid for nested docker
{ResourceTypeDockerVM, "301:postgres", "301"}, // Extract vmid for nested docker
{ResourceTypeAgent, "myhost", "myhost"},
}
for _, tt := range idTests {
if got := scanner.getTargetID(tt.resourceType, tt.resourceID); got != tt.wantID {
t.Errorf("getTargetID(%s, %s) = %s, want %s", tt.resourceType, tt.resourceID, got, tt.wantID)
}
}
}
func TestDeepScanner_BuildCommandAndProgress(t *testing.T) {
scanner := NewDeepScanner(&stubExecutor{})
// LXC: buildCommand returns raw command, agent handles pct exec wrapping
if cmd := scanner.buildCommand(ResourceTypeSystemContainer, "101", "echo hi"); cmd != "echo hi" {
t.Fatalf("LXC should return raw command (agent wraps), got: %s", cmd)
}
// VM: buildCommand returns raw command, agent handles qm guest exec wrapping
if cmd := scanner.buildCommand(ResourceTypeVM, "101", "echo hi"); cmd != "echo hi" {
t.Fatalf("VM should return raw command (agent wraps), got: %s", cmd)
}
// Docker: buildCommand wraps with docker exec since agent doesn't handle it
if cmd := scanner.buildCommand(ResourceTypeDocker, "web", "echo hi"); !strings.Contains(cmd, "docker exec") {
t.Fatalf("Docker should include docker exec, got: %s", cmd)
}
// Host: buildCommand returns raw command
if cmd := scanner.buildCommand(ResourceTypeAgent, "host", "echo hi"); cmd != "echo hi" {
t.Fatalf("Host should return raw command, got: %s", cmd)
}
// DockerLXC: buildCommand adds docker exec, agent adds pct exec
// So we should only see docker exec in the command (agent adds pct exec at runtime)
dockerLXC := scanner.buildCommand(ResourceTypeDockerSystemContainer, "201:web", "echo hi")
if !strings.Contains(dockerLXC, "docker exec") {
t.Fatalf("DockerLXC should include docker exec, got: %s", dockerLXC)
}
if strings.Contains(dockerLXC, "pct exec") {
t.Fatalf("DockerLXC should NOT include pct exec (agent adds it), got: %s", dockerLXC)
}
if cmd := scanner.buildCommand(ResourceTypeDockerSystemContainer, "bad", "echo hi"); cmd != "echo hi" {
t.Fatalf("DockerLXC with bad ID should fallback, got: %s", cmd)
}
// DockerVM: buildCommand adds docker exec, agent adds qm guest exec
dockerVM := scanner.buildCommand(ResourceTypeDockerVM, "301:web", "echo hi")
if !strings.Contains(dockerVM, "docker exec") {
t.Fatalf("DockerVM should include docker exec, got: %s", dockerVM)
}
if strings.Contains(dockerVM, "qm guest exec") {
t.Fatalf("DockerVM should NOT include qm guest exec (agent adds it), got: %s", dockerVM)
}
if cmd := scanner.buildCommand(ResourceTypeDockerVM, "bad", "echo hi"); cmd != "echo hi" {
t.Fatalf("DockerVM with bad ID should fallback, got: %s", cmd)
}
// Unknown type: returns raw command
if cmd := scanner.buildCommand(ResourceType("unknown"), "id", "echo hi"); cmd != "echo hi" {
t.Fatalf("Unknown type should return raw command, got: %s", cmd)
}
scanner.progress["id"] = &DiscoveryProgress{ResourceID: "id"}
if scanner.GetProgress("id") == nil {
t.Fatalf("expected progress")
}
if !scanner.IsScanning("id") {
t.Fatalf("expected IsScanning true")
}
if scanner.GetProgress("missing") != nil {
t.Fatalf("expected nil progress")
}
if scanner.IsScanning("missing") {
t.Fatalf("expected IsScanning false")
}
noExec := NewDeepScanner(nil)
if _, err := noExec.ScanHost(context.Background(), "host1", "host1"); err == nil {
t.Fatalf("expected error without executor")
}
}
func TestDeepScanner_ScanWrappers(t *testing.T) {
exec := &stubExecutor{
agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}},
}
scanner := NewDeepScanner(exec)
scanner.maxParallel = 1
if _, err := scanner.ScanDocker(context.Background(), "host1", "host1", "web"); err != nil {
t.Fatalf("ScanDocker error: %v", err)
}
if _, err := scanner.ScanSystemContainer(context.Background(), "host1", "host1", "101"); err != nil {
t.Fatalf("ScanSystemContainer error: %v", err)
}
if _, err := scanner.ScanVM(context.Background(), "host1", "host1", "102"); err != nil {
t.Fatalf("ScanVM error: %v", err)
}
}
func TestDeepScanner_ScanErrors(t *testing.T) {
exec := &stubExecutor{
agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}},
}
scanner := NewDeepScanner(exec)
if _, err := scanner.Scan(context.Background(), DiscoveryRequest{
ResourceType: ResourceType("unknown"),
ResourceID: "id",
TargetID: "host1",
Hostname: "host1",
}); err == nil {
t.Fatalf("expected error for unknown resource type")
}
exec.agents = nil
if _, err := scanner.Scan(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
}); err == nil {
t.Fatalf("expected error for missing agent")
}
}
func TestDeepScanner_OutputHandling(t *testing.T) {
exec := outputExecutor{}
scanner := NewDeepScanner(exec)
scanner.maxParallel = 1
result, err := scanner.Scan(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDockerVM,
ResourceID: "101:web",
TargetID: "host1",
Hostname: "host1",
})
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if out := result.CommandOutputs["docker_containers"]; !strings.Contains(out, "--- stderr ---") {
t.Fatalf("expected combined stderr output, got %s", out)
}
if out := result.CommandOutputs["docker_images"]; out != "err-only" {
t.Fatalf("expected stderr-only output, got %s", out)
}
}
func TestDeepScanner_CommandErrorHandling(t *testing.T) {
scanner := NewDeepScanner(errorExecutor{})
scanner.maxParallel = 1
result, err := scanner.Scan(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDockerVM,
ResourceID: "101:web",
TargetID: "host1",
Hostname: "host1",
})
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if _, ok := result.Errors["docker_containers"]; !ok {
t.Fatalf("expected error for non-optional command")
}
}
func TestDeepScanner_ScanCanceledContext(t *testing.T) {
exec := &stubExecutor{
agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}},
}
scanner := NewDeepScanner(exec)
scanner.maxParallel = 0
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := scanner.Scan(ctx, DiscoveryRequest{
ResourceType: ResourceTypeDockerVM,
ResourceID: "101:web",
TargetID: "host1",
Hostname: "host1",
}); err != nil {
t.Fatalf("Scan error: %v", err)
}
}
func TestDeepScanner_ScanLogsStructuredContextWhenExecutorMissing(t *testing.T) {
logOutput := captureDeepScannerLogs(t)
scanner := NewDeepScanner(nil)
if _, err := scanner.ScanHost(context.Background(), "host1", "host1"); err == nil {
t.Fatalf("expected error without executor")
}
for _, expected := range []string{
`"component":"service_discovery_scanner"`,
`"action":"scan_precondition_failed"`,
`"reason":"executor_missing"`,
`"resource_id":"agent:host1:host1"`,
`"resource_type":"agent"`,
`"target_id":"host1"`,
`"message":"Deep scan unavailable"`,
} {
if !strings.Contains(logOutput.String(), expected) {
t.Fatalf("expected log output to include %s, got %q", expected, logOutput.String())
}
}
}
func TestDeepScanner_ScanLogsStructuredContextOnCommandResultFailure(t *testing.T) {
exec := &stubExecutor{
agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}},
}
scanner := NewDeepScanner(exec)
scanner.maxParallel = 1
logOutput := captureDeepScannerLogs(t)
result, err := scanner.Scan(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDockerVM,
ResourceID: "101:web",
TargetID: "host1",
Hostname: "host1",
})
if err != nil {
t.Fatalf("Scan error: %v", err)
}
if _, ok := result.Errors["docker_containers"]; !ok {
t.Fatalf("expected docker_containers error, got %#v", result.Errors)
}
for _, expected := range []string{
`"component":"service_discovery_scanner"`,
`"action":"command_result_failed"`,
`"command":"docker_containers"`,
`"optional":false`,
`"command_error":"boom"`,
`"resource_id":"docker_vm:host1:101:web"`,
`"resource_type":"docker_vm"`,
`"target_id":"host1"`,
`"message":"Deep scan command reported failure"`,
} {
if !strings.Contains(logOutput.String(), expected) {
t.Fatalf("expected log output to include %s, got %q", expected, logOutput.String())
}
}
}
func captureDeepScannerLogs(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
origLogger := log.Logger
log.Logger = zerolog.New(&buf).Level(zerolog.DebugLevel).With().Timestamp().Logger()
t.Cleanup(func() {
log.Logger = origLogger
})
return &buf
}