mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Retire ChangeDetector's dead change-detection path
Patrol stopped calling DetectChanges when it moved to agentic execution
(1b1c9bb2a3). Recent-change context comes from the unified-resource
timeline, and the detector survives only as a fallback reader of the
legacy ai_changes.json that older releases wrote. The previous change
removed the detector's unjoined save; the detection itself was still
there, kept alive only by tests.
Remove DetectChanges, its snapshot diffing, ResourceSnapshot (and its
internal/ai alias), change-ID generation, the description formatters and
the detector's lock. The history is now loaded once and never modified,
so reads need no lock. The read APIs, trimming and legacy loading are
unchanged.
Tests that used detection to seed the fallback now write a legacy
ai_changes.json and load it, the same path production reads. With
loading broken, 10 of the 12 re-seeded internal/ai tests fail; the other
two assert that the fallback is absent or loses to the canonical
timeline. Two formatter comparisons now reject empty output, which let
them pass against an empty detector. Tests of the detection logic, its
heap stability and ID uniqueness go with it.
This commit is contained in:
@@ -6633,15 +6633,17 @@ The patrol-local `memory.ChangeDetector.GetChangesSummary` path now also
|
||||
delegates to the shared memory recent-change presentation helper, so any
|
||||
future fallback summary entry point inherits the same heading, resource
|
||||
prefixing, and change-type labels without re-implementing the markdown shape.
|
||||
That detector's on-disk history (`ai_changes.json`) is legacy and read-only.
|
||||
Nothing in the runtime has called `DetectChanges` since patrol moved to
|
||||
agentic execution, so the detector only loads that file at construction and
|
||||
never writes it. Detected changes stay in memory, and there is no background
|
||||
save to join before a data directory is removed. The unjoined save goroutine
|
||||
that `DetectChanges` used to launch was reachable only from tests, and could
|
||||
outlive a `t.TempDir` cleanup; it was removed rather than hardened.
|
||||
`TestChangeDetector_LegacyHistoryIsReadOnly` proves detection leaves the file
|
||||
and directory untouched.
|
||||
That detector is a reader of legacy history, not a detector. Its on-disk
|
||||
history (`ai_changes.json`) was written by releases that still ran change
|
||||
detection, which patrol dropped when it moved to agentic execution. The
|
||||
detector loads that file once at construction and never modifies it, so it
|
||||
holds no lock and has no background save to join before a data directory is
|
||||
removed. `DetectChanges`, its snapshot diffing and `ResourceSnapshot` were
|
||||
reachable only from tests and are retired; new change history belongs in the
|
||||
unified-resource timeline, not in this fallback. Tests seed the fallback
|
||||
through a legacy `ai_changes.json`, the same path production reads, and
|
||||
`TestChangeDetector_LegacyHistoryIsReadOnly` proves reads leave the file and
|
||||
directory untouched.
|
||||
Those unified-resource action and export audit records are now also exposed
|
||||
through the enterprise audit read surface so operators can inspect the
|
||||
execution trail without reaching into storage internals.
|
||||
|
||||
@@ -338,18 +338,7 @@ func TestService_BuildEnrichedResourceContext(t *testing.T) {
|
||||
s.mu.Unlock()
|
||||
|
||||
// Case 8: With Change Logic
|
||||
cd := NewChangeDetector(memory.ChangeDetectorConfig{})
|
||||
// Simulate a "created" change
|
||||
cd.DetectChanges([]memory.ResourceSnapshot{
|
||||
{
|
||||
ID: "res1",
|
||||
Name: "res1",
|
||||
Type: "vm",
|
||||
Status: "running",
|
||||
SnapshotTime: now,
|
||||
},
|
||||
})
|
||||
// Force persistence flush/processing if needed (DetectChanges does it async but returns changes immediately)
|
||||
cd := newLegacyChangeDetector(t, legacyCreatedChange("res1", "res1", "vm"))
|
||||
|
||||
ps.SetChangeDetector(cd)
|
||||
|
||||
@@ -607,16 +596,13 @@ func TestService_BuildRelationshipContext_UsesCanonicalReadState(t *testing.T) {
|
||||
func TestService_BuildRecentResourceChangesContext_FallsBackToMemoryFormatter(t *testing.T) {
|
||||
s := &Service{}
|
||||
ps := NewPatrolService(nil, nil)
|
||||
cd := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
|
||||
cd.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "res-fallback", Name: "fallback-resource", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
||||
})
|
||||
cd := newLegacyChangeDetector(t, legacyCreatedChange("res-fallback", "fallback-resource", "vm"))
|
||||
ps.SetChangeDetector(cd)
|
||||
s.patrolService = ps
|
||||
|
||||
got := s.buildRecentResourceChangesContext("res-fallback")
|
||||
want := memory.FormatRecentChangesContext(cd.GetChangesForResource("res-fallback", 5), false, "###")
|
||||
if got != want {
|
||||
if got == "" || got != want {
|
||||
t.Fatalf("expected fallback recent-resource-changes context to use shared memory formatter:\nwant %q\n got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,8 +740,7 @@ func TestIntelligence_GetSummary_WithSubsystems(t *testing.T) {
|
||||
patternDetector.RecordEvent(patterns.HistoricalEvent{ResourceID: "vm-sum", EventType: patterns.EventHighCPU, Timestamp: patternStart})
|
||||
patternDetector.RecordEvent(patterns.HistoricalEvent{ResourceID: "vm-sum", EventType: patterns.EventHighCPU, Timestamp: patternStart.Add(60 * time.Minute)})
|
||||
|
||||
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
||||
changes.DetectChanges([]memory.ResourceSnapshot{{ID: "vm-sum", Name: "vm-sum", Type: "vm", Status: "running", SnapshotTime: time.Now()}})
|
||||
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-sum", "vm-sum", "vm"))
|
||||
|
||||
remediations := memory.NewRemediationLog(memory.RemediationLogConfig{MaxRecords: 10})
|
||||
_ = remediations.Log(memory.RemediationRecord{ResourceID: "vm-sum", Problem: "cpu", Action: "restart", Outcome: memory.OutcomeResolved})
|
||||
@@ -762,7 +761,7 @@ func TestIntelligence_GetSummary_WithSubsystems(t *testing.T) {
|
||||
t.Error("expected recent changes in summary")
|
||||
}
|
||||
if len(summary.RecentChanges) == 0 {
|
||||
t.Error("expected recent change entries in summary")
|
||||
t.Fatal("expected recent change entries in summary")
|
||||
}
|
||||
if summary.RecentChanges[0].SourceType != ur.SourceHeuristic {
|
||||
t.Fatalf("expected heuristic source type for fallback summary, got %s", summary.RecentChanges[0].SourceType)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -13,6 +16,49 @@ import (
|
||||
ur "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
// newLegacyChangeDetector loads changes the only way the detector still gets
|
||||
// them in production: from a legacy ai_changes.json in its data directory.
|
||||
func newLegacyChangeDetector(t *testing.T, changes ...memory.Change) *memory.ChangeDetector {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
data, err := json.Marshal(changes)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal legacy change history: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "ai_changes.json"), data, 0o600); err != nil {
|
||||
t.Fatalf("write legacy change history: %v", err)
|
||||
}
|
||||
return memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10, DataDir: dir})
|
||||
}
|
||||
|
||||
// legacyCreatedChange is a "created" entry as the retired detector recorded it.
|
||||
func legacyCreatedChange(resourceID, name, resourceType string) memory.Change {
|
||||
return memory.Change{
|
||||
ID: "legacy-created-" + resourceID,
|
||||
ResourceID: resourceID,
|
||||
ResourceType: resourceType,
|
||||
ResourceName: name,
|
||||
ChangeType: memory.ChangeCreated,
|
||||
DetectedAt: time.Now(),
|
||||
Description: resourceType + " '" + name + "' created",
|
||||
}
|
||||
}
|
||||
|
||||
// legacyStatusChange is a status transition as the retired detector recorded it.
|
||||
func legacyStatusChange(resourceID, name, resourceType, before, after string) memory.Change {
|
||||
return memory.Change{
|
||||
ID: "legacy-status-" + resourceID,
|
||||
ResourceID: resourceID,
|
||||
ResourceType: resourceType,
|
||||
ResourceName: name,
|
||||
ChangeType: memory.ChangeStatus,
|
||||
Before: before,
|
||||
After: after,
|
||||
DetectedAt: time.Now(),
|
||||
Description: "'" + name + "' status changed: " + before + " → " + after,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIntelligence(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{DataDir: "/tmp/test"})
|
||||
if intel == nil {
|
||||
@@ -329,10 +375,7 @@ func TestIntelligence_GetResourceIntelligence_WithSubsystems(t *testing.T) {
|
||||
|
||||
func TestIntelligence_GetResourceIntelligence_FallsBackToChangeDetector(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
||||
changes.DetectChanges([]memory.ResourceSnapshot{
|
||||
{ID: "vm-rc", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
||||
})
|
||||
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-rc", "resource-vm", "vm"))
|
||||
|
||||
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
||||
|
||||
@@ -374,10 +417,7 @@ func TestIntelligence_GetRecentChanges_UsesCanonicalTimeline(t *testing.T) {
|
||||
|
||||
func TestIntelligence_GetRecentChanges_FallsBackToMemoryDetector(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
||||
changes.DetectChanges([]memory.ResourceSnapshot{
|
||||
{ID: "vm-fallback", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
||||
})
|
||||
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-fallback", "resource-vm", "vm"))
|
||||
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
||||
|
||||
recent := intel.GetRecentChanges(time.Now().Add(-time.Hour), 100)
|
||||
@@ -483,10 +523,7 @@ func TestIntelligence_FormatContext_WithKnowledge(t *testing.T) {
|
||||
|
||||
func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
||||
changes.DetectChanges([]memory.ResourceSnapshot{
|
||||
{ID: "vm-300", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
||||
})
|
||||
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-300", "fallback-vm", "vm"))
|
||||
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
||||
|
||||
canonicalStore := ur.NewMemoryStore()
|
||||
@@ -514,10 +551,7 @@ func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T)
|
||||
|
||||
func TestIntelligence_FormatContext_FallsBackToChangeDetector(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
||||
changes.DetectChanges([]memory.ResourceSnapshot{
|
||||
{ID: "vm-301", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
||||
})
|
||||
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-301", "fallback-vm", "vm"))
|
||||
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
||||
|
||||
ctx := intel.FormatContext("vm-301")
|
||||
@@ -559,10 +593,7 @@ func TestIntelligence_FormatGlobalContext_IncludesCanonicalRecentChanges(t *test
|
||||
|
||||
func TestIntelligence_FormatGlobalContext_FallsBackToChangeDetector(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
||||
changes.DetectChanges([]memory.ResourceSnapshot{
|
||||
{ID: "node-fallback", Name: "fallback-node", Type: "node", Status: "running", SnapshotTime: time.Now()},
|
||||
})
|
||||
changes := newLegacyChangeDetector(t, legacyCreatedChange("node-fallback", "fallback-node", "node"))
|
||||
intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil)
|
||||
|
||||
ctx := intel.FormatGlobalContext()
|
||||
@@ -603,14 +634,11 @@ func TestIntelligence_BuildRecentChangesContext_UsesCanonicalSectionFormatter(t
|
||||
|
||||
func TestIntelligence_BuildRecentChangesContext_FallsBackToMemoryFormatter(t *testing.T) {
|
||||
intel := NewIntelligence(IntelligenceConfig{})
|
||||
changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10})
|
||||
changes.DetectChanges([]memory.ResourceSnapshot{
|
||||
{ID: "vm-302", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()},
|
||||
})
|
||||
changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-302", "fallback-vm", "vm"))
|
||||
|
||||
ctx := intel.buildRecentChangesContext("vm-302", nil, changes, true, 5)
|
||||
want := memory.FormatRecentChangesContext(changes.GetChangesForResource("vm-302", 5), true, "##")
|
||||
if ctx != want {
|
||||
if ctx == "" || ctx != want {
|
||||
t.Fatalf("expected fallback recent-changes context to use shared memory formatter:\nwant %q\n got %q", want, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChangeDetectorMemoryStability(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping memory regression in short mode")
|
||||
}
|
||||
|
||||
detector := NewChangeDetector(ChangeDetectorConfig{
|
||||
MaxChanges: 50,
|
||||
})
|
||||
|
||||
resourcesPerCycle := 120
|
||||
warmupCycles := 4
|
||||
measureCycles := 12
|
||||
|
||||
buildSnapshots := func(offset int, status string) []ResourceSnapshot {
|
||||
now := time.Now()
|
||||
snapshots := make([]ResourceSnapshot, 0, resourcesPerCycle)
|
||||
for i := 0; i < resourcesPerCycle; i++ {
|
||||
snapshots = append(snapshots, ResourceSnapshot{
|
||||
ID: fmt.Sprintf("vm-%03d", i),
|
||||
Name: fmt.Sprintf("vm-%03d", i),
|
||||
Type: "vm",
|
||||
Status: status,
|
||||
Node: fmt.Sprintf("node-%d", i%3),
|
||||
CPUCores: 2 + ((i + offset) % 4),
|
||||
MemoryBytes: int64(2+(i+offset)%4) * 1024 * 1024 * 1024,
|
||||
DiskBytes: int64(20+i%10) * 1024 * 1024 * 1024,
|
||||
LastBackup: now.Add(-time.Duration((i+offset)%5) * time.Hour),
|
||||
SnapshotTime: now,
|
||||
})
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
for i := 0; i < warmupCycles; i++ {
|
||||
status := "running"
|
||||
if i%2 == 1 {
|
||||
status = "stopped"
|
||||
}
|
||||
detector.DetectChanges(buildSnapshots(i*resourcesPerCycle, status))
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
debug.FreeOSMemory()
|
||||
var baseline runtime.MemStats
|
||||
runtime.ReadMemStats(&baseline)
|
||||
|
||||
for i := 0; i < measureCycles; i++ {
|
||||
status := "running"
|
||||
if i%2 == 1 {
|
||||
status = "stopped"
|
||||
}
|
||||
detector.DetectChanges(buildSnapshots(100000+i*resourcesPerCycle, status))
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
debug.FreeOSMemory()
|
||||
var after runtime.MemStats
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
if baseline.HeapAlloc > 0 {
|
||||
allowed := baseline.HeapAlloc + 5*1024*1024
|
||||
growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc)
|
||||
if after.HeapAlloc > allowed && growthRatio > 1.25 {
|
||||
t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-263
@@ -1,11 +1,10 @@
|
||||
// Package memory provides operational memory for AI context.
|
||||
// It tracks changes to infrastructure, remediation actions, and their outcomes
|
||||
// to enable the AI to provide more informed, experience-based recommendations.
|
||||
// It keeps legacy infrastructure change history, remediation actions, and their
|
||||
// outcomes to enable the AI to provide more informed, experience-based
|
||||
// recommendations.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -37,31 +36,16 @@ type Change struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ResourceSnapshot captures key attributes for change detection
|
||||
type ResourceSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Node string `json:"node,omitempty"`
|
||||
CPUCores int `json:"cpu_cores,omitempty"`
|
||||
MemoryBytes int64 `json:"memory_bytes,omitempty"`
|
||||
DiskBytes int64 `json:"disk_bytes,omitempty"`
|
||||
LastBackup time.Time `json:"last_backup,omitempty"`
|
||||
SnapshotTime time.Time `json:"snapshot_time"`
|
||||
}
|
||||
|
||||
// ChangeDetector tracks infrastructure changes over time.
|
||||
// ChangeDetector serves legacy infrastructure change history.
|
||||
//
|
||||
// Recent-change context comes from the unified-resource timeline; the detector
|
||||
// is only its fallback. Its on-disk history (ai_changes.json) is legacy data
|
||||
// from before patrol stopped calling DetectChanges: it is loaded once at
|
||||
// construction and never written, so changes detected now stay in memory.
|
||||
// is only its fallback. Its history (ai_changes.json) was written by releases
|
||||
// that still ran change detection, which patrol dropped when it moved to
|
||||
// agentic execution. The history is loaded once at construction and never
|
||||
// modified afterwards, so reads need no lock.
|
||||
type ChangeDetector struct {
|
||||
mu sync.RWMutex
|
||||
previousState map[string]ResourceSnapshot // resourceID -> snapshot
|
||||
changes []Change
|
||||
maxChanges int
|
||||
changes []Change
|
||||
maxChanges int
|
||||
|
||||
// dataDir holds the legacy history read by loadFromDisk.
|
||||
dataDir string
|
||||
@@ -80,10 +64,9 @@ func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector {
|
||||
}
|
||||
|
||||
d := &ChangeDetector{
|
||||
previousState: make(map[string]ResourceSnapshot),
|
||||
changes: make([]Change, 0),
|
||||
maxChanges: cfg.MaxChanges,
|
||||
dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir),
|
||||
changes: make([]Change, 0),
|
||||
maxChanges: cfg.MaxChanges,
|
||||
dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir),
|
||||
}
|
||||
|
||||
// Load existing changes from disk
|
||||
@@ -98,167 +81,8 @@ func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector {
|
||||
return d
|
||||
}
|
||||
|
||||
// DetectChanges compares current snapshots to previous state and detects changes
|
||||
func (d *ChangeDetector) DetectChanges(currentSnapshots []ResourceSnapshot) []Change {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
var newChanges []Change
|
||||
|
||||
// Track which resources we've seen in current snapshot
|
||||
currentIDs := make(map[string]bool)
|
||||
|
||||
for _, current := range currentSnapshots {
|
||||
currentIDs[current.ID] = true
|
||||
|
||||
prev, exists := d.previousState[current.ID]
|
||||
if !exists {
|
||||
// New resource
|
||||
change := Change{
|
||||
ID: generateChangeID(),
|
||||
ResourceID: current.ID,
|
||||
ResourceType: current.Type,
|
||||
ResourceName: current.Name,
|
||||
ChangeType: ChangeCreated,
|
||||
After: current,
|
||||
DetectedAt: now,
|
||||
Description: formatCreateDescription(current),
|
||||
}
|
||||
newChanges = append(newChanges, change)
|
||||
} else {
|
||||
// Check for changes
|
||||
changes := d.detectResourceChanges(prev, current, now)
|
||||
newChanges = append(newChanges, changes...)
|
||||
}
|
||||
|
||||
// Update previous state
|
||||
d.previousState[current.ID] = current
|
||||
}
|
||||
|
||||
// Check for deleted resources
|
||||
for id, prev := range d.previousState {
|
||||
if !currentIDs[id] {
|
||||
change := Change{
|
||||
ID: generateChangeID(),
|
||||
ResourceID: id,
|
||||
ResourceType: prev.Type,
|
||||
ResourceName: prev.Name,
|
||||
ChangeType: ChangeDeleted,
|
||||
Before: prev,
|
||||
DetectedAt: now,
|
||||
Description: formatDeleteDescription(prev),
|
||||
}
|
||||
newChanges = append(newChanges, change)
|
||||
delete(d.previousState, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Store new changes in memory only; the legacy history is read-only.
|
||||
if len(newChanges) > 0 {
|
||||
d.changes = append(d.changes, newChanges...)
|
||||
d.trimChanges()
|
||||
}
|
||||
|
||||
return newChanges
|
||||
}
|
||||
|
||||
// detectResourceChanges checks for changes between two snapshots of the same resource
|
||||
func (d *ChangeDetector) detectResourceChanges(prev, current ResourceSnapshot, now time.Time) []Change {
|
||||
var changes []Change
|
||||
|
||||
// Status change
|
||||
if prev.Status != current.Status {
|
||||
change := Change{
|
||||
ID: generateChangeID(),
|
||||
ResourceID: current.ID,
|
||||
ResourceType: current.Type,
|
||||
ResourceName: current.Name,
|
||||
ChangeType: ChangeStatus,
|
||||
Before: prev.Status,
|
||||
After: current.Status,
|
||||
DetectedAt: now,
|
||||
Description: formatStatusDescription(current.Name, prev.Status, current.Status),
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
|
||||
// Node change (migration)
|
||||
if prev.Node != "" && current.Node != "" && prev.Node != current.Node {
|
||||
change := Change{
|
||||
ID: generateChangeID(),
|
||||
ResourceID: current.ID,
|
||||
ResourceType: current.Type,
|
||||
ResourceName: current.Name,
|
||||
ChangeType: ChangeMigrated,
|
||||
Before: prev.Node,
|
||||
After: current.Node,
|
||||
DetectedAt: now,
|
||||
Description: formatMigrationDescription(current.Name, prev.Node, current.Node),
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
|
||||
// CPU change
|
||||
if prev.CPUCores > 0 && current.CPUCores > 0 && prev.CPUCores != current.CPUCores {
|
||||
change := Change{
|
||||
ID: generateChangeID(),
|
||||
ResourceID: current.ID,
|
||||
ResourceType: current.Type,
|
||||
ResourceName: current.Name,
|
||||
ChangeType: ChangeConfig,
|
||||
Before: map[string]int{"cpu_cores": prev.CPUCores},
|
||||
After: map[string]int{"cpu_cores": current.CPUCores},
|
||||
DetectedAt: now,
|
||||
Description: formatCPUChangeDescription(current.Name, prev.CPUCores, current.CPUCores),
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
|
||||
// Memory change (significant change > 5%)
|
||||
if prev.MemoryBytes > 0 && current.MemoryBytes > 0 {
|
||||
pctChange := float64(current.MemoryBytes-prev.MemoryBytes) / float64(prev.MemoryBytes)
|
||||
if pctChange > 0.05 || pctChange < -0.05 {
|
||||
change := Change{
|
||||
ID: generateChangeID(),
|
||||
ResourceID: current.ID,
|
||||
ResourceType: current.Type,
|
||||
ResourceName: current.Name,
|
||||
ChangeType: ChangeConfig,
|
||||
Before: map[string]int64{"memory_bytes": prev.MemoryBytes},
|
||||
After: map[string]int64{"memory_bytes": current.MemoryBytes},
|
||||
DetectedAt: now,
|
||||
Description: formatMemoryChangeDescription(current.Name, prev.MemoryBytes, current.MemoryBytes),
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
}
|
||||
|
||||
// Backup completed
|
||||
if !prev.LastBackup.IsZero() && !current.LastBackup.IsZero() &&
|
||||
current.LastBackup.After(prev.LastBackup) {
|
||||
change := Change{
|
||||
ID: generateChangeID(),
|
||||
ResourceID: current.ID,
|
||||
ResourceType: current.Type,
|
||||
ResourceName: current.Name,
|
||||
ChangeType: ChangeBackedUp,
|
||||
Before: prev.LastBackup,
|
||||
After: current.LastBackup,
|
||||
DetectedAt: now,
|
||||
Description: formatBackupDescription(current.Name, current.LastBackup),
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// GetChangesForResource returns recent changes for a specific resource
|
||||
func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []Change {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
var result []Change
|
||||
// Iterate in reverse to get most recent first
|
||||
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
|
||||
@@ -271,9 +95,6 @@ func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []C
|
||||
|
||||
// GetRecentChanges returns the most recent changes across all resources
|
||||
func (d *ChangeDetector) GetRecentChanges(limit int, since time.Time) []Change {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
var result []Change
|
||||
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
|
||||
if d.changes[i].DetectedAt.After(since) {
|
||||
@@ -313,41 +134,6 @@ func (d *ChangeDetector) loadFromDisk() error {
|
||||
|
||||
// Helper functions
|
||||
|
||||
var changeCounter atomic.Uint64
|
||||
|
||||
func generateChangeID() string {
|
||||
count := changeCounter.Add(1)
|
||||
return time.Now().Format("20060102150405") + "-" + intToString(int(count%1000))
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
)
|
||||
switch {
|
||||
case bytes >= GB:
|
||||
return formatFloat(float64(bytes)/GB) + " GB"
|
||||
case bytes >= MB:
|
||||
return formatFloat(float64(bytes)/MB) + " MB"
|
||||
case bytes >= KB:
|
||||
return formatFloat(float64(bytes)/KB) + " KB"
|
||||
default:
|
||||
return intToString(int(bytes)) + " B"
|
||||
}
|
||||
}
|
||||
|
||||
func formatFloat(v float64) string {
|
||||
// Simple formatting without fmt dependency
|
||||
whole := int(v)
|
||||
frac := int((v - float64(whole)) * 10)
|
||||
if frac == 0 {
|
||||
return intToString(whole)
|
||||
}
|
||||
return intToString(whole) + "." + string(rune('0'+frac))
|
||||
}
|
||||
|
||||
func intToString(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
@@ -359,39 +145,3 @@ func intToString(n int) string {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func formatCreateDescription(r ResourceSnapshot) string {
|
||||
return r.Type + " '" + r.Name + "' created"
|
||||
}
|
||||
|
||||
func formatDeleteDescription(r ResourceSnapshot) string {
|
||||
return r.Type + " '" + r.Name + "' deleted"
|
||||
}
|
||||
|
||||
func formatStatusDescription(name, before, after string) string {
|
||||
return "'" + name + "' status changed: " + before + " → " + after
|
||||
}
|
||||
|
||||
func formatMigrationDescription(name, fromNode, toNode string) string {
|
||||
return "'" + name + "' migrated from " + fromNode + " to " + toNode
|
||||
}
|
||||
|
||||
func formatCPUChangeDescription(name string, before, after int) string {
|
||||
direction := "increased"
|
||||
if after < before {
|
||||
direction = "decreased"
|
||||
}
|
||||
return "'" + name + "' CPU " + direction + ": " + intToString(before) + " → " + intToString(after) + " cores"
|
||||
}
|
||||
|
||||
func formatMemoryChangeDescription(name string, before, after int64) string {
|
||||
direction := "increased"
|
||||
if after < before {
|
||||
direction = "decreased"
|
||||
}
|
||||
return "'" + name + "' memory " + direction + ": " + formatBytes(before) + " → " + formatBytes(after)
|
||||
}
|
||||
|
||||
func formatBackupDescription(name string, backupTime time.Time) string {
|
||||
return "'" + name + "' backup completed at " + backupTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateChangeID_ConcurrentUnique(t *testing.T) {
|
||||
changeCounter.Store(0)
|
||||
|
||||
const workers = 500
|
||||
start := make(chan struct{})
|
||||
ids := make(chan string, workers)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
ids <- generateChangeID()
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(ids)
|
||||
|
||||
seen := make(map[string]struct{}, workers)
|
||||
for id := range ids {
|
||||
if _, exists := seen[id]; exists {
|
||||
t.Fatalf("duplicate change ID generated under concurrency: %s", id)
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
|
||||
if len(seen) != workers {
|
||||
t.Fatalf("expected %d IDs, got %d", workers, len(seen))
|
||||
}
|
||||
}
|
||||
@@ -23,21 +23,6 @@ func TestChangeDetector_DefaultsAndHelpers(t *testing.T) {
|
||||
if got := intToString(42); got != "42" {
|
||||
t.Errorf("intToString(42) = %q", got)
|
||||
}
|
||||
if got := formatFloat(2.0); got != "2" {
|
||||
t.Errorf("formatFloat(2.0) = %q", got)
|
||||
}
|
||||
if got := formatFloat(2.5); got != "2.5" {
|
||||
t.Errorf("formatFloat(2.5) = %q", got)
|
||||
}
|
||||
|
||||
cpu := formatCPUChangeDescription("vm-1", 4, 2)
|
||||
if !strings.Contains(cpu, "decreased") {
|
||||
t.Errorf("expected cpu decrease description, got %q", cpu)
|
||||
}
|
||||
mem := formatMemoryChangeDescription("vm-1", 8<<30, 4<<30)
|
||||
if !strings.Contains(mem, "decreased") {
|
||||
t.Errorf("expected memory decrease description, got %q", mem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChangeDetector_LoadsFromDisk(t *testing.T) {
|
||||
|
||||
@@ -12,104 +12,16 @@ import (
|
||||
|
||||
// Additional tests to improve coverage
|
||||
|
||||
func TestChangeDetector_ConfigChange(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Initial state with memory
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 4 * 1024 * 1024 * 1024},
|
||||
})
|
||||
|
||||
// Memory increased - should detect config change
|
||||
changes := d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 8 * 1024 * 1024 * 1024},
|
||||
})
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 config change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].ChangeType != ChangeConfig {
|
||||
t.Errorf("Expected ChangeConfig, got %s", changes[0].ChangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_DiskChange(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Initial state with disk
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", DiskBytes: 100 * 1024 * 1024 * 1024},
|
||||
})
|
||||
|
||||
// Disk increased significantly (>5%) - should detect config change
|
||||
// Note: The implementation may not track disk changes, so this test documents behavior
|
||||
changes := d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", DiskBytes: 200 * 1024 * 1024 * 1024},
|
||||
})
|
||||
|
||||
// Disk changes may not be tracked by the implementation - adjust expectation
|
||||
// This test documents the current behavior
|
||||
_ = changes
|
||||
}
|
||||
|
||||
func TestChangeDetector_CPUChange(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Initial state with CPUCores
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", CPUCores: 2},
|
||||
})
|
||||
|
||||
// CPUCores increased - should detect config change
|
||||
changes := d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", CPUCores: 4},
|
||||
})
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 config change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].ChangeType != ChangeConfig {
|
||||
t.Errorf("Expected ChangeConfig, got %s", changes[0].ChangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_BackupChange(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
oldBackup := time.Now().Add(-24 * time.Hour)
|
||||
newBackup := time.Now()
|
||||
|
||||
// Initial state with old backup
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", LastBackup: oldBackup},
|
||||
})
|
||||
|
||||
// New backup completed
|
||||
changes := d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", LastBackup: newBackup},
|
||||
})
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("Expected 1 backup change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].ChangeType != ChangeBackedUp {
|
||||
t.Errorf("Expected ChangeBackedUp, got %s", changes[0].ChangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_GetChangesSummary(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Create some changes
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
d := &ChangeDetector{maxChanges: 100, changes: []Change{
|
||||
{ID: "c1", ResourceID: "vm-100", ResourceType: "vm", ResourceName: "web-server", ChangeType: ChangeCreated, DetectedAt: time.Now(), Description: "vm 'web-server' created"},
|
||||
}}
|
||||
|
||||
since := time.Now().Add(-1 * time.Hour)
|
||||
summary := d.GetChangesSummary(since, 5)
|
||||
|
||||
want := FormatRecentChangesContext(d.GetRecentChanges(5, since), false, "##")
|
||||
if summary != want {
|
||||
if summary == "" || summary != want {
|
||||
t.Fatalf("expected shared recent-changes formatter, got %q want %q", summary, want)
|
||||
}
|
||||
}
|
||||
@@ -125,29 +37,10 @@ func TestChangeDetector_GetChangesSummary_NoChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_MultipleChangesAtOnce(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Initial state
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 4 * 1024 * 1024 * 1024, CPUCores: 2},
|
||||
})
|
||||
|
||||
// Multiple changes at once: status, memory, CPUCores, and migration
|
||||
changes := d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "stopped", Node: "node2", MemoryBytes: 8 * 1024 * 1024 * 1024, CPUCores: 4},
|
||||
})
|
||||
|
||||
// Should detect multiple changes
|
||||
if len(changes) < 2 {
|
||||
t.Errorf("Expected multiple changes, got %d", len(changes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_LegacyHistoryIsReadOnly(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "ai_changes.json")
|
||||
legacy := []byte(`[{"id":"legacy-1","resource_id":"vm-100","resource_type":"vm","change_type":"created","detected_at":"2026-01-01T00:00:00Z"}]`)
|
||||
legacy := []byte(`[{"id":"legacy-1","resource_id":"vm-100","resource_type":"vm","resource_name":"web-server","change_type":"created","detected_at":"2026-01-01T00:00:00Z","description":"vm 'web-server' created"}]`)
|
||||
if err := os.WriteFile(path, legacy, 0600); err != nil {
|
||||
t.Fatalf("write legacy history: %v", err)
|
||||
}
|
||||
@@ -156,17 +49,16 @@ func TestChangeDetector_LegacyHistoryIsReadOnly(t *testing.T) {
|
||||
MaxChanges: 100,
|
||||
DataDir: tmpDir,
|
||||
})
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
|
||||
// Detected changes join the loaded legacy history in memory.
|
||||
if got := d.GetChangesForResource("vm-100", 10); len(got) != 2 {
|
||||
t.Fatalf("expected legacy and detected change, got %d", len(got))
|
||||
// The legacy history is served through the read APIs.
|
||||
if got := d.GetChangesForResource("vm-100", 10); len(got) != 1 || got[0].ID != "legacy-1" {
|
||||
t.Fatalf("expected the legacy change, got %v", got)
|
||||
}
|
||||
if summary := d.GetChangesSummary(time.Time{}, 10); !containsStr(summary, "web-server") {
|
||||
t.Fatalf("expected the legacy change in the summary, got %q", summary)
|
||||
}
|
||||
|
||||
// Nothing writes the data directory, so no background save can outlive
|
||||
// the test and race TempDir cleanup.
|
||||
// Nothing rewrites the file or adds to the data directory.
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("read data dir: %v", err)
|
||||
@@ -362,27 +254,6 @@ func TestFormatDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
input int64
|
||||
contains string
|
||||
}{
|
||||
{500, "B"},
|
||||
{1024, "KB"},
|
||||
{1024 * 1024, "MB"},
|
||||
{1024 * 1024 * 1024, "GB"},
|
||||
// Note: formatBytes doesn't handle TB, it shows as GB
|
||||
{1024 * 1024 * 1024 * 1024, "GB"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := formatBytes(tt.input)
|
||||
if !containsStr(result, tt.contains) {
|
||||
t.Errorf("formatBytes(%d) = %q, expected to contain %q", tt.input, result, tt.contains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
@@ -402,23 +273,6 @@ func TestTruncateOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_TrimChanges(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 3})
|
||||
|
||||
// Add 5 changes (exceeds max of 3)
|
||||
for i := 0; i < 5; i++ {
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1", CPUCores: i + 1},
|
||||
})
|
||||
}
|
||||
|
||||
// Should only have 3 changes after trimming
|
||||
changes := d.GetRecentChanges(100, time.Time{})
|
||||
if len(changes) > 3 {
|
||||
t.Errorf("Expected at most 3 changes after trimming, got %d", len(changes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemediationLog_TrimRecords(t *testing.T) {
|
||||
r := NewRemediationLog(RemediationLogConfig{MaxRecords: 3})
|
||||
|
||||
|
||||
@@ -5,123 +5,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestChangeDetector_DetectNew(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// First detection - should see new resource
|
||||
snapshots := []ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
|
||||
}
|
||||
|
||||
changes := d.DetectChanges(snapshots)
|
||||
if len(changes) != 1 {
|
||||
t.Errorf("Expected 1 change (creation), got %d", len(changes))
|
||||
}
|
||||
if changes[0].ChangeType != ChangeCreated {
|
||||
t.Errorf("Expected ChangeCreated, got %s", changes[0].ChangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_DetectStatusChange(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Initial state
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
|
||||
// Status change
|
||||
changes := d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "stopped", Node: "node1"},
|
||||
})
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Errorf("Expected 1 status change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].ChangeType != ChangeStatus {
|
||||
t.Errorf("Expected ChangeStatus, got %s", changes[0].ChangeType)
|
||||
}
|
||||
if changes[0].Before != "running" || changes[0].After != "stopped" {
|
||||
t.Errorf("Expected running->stopped, got %v->%v", changes[0].Before, changes[0].After)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_DetectMigration(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Initial state
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
|
||||
// Migration
|
||||
changes := d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node2"},
|
||||
})
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Errorf("Expected 1 migration change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].ChangeType != ChangeMigrated {
|
||||
t.Errorf("Expected ChangeMigrated, got %s", changes[0].ChangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_DetectDeleted(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
// Initial state
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
|
||||
// Delete (empty snapshot)
|
||||
changes := d.DetectChanges([]ResourceSnapshot{})
|
||||
|
||||
if len(changes) != 1 {
|
||||
t.Errorf("Expected 1 deletion change, got %d", len(changes))
|
||||
}
|
||||
if changes[0].ChangeType != ChangeDeleted {
|
||||
t.Errorf("Expected ChangeDeleted, got %s", changes[0].ChangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_NoChanges(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
|
||||
snapshot := []ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"},
|
||||
}
|
||||
|
||||
// First time - creates
|
||||
d.DetectChanges(snapshot)
|
||||
|
||||
// Second time - no changes
|
||||
changes := d.DetectChanges(snapshot)
|
||||
if len(changes) != 0 {
|
||||
t.Errorf("Expected 0 changes, got %d", len(changes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_GetChangesForResource(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
now := time.Now()
|
||||
d := &ChangeDetector{maxChanges: 100, changes: []Change{
|
||||
{ID: "c1", ResourceID: "vm-100", ChangeType: ChangeCreated, DetectedAt: now.Add(-3 * time.Minute)},
|
||||
{ID: "c2", ResourceID: "vm-200", ChangeType: ChangeCreated, DetectedAt: now.Add(-2 * time.Minute)},
|
||||
{ID: "c3", ResourceID: "vm-100", ChangeType: ChangeStatus, DetectedAt: now.Add(-time.Minute)},
|
||||
}}
|
||||
|
||||
// Create and change a few times
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web", Type: "vm", Status: "stopped", Node: "node1"},
|
||||
{ID: "vm-200", Name: "db", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"},
|
||||
{ID: "vm-200", Name: "db", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
|
||||
// Get changes for vm-100 only
|
||||
// Get changes for vm-100 only, most recent first
|
||||
changes := d.GetChangesForResource("vm-100", 10)
|
||||
for _, c := range changes {
|
||||
if c.ResourceID != "vm-100" {
|
||||
t.Errorf("Got change for wrong resource: %s", c.ResourceID)
|
||||
}
|
||||
if len(changes) != 2 {
|
||||
t.Fatalf("Expected 2 changes for vm-100, got %d", len(changes))
|
||||
}
|
||||
if changes[0].ID != "c3" || changes[1].ID != "c1" {
|
||||
t.Errorf("Expected vm-100 changes newest first, got %s, %s", changes[0].ID, changes[1].ID)
|
||||
}
|
||||
if limited := d.GetChangesForResource("vm-100", 1); len(limited) != 1 || limited[0].ID != "c3" {
|
||||
t.Errorf("Expected the limit to keep only the newest change, got %v", limited)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,18 +65,16 @@ func TestRemediationLog_Stats(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChangeDetector_GetRecentChanges(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100})
|
||||
now := time.Now()
|
||||
d := &ChangeDetector{maxChanges: 100, changes: []Change{
|
||||
{ID: "old", ResourceID: "vm-100", DetectedAt: now.Add(-2 * time.Hour)},
|
||||
{ID: "new", ResourceID: "vm-100", DetectedAt: now.Add(-10 * time.Minute)},
|
||||
}}
|
||||
|
||||
// Create some changes
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
|
||||
// Get recent changes
|
||||
since := time.Now().Add(-1 * time.Hour)
|
||||
changes := d.GetRecentChanges(10, since)
|
||||
if len(changes) == 0 {
|
||||
t.Error("Expected at least 1 recent change")
|
||||
// Only changes inside the window are recent
|
||||
changes := d.GetRecentChanges(10, now.Add(-1*time.Hour))
|
||||
if len(changes) != 1 || changes[0].ID != "new" {
|
||||
t.Errorf("Expected only the change inside the window, got %v", changes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,24 +159,3 @@ func TestRemediationLog_AutomaticVsManual(t *testing.T) {
|
||||
t.Errorf("Expected 2 total, got %d", stats["total"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeDetector_Limit(t *testing.T) {
|
||||
d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 5})
|
||||
|
||||
// Create many changes to exceed limit
|
||||
for i := 0; i < 10; i++ {
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"},
|
||||
})
|
||||
// Alternate status to create changes
|
||||
d.DetectChanges([]ResourceSnapshot{
|
||||
{ID: "vm-100", Name: "web", Type: "vm", Status: "stopped", Node: "node1"},
|
||||
})
|
||||
}
|
||||
|
||||
// Should have limited records
|
||||
allChanges := d.GetRecentChanges(100, time.Time{})
|
||||
if len(allChanges) > 5 {
|
||||
t.Errorf("Expected max 5 changes due to limit, got %d", len(allChanges))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,6 @@ type ChangeDetectorConfig = memory.ChangeDetectorConfig
|
||||
// Change is an alias for memory.Change
|
||||
type Change = memory.Change
|
||||
|
||||
// ResourceSnapshot is an alias for memory.ResourceSnapshot
|
||||
type ResourceSnapshot = memory.ResourceSnapshot
|
||||
|
||||
// ChangeType is an alias for memory.ChangeType
|
||||
type ChangeType = memory.ChangeType
|
||||
|
||||
|
||||
@@ -193,21 +193,10 @@ func TestPatrolService_BuildScopedSetForRuntime_TypeOnlyScopeWithCorrelation(t *
|
||||
func TestBuildSeedContextState_TypeOnlyScopeUsesRuntimeScopedSet(t *testing.T) {
|
||||
ps := NewPatrolService(nil, nil)
|
||||
|
||||
changeDetector := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
|
||||
changeDetector.DetectChanges([]ResourceSnapshot{{
|
||||
ID: "qemu/101",
|
||||
Name: "vm-1",
|
||||
Type: "vm",
|
||||
Status: "running",
|
||||
SnapshotTime: time.Now().Add(-2 * time.Hour),
|
||||
}})
|
||||
changeDetector.DetectChanges([]ResourceSnapshot{{
|
||||
ID: "qemu/101",
|
||||
Name: "vm-1",
|
||||
Type: "vm",
|
||||
Status: "stopped",
|
||||
SnapshotTime: time.Now().Add(-time.Hour),
|
||||
}})
|
||||
changeDetector := newLegacyChangeDetector(t,
|
||||
legacyCreatedChange("qemu/101", "vm-1", "vm"),
|
||||
legacyStatusChange("qemu/101", "vm-1", "vm", "running", "stopped"),
|
||||
)
|
||||
ps.SetChangeDetector(changeDetector)
|
||||
|
||||
node := ur.NewNodeView(&ur.Resource{ID: "node-1", Name: "node-1", Type: ur.ResourceTypeAgent})
|
||||
@@ -230,21 +219,10 @@ func TestBuildSeedContextState_TypeOnlyScopeUsesRuntimeScopedSet(t *testing.T) {
|
||||
func TestBuildSeedContextState_TypeOnlyScopeIncludesCorrelatedRuntimeChange(t *testing.T) {
|
||||
ps := NewPatrolService(nil, nil)
|
||||
|
||||
changeDetector := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
|
||||
changeDetector.DetectChanges([]ResourceSnapshot{{
|
||||
ID: "qemu/101",
|
||||
Name: "vm-1",
|
||||
Type: "vm",
|
||||
Status: "running",
|
||||
SnapshotTime: time.Now().Add(-2 * time.Hour),
|
||||
}})
|
||||
changeDetector.DetectChanges([]ResourceSnapshot{{
|
||||
ID: "qemu/101",
|
||||
Name: "vm-1",
|
||||
Type: "vm",
|
||||
Status: "stopped",
|
||||
SnapshotTime: time.Now().Add(-time.Hour),
|
||||
}})
|
||||
changeDetector := newLegacyChangeDetector(t,
|
||||
legacyCreatedChange("qemu/101", "vm-1", "vm"),
|
||||
legacyStatusChange("qemu/101", "vm-1", "vm", "running", "stopped"),
|
||||
)
|
||||
ps.SetChangeDetector(changeDetector)
|
||||
|
||||
cfg := correlation.DefaultConfig()
|
||||
|
||||
@@ -143,9 +143,10 @@ func TestSeedPrecomputeIntelligence_PopulatesSignals(t *testing.T) {
|
||||
pd.RecordEvent(HistoricalEvent{ResourceID: "vm-1", EventType: EventHighCPU, Timestamp: now.Add(-12 * time.Hour)})
|
||||
ps.SetPatternDetector(pd)
|
||||
|
||||
cd := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10})
|
||||
cd.DetectChanges([]ResourceSnapshot{{ID: "vm-1", Name: "vm-1", Type: "vm", Status: "running", SnapshotTime: now.Add(-2 * time.Hour)}})
|
||||
cd.DetectChanges([]ResourceSnapshot{{ID: "vm-1", Name: "vm-1", Type: "vm", Status: "stopped", SnapshotTime: now.Add(-1 * time.Hour)}})
|
||||
cd := newLegacyChangeDetector(t,
|
||||
legacyCreatedChange("vm-1", "vm-1", "vm"),
|
||||
legacyStatusChange("vm-1", "vm-1", "vm", "running", "stopped"),
|
||||
)
|
||||
ps.SetChangeDetector(cd)
|
||||
|
||||
corrCfg := DefaultCorrelationConfig()
|
||||
|
||||
Reference in New Issue
Block a user