561 lines
12 KiB
Go
561 lines
12 KiB
Go
package ai
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/memory"
|
|
)
|
|
|
|
func TestMatchRunbooksForFinding_DockerRestartLoop(t *testing.T) {
|
|
finding := &Finding{
|
|
Key: "restart-loop",
|
|
ResourceType: "docker_container",
|
|
}
|
|
|
|
runbooks := matchRunbooksForFinding(finding)
|
|
if len(runbooks) == 0 {
|
|
t.Fatal("expected at least one runbook")
|
|
}
|
|
|
|
found := false
|
|
for _, rb := range runbooks {
|
|
if rb.ID == "docker-restart-loop" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
t.Fatal("expected docker-restart-loop runbook to match")
|
|
}
|
|
}
|
|
|
|
func TestMatchRunbooksForFinding_DockerHighMemory(t *testing.T) {
|
|
finding := &Finding{
|
|
Key: "high-memory",
|
|
ResourceType: "docker_container",
|
|
}
|
|
|
|
runbooks := matchRunbooksForFinding(finding)
|
|
found := false
|
|
for _, rb := range runbooks {
|
|
if rb.ID == "docker-high-memory-restart" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("expected docker-high-memory-restart runbook to match")
|
|
}
|
|
}
|
|
|
|
func TestVerifyDiskUsage(t *testing.T) {
|
|
finding := &Finding{
|
|
ResourceType: "container",
|
|
Evidence: "Disk: 95.0%",
|
|
}
|
|
thresholds := PatrolThresholds{
|
|
GuestDiskWatch: 85,
|
|
}
|
|
|
|
output := `Filesystem 1024-blocks Used Available Capacity Mounted on
|
|
/dev/sda1 20511356 1000000 19511356 80% /`
|
|
|
|
outcome, note := verifyDiskUsage(output, finding, thresholds)
|
|
if outcome != memory.OutcomeResolved {
|
|
t.Fatalf("expected resolved, got %s (%s)", outcome, note)
|
|
}
|
|
|
|
output = `Filesystem 1024-blocks Used Available Capacity Mounted on
|
|
/dev/sda1 20511356 1000000 19511356 93% /`
|
|
|
|
outcome, _ = verifyDiskUsage(output, finding, thresholds)
|
|
if outcome != memory.OutcomePartial {
|
|
t.Fatalf("expected partial, got %s", outcome)
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// escapeShellArg tests
|
|
// ========================================
|
|
|
|
func TestEscapeShellArg(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
expected: "''",
|
|
},
|
|
{
|
|
name: "simple string",
|
|
input: "hello",
|
|
expected: "hello",
|
|
},
|
|
{
|
|
name: "string with space",
|
|
input: "hello world",
|
|
expected: "'hello world'",
|
|
},
|
|
{
|
|
name: "string with single quote",
|
|
input: "it's",
|
|
expected: "'it'\\''s'",
|
|
},
|
|
{
|
|
name: "string with tab",
|
|
input: "hello\tworld",
|
|
expected: "'hello\tworld'",
|
|
},
|
|
{
|
|
name: "alphanumeric only",
|
|
input: "test123",
|
|
expected: "test123",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := escapeShellArg(tt.input)
|
|
if result != tt.expected {
|
|
t.Errorf("escapeShellArg(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// parseVMID tests
|
|
// ========================================
|
|
|
|
func TestParseVMID(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resourceID string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "empty string",
|
|
resourceID: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "simple vmid",
|
|
resourceID: "node1-100",
|
|
expected: "100",
|
|
},
|
|
{
|
|
name: "complex id",
|
|
resourceID: "pve-cluster-node-200",
|
|
expected: "200",
|
|
},
|
|
{
|
|
name: "non-numeric ending",
|
|
resourceID: "node1-abc",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "single number",
|
|
resourceID: "100",
|
|
expected: "100",
|
|
},
|
|
{
|
|
name: "no dash prefix",
|
|
resourceID: "node",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := parseVMID(tt.resourceID)
|
|
if result != tt.expected {
|
|
t.Errorf("parseVMID(%q) = %q, want %q", tt.resourceID, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// truncateRunbookOutput tests
|
|
// ========================================
|
|
|
|
func TestTruncateRunbookOutput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
output string
|
|
limit int
|
|
expected string
|
|
}{
|
|
{
|
|
name: "empty string",
|
|
output: "",
|
|
limit: 10,
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "under limit",
|
|
output: "hello",
|
|
limit: 10,
|
|
expected: "hello",
|
|
},
|
|
{
|
|
name: "at limit",
|
|
output: "helloworld",
|
|
limit: 10,
|
|
expected: "helloworld",
|
|
},
|
|
{
|
|
name: "over limit",
|
|
output: "hello world example",
|
|
limit: 10,
|
|
expected: "hello worl...",
|
|
},
|
|
{
|
|
name: "with whitespace",
|
|
output: " hello ",
|
|
limit: 10,
|
|
expected: "hello",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := truncateRunbookOutput(tt.output, tt.limit)
|
|
if result != tt.expected {
|
|
t.Errorf("truncateRunbookOutput(%q, %d) = %q, want %q", tt.output, tt.limit, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// getRunbookByID tests
|
|
// ========================================
|
|
|
|
func TestGetRunbookByID(t *testing.T) {
|
|
// Test finding a real runbook from the catalog
|
|
rb, found := getRunbookByID("docker-restart-loop")
|
|
if !found {
|
|
t.Fatal("expected to find docker-restart-loop runbook")
|
|
}
|
|
if rb.ID != "docker-restart-loop" {
|
|
t.Errorf("expected ID 'docker-restart-loop', got %q", rb.ID)
|
|
}
|
|
|
|
// Test not finding a non-existent runbook
|
|
_, found = getRunbookByID("non-existent-runbook")
|
|
if found {
|
|
t.Error("expected not to find non-existent-runbook")
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// parseDFUsagePercent tests
|
|
// ========================================
|
|
|
|
func TestParseDFUsagePercent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
output string
|
|
expected int
|
|
ok bool
|
|
}{
|
|
{
|
|
name: "standard df output",
|
|
output: "Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/sda1 20511356 1000000 19511356 80% /",
|
|
expected: 80,
|
|
ok: true,
|
|
},
|
|
{
|
|
name: "no percentage",
|
|
output: "No disk info here",
|
|
expected: 0,
|
|
ok: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, ok := parseDFUsagePercent(tt.output)
|
|
if ok != tt.ok {
|
|
t.Errorf("parseDFUsagePercent(%q) ok = %v, want %v", tt.output, ok, tt.ok)
|
|
}
|
|
if ok && result != tt.expected {
|
|
t.Errorf("parseDFUsagePercent(%q) = %v, want %v", tt.output, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// parsePercentFromFinding tests
|
|
// ========================================
|
|
|
|
func TestParsePercentFromFinding(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
evidence string
|
|
expected float64
|
|
}{
|
|
{
|
|
name: "disk percentage",
|
|
evidence: "Disk: 95.0%",
|
|
expected: 95.0,
|
|
},
|
|
{
|
|
name: "cpu percentage",
|
|
evidence: "CPU usage is at 87.5% utilization",
|
|
expected: 87.5,
|
|
},
|
|
{
|
|
name: "no percentage",
|
|
evidence: "No percentage here",
|
|
expected: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := parsePercentFromFinding(tt.evidence)
|
|
if result != tt.expected {
|
|
t.Errorf("parsePercentFromFinding(%q) = %v, want %v", tt.evidence, result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// runbookApplies tests
|
|
// ========================================
|
|
|
|
func TestRunbookApplies(t *testing.T) {
|
|
// Test with matching resource type and exact key
|
|
rb := Runbook{
|
|
ResourceTypes: []string{"docker_container"},
|
|
FindingKeys: []string{"restart-loop"},
|
|
}
|
|
finding := &Finding{
|
|
Key: "restart-loop", // exact match required
|
|
ResourceType: "docker_container",
|
|
}
|
|
if !runbookApplies(rb, finding) {
|
|
t.Error("expected runbook to apply")
|
|
}
|
|
|
|
// Test with non-matching resource type
|
|
finding2 := &Finding{
|
|
Key: "restart-loop",
|
|
ResourceType: "vm",
|
|
}
|
|
if runbookApplies(rb, finding2) {
|
|
t.Error("expected runbook NOT to apply (wrong resource type)")
|
|
}
|
|
|
|
// Test with non-matching key
|
|
finding3 := &Finding{
|
|
Key: "high-memory",
|
|
ResourceType: "docker_container",
|
|
}
|
|
if runbookApplies(rb, finding3) {
|
|
t.Error("expected runbook NOT to apply (wrong key)")
|
|
}
|
|
|
|
// Test with empty FindingKeys (should match any key)
|
|
rb2 := Runbook{
|
|
ResourceTypes: []string{"vm"},
|
|
FindingKeys: []string{}, // empty means match any key
|
|
}
|
|
finding4 := &Finding{
|
|
Key: "any-key",
|
|
ResourceType: "vm",
|
|
}
|
|
if !runbookApplies(rb2, finding4) {
|
|
t.Error("expected runbook with empty FindingKeys to apply to any key")
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// parsePBSResourceParts tests
|
|
// ========================================
|
|
|
|
func TestParsePBSResourceParts(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resourceID string
|
|
expectedPBS string
|
|
expectedDS string
|
|
expectedJobID string
|
|
}{
|
|
{
|
|
name: "empty string",
|
|
resourceID: "",
|
|
expectedPBS: "",
|
|
expectedDS: "",
|
|
expectedJobID: "",
|
|
},
|
|
{
|
|
name: "simple pbs id only",
|
|
resourceID: "pbs1",
|
|
expectedPBS: "pbs1",
|
|
expectedDS: "",
|
|
expectedJobID: "",
|
|
},
|
|
{
|
|
name: "pbs with datastore",
|
|
resourceID: "pbs1:datastore1",
|
|
expectedPBS: "pbs1",
|
|
expectedDS: "datastore1",
|
|
expectedJobID: "",
|
|
},
|
|
{
|
|
name: "pbs with job",
|
|
resourceID: "pbs1:job:backup-job-1",
|
|
expectedPBS: "pbs1",
|
|
expectedDS: "",
|
|
expectedJobID: "backup-job-1",
|
|
},
|
|
{
|
|
name: "pbs with verify job",
|
|
resourceID: "pbs1:verify:verify-job-1",
|
|
expectedPBS: "pbs1",
|
|
expectedDS: "",
|
|
expectedJobID: "verify-job-1",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pbsID, ds, jobID := parsePBSResourceParts(tt.resourceID)
|
|
if pbsID != tt.expectedPBS {
|
|
t.Errorf("pbsID = %q, want %q", pbsID, tt.expectedPBS)
|
|
}
|
|
if ds != tt.expectedDS {
|
|
t.Errorf("ds = %q, want %q", ds, tt.expectedDS)
|
|
}
|
|
if jobID != tt.expectedJobID {
|
|
t.Errorf("jobID = %q, want %q", jobID, tt.expectedJobID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// runbookTargetType tests
|
|
// ========================================
|
|
|
|
func TestRunbookTargetType(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
finding *Finding
|
|
runOnHost bool
|
|
expected string
|
|
}{
|
|
{
|
|
name: "run on host override",
|
|
finding: &Finding{ResourceType: "vm"},
|
|
runOnHost: true,
|
|
expected: "host",
|
|
},
|
|
{
|
|
name: "vm resource",
|
|
finding: &Finding{ResourceType: "vm"},
|
|
runOnHost: false,
|
|
expected: "vm",
|
|
},
|
|
{
|
|
name: "container resource",
|
|
finding: &Finding{ResourceType: "container"},
|
|
runOnHost: false,
|
|
expected: "container",
|
|
},
|
|
{
|
|
name: "other resource type",
|
|
finding: &Finding{ResourceType: "storage"},
|
|
runOnHost: false,
|
|
expected: "host",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := runbookTargetType(tt.finding, tt.runOnHost)
|
|
if result != tt.expected {
|
|
t.Errorf("runbookTargetType() = %q, want %q", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// renderRunbookCommand tests
|
|
// ========================================
|
|
|
|
func TestRenderRunbookCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
command string
|
|
ctx runbookContext
|
|
expected string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "simple command no placeholders",
|
|
command: "echo hello",
|
|
ctx: runbookContext{},
|
|
expected: "echo hello",
|
|
},
|
|
{
|
|
name: "command with resource_id",
|
|
command: "restart {{resource_id}}",
|
|
ctx: runbookContext{ResourceID: "node1-100"},
|
|
expected: "restart node1-100",
|
|
},
|
|
{
|
|
name: "command with vmid",
|
|
command: "qm reboot {{vmid}}",
|
|
ctx: runbookContext{VMID: "100"},
|
|
expected: "qm reboot 100",
|
|
},
|
|
{
|
|
name: "command with node",
|
|
command: "ssh {{node}} hostname",
|
|
ctx: runbookContext{Node: "node1"},
|
|
expected: "ssh node1 hostname",
|
|
},
|
|
{
|
|
name: "missing required value",
|
|
command: "restart {{vmid}}",
|
|
ctx: runbookContext{VMID: ""},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "command with value needing escaping",
|
|
command: "echo {{resource_name}}",
|
|
ctx: runbookContext{ResourceName: "my vm"},
|
|
expected: "echo 'my vm'",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := renderRunbookCommand(tt.command, tt.ctx)
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Error("expected error but got none")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result != tt.expected {
|
|
t.Errorf("renderRunbookCommand() = %q, want %q", result, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|