diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 8595baa..15e8799 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -793,12 +793,7 @@ func (p *PatrolService) runPatrol(ctx context.Context) { autoFixEnabled = aiCfg.PatrolAutoFix } } - if hasPatrolFeature && autoFixEnabled && p.aiService.HasLicenseFeature(FeatureAIAutoFix) { - runbookResolved = p.AutoFixWithRunbooks(ctx, newFindings) - if runbookResolved > 0 { - log.Info().Int("resolved", runbookResolved).Msg("AI Patrol: Auto-fix runbooks resolved findings") - } - } + _ = autoFixEnabled // Auto-fix via runbooks removed - dynamic AI remediation handles this // Auto-resolve findings that weren't seen in this patrol run var resolvedCount int diff --git a/internal/ai/runbooks.go b/internal/ai/runbooks.go deleted file mode 100644 index e086cb5..0000000 --- a/internal/ai/runbooks.go +++ /dev/null @@ -1,706 +0,0 @@ -package ai - -import ( - "context" - "errors" - "fmt" - "regexp" - "strconv" - "strings" - "time" - - "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" - "github.com/rs/zerolog/log" -) - -type RunbookRisk string - -const ( - RunbookRiskLow RunbookRisk = "low" - RunbookRiskMedium RunbookRisk = "medium" - RunbookRiskHigh RunbookRisk = "high" -) - -const runbookVerifierDiskUsage = "disk-usage" - -var ( - ErrRunbookNotFound = errors.New("runbook not found") - ErrRunbookNotApplicable = errors.New("runbook does not apply to finding") -) - -type RunbookInfo struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Risk RunbookRisk `json:"risk"` -} - -type RunbookStep struct { - Name string - Command string - RunOnHost bool - AllowFailure bool -} - -type RunbookVerification struct { - Name string - Command string - RunOnHost bool - SuccessRegex string - FailureRegex string - Verifier string -} - -type Runbook struct { - ID string - Title string - Description string - Risk RunbookRisk - FindingKeys []string - ResourceTypes []string - Steps []RunbookStep - Verification *RunbookVerification - ResolutionNote string -} - -type RunbookStepResult struct { - Name string `json:"name"` - Command string `json:"command"` - Output string `json:"output"` - Success bool `json:"success"` -} - -type RunbookExecutionResult struct { - RunbookID string `json:"runbook_id"` - Outcome memory.Outcome `json:"outcome"` - Message string `json:"message"` - Steps []RunbookStepResult `json:"steps"` - VerifyStep *RunbookStepResult `json:"verification,omitempty"` - Resolved bool `json:"resolved"` - ExecutedAt time.Time `json:"executed_at"` - FindingID string `json:"finding_id"` - FindingKey string `json:"finding_key,omitempty"` -} - -func (p *PatrolService) GetRunbooksForFinding(findingID string) ([]RunbookInfo, error) { - finding := p.findings.Get(findingID) - if finding == nil { - return nil, fmt.Errorf("finding not found") - } - - runbooks := matchRunbooksForFinding(finding) - infos := make([]RunbookInfo, 0, len(runbooks)) - for _, rb := range runbooks { - infos = append(infos, RunbookInfo{ - ID: rb.ID, - Title: rb.Title, - Description: rb.Description, - Risk: rb.Risk, - }) - } - return infos, nil -} - -func (p *PatrolService) ExecuteRunbook(ctx context.Context, findingID, runbookID string) (*RunbookExecutionResult, error) { - return p.executeRunbook(ctx, findingID, runbookID, false) -} - -func (p *PatrolService) executeRunbook(ctx context.Context, findingID, runbookID string, automatic bool) (*RunbookExecutionResult, error) { - finding := p.findings.Get(findingID) - if finding == nil { - return nil, fmt.Errorf("finding not found") - } - - runbook, ok := getRunbookByID(runbookID) - if !ok { - return nil, ErrRunbookNotFound - } - if !runbookApplies(runbook, finding) { - return nil, ErrRunbookNotApplicable - } - if p.aiService == nil { - return nil, fmt.Errorf("AI service not available") - } - - context := buildRunbookContext(finding) - results := make([]RunbookStepResult, 0, len(runbook.Steps)) - executionTime := time.Now() - - for _, step := range runbook.Steps { - if step.RunOnHost && context.Node == "" { - return nil, fmt.Errorf("target host is required for runbook step %q", step.Name) - } - command, err := renderRunbookCommand(step.Command, context) - if err != nil { - return nil, err - } - - resp, err := p.aiService.RunCommand(ctx, RunCommandRequest{ - Command: command, - TargetType: runbookTargetType(finding, step.RunOnHost), - TargetID: finding.ResourceID, - RunOnHost: step.RunOnHost, - VMID: context.VMID, - TargetHost: context.Node, - }) - if err != nil { - return nil, err - } - - stepResult := RunbookStepResult{ - Name: step.Name, - Command: command, - Output: strings.TrimSpace(resp.Output), - Success: resp.Success, - } - results = append(results, stepResult) - - if !resp.Success && !step.AllowFailure { - outcome := memory.OutcomeFailed - message := fmt.Sprintf("Runbook step failed: %s", step.Name) - p.logRunbookExecution(finding, runbook, results, nil, outcome, message, executionTime, automatic) - return &RunbookExecutionResult{ - RunbookID: runbook.ID, - Outcome: outcome, - Message: message, - Steps: results, - Resolved: false, - ExecutedAt: executionTime, - FindingID: finding.ID, - FindingKey: finding.Key, - }, nil - } - } - - var verifyResult *RunbookStepResult - outcome := memory.OutcomeUnknown - message := "Runbook completed" - - if runbook.Verification != nil { - if runbook.Verification.RunOnHost && context.Node == "" { - return nil, fmt.Errorf("target host is required for runbook verification") - } - command, err := renderRunbookCommand(runbook.Verification.Command, context) - if err != nil { - return nil, err - } - - resp, err := p.aiService.RunCommand(ctx, RunCommandRequest{ - Command: command, - TargetType: runbookTargetType(finding, runbook.Verification.RunOnHost), - TargetID: finding.ResourceID, - RunOnHost: runbook.Verification.RunOnHost, - VMID: context.VMID, - TargetHost: context.Node, - }) - if err != nil { - return nil, err - } - - verifyResult = &RunbookStepResult{ - Name: runbook.Verification.Name, - Command: command, - Output: strings.TrimSpace(resp.Output), - Success: resp.Success, - } - - outcome, message = evaluateRunbookVerification(runbook.Verification, verifyResult.Output, finding, p.thresholds) - if !resp.Success && outcome == memory.OutcomeResolved { - outcome = memory.OutcomePartial - message = "Verification command failed" - } - } else { - outcome = memory.OutcomeUnknown - message = "Runbook completed without verification" - } - - resolved := false - if outcome == memory.OutcomeResolved { - note := runbook.ResolutionNote - if note == "" { - note = fmt.Sprintf("Applied runbook: %s", runbook.Title) - } - if err := p.ResolveFinding(finding.ID, note); err == nil { - resolved = true - } - } - - p.logRunbookExecution(finding, runbook, results, verifyResult, outcome, message, executionTime, automatic) - - return &RunbookExecutionResult{ - RunbookID: runbook.ID, - Outcome: outcome, - Message: message, - Steps: results, - VerifyStep: verifyResult, - Resolved: resolved, - ExecutedAt: executionTime, - FindingID: finding.ID, - FindingKey: finding.Key, - }, nil -} - -func (p *PatrolService) AutoFixWithRunbooks(ctx context.Context, findings []*Finding) int { - if ctx == nil { - ctx = context.Background() - } - if p.aiService == nil || len(findings) == 0 { - return 0 - } - - resolved := 0 - for _, finding := range findings { - if finding == nil || finding.Key == "" { - continue - } - select { - case <-ctx.Done(): - return resolved - default: - } - - runbook, ok := selectRunbookForAutoFix(finding) - if !ok { - continue - } - if !p.shouldAutoFixFinding(finding) { - continue - } - - result, err := p.executeRunbook(ctx, finding.ID, runbook.ID, true) - if err != nil { - log.Warn().Err(err).Str("runbook_id", runbook.ID).Str("finding_id", finding.ID).Msg("Runbook auto-fix failed") - continue - } - if result != nil && result.Resolved { - resolved++ - } - } - - return resolved -} - -func (p *PatrolService) logRunbookExecution(finding *Finding, runbook Runbook, steps []RunbookStepResult, verify *RunbookStepResult, outcome memory.Outcome, message string, executedAt time.Time, automatic bool) { - if p.remediationLog == nil { - return - } - - noteParts := []string{} - for _, step := range steps { - status := "ok" - if !step.Success { - status = "failed" - } - noteParts = append(noteParts, fmt.Sprintf("%s (%s)", step.Name, status)) - } - if verify != nil { - noteParts = append(noteParts, fmt.Sprintf("verification: %s", verify.Name)) - } - note := strings.Join(noteParts, "; ") - if message != "" { - note = message + ". " + note - } - - output := "" - if verify != nil { - output = verify.Output - } - - record := memory.RemediationRecord{ - Timestamp: executedAt, - ResourceID: finding.ResourceID, - ResourceType: finding.ResourceType, - ResourceName: finding.ResourceName, - FindingID: finding.ID, - Problem: finding.Title, - Action: runbook.Title, - Output: truncateRunbookOutput(output, 1000), - Outcome: outcome, - Note: truncateRunbookOutput(note, 500), - Automatic: automatic, - } - - if err := p.remediationLog.Log(record); err != nil { - log.Warn().Err(err).Msg("Failed to log runbook execution") - } - - if p.aiService != nil && finding != nil && finding.AlertID != "" { - p.aiService.RecordIncidentRunbook(finding.AlertID, runbook.ID, runbook.Title, outcome, automatic, message) - } -} - -type runbookContext struct { - ResourceID string - ResourceName string - Node string - VMID string - PBSID string - Datastore string - JobID string -} - -func buildRunbookContext(finding *Finding) runbookContext { - vmid := parseVMID(finding.ResourceID) - pbsID, datastore, jobID := parsePBSResourceParts(finding.ResourceID) - - return runbookContext{ - ResourceID: finding.ResourceID, - ResourceName: finding.ResourceName, - Node: finding.Node, - VMID: vmid, - PBSID: pbsID, - Datastore: datastore, - JobID: jobID, - } -} - -func runbookTargetType(finding *Finding, runOnHost bool) string { - if runOnHost { - return "host" - } - switch finding.ResourceType { - case "vm": - return "vm" - case "container": - return "container" - default: - return "host" - } -} - -func renderRunbookCommand(command string, ctx runbookContext) (string, error) { - placeholders := map[string]string{ - "resource_id": ctx.ResourceID, - "resource_name": ctx.ResourceName, - "node": ctx.Node, - "vmid": ctx.VMID, - "pbs_id": ctx.PBSID, - "datastore": ctx.Datastore, - "job_id": ctx.JobID, - } - - result := command - for key, value := range placeholders { - placeholder := "{{" + key + "}}" - if strings.Contains(result, placeholder) { - if value == "" { - return "", fmt.Errorf("missing value for %s", key) - } - result = strings.ReplaceAll(result, placeholder, escapeShellArg(value)) - } - } - - return result, nil -} - -func escapeShellArg(value string) string { - if value == "" { - return "''" - } - if !strings.ContainsAny(value, " \t\n'\"") { - return value - } - escaped := strings.ReplaceAll(value, `'`, `'\''`) - return "'" + escaped + "'" -} - -func parseVMID(resourceID string) string { - if resourceID == "" { - return "" - } - parts := strings.Split(resourceID, "-") - last := parts[len(parts)-1] - if _, err := strconv.Atoi(last); err == nil { - return last - } - return "" -} - -func parsePBSResourceParts(resourceID string) (string, string, string) { - if resourceID == "" { - return "", "", "" - } - parts := strings.Split(resourceID, ":") - if len(parts) < 2 { - return resourceID, "", "" - } - pbsID := parts[0] - if len(parts) >= 3 && (parts[1] == "job" || parts[1] == "verify") { - return pbsID, "", parts[2] - } - return pbsID, parts[1], "" -} - -func evaluateRunbookVerification(verification *RunbookVerification, output string, finding *Finding, thresholds PatrolThresholds) (memory.Outcome, string) { - if verification == nil { - return memory.OutcomeUnknown, "No verification configured" - } - - if verification.Verifier == runbookVerifierDiskUsage { - return verifyDiskUsage(output, finding, thresholds) - } - - if verification.SuccessRegex != "" { - matched, _ := regexp.MatchString(verification.SuccessRegex, output) - if matched { - return memory.OutcomeResolved, "Verification passed" - } - } - - if verification.FailureRegex != "" { - matched, _ := regexp.MatchString(verification.FailureRegex, output) - if matched { - return memory.OutcomeFailed, "Verification failed" - } - } - - return memory.OutcomeUnknown, "Verification inconclusive" -} - -func verifyDiskUsage(output string, finding *Finding, thresholds PatrolThresholds) (memory.Outcome, string) { - usage, ok := parseDFUsagePercent(output) - if !ok { - return memory.OutcomeUnknown, "Unable to parse disk usage" - } - - threshold := thresholds.GuestDiskWatch - if finding.ResourceType == "storage" { - threshold = thresholds.StorageWatch - } - - note := fmt.Sprintf("Disk usage now %d%% (threshold %.0f%%)", usage, threshold) - - if float64(usage) < threshold { - return memory.OutcomeResolved, note - } - - baseline := parsePercentFromFinding(finding.Evidence) - if baseline > 0 { - if float64(usage) < baseline { - return memory.OutcomePartial, note + fmt.Sprintf(", was %.0f%%", baseline) - } - return memory.OutcomeFailed, note + fmt.Sprintf(", was %.0f%%", baseline) - } - - return memory.OutcomeUnknown, note -} - -func parseDFUsagePercent(output string) (int, bool) { - lines := strings.Split(output, "\n") - for _, line := range lines { - fields := strings.Fields(line) - if len(fields) < 6 { - continue - } - mount := fields[len(fields)-1] - if mount != "/" { - continue - } - usageField := fields[4] - usageField = strings.TrimSuffix(usageField, "%") - usage, err := strconv.Atoi(usageField) - if err != nil { - return 0, false - } - return usage, true - } - return 0, false -} - -func parsePercentFromFinding(value string) float64 { - if value == "" { - return 0 - } - re := regexp.MustCompile(`([0-9]+(?:\.[0-9]+)?)%`) - match := re.FindStringSubmatch(value) - if len(match) < 2 { - return 0 - } - parsed, err := strconv.ParseFloat(match[1], 64) - if err != nil { - return 0 - } - return parsed -} - -func truncateRunbookOutput(output string, limit int) string { - output = strings.TrimSpace(output) - if len(output) <= limit { - return output - } - return output[:limit] + "..." -} - -func runbookApplies(runbook Runbook, finding *Finding) bool { - if len(runbook.FindingKeys) > 0 { - matched := false - for _, key := range runbook.FindingKeys { - if key == finding.Key { - matched = true - break - } - } - if !matched { - return false - } - } - - if len(runbook.ResourceTypes) > 0 { - matched := false - for _, rt := range runbook.ResourceTypes { - if rt == finding.ResourceType { - matched = true - break - } - } - if !matched { - return false - } - } - - return true -} - -func selectRunbookForAutoFix(finding *Finding) (Runbook, bool) { - for _, runbook := range runbooksCatalog { - if runbook.Risk != RunbookRiskLow { - continue - } - if runbookApplies(runbook, finding) { - return runbook, true - } - } - return Runbook{}, false -} - -func (p *PatrolService) shouldAutoFixFinding(finding *Finding) bool { - if p.remediationLog == nil { - return false - } - - records := p.remediationLog.GetForFinding(finding.ID, 1) - if len(records) == 0 { - return true - } - - last := records[0] - if time.Since(last.Timestamp) < 6*time.Hour { - return false - } - - return true -} - -func matchRunbooksForFinding(finding *Finding) []Runbook { - var matches []Runbook - for _, runbook := range runbooksCatalog { - if runbookApplies(runbook, finding) { - matches = append(matches, runbook) - } - } - return matches -} - -func getRunbookByID(id string) (Runbook, bool) { - for _, runbook := range runbooksCatalog { - if runbook.ID == id { - return runbook, true - } - } - return Runbook{}, false -} - -var runbooksCatalog = []Runbook{ - { - ID: "docker-restart-loop", - Title: "Restart Docker container and verify", - Description: "Collect recent logs, restart the container, then verify it is running.", - Risk: RunbookRiskLow, - FindingKeys: []string{"restart-loop"}, - ResourceTypes: []string{ - "docker_container", - }, - Steps: []RunbookStep{ - { - Name: "Fetch recent logs", - Command: "docker logs --tail 200 {{resource_name}}", - RunOnHost: true, - AllowFailure: true, - }, - { - Name: "Restart container", - Command: "docker restart {{resource_name}}", - RunOnHost: true, - }, - }, - Verification: &RunbookVerification{ - Name: "Verify container running", - Command: "docker inspect -f '{{.State.Status}}' {{resource_name}}", - RunOnHost: true, - SuccessRegex: "(?i)running", - }, - ResolutionNote: "Restarted docker container and verified it is running.", - }, - { - ID: "guest-disk-cleanup", - Title: "Clean package cache and old logs", - Description: "Vacuum systemd journal and clean package cache to free disk space.", - Risk: RunbookRiskMedium, - FindingKeys: []string{"high-disk"}, - ResourceTypes: []string{ - "vm", - "container", - }, - Steps: []RunbookStep{ - { - Name: "Vacuum systemd journal", - Command: "journalctl --vacuum-time=7d", - RunOnHost: false, - AllowFailure: true, - }, - { - Name: "Clean package cache", - Command: "apt-get clean", - RunOnHost: false, - AllowFailure: true, - }, - }, - Verification: &RunbookVerification{ - Name: "Check root filesystem usage", - Command: "df -P /", - RunOnHost: false, - Verifier: runbookVerifierDiskUsage, - }, - ResolutionNote: "Cleaned logs and package cache, then verified disk usage.", - }, - { - ID: "docker-high-memory-restart", - Title: "Restart Docker container to clear memory", - Description: "Capture current memory usage, restart the container, then verify it is running.", - Risk: RunbookRiskMedium, - FindingKeys: []string{"high-memory"}, - ResourceTypes: []string{ - "docker_container", - }, - Steps: []RunbookStep{ - { - Name: "Capture memory usage", - Command: "docker stats --no-stream {{resource_name}}", - RunOnHost: true, - AllowFailure: true, - }, - { - Name: "Restart container", - Command: "docker restart {{resource_name}}", - RunOnHost: true, - }, - }, - Verification: &RunbookVerification{ - Name: "Verify container running", - Command: "docker inspect -f '{{.State.Status}}' {{resource_name}}", - RunOnHost: true, - SuccessRegex: "(?i)running", - }, - ResolutionNote: "Restarted docker container to clear memory usage.", - }, -} diff --git a/internal/ai/runbooks_test.go b/internal/ai/runbooks_test.go deleted file mode 100644 index 8aa3005..0000000 --- a/internal/ai/runbooks_test.go +++ /dev/null @@ -1,561 +0,0 @@ -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) - } - }) - } -} - diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 3cab638..b2709d0 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -735,11 +735,6 @@ type AIKubernetesAnalyzeRequest struct { ClusterID string `json:"cluster_id"` } -type AIRunbookExecuteRequest struct { - FindingID string `json:"finding_id"` - RunbookID string `json:"runbook_id"` -} - // HandleExecute executes an AI prompt (POST /api/ai/execute) func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -881,91 +876,6 @@ func (h *AISettingsHandler) HandleAnalyzeKubernetesCluster(w http.ResponseWriter } } -// HandleGetRunbooksForFinding returns available runbooks for a finding (GET /api/ai/runbooks) -func (h *AISettingsHandler) HandleGetRunbooksForFinding(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - if !CheckAuth(h.config, w, r) { - return - } - - findingID := strings.TrimSpace(r.URL.Query().Get("finding_id")) - if findingID == "" { - http.Error(w, "finding_id is required", http.StatusBadRequest) - return - } - - patrol := h.aiService.GetPatrolService() - if patrol == nil { - http.Error(w, "AI patrol not available", http.StatusServiceUnavailable) - return - } - - runbooks, err := patrol.GetRunbooksForFinding(findingID) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - - if err := utils.WriteJSONResponse(w, runbooks); err != nil { - log.Error().Err(err).Msg("Failed to write runbooks response") - } -} - -// HandleExecuteRunbook executes a runbook for a finding (POST /api/ai/runbooks/execute) -func (h *AISettingsHandler) HandleExecuteRunbook(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - if !CheckAuth(h.config, w, r) { - return - } - - r.Body = http.MaxBytesReader(w, r.Body, 16*1024) - var req AIRunbookExecuteRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if strings.TrimSpace(req.FindingID) == "" || strings.TrimSpace(req.RunbookID) == "" { - http.Error(w, "finding_id and runbook_id are required", http.StatusBadRequest) - return - } - - patrol := h.aiService.GetPatrolService() - if patrol == nil { - http.Error(w, "AI patrol not available", http.StatusServiceUnavailable) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) - defer cancel() - - result, err := patrol.ExecuteRunbook(ctx, req.FindingID, req.RunbookID) - if err != nil { - switch { - case errors.Is(err, ai.ErrRunbookNotFound): - http.Error(w, "Runbook not found", http.StatusNotFound) - case errors.Is(err, ai.ErrRunbookNotApplicable): - http.Error(w, "Runbook does not apply to finding", http.StatusBadRequest) - default: - log.Error().Err(err).Str("runbook_id", req.RunbookID).Msg("Runbook execution failed") - http.Error(w, "Runbook execution failed: "+err.Error(), http.StatusInternalServerError) - } - return - } - - if err := utils.WriteJSONResponse(w, result); err != nil { - log.Error().Err(err).Msg("Failed to write runbook execution response") - } -} - // HandleExecuteStream executes an AI prompt with SSE streaming (POST /api/ai/execute/stream) func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.Request) { // Handle CORS for dev mode (frontend on different port) diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 112fed3..df23b31 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -846,82 +846,6 @@ func TestHandleAnalyzeKubernetesCluster_InvalidBody(t *testing.T) { } } -// ======================================== -// HandleGetRunbooksForFinding tests -// ======================================== - -func TestHandleGetRunbooksForFinding_MethodNotAllowed(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - cfg := &config.Config{DataPath: tmp} - persistence := config.NewConfigPersistence(tmp) - handler := NewAISettingsHandler(cfg, persistence, nil) - - req := httptest.NewRequest(http.MethodPost, "/api/ai/runbooks", nil) - rec := httptest.NewRecorder() - handler.HandleGetRunbooksForFinding(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) - } -} - -func TestHandleGetRunbooksForFinding_MissingFindingID(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - cfg := &config.Config{DataPath: tmp} - persistence := config.NewConfigPersistence(tmp) - handler := NewAISettingsHandler(cfg, persistence, nil) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/runbooks", nil) - rec := httptest.NewRecorder() - handler.HandleGetRunbooksForFinding(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) - } -} - -// ======================================== -// HandleExecuteRunbook tests -// ======================================== - -func TestHandleExecuteRunbook_MethodNotAllowed(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - cfg := &config.Config{DataPath: tmp} - persistence := config.NewConfigPersistence(tmp) - handler := NewAISettingsHandler(cfg, persistence, nil) - - req := httptest.NewRequest(http.MethodGet, "/api/ai/runbooks/execute", nil) - rec := httptest.NewRecorder() - handler.HandleExecuteRunbook(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) - } -} - -func TestHandleExecuteRunbook_InvalidBody(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - cfg := &config.Config{DataPath: tmp} - persistence := config.NewConfigPersistence(tmp) - handler := NewAISettingsHandler(cfg, persistence, nil) - - req := httptest.NewRequest(http.MethodPost, "/api/ai/runbooks/execute", bytes.NewReader([]byte(`{invalid json}`))) - rec := httptest.NewRecorder() - handler.HandleExecuteRunbook(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code) - } -} - // ======================================== // HandleInvestigateAlert tests // ======================================== diff --git a/internal/api/router.go b/internal/api/router.go index fad676c..3cdbec0 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1135,9 +1135,8 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/ai/execute", RequireAuth(r.config, r.aiSettingsHandler.HandleExecute)) r.mux.HandleFunc("/api/ai/execute/stream", RequireAuth(r.config, r.aiSettingsHandler.HandleExecuteStream)) r.mux.HandleFunc("/api/ai/kubernetes/analyze", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureKubernetesAI, r.aiSettingsHandler.HandleAnalyzeKubernetesCluster))) - r.mux.HandleFunc("/api/ai/runbooks", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIAutoFix, r.aiSettingsHandler.HandleGetRunbooksForFinding))) - r.mux.HandleFunc("/api/ai/runbooks/execute", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIAutoFix, r.aiSettingsHandler.HandleExecuteRunbook))) r.mux.HandleFunc("/api/ai/investigate-alert", RequireAuth(r.config, RequireLicenseFeature(r.licenseHandlers.Service(), license.FeatureAIAlerts, r.aiSettingsHandler.HandleInvestigateAlert))) + r.mux.HandleFunc("/api/ai/run-command", RequireAuth(r.config, r.aiSettingsHandler.HandleRunCommand)) r.mux.HandleFunc("/api/ai/knowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleGetGuestKnowledge)) r.mux.HandleFunc("/api/ai/knowledge/save", RequireAuth(r.config, r.aiSettingsHandler.HandleSaveGuestNote))