From 19d016d7dfab74d8cb2f1ec001e0d3d98ab1e3b2 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:22:29 +0100 Subject: [PATCH] fix(patrol): resolve disk alerts through canonical resource owners Host filesystem alerts identify a disk child that Patrol does not collect as a separate resource. Bind an exact collected alert to its current canonical owner through the shared inventory contract, including linked agents, and retain its filesystem, threshold and breach evidence in the scoped prompt. Keep missing owners, ambiguous source identities and uncollected child subjects fail-closed. Add evaluator-to-handler, repeated scope resolution and browser admission coverage. --- .../v6/internal/subsystems/ai-runtime.md | 21 ++ .../internal/subsystems/unified-resources.md | 15 ++ internal/ai/patrol_ai.go | 19 +- internal/ai/patrol_alert_scope.go | 117 +++++++++++ internal/ai/patrol_alert_scope_test.go | 169 +++++++++++++++ internal/ai/patrol_run.go | 18 +- internal/ai/patrol_state.go | 115 +++++++++- internal/ai/patrol_triggers.go | 47 +++-- internal/api/patrol_host_disk_scope_test.go | 197 ++++++++++++++++++ internal/unifiedresources/history_identity.go | 89 +++++++- .../unifiedresources/history_identity_test.go | 50 +++++ scripts/check-host-disk-patrol.mjs | 120 +++++++++++ 12 files changed, 934 insertions(+), 43 deletions(-) create mode 100644 internal/ai/patrol_alert_scope.go create mode 100644 internal/ai/patrol_alert_scope_test.go create mode 100644 internal/api/patrol_host_disk_scope_test.go create mode 100644 scripts/check-host-disk-patrol.mjs diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 0150ca214e..16be3542e0 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -25,6 +25,27 @@ that same result. Successful reads retain their content and execution provenance ## Purpose +### Host filesystem alerts retain exact subjects in scoped Patrol + +An alert-button request may name a collected child subject such as +`agent:/disk:docker-data`. Patrol binds that reference through the +shared current-inventory resolver only when the exact collected alert identifier +and subject agree. The canonical owner becomes the collection scope. The alert +keeps its original child identity, filesystem metadata, observed value, threshold +and message in both full and budgeted model context. Related child alert evidence +is retained only for the selected current owner. Missing owners, caller-authored +child paths, names and conflicting source identities cannot broaden scope. + +Linked VM, container and node owners retain their agent observations. Node/host +views of the same proven canonical owner are one identity. Automatic alert type +hints follow that owner while explicit caller type restrictions stay intact. +Admission and execution resolve independently against current collection state. +`patrol_alert_scope_test.go` covers linked owners, evidence, repeated resolution +and rejected subjects. `patrol_host_disk_scope_test.go` exercises the production +host alert evaluator and HTTP handler. The opt-in browser backend and +`scripts/check-host-disk-patrol.mjs` verify `/alerts` admission and feedback at +1440 and 390 pixels. Browser fixtures qualify admission, not model diagnosis. + ### Fully cached Patrol usage — issues #2118 / #2350 Zero ordinary input tokens do not mean a run was free or skipped. Any positive diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index e4bf9257e1..ff859e1348 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,21 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Collected alert subjects share current canonical ownership + +`AlertResourceReferenceResolver` exposes the current-inventory identity owner +used by history correlation to runtime consumers. Supported producer child +references bind through durable source identities only. Resolution reads the +owner, canonical type and source targets under one registry read lock. It never +uses display names or retained history aliases to resurrect a removed owner. +An agent's disk, temperature, RAID and custom observations remain owned when an +operator link folds the agent into a VM, container or node. The presence of the +agent facet is required for that cross-type ownership. Patrol can therefore +select the canonical owner without interpreting alert strings independently. +`alert_scope_test.go` verifies the registry and both read adapters, rejected +references and absent current owners. Scoped Patrol tests cover linked owners +and conflicting unqualified source IDs. + ### Proxmox node temperature cell follows its own open alert — issue #2068 `ProxmoxNodesTable` finds the node's own temperature alert through diff --git a/internal/ai/patrol_ai.go b/internal/ai/patrol_ai.go index e8462ae8f8..b98f7cb5e3 100644 --- a/internal/ai/patrol_ai.go +++ b/internal/ai/patrol_ai.go @@ -1540,6 +1540,13 @@ func buildScopeSection(scope *PatrolScope, effectiveIdentityAliases []string) st if scope.AlertIdentifier != "" { sb.WriteString(fmt.Sprintf("Alert Identifier: %s\n", scope.AlertIdentifier)) } + if ac := scope.AlertContext; ac != nil { + sb.WriteString(fmt.Sprintf("Collected alert subject: %q (%q)\nAlert: %s %s, value %.1f, threshold %.1f\nAlert message: %q\n", + ac.ResourceID, ac.ResourceName, ac.Level, ac.AlertType, ac.Value, ac.Threshold, ac.Message)) + if ac.Mountpoint != "" || ac.Device != "" { + sb.WriteString(fmt.Sprintf("Alert filesystem: mountpoint %q, device %q.\n", ac.Mountpoint, ac.Device)) + } + } if scope.FindingID != "" { sb.WriteString(fmt.Sprintf("Finding ID: %s\n", scope.FindingID)) } @@ -2658,7 +2665,7 @@ func patrolActiveAlertsInScope(snap patrolRuntimeState, scopedSet map[string]boo } alerts := make([]models.Alert, 0, len(snap.ActiveAlerts)) for _, alert := range snap.ActiveAlerts { - if seedIsInScope(scopedSet, alert.ResourceID) { + if patrolAlertBelongsToScope(snap, alert.ResourceID, scopedSet) { alerts = append(alerts, alert) } } @@ -2671,7 +2678,7 @@ func patrolResolvedAlertsInScope(snap patrolRuntimeState, scopedSet map[string]b } alerts := make([]models.ResolvedAlert, 0, len(snap.RecentlyResolved)) for _, resolved := range snap.RecentlyResolved { - if seedIsInScope(scopedSet, resolved.Alert.ResourceID) { + if patrolAlertBelongsToScope(snap, resolved.Alert.ResourceID, scopedSet) { alerts = append(alerts, resolved) } } @@ -3685,16 +3692,18 @@ func (p *PatrolService) seedFindingsAndContextState(scope *PatrolScope, snap pat sb.WriteString(fmt.Sprintf("- Guest Memory warning: %.0f%%\n", thresholds.GuestMemWarning)) sb.WriteString(fmt.Sprintf("- Guest Disk warning: %.0f%%, critical: %.0f%%\n", thresholds.GuestDiskWarn, thresholds.GuestDiskCrit)) sb.WriteString(fmt.Sprintf("- Storage warning: %.0f%%, critical: %.0f%%\n", thresholds.StorageWarning, thresholds.StorageCritical)) - if scope != nil && scope.Reason == TriggerReasonAlertFired && scope.AlertContext != nil { + if scope != nil && scope.AlertContext != nil && scope.Reason != TriggerReasonAlertCleared { ac := scope.AlertContext level := ac.Level if level == "" { level = "threshold" } - sb.WriteString(fmt.Sprintf("Note: A live %s alert just fired on the scoped resource: %s = %.1f (threshold %.1f). "+ + sb.WriteString(fmt.Sprintf("Note: This check investigates the collected %s alert on %q: %s = %.1f (threshold %.1f). "+ "Investigate the root cause of THIS breach specifically: what changed, whether it is transient or sustained, the blast radius on dependent workloads, and the concrete remediation. "+ "Reporting a finding for this breach is expected. It is the reason this patrol was triggered.\n\n", - level, ac.AlertType, ac.Value, ac.Threshold)) + level, ac.ResourceName, ac.AlertType, ac.Value, ac.Threshold)) + } else if scope != nil && scope.AlertContext != nil && scope.Reason == TriggerReasonAlertCleared { + sb.WriteString("Note: Verify that the collected alert's breach has resolved using current evidence.\n\n") } else { sb.WriteString("Note: The real-time alerting system monitors these thresholds continuously. Do NOT report findings for threshold breaches. Focus on trends, capacity planning, and issues alerts cannot detect.\n\n") } diff --git a/internal/ai/patrol_alert_scope.go b/internal/ai/patrol_alert_scope.go new file mode 100644 index 0000000000..df425fc51e --- /dev/null +++ b/internal/ai/patrol_alert_scope.go @@ -0,0 +1,117 @@ +package ai + +import ( + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// A child reference selects its owner only through an exact collected alert. +// An arbitrary caller-authored child path cannot broaden Patrol scope. +func patrolScopeAlert(state patrolRuntimeState, scope PatrolScope) *models.Alert { + identifier := strings.TrimSpace(scope.AlertIdentifier) + if identifier == "" { + return nil + } + var found *models.Alert + if scope.Reason != TriggerReasonAlertCleared { + for i := range state.ActiveAlerts { + if state.ActiveAlerts[i].ID != identifier { + continue + } + if found != nil { + return nil + } + alert := state.ActiveAlerts[i] + found = &alert + } + if found != nil { + return found + } + } + // Recurrences reuse an alert ID. An open occurrence takes precedence; + // after clearing, use the latest collected resolution for that ID. + var latest *models.ResolvedAlert + for i := range state.RecentlyResolved { + if state.RecentlyResolved[i].Alert.ID != identifier { + continue + } + candidate := &state.RecentlyResolved[i] + if latest == nil || candidate.ResolvedTime.After(latest.ResolvedTime) { + latest = candidate + } + } + if latest == nil { + return nil + } + alert := latest.Alert + return &alert +} + +func patrolAlertReferenceResolver(state patrolRuntimeState) unifiedresources.AlertResourceReferenceResolver { + if state.alertResourceResolver != nil { + return state.alertResourceResolver + } + if resolver, ok := state.readState.(unifiedresources.AlertResourceReferenceResolver); ok { + return resolver + } + if resolver, ok := state.unifiedResourceProvider.(unifiedresources.AlertResourceReferenceResolver); ok { + return resolver + } + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(state.resourceSnapshot()) + if state.unifiedResourceProvider != nil { + registry.IngestResources(state.unifiedResourceProvider.GetAll()) + } + return registry +} + +func patrolAlertOwnerIDs(resolver unifiedresources.AlertResourceReferenceResolver, binding unifiedresources.AlertResourceReference) []string { + ids := []string{binding.ResourceID} + for _, target := range binding.SourceTargets { + // Unqualified source IDs can collide across collectors. Only project + // IDs that still uniquely identify this owner into the snapshot matcher. + candidate, _ := resolver.ResolveAlertResourceReference(target.SourceID) + if candidate.ResourceID == binding.ResourceID { + ids = append(ids, target.SourceID) + } + } + return ids +} + +// Keep exact child evidence when its current owner is selected. The subject's +// original identity stays on the alert; it is not an extra scoped resource. +func patrolAlertBelongsToScope(state patrolRuntimeState, resourceID string, included map[string]bool) bool { + if included[resourceID] { + return true + } + resolver := patrolAlertReferenceResolver(state) + binding, _ := resolver.ResolveAlertResourceReference(resourceID) + if binding.ResourceID == "" { + return false + } + if included[binding.ResourceID] { + return true + } + for _, target := range binding.SourceTargets { + if !included[target.SourceID] { + continue + } + candidate, _ := resolver.ResolveAlertResourceReference(target.SourceID) + if candidate.ResourceID == binding.ResourceID { + return true + } + } + return false +} + +func patrolAlertContext(alert *models.Alert) *PatrolAlertContext { + if alert == nil { + return nil + } + metadataString := func(key string) string { value, _ := alert.Metadata[key].(string); return value } + return &PatrolAlertContext{AlertType: alert.Type, Level: alert.Level, Value: alert.Value, Threshold: alert.Threshold, + Message: alert.Message, ResourceID: alert.ResourceID, ResourceName: alert.ResourceName, + Mountpoint: metadataString("mountpoint"), Device: metadataString("device")} +} diff --git a/internal/ai/patrol_alert_scope_test.go b/internal/ai/patrol_alert_scope_test.go new file mode 100644 index 0000000000..e75ed7a6e6 --- /dev/null +++ b/internal/ai/patrol_alert_scope_test.go @@ -0,0 +1,169 @@ +package ai + +import ( + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// Control: selecting a known owner must preserve the exact triggering child +// alert. This exposes why a string-prefix rewrite alone is not a complete fix. +func TestSupportHostDiskOwnerScopeRetainsTrigger(t *testing.T) { + host := models.Host{ID: "repro-host-source-01", Hostname: "docker-host-ext-01", MachineID: "repro-machine-01", Disks: []models.Disk{{Mountpoint: "/docker-data", Usage: 85}}} + alert := models.Alert{ID: "repro-disk-alert", ResourceID: "agent:repro-host-source-01/disk:docker-data", Type: "disk", Metadata: map[string]interface{}{"hostId": host.ID, "mountpoint": "/docker-data"}} + snapshot := models.StateSnapshot{Hosts: []models.Host{host}, ActiveAlerts: []models.Alert{alert}} + registry := unifiedresources.NewRegistry(nil) + registry.IngestSnapshot(snapshot) + for _, unified := range []bool{false, true} { + name := "snapshot" + state := newPatrolRuntimeState(snapshot) + parentID := host.ID + if unified { + name = "unified" + state = newPatrolRuntimeStateWithProviders(snapshot, registry, unifiedresources.NewUnifiedAIAdapter(registry)) + _, id, found := registry.GetByReference(host.ID) + if !found { + t.Fatal("canonical owner missing") + } + parentID = id + } + t.Run(name, func(t *testing.T) { + resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{parentID}, AlertIdentifier: alert.ID}) + if len(resolution.UnmatchedResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 { + t.Fatalf("owner control did not resolve: %+v", resolution) + } + filtered := filterPatrolStateByScopeState(state, resolved) + t.Logf("resolved=%+v effective=%v hosts=%d active_alerts=%d", resolved, resolution.EffectiveResourceIDs, len(filtered.Hosts), len(filtered.ActiveAlerts)) + if len(filtered.ActiveAlerts) != 1 || filtered.ActiveAlerts[0].ID != alert.ID { + t.Fatalf("owner scope discarded the exact disk alert despite AlertIdentifier=%q", alert.ID) + } + }) + } +} + +func TestPatrolDiskAlertRecurrenceUsesCurrentEvidence(t *testing.T) { + active := models.Alert{ID: "disk-alert", ResourceID: "agent:source-host/disk:data", Type: "disk", Value: 85, Threshold: 85} + old := active + old.Value = 95 + latest := active + latest.Value = 75 + state := newPatrolRuntimeState(models.StateSnapshot{Hosts: []models.Host{{ID: "source-host", MachineID: "machine"}}, ActiveAlerts: []models.Alert{active}, RecentlyResolved: []models.ResolvedAlert{{Alert: old, ResolvedTime: time.Unix(1, 0)}, {Alert: latest, ResolvedTime: time.Unix(2, 0)}}}) + for _, tc := range []struct { + reason TriggerReason + value float64 + }{{TriggerReasonManual, 85}, {TriggerReasonAlertCleared, 75}} { + resolved, resolution := resolvePatrolScopeState(state, PatrolScope{ResourceIDs: []string{active.ResourceID}, AlertIdentifier: active.ID, Reason: tc.reason}) + if len(resolution.EffectiveResourceIDs) == 0 || resolved.AlertContext == nil || resolved.AlertContext.Value != tc.value { + t.Fatalf("wrong occurrence evidence for %s: %+v, %+v", tc.reason, resolution, resolved.AlertContext) + } + } +} + +func TestPatrolDiskAlertOwnerAndEvidence(t *testing.T) { + for _, ownerType := range []string{"agent", "vm", "system-container", "node"} { + t.Run(ownerType, func(t *testing.T) { + host := models.Host{ID: "disk-host-source", Hostname: "disk-host", MachineID: "disk-machine"} + alert := models.Alert{ID: "disk-alert", ResourceID: "agent:disk-host-source/disk:docker-data", ResourceName: "disk-host (/docker-data)", Type: "disk", Level: "warning", Value: 85, Threshold: 85, Message: "Disk usage reached 85%", Metadata: map[string]interface{}{"mountpoint": "/docker-data", "device": "/dev/sdb1"}} + unrelated := models.Alert{ID: "other-alert", ResourceID: "agent:other-host/disk:docker-data", Type: "disk"} + snapshot := models.StateSnapshot{Hosts: []models.Host{host, {ID: "other-host", MachineID: "other-machine"}}, ActiveAlerts: []models.Alert{alert, unrelated}, RecentlyResolved: []models.ResolvedAlert{{Alert: alert}, {Alert: unrelated}}} + // Keep this occurrence distinct; ID reuse is covered separately above. + snapshot.RecentlyResolved[0].ID = "resolved-disk-alert" + ownerSourceID := host.ID + switch ownerType { + case "vm": + ownerSourceID = "vm-owner" + snapshot.VMs = []models.VM{{ID: ownerSourceID, VMID: 101, Instance: "pve", Node: "node-owner", Name: "linked-vm"}} + case "system-container": + ownerSourceID = "ct-owner" + snapshot.Containers = []models.Container{{ID: ownerSourceID, VMID: 102, Instance: "pve", Node: "node-owner", Name: "linked-container"}} + case "node": + ownerSourceID = "node-owner" + snapshot.Nodes = []models.Node{{ID: ownerSourceID, Name: "linked-node", Instance: "pve"}} + } + store := unifiedresources.NewMemoryStore() + registry := unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + _, hostID, hostFound := registry.GetByReference(host.ID) + _, ownerID, ownerFound := registry.GetByReference(ownerSourceID) + if !hostFound || !ownerFound { + t.Fatal("fixture owner missing") + } + if ownerType != "agent" { + if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: hostID, ResourceB: ownerID, PrimaryID: ownerID}); err != nil { + t.Fatal(err) + } + registry = unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + } + state := newPatrolRuntimeStateWithProviders(snapshot, registry, unifiedresources.NewUnifiedAIAdapter(registry)) + for _, scope := range []PatrolScope{ + {ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID, Reason: TriggerReasonManual}, + AlertTriggeredPatrolScope(alert.ID, alert.ResourceID, "agent", "disk"), + AlertClearedPatrolScope("resolved-disk-alert", alert.ResourceID, "agent"), + } { + resolved, resolution := resolvePatrolScopeState(state, scope) + if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 { + t.Fatalf("%s admission failed: %+v", scope.Reason, resolution) + } + // Admission and execution each resolve against current inventory. + resolved, resolution = resolvePatrolScopeState(state, resolved) + if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 0 || len(resolution.EffectiveResourceIDs) == 0 { + t.Fatalf("%s execution failed: %+v", scope.Reason, resolution) + } + filtered := filterPatrolStateByScopeState(state, resolved) + if len(filtered.ActiveAlerts) != 1 || filtered.ActiveAlerts[0].ResourceID != alert.ResourceID || len(filtered.RecentlyResolved) != 1 { + t.Fatalf("lost subject or leaked neighbor evidence: %+v", filtered.ActiveAlerts) + } + if resource := filtered.unifiedResourceProvider.GetAll(); len(resource) != 1 || resource[0].ID != ownerID { + t.Fatalf("scope expanded beyond canonical owner: %+v", resource) + } + if resolved.AlertContext == nil || resolved.AlertContext.ResourceID != alert.ResourceID || resolved.AlertContext.Value != 85 || resolved.AlertContext.Threshold != 85 { + t.Fatalf("lost trusted alert evidence: %+v", resolved.AlertContext) + } + prompt := buildScopeSection(&resolved, resolution.EffectiveResourceIDs) + for _, evidence := range []string{alert.ResourceID, "/docker-data", "/dev/sdb1", "value 85.0, threshold 85.0", alert.Message} { + if !strings.Contains(prompt, evidence) { + t.Fatalf("missing %q in prompt: %s", evidence, prompt) + } + } + } + }) + } +} + +func TestPatrolDiskAlertRejectsUntrustedSubjects(t *testing.T) { + for _, scenario := range []string{"missing-alert", "wrong-alert", "duplicate-alert", "wrong-subject", "removed-owner", "display-name-owner", "malformed-child", "source-collision"} { + t.Run(scenario, func(t *testing.T) { + alert := models.Alert{ID: "disk-alert", ResourceID: "agent:disk-host-source/disk:docker-data", Type: "disk"} + snapshot := models.StateSnapshot{Hosts: []models.Host{{ID: "disk-host-source", Hostname: "disk-host", MachineID: "machine"}}, ActiveAlerts: []models.Alert{alert}} + scope := PatrolScope{ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID, AlertContext: &PatrolAlertContext{Message: "caller-authored"}} + switch scenario { + case "missing-alert": + snapshot.ActiveAlerts = nil + case "wrong-alert": + scope.AlertIdentifier = "different-alert" + case "duplicate-alert": + snapshot.ActiveAlerts = append(snapshot.ActiveAlerts, alert) + case "wrong-subject": + scope.ResourceIDs[0] = "agent:disk-host-source/disk:other" + case "removed-owner": + snapshot.Hosts = nil + case "display-name-owner": + snapshot.ActiveAlerts[0].ResourceID = "agent:disk-host/disk:docker-data" + scope.ResourceIDs[0] = snapshot.ActiveAlerts[0].ResourceID + case "malformed-child": + snapshot.ActiveAlerts[0].ResourceID += "/nested" + scope.ResourceIDs[0] = snapshot.ActiveAlerts[0].ResourceID + case "source-collision": + snapshot.Nodes = []models.Node{{ID: "disk-host-source", Name: "unrelated-node", Instance: "pve"}} + } + resolved, resolution := resolvePatrolScopeState(newPatrolRuntimeState(snapshot), scope) + if len(resolution.UnmatchedResourceIDs)+len(resolution.AmbiguousResourceIDs) != 1 || len(resolution.EffectiveResourceIDs) != 0 || resolved.AlertContext != nil { + t.Fatalf("unsafe scope accepted: %+v, context=%+v", resolution, resolved.AlertContext) + } + }) + } +} diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go index db7c3decc2..63250c668e 100644 --- a/internal/ai/patrol_run.go +++ b/internal/ai/patrol_run.go @@ -1857,20 +1857,20 @@ func patrolKubernetesScopeName(k models.KubernetesCluster) string { return clusterName } -func collectPatrolScopedActiveAlerts(alerts []models.Alert, includedResourceIDs map[string]bool) []models.Alert { +func collectPatrolScopedActiveAlerts(state patrolRuntimeState, alerts []models.Alert, includedResourceIDs map[string]bool) []models.Alert { filtered := make([]models.Alert, 0, len(alerts)) for _, alert := range alerts { - if includedResourceIDs[alert.ResourceID] { + if patrolAlertBelongsToScope(state, alert.ResourceID, includedResourceIDs) { filtered = append(filtered, alert) } } return filtered } -func collectPatrolScopedResolvedAlerts(alerts []models.ResolvedAlert, includedResourceIDs map[string]bool) []models.ResolvedAlert { +func collectPatrolScopedResolvedAlerts(state patrolRuntimeState, alerts []models.ResolvedAlert, includedResourceIDs map[string]bool) []models.ResolvedAlert { filtered := make([]models.ResolvedAlert, 0, len(alerts)) for _, resolved := range alerts { - if includedResourceIDs[resolved.ResourceID] { + if patrolAlertBelongsToScope(state, resolved.ResourceID, includedResourceIDs) { filtered = append(filtered, resolved) } } @@ -1930,10 +1930,10 @@ func collectPatrolScopedPBSBackups(backups []models.PBSBackup, includedGuestVMID func copyScopedPatrolMetadata(dst *patrolRuntimeState, snap patrolRuntimeState, includedResourceIDs map[string]bool, includedGuestVMIDs map[int]bool) { if len(snap.ActiveAlerts) > 0 { - dst.ActiveAlerts = collectPatrolScopedActiveAlerts(snap.ActiveAlerts, includedResourceIDs) + dst.ActiveAlerts = collectPatrolScopedActiveAlerts(snap, snap.ActiveAlerts, includedResourceIDs) } if len(snap.RecentlyResolved) > 0 { - dst.RecentlyResolved = collectPatrolScopedResolvedAlerts(snap.RecentlyResolved, includedResourceIDs) + dst.RecentlyResolved = collectPatrolScopedResolvedAlerts(snap, snap.RecentlyResolved, includedResourceIDs) } if len(snap.ConnectionHealth) > 0 { dst.ConnectionHealth = collectPatrolScopedConnectionHealth(snap.ConnectionHealth, includedResourceIDs) @@ -1949,6 +1949,9 @@ func copyScopedPatrolMetadata(dst *patrolRuntimeState, snap patrolRuntimeState, } func filterPatrolStateByScopeState(snap patrolRuntimeState, scope PatrolScope) patrolRuntimeState { + if len(snap.ActiveAlerts)+len(snap.RecentlyResolved) > 0 { + snap.alertResourceResolver = patrolAlertReferenceResolver(snap) + } matcher := newPatrolScopeMatcher(scope) filterState := newPatrolScopedFilterState(snap) @@ -2006,6 +2009,9 @@ func filterPatrolStateByScopeState(snap patrolRuntimeState, scope PatrolScope) p // context. filteredUnified, unifiedIDs := collectPatrolScopedUnifiedResources(snap.unifiedResourceProvider, matcher) filterState.includeResourceID(unifiedIDs...) + if len(filteredUnified) > 0 { + filterState.filtered.alertResourceResolver = patrolAlertReferenceResolver(snap) + } copyScopedPatrolMetadata(&filterState.filtered, snap, filterState.includedResourceIDs, filterState.includedGuestVMIDs) diff --git a/internal/ai/patrol_state.go b/internal/ai/patrol_state.go index 4b8b3d9b52..e913cedf29 100644 --- a/internal/ai/patrol_state.go +++ b/internal/ai/patrol_state.go @@ -77,6 +77,7 @@ type patrolRuntimeResourceRecord struct { // It intentionally includes only the fields patrol currently consumes, so the // subsystem can stop mirroring the full global StateSnapshot shape. type patrolRuntimeState struct { + alertResourceResolver unifiedresources.AlertResourceReferenceResolver readState unifiedresources.ReadState unifiedResourceProvider UnifiedResourceProvider Nodes []models.Node @@ -170,7 +171,39 @@ func (s patrolRuntimeState) withDerivedProvidersAndResources(resources []unified registry := unifiedresources.NewRegistry(nil) registry.IngestSnapshot(s.resourceSnapshot()) if len(resources) > 0 { - registry.IngestResources(resources) + // Already-unified rows own their source observations. Re-ingesting a + // linked agent snapshot without its operator link would invent a second + // host beside the canonical guest/node. Keep only supplemental snapshot + // rows whose source observations are not already fully represented. + resolver := patrolAlertReferenceResolver(s) + type sourceKey struct { + source unifiedresources.DataSource + id string + } + covered := make(map[sourceKey]bool) + for _, resource := range resources { + binding, _ := resolver.ResolveAlertResourceReference(resource.ID) + for _, target := range binding.SourceTargets { + covered[sourceKey{target.Source, target.SourceID}] = true + } + } + retained := make([]unifiedresources.Resource, 0) + bindings := registry.AlertResourceReferences() + for _, resource := range registry.List() { + targets := bindings[resource.ID].SourceTargets + fullyCovered := len(targets) > 0 + for _, target := range targets { + if !covered[sourceKey{target.Source, target.SourceID}] { + fullyCovered = false + break + } + } + if !fullyCovered { + retained = append(retained, resource) + } + } + registry = unifiedresources.NewRegistry(nil) + registry.IngestResources(append(retained, resources...)) } s.readState = registry s.unifiedResourceProvider = unifiedresources.NewUnifiedAIAdapter(registry) @@ -540,10 +573,23 @@ func canonicalPatrolRuntimeResourceType(kind patrolRuntimeResourceKind) string { // unique known aliases onto the complete identity set for the matching Patrol // runtime record. It never fuzzy-matches and refuses ambiguous aliases. func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (PatrolScope, PatrolScopeResolution) { + if scope.AlertIdentifier != "" { + scope.AlertContext = nil + state.alertResourceResolver = patrolAlertReferenceResolver(state) + } // Recompute private type evidence from the current runtime snapshot on every // resolution pass; callers may synchronously pre-resolve and the accepted run // resolves again to close collection races. scope.resolvedResourceTypes = nil + alert := patrolScopeAlert(state, scope) + var alertOwner unifiedresources.AlertResourceReference + var alertOwnerIDs []string + if alert != nil { + alertOwner, _ = patrolAlertReferenceResolver(state).ResolveAlertResourceReference(alert.ResourceID) + if alertOwner.ResourceID != "" { + alertOwnerIDs = patrolAlertOwnerIDs(patrolAlertReferenceResolver(state), alertOwner) + } + } resolution := PatrolScopeResolution{} for _, requested := range scope.ResourceIDs { if requested = strings.TrimSpace(requested); requested != "" { @@ -628,21 +674,72 @@ func resolvePatrolScopeState(state patrolRuntimeState, scope PatrolScope) (Patro } } } - switch len(matches) { - case 0: - resolution.UnmatchedResourceIDs = append(resolution.UnmatchedResourceIDs, requested) - case 1: - for _, id := range matches[0].ids { - addExpanded(id) - addResolved(id) + if len(matches) == 0 && alert != nil && alertOwner.ResourceID != "" { + requestedAlertIdentity := requested == alert.ResourceID + for _, ownerID := range alertOwnerIDs { + requestedAlertIdentity = requestedAlertIdentity || requested == ownerID + } + if requestedAlertIdentity { + for _, ownerID := range alertOwnerIDs { + for _, record := range records { + if record.idTokens[canonicalPatrolScopeToken(ownerID)] { + matches = append(matches, record) + } + } + if len(matches) > 0 { + break + } + } + } + } + // A linked node may expose both node and host views of one canonical + // resource. Only collected-alert ownership can prove those facets are + // the same identity; unrelated ID/alias collisions remain ambiguous. + sameAlertOwner := alertOwner.ResourceID != "" && len(matches) > 0 + if sameAlertOwner { + for _, record := range matches { + owner, _ := patrolAlertReferenceResolver(state).ResolveAlertResourceReference(record.ids[0]) + if owner.ResourceID != alertOwner.ResourceID { + sameAlertOwner = false + break + } + } + } + switch { + case len(matches) == 0: + resolution.UnmatchedResourceIDs = append(resolution.UnmatchedResourceIDs, requested) + case len(matches) == 1 || sameAlertOwner: + for _, record := range matches { + for _, id := range record.ids { + addExpanded(id) + addResolved(id) + } + addResolvedType(record.resourceType) + } + if sameAlertOwner { + for _, id := range alertOwnerIDs { + addExpanded(id) + addResolved(id) + } } - addResolvedType(matches[0].resourceType) default: resolution.AmbiguousResourceIDs = append(resolution.AmbiguousResourceIDs, requested) } } scope.ResourceIDs = expanded scope.resolvedIdentityOnly = len(expanded) > 0 + if alert != nil { + included := make(map[string]bool, len(expanded)) + for _, id := range expanded { + included[id] = true + } + if patrolAlertBelongsToScope(state, alert.ResourceID, included) { + scope.AlertContext = patrolAlertContext(alert) + if scope.alertResourceTypeHint { + scope.ResourceTypes = append([]string(nil), scope.resolvedResourceTypes...) + } + } + } if len(expanded) > 0 { filtered := filterPatrolStateByScopeState(state, scope) resolution.EffectiveResourceIDs = patrolRuntimeSortedResourceIDs(filtered) diff --git a/internal/ai/patrol_triggers.go b/internal/ai/patrol_triggers.go index f6043225ac..88b92f0409 100644 --- a/internal/ai/patrol_triggers.go +++ b/internal/ai/patrol_triggers.go @@ -108,6 +108,9 @@ type PatrolScope struct { // scopes. It is used only to reduce the provider tool manifest; it never // broadens collection scope or mutation authority. resolvedResourceTypes []string + // Alert constructors receive the producer's type hint. Current canonical + // ownership can replace that hint when an agent is linked into a guest/node. + alertResourceTypeHint bool } // PatrolScopeResolution records how caller-supplied identities map onto the @@ -123,11 +126,15 @@ type PatrolScopeResolution struct { // PatrolAlertContext describes the alert that triggered a scoped patrol, so the // patrol prompt can focus the investigation on the specific breach. type PatrolAlertContext struct { - AlertType string // cpu, memory, disk, etc. - Level string // warning | critical - Value float64 // observed metric value at fire time - Threshold float64 // threshold that was crossed - Message string // human-readable alert message + ResourceID string + ResourceName string + Mountpoint string + Device string + AlertType string // cpu, memory, disk, etc. + Level string // warning | critical + Value float64 // observed metric value at fire time + Threshold float64 // threshold that was crossed + Message string // human-readable alert message } // PatrolDepth controls how thorough a patrol run should be @@ -693,26 +700,28 @@ func patrolScopeResourceTypes(resourceType string) []string { // AlertTriggeredPatrolScope creates a patrol scope for an alert that fired func AlertTriggeredPatrolScope(alertIdentifier, resourceID, resourceType, alertType string) PatrolScope { return PatrolScope{ - ResourceIDs: []string{resourceID}, - ResourceTypes: patrolScopeResourceTypes(resourceType), - Depth: PatrolDepthQuick, - Reason: TriggerReasonAlertFired, - Context: "Alert: " + alertType, - Priority: triggerPriorityAlertFired, - AlertIdentifier: alertIdentifier, + ResourceIDs: []string{resourceID}, + ResourceTypes: patrolScopeResourceTypes(resourceType), + Depth: PatrolDepthQuick, + Reason: TriggerReasonAlertFired, + Context: "Alert: " + alertType, + Priority: triggerPriorityAlertFired, + AlertIdentifier: alertIdentifier, + alertResourceTypeHint: true, } } // AlertClearedPatrolScope creates a patrol scope for an alert that cleared func AlertClearedPatrolScope(alertIdentifier, resourceID, resourceType string) PatrolScope { return PatrolScope{ - ResourceIDs: []string{resourceID}, - ResourceTypes: patrolScopeResourceTypes(resourceType), - Depth: PatrolDepthQuick, - Reason: TriggerReasonAlertCleared, - Context: "Verify resolution", - Priority: triggerPriorityAlertCleared, - AlertIdentifier: alertIdentifier, + ResourceIDs: []string{resourceID}, + ResourceTypes: patrolScopeResourceTypes(resourceType), + Depth: PatrolDepthQuick, + Reason: TriggerReasonAlertCleared, + Context: "Verify resolution", + Priority: triggerPriorityAlertCleared, + AlertIdentifier: alertIdentifier, + alertResourceTypeHint: true, } } diff --git a/internal/api/patrol_host_disk_scope_test.go b/internal/api/patrol_host_disk_scope_test.go new file mode 100644 index 0000000000..1bbd9c0f32 --- /dev/null +++ b/internal/api/patrol_host_disk_scope_test.go @@ -0,0 +1,197 @@ +package api + +import ( + "bytes" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// Reproduce the alert button's exact request using a disk alert emitted by +// CheckHost. Keep Patrol busy so successful identity admission cannot call a +// model or start a background run. A valid identity must reach the busy guard. +func TestSupportHostDiskInvestigationContract(t *testing.T) { + for _, linked := range []bool{false, true} { + for _, unified := range []bool{false, true} { + name := "snapshot" + if unified { + name = "unified" + } + if linked { + name += "-linked-vm" + } else { + name += "-standalone" + } + t.Run(name, func(t *testing.T) { + host := models.Host{ + ID: "repro-host-source-01", Hostname: "docker-host-ext-01", + DisplayName: "docker-host-ext-01", Status: "online", + MachineID: "repro-machine-01", LastSeen: time.Now(), + Disks: []models.Disk{{Mountpoint: "/docker-data", Device: "/dev/sdb1", Usage: 85, Total: 1000, Used: 850, Free: 150}}, + } + snapshot := models.StateSnapshot{Hosts: []models.Host{host, { + ID: "unrelated-host", Hostname: "unrelated", MachineID: "unrelated-machine", Status: "online", + }}} + if linked { + host.LinkedVMID = "vm-101" + snapshot.Hosts[0] = host + snapshot.VMs = []models.VM{{ID: "vm-101", Name: host.Hostname, VMID: 101, Node: "pve-1", Instance: "pve", Status: "running"}} + } + manager := alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore()) + t.Cleanup(manager.Stop) + // The production evaluator is unchanged; only temporal delay and + // unrelated default policies are disabled for this immediate proof. + setUnexportedField(t, manager, "config", alerts.AlertConfig{ + Enabled: true, ActivationState: alerts.ActivationActive, + AgentDefaults: alerts.ThresholdConfig{Disk: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}}, + Overrides: map[string]alerts.ThresholdConfig{}, TimeThresholds: map[string]int{}, + }) + manager.CheckHost(host) + active := manager.GetActiveAlerts() + if len(active) != 1 { + t.Fatalf("disk fixture did not produce exactly one alert: %+v", active) + } + alert := active[0] + if alert.ResourceID != "agent:repro-host-source-01/disk:docker-data" || alert.Type != "disk" { + t.Fatalf("unexpected emitted disk identity: %+v", alert) + } + t.Logf("emitted resource_id=%q resource_name=%q hostId=%v mountpoint=%v", alert.ResourceID, alert.ResourceName, alert.Metadata["hostId"], alert.Metadata["mountpoint"]) + snapshot.ActiveAlerts = []models.Alert{hostDiskPatrolStateAlert(alert)} + handler, patrol, _, _ := setupAIHandlerWithPatrol(t) + seedReadyAnthropicPatrolRuntime(t, handler) + handler.defaultAIService.SetStateProvider(&scopedPatrolStateProvider{state: snapshot}) + parentID := host.ID + if unified { + store := unifiedresources.NewMemoryStore() + registry := unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + if linked { + _, hostCanonicalID, hostFound := registry.GetByReference(host.ID) + _, vmCanonicalID, vmFound := registry.GetByReference("vm-101") + if !hostFound || !vmFound { + t.Fatal("manual-link fixture is missing a source") + } + if err := store.AddLink(unifiedresources.ResourceLink{ResourceA: hostCanonicalID, ResourceB: vmCanonicalID, PrimaryID: vmCanonicalID}); err != nil { + t.Fatal(err) + } + registry = unifiedresources.NewRegistry(store) + registry.IngestSnapshot(snapshot) + _, foldedOwner, found := registry.GetByReference(host.ID) + if !found || foldedOwner != vmCanonicalID { + t.Fatalf("fixture host did not merge into VM: %q want %q", foldedOwner, vmCanonicalID) + } + t.Logf("manual-link host=%q folded into VM=%q", hostCanonicalID, foldedOwner) + } + handler.defaultAIService.SetReadState(registry) + patrol.SetUnifiedResourceProvider(unifiedresources.NewUnifiedAIAdapter(registry)) + resource, canonicalID, ok := registry.GetByReference(host.ID) + if !ok { + t.Fatalf("host source identity missing from canonical registry") + } + parentID = canonicalID + t.Logf("canonical owner id=%q type=%q aliases=%+v sources=%+v", canonicalID, resource.Type, resource.Canonical, registry.SourceTargets(canonicalID)) + if _, _, ok := registry.GetByReference(alert.ResourceID); ok { + t.Log("disk alert unexpectedly has a canonical registry resource") + } + } + _, parentResolution := patrol.ResolvePatrolScope(ai.PatrolScope{ResourceIDs: []string{parentID}}) + if len(parentResolution.UnmatchedResourceIDs) != 0 || len(parentResolution.AmbiguousResourceIDs) != 0 || len(parentResolution.EffectiveResourceIDs) == 0 { + t.Fatalf("parent control did not resolve: %+v", parentResolution) + } + t.Logf("parent control resolution=%+v", parentResolution) + _, diskResolution := patrol.ResolvePatrolScope(ai.PatrolScope{ResourceIDs: []string{alert.ResourceID}, AlertIdentifier: alert.ID}) + t.Logf("disk resolution=%+v", diskResolution) + setUnexportedField(t, patrol, "runInProgress", true) + setUnexportedField(t, patrol, "currentRunID", "repro-busy-sentinel") + request := func(resourceID string) *httptest.ResponseRecorder { + body, err := json.Marshal(map[string]any{"resource_ids": []string{resourceID}, "alert_identifier": alert.ID, "alert_type": alert.Type}) + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + handler.HandleForcePatrol(rec, newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", bytes.NewReader(body))) + return rec + } + control := request(parentID) + if control.Code != http.StatusConflict || !strings.Contains(control.Body.String(), "patrol_already_running") { + t.Fatalf("known parent did not reach downstream busy guard: %d %s", control.Code, control.Body.String()) + } + rec := request(alert.ResourceID) + t.Logf("disk HTTP status=%d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "patrol_already_running") { + t.Fatalf("disk alert must resolve before busy admission, got %d %s", rec.Code, rec.Body.String()) + } + }) + } + } +} + +// Opt-in loopback backend for scripts/check-host-disk-patrol.mjs. The browser +// exercises the real admission handler against a production-emitted alert. +// Inference is absent, so acceptance is not a claim of completed diagnosis. +func TestPatrolHostDiskBrowserBackend(t *testing.T) { + address := os.Getenv("PULSE_HOST_DISK_BROWSER_ADDRESS") + if address == "" { + t.Skip("opt-in browser admission fixture") + } + hostIP, _, err := net.SplitHostPort(address) + if err != nil || hostIP != "127.0.0.1" { + t.Fatal("browser fixture requires a loopback address") + } + host := models.Host{ID: "repro-host-source-01", Hostname: "docker-host-ext-01", MachineID: "repro-machine-01", Status: "online", LastSeen: time.Now(), Disks: []models.Disk{{Mountpoint: "/docker-data", Device: "/dev/sdb1", Usage: 85, Total: 1000, Used: 850, Free: 150}}} + manager := alerts.NewManagerWithDataDir(t.TempDir(), alerts.WithoutPersistedAlertRestore()) + t.Cleanup(manager.Stop) + setUnexportedField(t, manager, "config", alerts.AlertConfig{Enabled: true, ActivationState: alerts.ActivationActive, AgentDefaults: alerts.ThresholdConfig{Disk: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80}}, Overrides: map[string]alerts.ThresholdConfig{}, TimeThresholds: map[string]int{}}) + manager.CheckHost(host) + active := manager.GetActiveAlerts() + if len(active) != 1 { + t.Fatalf("fixture alert missing: %+v", active) + } + stop := make(chan struct{}, 1) + mux := http.NewServeMux() + mux.HandleFunc("/proof/alert", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(active[0]) }) + mux.HandleFunc("/proof/stop", func(w http.ResponseWriter, r *http.Request) { stop <- struct{}{} }) + mux.HandleFunc("/api/ai/patrol/run", func(w http.ResponseWriter, r *http.Request) { + handler, patrol, _, _ := setupAIHandlerWithPatrol(t) + seedReadyAnthropicPatrolRuntime(t, handler) + handler.defaultAIService.SetChatService(nil) + snapshot := models.StateSnapshot{Hosts: []models.Host{host}, ActiveAlerts: []models.Alert{hostDiskPatrolStateAlert(active[0])}} + if r.URL.Query().Get("case") == "unresolved" { + snapshot.Hosts = nil + } + handler.defaultAIService.SetStateProvider(&scopedPatrolStateProvider{state: snapshot}) + if r.URL.Query().Get("case") == "busy" { + setUnexportedField(t, patrol, "runInProgress", true) + setUnexportedField(t, patrol, "currentRunID", "browser-busy") + } + handler.HandleForcePatrol(w, r) + }) + listener, err := net.Listen("tcp", address) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(mux) + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + t.Logf("browser admission fixture ready at %s", server.URL) + select { + case <-stop: + case <-time.After(4 * time.Minute): + t.Fatal("browser fixture was not stopped") + } +} + +func hostDiskPatrolStateAlert(alert alerts.Alert) models.Alert { + return models.Alert{ID: alert.ID, Type: alert.Type, Level: string(alert.Level), ResourceID: alert.ResourceID, ResourceName: alert.ResourceName, Message: alert.Message, Value: alert.Value, Threshold: alert.Threshold, StartTime: alert.StartTime, Metadata: alert.Metadata} +} diff --git a/internal/unifiedresources/history_identity.go b/internal/unifiedresources/history_identity.go index 3cb351ee8d..5c9ea41a1d 100644 --- a/internal/unifiedresources/history_identity.go +++ b/internal/unifiedresources/history_identity.go @@ -10,6 +10,76 @@ import ( "time" ) +// AlertResourceReference binds a collected alert subject to current canonical +// inventory. Source targets are durable identities, never display names. +type AlertResourceReference struct { + ResourceID string + ResourceType ResourceType + SourceTargets []SourceTarget +} + +type AlertResourceReferenceResolver interface { + ResolveAlertResourceReference(string) (AlertResourceReference, bool) +} + +// AlertResourceReferences captures current source ownership in one generation. +// Scoped projections use this batch view instead of scanning every source +// mapping once per resource when rebuilding a read-only registry. +func (rr *ResourceRegistry) AlertResourceReferences() map[string]AlertResourceReference { + if rr == nil { + return nil + } + rr.mu.RLock() + defer rr.mu.RUnlock() + targets := rr.cachedSourceTargets + if rr.viewsDirty || targets == nil { + targets = rr.buildSourceTargetsIndexLocked() + } + bindings := make(map[string]AlertResourceReference, len(rr.resources)) + for id, resource := range rr.resources { + bindings[id] = AlertResourceReference{ResourceID: id, ResourceType: resource.Type, SourceTargets: append([]SourceTarget(nil), targets[id]...)} + } + return bindings +} + +// ResolveAlertResourceReference shares current-inventory identity with history +// correlation. It never consults retained history bindings or matches names. +// claimed is true for an inventory conflict as well as a successful binding; +// conflicts have an empty ResourceID and must not fall back to another source. +func (rr *ResourceRegistry) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) { + if rr == nil { + return AlertResourceReference{}, false + } + rr.mu.RLock() + defer rr.mu.RUnlock() + id, claimed := rr.resolveHistoryReferenceLocked(ref) + if id == "" { + return AlertResourceReference{}, claimed + } + resource := rr.resources[id] + if resource == nil { + return AlertResourceReference{}, true + } + var targets []SourceTarget + if !rr.viewsDirty && rr.cachedSourceTargets != nil { + targets = append([]SourceTarget(nil), rr.cachedSourceTargets[id]...) + } else { + targets = rr.collectSourceTargetsLocked(id, resource.Type) + } + return AlertResourceReference{ResourceID: id, ResourceType: resource.Type, SourceTargets: targets}, true +} + +func (a *UnifiedAIAdapter) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) { + if a == nil || a.registry == nil { + return AlertResourceReference{}, false + } + return a.registry.ResolveAlertResourceReference(ref) +} + +func (a *MonitorAdapter) ResolveAlertResourceReference(ref string) (AlertResourceReference, bool) { + return a.currentRegistry().ResolveAlertResourceReference(ref) +} + // legacyDockerHistoryIdentity accepts the source identifier emitted by Docker // alerts only when it contains a complete container ID. Names and short IDs // cannot establish durable identity after inventory removal. @@ -128,7 +198,7 @@ func historySubResourceOwner(ref string) (ownerRef string, ownerType ResourceTyp if rest, found := strings.CutPrefix(ref, "agent:"); found { hostID, child, _ := strings.Cut(rest, "/") kind, label, _ := strings.Cut(child, ":") - if hostID == "" || label == "" { + if hostID == "" || label == "" || strings.ContainsAny(label, "/\\") { return "", "", false } switch kind { @@ -160,12 +230,19 @@ func historySubResourceOwner(ref string) (ownerRef string, ownerType ResourceTyp // possibly ambiguously; an ambiguous or conflicting reference resolves to // nothing and must not follow a retained binding. func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID string, claimed bool) { - ref = CanonicalResourceID(ref) - if rr == nil || ref == "" { + if rr == nil { return "", false } rr.mu.RLock() defer rr.mu.RUnlock() + return rr.resolveHistoryReferenceLocked(ref) +} + +func (rr *ResourceRegistry) resolveHistoryReferenceLocked(ref string) (resourceID string, claimed bool) { + ref = CanonicalResourceID(ref) + if ref == "" { + return "", false + } if isDockerHistoryReference(ref) { resourceID = rr.dockerHistoryOwnerLocked(ref) return resourceID, resourceID != "" @@ -181,7 +258,11 @@ func (rr *ResourceRegistry) resolveHistoryReference(ref string) (resourceID stri // conflict: the event keeps its own reference. owners := rr.durableHistoryMatchesLocked(ownerRef) for id := range owners { - if len(owners) != 1 || CanonicalResourceType(rr.resources[id].Type) != ownerType { + owner := rr.resources[id] + // A linked agent retains ownership of its disk/sensor observations + // when its canonical resource is a VM, container or node. + typeMatches := CanonicalResourceType(owner.Type) == ownerType || (ownerType == ResourceTypeAgent && owner.Agent != nil) + if len(owners) != 1 || !typeMatches { return "", true } matches[id] = struct{}{} diff --git a/internal/unifiedresources/history_identity_test.go b/internal/unifiedresources/history_identity_test.go index 39d0ff5ce7..95a303ab0f 100644 --- a/internal/unifiedresources/history_identity_test.go +++ b/internal/unifiedresources/history_identity_test.go @@ -858,3 +858,53 @@ func TestProxmoxPhysicalDiskAlertReferenceShape(t *testing.T) { require.Nil(t, ProxmoxPhysicalDiskAlertIdentifiers(ref), ref) } } + +func TestAlertResourceReferenceRequiresCurrentDurableOwner(t *testing.T) { + snapshot := models.StateSnapshot{Hosts: []models.Host{{ID: "host-source", Hostname: "display-name", MachineID: "machine"}}} + registry := NewRegistry(nil) + registry.IngestSnapshot(snapshot) + _, ownerID, found := registry.GetByReference("host-source") + if !found { + t.Fatal("fixture owner missing") + } + for _, resolver := range []AlertResourceReferenceResolver{registry, NewUnifiedAIAdapter(registry), NewMonitorAdapter(registry)} { + binding, claimed := resolver.ResolveAlertResourceReference("agent:host-source/disk:docker-data") + if !claimed || binding.ResourceID != ownerID || binding.ResourceType != ResourceTypeAgent || len(binding.SourceTargets) == 0 { + t.Fatalf("lost canonical owner/source: %+v, claimed=%v", binding, claimed) + } + for _, ref := range []string{"agent:display-name/disk:docker-data", "agent:host-source/disk:", "agent:host-source/disk:data/nested", "agent:host-source/service:sshd", "agent:removed/disk:data"} { + if binding, _ := resolver.ResolveAlertResourceReference(ref); binding.ResourceID != "" { + t.Fatalf("bound untrusted reference %q: %+v", ref, binding) + } + } + } + // A fresh collection without the owner cannot borrow retained history. + removed := NewRegistry(nil) + if binding, _ := removed.ResolveAlertResourceReference("agent:host-source/disk:docker-data"); binding.ResourceID != "" { + t.Fatalf("removed owner resolved: %+v", binding) + } +} + +func TestHistoryIdentityLinkedAgentDiskUsesCanonicalOwner(t *testing.T) { + store := NewMemoryStore() + snapshot := models.StateSnapshot{ + Hosts: []models.Host{{ID: "host-source", MachineID: "machine"}}, + VMs: []models.VM{{ID: "vm-source", VMID: 101, Instance: "pve", Node: "node"}}, + } + registry := NewRegistry(store) + registry.IngestSnapshot(snapshot) + _, hostID, hostFound := registry.GetByReference("host-source") + _, vmID, vmFound := registry.GetByReference("vm-source") + require.True(t, hostFound) + require.True(t, vmFound) + require.NoError(t, store.AddLink(ResourceLink{ResourceA: hostID, ResourceB: vmID, PrimaryID: vmID})) + registry = NewRegistry(store) + registry.IngestSnapshot(snapshot) + ownerID, claimed := registry.resolveHistoryReference("agent:host-source/disk:data") + require.True(t, claimed) + require.Equal(t, vmID, ownerID) + for _, ref := range []string{"agent:host-source/disk:data/nested", "agent:missing/disk:data"} { + ownerID, _ := registry.resolveHistoryReference(ref) + require.Empty(t, ownerID, ref) + } +} diff --git a/scripts/check-host-disk-patrol.mjs b/scripts/check-host-disk-patrol.mjs new file mode 100644 index 0000000000..b2a4d62c76 --- /dev/null +++ b/scripts/check-host-disk-patrol.mjs @@ -0,0 +1,120 @@ +// Admission/UI contract proof with a production-emitted disk alert. +// GET estate/session data is synthetic. Patrol POSTs use the current Go handler +// from TestPatrolHostDiskBrowserBackend. No inference or infrastructure writes. +import { chromium, expect } from '../tests/integration/node_modules/@playwright/test/index.mjs'; +import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; + +const base = process.env.PLAYWRIGHT_BASE_URL; +const backend = process.env.PULSE_HOST_DISK_BROWSER_URL; +if (!base || !backend) throw new Error('Set PLAYWRIGHT_BASE_URL and PULSE_HOST_DISK_BROWSER_URL to isolated loopback servers'); +for (const url of [base, backend]) { + if (new URL(url).hostname !== '127.0.0.1') throw new Error('Proof servers must use loopback'); +} +const output = new URL('../tmp/host-disk-patrol/', import.meta.url); +await mkdir(output, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const results = []; +const errors = []; +const diagnostics = []; +let lastPage; +try { + const request = await browser.newContext(); + const alert = await (await request.request.get(`${backend}/proof/alert`)).json(); + await request.close(); + expect(alert.resourceId).toBe('agent:repro-host-source-01/disk:docker-data'); + expect(alert.value).toBe(85); + expect(alert.threshold).toBe(85); + for (const width of [1440, 390]) { + const context = await browser.newContext({ viewport: { width, height: 1000 } }); + const page = await context.newPage(); + lastPage = page; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') diagnostics.push(message.text()); + }); + page.on('requestfailed', (request) => diagnostics.push(`${request.url()}: ${request.failure()?.errorText}`)); + let mode = 'accepted'; + const admissions = []; + const state = { nodes: [], vms: [], containers: [], dockerHosts: [], hosts: [], storage: [], resources: [], activeAlerts: [alert], recentlyResolved: [], connectionHealth: {}, timestamp: Date.now() }; + await page.routeWebSocket('**/ws**', (socket) => socket.send(JSON.stringify({ type: 'initialState', data: state }))); + await page.route('**/api/**', async (route) => { + const req = route.request(); + const path = new URL(req.url()).pathname; + // Vite also serves source modules below /src/api/. Only stub HTTP API + // endpoints; module requests must load the actual application source. + if (!path.startsWith('/api/')) return route.continue(); + if (path === '/api/ai/patrol/run' && req.method() === 'POST') { + const body = req.postDataJSON(); + expect(body).toEqual({ resource_ids: [alert.resourceId], alert_identifier: alert.id, alert_type: 'disk' }); + const response = await route.fetch({ url: `${backend}${path}?case=${mode}` }); + admissions.push({ mode, body, status: response.status(), response: await response.json() }); + return route.fulfill({ response }); + } + if (req.method() !== 'GET') return route.fulfill({ status: 403, json: { error: 'Other writes disabled in proof' } }); + if (path === '/api/version') return route.fulfill({ json: { version: '6.5.0', build: 'proof', runtime: 'development', isDocker: false, isSourceBuild: true, isDevelopment: true } }); + if (path === '/api/updates/check') return route.fulfill({ json: { available: false, currentVersion: '6.5.0', latestVersion: '6.5.0', releaseNotes: '', downloadUrl: '', isPrerelease: false, isMajorUpgrade: false } }); + if (path === '/api/updates/status') return route.fulfill({ json: { status: 'idle', progress: 0, message: '', updatedAt: new Date().toISOString() } }); + if (path === '/api/security/status') return route.fulfill({ json: { hasAuthentication: true, hasProxyAuth: true, proxyAuthUsername: 'proof-operator', sessionCapabilities: { assistantEnabled: true, settingsRead: true, settingsWrite: true } } }); + if (path === '/api/license/runtime-capabilities') return route.fulfill({ json: { capabilities: ['ai_patrol', 'sso'], limits: [], blocked_capabilities: [], hosted_mode: false } }); + if (path === '/api/orgs') return route.fulfill({ json: [{ id: 'default', displayName: 'Proof' }] }); + if (path === '/api/resources') return route.fulfill({ json: { data: [], aggregations: { platformAdmission: { proxmox: false, docker: false, kubernetes: false, truenas: false, vmware: false, standalone: true } } } }); + if (path === '/api/state') return route.fulfill({ json: state }); + if (path === '/api/alerts/active') return route.fulfill({ json: [alert] }); + if (path === '/api/alerts/config') return route.fulfill({ json: { enabled: true, activationState: 'active', nodeDefaults: {}, guestDefaults: {}, agentDefaults: {}, overrides: {}, timeThresholds: {}, notifications: {} } }); + if (path === '/api/ai/settings') return route.fulfill({ json: { enabled: true, model: 'fixture:admission', control_level: 'read_only' } }); + if (/\/(history|events|delivery-diagnosis|sessions|models|findings|incidents)$/.test(path)) return route.fulfill({ json: [] }); + return route.fulfill({ json: {} }); + }); + await page.goto(`${base}/alerts`, { waitUntil: 'domcontentloaded' }); + const button = page.getByTitle('Have Patrol investigate this alert', { exact: true }); + await expect(button).toBeVisible({ timeout: 30000 }); + await button.scrollIntoViewIfNeeded(); + const rect = await button.boundingBox(); + expect(rect.x).toBeGreaterThanOrEqual(0); + expect(rect.x + rect.width).toBeLessThanOrEqual(width); + await page.screenshot({ path: new URL(`default-${width}.png`, output).pathname, fullPage: true }); + const menu = page.getByRole('button', { name: 'More alert actions', exact: true }); + await menu.click(); + await page.screenshot({ path: new URL(`menu-${width}.png`, output).pathname, fullPage: true }); + await page.keyboard.press('Escape'); + await expect(menu).toHaveAttribute('aria-expanded', 'false'); + await menu.click(); + await page.getByText('Active Alerts', { exact: true }).first().click(); + await expect(menu).toHaveAttribute('aria-expanded', 'false'); + for (const [scenario, status, text] of [ + ['accepted', 200, `Patrol is investigating ${alert.resourceName}`], + ['busy', 409, 'Patrol is already running.'], + ['unresolved', 422, 'One or more requested resource identities could not be resolved exactly'], + ]) { + mode = scenario; + const before = admissions.length; + await button.focus(); + await page.keyboard.press('Enter'); + await expect.poll(() => admissions.length).toBe(before + 1); + expect(admissions.at(-1).status).toBe(status); + await expect(page.getByText(text, { exact: false }).first()).toBeVisible(); + await page.screenshot({ path: new URL(`${scenario}-${width}.png`, output).pathname, fullPage: true }); + } + mode = 'accepted'; + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(button).toBeVisible(); + await button.click(); + await expect.poll(() => admissions.at(-1).mode).toBe('accepted'); + await expect(page.getByText(`Patrol is investigating ${alert.resourceName}`, { exact: true })).toBeVisible(); + results.push({ width, admissions }); + await context.close(); + } + expect(errors).toEqual([]); + const paths = ['internal/unifiedresources/history_identity.go', 'internal/ai/patrol_alert_scope.go', 'internal/ai/patrol_state.go', 'internal/ai/patrol_run.go', 'internal/ai/patrol_ai.go', 'internal/ai/patrol_triggers.go']; + const content_sha256 = Object.fromEntries(await Promise.all(paths.map(async (path) => [path, createHash('sha256').update(await readFile(new URL(`../${path}`, import.meta.url))).digest('hex')]))); + await writeFile(new URL('result.json', output), JSON.stringify({ result: 'passed', verified_at: new Date().toISOString(), frontend_base_sha: process.env.PROOF_BASE_SHA, content_sha256, routes: ['/alerts'], viewports: [1440, 390], states: ['default', 'menu open/closed', 'accepted', 'busy', 'unresolved owner', 'reload'], interactions: ['pointer', 'Enter', 'Escape', 'outside click', 'reload then re-submit'], results, errors, limits: 'Real API admission, synthetic estate/session, no inference or diagnosis qualification' }, null, 2)); +} catch (error) { + if (lastPage && !lastPage.isClosed()) { + await lastPage.screenshot({ path: new URL('failure.png', output).pathname, fullPage: true }); + await writeFile(new URL('failure.txt', output), `${error.stack}\nPage errors: ${JSON.stringify(errors)}\nDiagnostics: ${JSON.stringify(diagnostics)}\nURL: ${lastPage.url()}\n${await lastPage.locator('body').innerText()}`); + } + throw error; +} finally { + await browser.close(); +}