mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 04:06:21 +00:00
8439ce6e6b
Clears the ten dupl pairs across internal/ai: - patrol_intelligence.go, tools_query.go, tools_storage.go: VM and LXC system-container paths collapse into generics over read-state view method subsets (gatherGuestIntelligenceFromViews, canonicalGuestGetResult + guestViewGetResult, addCanonicalGuestSearchMatches + addGuestViewSearchMatches, appendGuestDiskSummaries). - tools_file.go: append/write share executeFileMutation driven by fileMutationSpec (approval-command text, shell redirect, verification strategy stay per-action and verbatim). - tools_kubernetes.go: deployment restart / pod delete share executeKubernetesResourceAction driven by kubernetesResourceAction. - providers/anthropic.go + anthropic_oauth.go: message conversion shared via convertMessagesToAnthropic (the OAuth copy was annotated 'same as regular client'). - memory/changes.go + memory/remediation.go: history loading shared via the generic loadMemoryHistory in memory/paths.go (10 MiB cap, sort, missing-file semantics preserved via a found flag). - findings.go and unified/alerts.go: Finding/findingJSON and UnifiedFinding/unifiedFindingJSON are deliberate marshal-mirror twins (AlertIdentifier json:"-" vs alert_identifier round-trip); merging would break every public literal. Suppressed with nolint:dupl and enforced instead by new reflect-based mirror-sync tests. Contract Extension Points name the shared helpers and the mirror invariant. Full ./internal/ai/... test tree passes.
399 lines
11 KiB
Go
399 lines
11 KiB
Go
package memory
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// Outcome represents the result of a remediation action
|
|
type Outcome string
|
|
|
|
const (
|
|
OutcomeResolved Outcome = "resolved" // Problem was fixed
|
|
OutcomePartial Outcome = "partial" // Partially fixed
|
|
OutcomeFailed Outcome = "failed" // Action failed
|
|
OutcomeUnknown Outcome = "unknown" // Outcome not determined
|
|
)
|
|
|
|
// RollbackInfo contains information about reversibility of an action.
|
|
type RollbackInfo struct {
|
|
Reversible bool `json:"reversible"` // Whether this action can be undone
|
|
RollbackCmd string `json:"rollbackCmd,omitempty"` // Command to undo
|
|
PreState string `json:"preState,omitempty"` // JSON snapshot of state before action
|
|
RolledBack bool `json:"rolledBack"` // Whether this was rolled back
|
|
RolledBackAt *time.Time `json:"rolledBackAt,omitempty"` // When it was rolled back
|
|
RolledBackBy string `json:"rolledBackBy,omitempty"` // Who rolled it back
|
|
RollbackID string `json:"rollbackId,omitempty"` // ID of the rollback remediation record
|
|
}
|
|
|
|
// RemediationRecord represents a logged remediation action
|
|
type RemediationRecord struct {
|
|
ID string `json:"id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
ResourceID string `json:"resource_id"`
|
|
ResourceType string `json:"resource_type,omitempty"`
|
|
ResourceName string `json:"resource_name,omitempty"`
|
|
FindingID string `json:"finding_id,omitempty"` // Linked AI finding if any
|
|
Problem string `json:"problem"` // What was wrong (user's original message)
|
|
Summary string `json:"summary,omitempty"` // AI-generated summary of what was achieved
|
|
Action string `json:"action"` // What was done (command or action)
|
|
Output string `json:"output,omitempty"` // Command output if any
|
|
Outcome Outcome `json:"outcome"` // Did it work?
|
|
Duration time.Duration `json:"duration,omitempty"` // How long until resolved
|
|
Note string `json:"note,omitempty"` // Optional user/AI note
|
|
Automatic bool `json:"automatic"` // Was this triggered automatically by AI
|
|
Rollback *RollbackInfo `json:"rollback,omitempty"` // Rollback information (Pro feature)
|
|
IsRollback bool `json:"isRollback,omitempty"` // True if this is a rollback of another action
|
|
RollbackOf string `json:"rollbackOf,omitempty"` // ID of original action if this is a rollback
|
|
}
|
|
|
|
// RemediationLog stores remediation history
|
|
type RemediationLog struct {
|
|
mu sync.RWMutex
|
|
records []RemediationRecord
|
|
maxRecords int
|
|
|
|
// Persistence
|
|
dataDir string
|
|
}
|
|
|
|
// RemediationLogConfig configures the remediation log
|
|
type RemediationLogConfig struct {
|
|
MaxRecords int
|
|
DataDir string
|
|
}
|
|
|
|
// NewRemediationLog creates a new remediation log
|
|
func NewRemediationLog(cfg RemediationLogConfig) *RemediationLog {
|
|
if cfg.MaxRecords <= 0 {
|
|
cfg.MaxRecords = 500
|
|
}
|
|
|
|
r := &RemediationLog{
|
|
records: make([]RemediationRecord, 0),
|
|
maxRecords: cfg.MaxRecords,
|
|
dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir),
|
|
}
|
|
|
|
// Load existing records from disk
|
|
if cfg.DataDir != "" {
|
|
if err := r.loadFromDisk(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to load remediation log from disk")
|
|
} else if len(r.records) > 0 {
|
|
log.Info().Int("count", len(r.records)).Msg("loaded remediation log from disk")
|
|
}
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// Log records a remediation action
|
|
func (r *RemediationLog) Log(record RemediationRecord) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if record.ID == "" {
|
|
record.ID = generateRecordID()
|
|
}
|
|
if record.Timestamp.IsZero() {
|
|
record.Timestamp = time.Now()
|
|
}
|
|
|
|
r.records = append(r.records, record)
|
|
r.trimRecords()
|
|
|
|
// Persist asynchronously
|
|
go func() {
|
|
if err := r.saveToDisk(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to save remediation log")
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// LogCommand is a convenience method for logging a command execution
|
|
func (r *RemediationLog) LogCommand(resourceID, resourceType, resourceName, findingID, problem, command, output string, success bool, automatic bool) {
|
|
outcome := OutcomeUnknown
|
|
if success {
|
|
outcome = OutcomeResolved
|
|
} else {
|
|
outcome = OutcomeFailed
|
|
}
|
|
|
|
if err := r.Log(RemediationRecord{
|
|
ResourceID: resourceID,
|
|
ResourceType: resourceType,
|
|
ResourceName: resourceName,
|
|
FindingID: findingID,
|
|
Problem: problem,
|
|
Action: command,
|
|
Output: truncateOutput(output, 1000),
|
|
Outcome: outcome,
|
|
Automatic: automatic,
|
|
}); err != nil {
|
|
log.Warn().Err(err).Msg("failed to log remediation command")
|
|
}
|
|
}
|
|
|
|
// GetForResource returns remediation history for a specific resource
|
|
func (r *RemediationLog) GetForResource(resourceID string, limit int) []RemediationRecord {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
var result []RemediationRecord
|
|
// Iterate in reverse to get most recent first
|
|
for i := len(r.records) - 1; i >= 0 && len(result) < limit; i-- {
|
|
if r.records[i].ResourceID == resourceID {
|
|
result = append(result, r.records[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetForFinding returns remediation history linked to a specific finding
|
|
func (r *RemediationLog) GetForFinding(findingID string, limit int) []RemediationRecord {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
var result []RemediationRecord
|
|
for i := len(r.records) - 1; i >= 0 && len(result) < limit; i-- {
|
|
if r.records[i].FindingID == findingID {
|
|
result = append(result, r.records[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetRecentRemediations returns the most recent remediations
|
|
func (r *RemediationLog) GetRecentRemediations(limit int, since time.Time) []RemediationRecord {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
var result []RemediationRecord
|
|
for i := len(r.records) - 1; i >= 0 && len(result) < limit; i-- {
|
|
if r.records[i].Timestamp.After(since) {
|
|
result = append(result, r.records[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetRecentRemediationStats returns remediation stats for actions since the given time.
|
|
func (r *RemediationLog) GetRecentRemediationStats(since time.Time) map[string]int {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
stats := map[string]int{
|
|
"total": 0,
|
|
"resolved": 0,
|
|
"partial": 0,
|
|
"failed": 0,
|
|
"unknown": 0,
|
|
"automatic": 0,
|
|
"manual": 0,
|
|
}
|
|
|
|
for _, rec := range r.records {
|
|
if rec.Timestamp.Before(since) {
|
|
continue
|
|
}
|
|
stats["total"]++
|
|
switch rec.Outcome {
|
|
case OutcomeResolved:
|
|
stats["resolved"]++
|
|
case OutcomePartial:
|
|
stats["partial"]++
|
|
case OutcomeFailed:
|
|
stats["failed"]++
|
|
default:
|
|
stats["unknown"]++
|
|
}
|
|
if rec.Automatic {
|
|
stats["automatic"]++
|
|
} else {
|
|
stats["manual"]++
|
|
}
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// FormatForContext creates AI-consumable summary of remediation history
|
|
func (r *RemediationLog) FormatForContext(resourceID string, limit int) string {
|
|
records := r.GetForResource(resourceID, limit)
|
|
if len(records) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var result string
|
|
for _, rec := range records {
|
|
ago := time.Since(rec.Timestamp)
|
|
outcomeStr := string(rec.Outcome)
|
|
result += "- " + utils.FormatDurationAgo(ago) + ": " + rec.Problem + "\n"
|
|
result += " Action: " + rec.Action + " (" + outcomeStr + ")\n"
|
|
if rec.Note != "" {
|
|
result += " Note: " + rec.Note + "\n"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetRemediationStats returns statistics about remediation effectiveness
|
|
func (r *RemediationLog) GetRemediationStats() map[string]int {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
stats := map[string]int{
|
|
"total": len(r.records),
|
|
"resolved": 0,
|
|
"partial": 0,
|
|
"failed": 0,
|
|
"unknown": 0,
|
|
}
|
|
|
|
for _, rec := range r.records {
|
|
switch rec.Outcome {
|
|
case OutcomeResolved:
|
|
stats["resolved"]++
|
|
case OutcomePartial:
|
|
stats["partial"]++
|
|
case OutcomeFailed:
|
|
stats["failed"]++
|
|
default:
|
|
stats["unknown"]++
|
|
}
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// trimRecords removes old records beyond maxRecords
|
|
func (r *RemediationLog) trimRecords() {
|
|
if len(r.records) > r.maxRecords {
|
|
r.records = r.records[len(r.records)-r.maxRecords:]
|
|
}
|
|
}
|
|
|
|
// saveToDisk persists records to JSON file
|
|
func (r *RemediationLog) saveToDisk() error {
|
|
if r.dataDir == "" {
|
|
return nil
|
|
}
|
|
|
|
r.mu.RLock()
|
|
records := make([]RemediationRecord, len(r.records))
|
|
copy(records, r.records)
|
|
r.mu.RUnlock()
|
|
|
|
path, err := memoryPersistencePath(r.dataDir, remediationHistoryFileName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(records, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tmpPath := path + ".tmp"
|
|
if err := os.WriteFile(tmpPath, data, 0600); err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.Rename(tmpPath, path)
|
|
}
|
|
|
|
// loadFromDisk loads records from JSON file
|
|
func (r *RemediationLog) loadFromDisk() error {
|
|
records, ok, err := loadMemoryHistory(r.dataDir, remediationHistoryFileName, "remediation history", func(a, b RemediationRecord) bool {
|
|
return a.Timestamp.Before(b.Timestamp)
|
|
})
|
|
if err != nil || !ok {
|
|
return err
|
|
}
|
|
|
|
r.records = records
|
|
r.trimRecords()
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
var recordCounter int64
|
|
|
|
func generateRecordID() string {
|
|
recordCounter++
|
|
return "rem-" + time.Now().Format("20060102150405") + "-" + intToString(int(recordCounter%1000))
|
|
}
|
|
|
|
func truncateOutput(output string, maxLen int) string {
|
|
if len(output) <= maxLen {
|
|
return output
|
|
}
|
|
return output[:maxLen-3] + "..."
|
|
}
|
|
|
|
// GetByID returns a remediation record by its ID.
|
|
func (r *RemediationLog) GetByID(id string) (*RemediationRecord, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
for i := range r.records {
|
|
if r.records[i].ID == id {
|
|
return &r.records[i], true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// MarkRolledBack marks a remediation record as rolled back.
|
|
func (r *RemediationLog) MarkRolledBack(id, rollbackID, username string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
for i := range r.records {
|
|
if r.records[i].ID == id {
|
|
if r.records[i].Rollback == nil {
|
|
r.records[i].Rollback = &RollbackInfo{}
|
|
}
|
|
now := time.Now()
|
|
r.records[i].Rollback.RolledBack = true
|
|
r.records[i].Rollback.RolledBackAt = &now
|
|
r.records[i].Rollback.RolledBackBy = username
|
|
r.records[i].Rollback.RollbackID = rollbackID
|
|
|
|
// Persist
|
|
go func() {
|
|
if err := r.saveToDisk(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to save remediation log after rollback")
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("remediation record not found: %s", id)
|
|
}
|
|
|
|
// GetRollbackable returns remediations that can be rolled back.
|
|
func (r *RemediationLog) GetRollbackable(limit int) []RemediationRecord {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
var result []RemediationRecord
|
|
for i := len(r.records) - 1; i >= 0 && len(result) < limit; i-- {
|
|
rec := r.records[i]
|
|
// Must have rollback info, be reversible, and not already rolled back
|
|
if rec.Rollback != nil && rec.Rollback.Reversible && !rec.Rollback.RolledBack && !rec.IsRollback {
|
|
result = append(result, rec)
|
|
}
|
|
}
|
|
return result
|
|
}
|