434 lines
11 KiB
Go
434 lines
11 KiB
Go
package memory
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"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
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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: 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
|
|
}
|
|
|
|
r.Log(RemediationRecord{
|
|
ResourceID: resourceID,
|
|
ResourceType: resourceType,
|
|
ResourceName: resourceName,
|
|
FindingID: findingID,
|
|
Problem: problem,
|
|
Action: command,
|
|
Output: truncateOutput(output, 1000),
|
|
Outcome: outcome,
|
|
Automatic: automatic,
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetSimilar finds remediation records with similar problems
|
|
func (r *RemediationLog) GetSimilar(problem string, limit int) []RemediationRecord {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
// Simple keyword-based matching
|
|
keywords := extractKeywords(problem)
|
|
if len(keywords) == 0 {
|
|
return nil
|
|
}
|
|
|
|
type scored struct {
|
|
record RemediationRecord
|
|
score int
|
|
}
|
|
|
|
var matches []scored
|
|
for _, record := range r.records {
|
|
recordKeywords := extractKeywords(record.Problem)
|
|
score := countMatches(keywords, recordKeywords)
|
|
if score > 0 {
|
|
matches = append(matches, scored{record: record, score: score})
|
|
}
|
|
}
|
|
|
|
// Sort by score descending
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
return matches[i].score > matches[j].score
|
|
})
|
|
|
|
var result []RemediationRecord
|
|
for i := 0; i < len(matches) && len(result) < limit; i++ {
|
|
result = append(result, matches[i].record)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetSuccessfulRemediations returns successful remediations for similar problems
|
|
func (r *RemediationLog) GetSuccessfulRemediations(problem string, limit int) []RemediationRecord {
|
|
similar := r.GetSimilar(problem, limit*3) // Get more to filter
|
|
var result []RemediationRecord
|
|
for _, rec := range similar {
|
|
if rec.Outcome == OutcomeResolved || rec.Outcome == OutcomePartial {
|
|
result = append(result, rec)
|
|
if len(result) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
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 += "- " + formatDuration(ago) + " 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 := filepath.Join(r.dataDir, "ai_remediations.json")
|
|
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 {
|
|
if r.dataDir == "" {
|
|
return nil
|
|
}
|
|
|
|
path := filepath.Join(r.dataDir, "ai_remediations.json")
|
|
if st, err := os.Stat(path); err == nil {
|
|
const maxOnDiskBytes = 10 << 20 // 10 MiB safety cap
|
|
if st.Size() > maxOnDiskBytes {
|
|
return fmt.Errorf("remediation history file too large (%d bytes)", st.Size())
|
|
}
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
var records []RemediationRecord
|
|
if err := json.Unmarshal(data, &records); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Sort by timestamp
|
|
sort.Slice(records, func(i, j int) bool {
|
|
return records[i].Timestamp.Before(records[j].Timestamp)
|
|
})
|
|
|
|
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] + "..."
|
|
}
|
|
|
|
func extractKeywords(text string) []string {
|
|
// Simple keyword extraction - split by common delimiters
|
|
// In production, this could use NLP or embeddings
|
|
var keywords []string
|
|
var current string
|
|
|
|
for _, c := range text {
|
|
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' {
|
|
current += string(c)
|
|
} else {
|
|
if len(current) > 3 { // Only keep words > 3 chars
|
|
keywords = append(keywords, current)
|
|
}
|
|
current = ""
|
|
}
|
|
}
|
|
if len(current) > 3 {
|
|
keywords = append(keywords, current)
|
|
}
|
|
|
|
return keywords
|
|
}
|
|
|
|
func countMatches(a, b []string) int {
|
|
bSet := make(map[string]bool)
|
|
for _, s := range b {
|
|
bSet[s] = true
|
|
}
|
|
|
|
count := 0
|
|
for _, s := range a {
|
|
if bSet[s] {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|