Pulse/internal/ai/runbooks_test.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)
}
})
}
}