From a04941b55ab79507906595759d6578e0e85f4d07 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 11 Aug 2026 11:48:51 +0100 Subject: [PATCH] Implement canonical resource monitoring policy --- cmd/pulse-mcp/README.md | 2 +- .../v6/internal/subsystems/agent-lifecycle.md | 12 ++ .../v6/internal/subsystems/ai-runtime.md | 16 +++ .../v6/internal/subsystems/alerts.md | 21 +++ .../v6/internal/subsystems/api-contracts.md | 19 +++ .../subsystems/frontend-primitives.md | 12 ++ .../v6/internal/subsystems/monitoring.md | 11 ++ .../subsystems/patrol-intelligence.md | 17 +++ .../subsystems/performance-and-scalability.md | 9 ++ .../internal/subsystems/security-privacy.md | 9 ++ .../internal/subsystems/storage-recovery.md | 10 ++ .../internal/subsystems/unified-resources.md | 21 +++ frontend-modern/browser-verification.json | 46 ++++--- .../__tests__/resourceOperatorState.test.ts | 8 ++ .../src/api/resourceOperatorState.ts | 17 ++- .../ResourceDetailDrawerOverviewTab.tsx | 2 + .../ResourceOperatorStateSection.tsx | 108 ++++++++++++---- ...sourceOperatorStateSection.render.test.tsx | 2 +- .../ResourceOperatorStateSection.test.ts | 27 +++- .../components/Workloads/GuestDrawer.test.tsx | 7 + .../Workloads/GuestDrawerOverview.tsx | 8 +- .../alerts/AlertOverviewAlertCard.tsx | 34 +++++ .../alerts/ResourceMonitoringPolicyAction.tsx | 122 ++++++++++++++++++ .../__tests__/OverviewTab.total24h.test.tsx | 1 + .../ResourceMonitoringPolicyAction.test.tsx | 89 +++++++++++++ .../src/utils/resourceMonitoringPolicy.ts | 63 +++++++++ internal/actionlifecycle/service.go | 4 +- internal/actionlifecycle/service_test.go | 27 ++++ internal/agentcapabilities/manifest.go | 20 ++- internal/agentcapabilities/manifest_test.go | 23 ++++ internal/agentcapabilities/markdown_test.go | 7 + .../agentcontext/resource_context_sections.go | 9 +- .../resource_context_sections_test.go | 4 +- internal/ai/findings.go | 43 ++++-- internal/ai/findings_test.go | 5 + internal/ai/tools/action_audit.go | 2 +- internal/alerts/alert_store.go | 3 + internal/alerts/intent_policy.go | 51 ++++++-- internal/alerts/intent_policy_test.go | 84 ++++++++++++ internal/alerts/notification_policy.go | 22 ++++ internal/alerts/resource_monitoring_policy.go | 86 ++++++++++++ internal/api/agent_resource_context.go | 15 ++- internal/api/resources.go | 7 + internal/api/resources_operator_state.go | 34 +++++ internal/api/resources_operator_state_test.go | 85 ++++++++++++ internal/api/router.go | 33 +++++ internal/monitoring/monitor_alert_intent.go | 2 + .../monitoring/monitor_alert_intent_test.go | 31 +++++ .../unifiedresources/loop_reports_store.go | 11 +- .../resource_operator_state.go | 104 ++++++++++++++- .../resource_operator_state_policy_test.go | 7 +- .../resource_operator_state_test.go | 43 ++++++ internal/unifiedresources/store.go | 44 ++++++- 53 files changed, 1402 insertions(+), 97 deletions(-) create mode 100644 frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx create mode 100644 frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx create mode 100644 frontend-modern/src/utils/resourceMonitoringPolicy.ts create mode 100644 internal/alerts/resource_monitoring_policy.go diff --git a/cmd/pulse-mcp/README.md b/cmd/pulse-mcp/README.md index 87b0e30f7..abd8bf489 100644 --- a/cmd/pulse-mcp/README.md +++ b/cmd/pulse-mcp/README.md @@ -234,7 +234,7 @@ the live set. **Operator state (per-resource intent):** -- `get_operator_state` (Get operator state, `GET /api/resources/{resourceId}/operator-state`, scope `monitoring:read`, mode `read`, approval `scope_only`): Read the operator-set state for a resource (intentionally offline, never auto-remediate, maintenance window, criticality). +- `get_operator_state` (Get operator state, `GET /api/resources/{resourceId}/operator-state`, scope `monitoring:read`, mode `read`, approval `scope_only`): Read the operator-set monitoring mode, lifecycle state, remediation lock, maintenance window, and criticality for a resource. - `set_operator_state` (Set operator state, `PUT /api/resources/{resourceId}/operator-state`, scope `monitoring:write`, mode `write`, approval `scope_only`): Replace the operator-set state for a resource. URL canonicalId wins over body; server populates setAt and setBy from the authenticated identity. - `clear_operator_state` (Clear operator state, `DELETE /api/resources/{resourceId}/operator-state`, scope `monitoring:write`, mode `write`, approval `scope_only`): Remove any operator-set state for a resource. Idempotent — succeeds whether or not an entry was present. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 87d49f0cb..f386e97e9 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6149,3 +6149,15 @@ agent tokens, command sessions, and report identity. Deprovisioning a human principal revokes that principal's browser sessions only and does not delete, reassign, or synthesize agent lifecycle state. The api-contracts and security-privacy contracts own the authoritative behavior and proof. + +### Resource monitoring policy does not mutate agent lifecycle + +The shared `internal/api/` router now reconciles alert attention after a +resource operator-state mutation, but it does not unregister, delete, retoken, +or reassign an agent. Monitoring and lifecycle policy is keyed through the +tenant's canonical resource registry and is separate from enrolled agent +identity. Agent context may report `monitoringMode` and `lifecycleState` so an +operator or external agent can explain current attention behavior, without +changing registration or command authority. Retiring an agent-backed resource +blocks automated remediation through the shared action policy while preserving +its enrollment and history. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c6e98fe4f..5920e4f7b 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -6904,6 +6904,22 @@ to the same policy as the outer address, so `PULSE_AI_ALLOW_LOOPBACK` and `PULSE_AI_ALLOW_PRIVATE_IPS` relax the embedded check exactly as they relax the outer one, and a transition address wrapping a permitted public target stays permitted. + +### Patrol consumes canonical monitoring and lifecycle policy + +The findings operator-state projection carries canonical `MonitoringMode` and +`LifecycleState` in the same store read as maintenance, criticality, and action +lock state. Active maintenance, expected-offline, muted, and retired policy +produce distinct lifecycle causes in durable finding history. Suppression is +reversible when the canonical cause lifts. Legacy providers that project only +`IntentionallyOffline` retain their established cause and behavior. + +Retired lifecycle also projects an effective remediation lock into Patrol +investigation and the shared execution admission paths, so a resource cannot +be retired from attention while remaining eligible for autonomous action. +Canonical enum fields are additive in the agent capability manifest and its +generated documentation; the legacy boolean remains a compatibility edge, not +the AI runtime policy source of truth. ### Patrol run records no longer carry a dead auto-fix count `PatrolRunRecord.AutoFixCount` was removed from `internal/ai/patrol.go`, diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index a33a0cf13..6be9b69f2 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -1633,3 +1633,24 @@ incident from cluster health. `internal/truenas/provider_pool_health_contract_test.go`, and `internal/unifiedresources/ceph_pool_health_contract_test.go` prove the lifecycle and deduplication matrix. + +### Per-resource monitoring policy is an alert-runtime invariant + +The canonical unified-resource operator state is honored without requiring an +explicit versioned alert-intent rule. Factory behavior enables operator-state +evaluation, and a signal rule cannot weaken a persisted resource monitoring or +lifecycle policy. `expected_offline` suppresses only `state.offline` and +`incident.availability`; `muted`, `retired`, and active maintenance suppress all +signals. Unknown alert families fail safe to the default signal and cannot be +hidden by expected-offline. + +Every active-alert writer passes through the same operator-state gate, and +notification delivery consults the same decision before quiet-hours policy. +Operator suppression is never converted into a quiet-hours replay, including +for recovery notifications carrying stale replay metadata. +After a persisted policy mutation, `ReconcileResourceOperatorState` resolves +already-active records for that resource immediately; later detector writes +cannot recreate them while suppression remains active. Existing prefix, tag, +and `pulse-no-alerts` bulk rules remain compatible inputs and do not create a +second per-resource state store. `internal/alerts/intent_policy_test.go` pins +factory operator-state evaluation, writer gating, and active reconciliation. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index d50701a62..d78ef04a0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9255,3 +9255,22 @@ The route-authorization test proves the exact viewer 200 / ledger 403 pairing, the scope test proves unauthenticated and wrong-scope refusal, and the frontend API plus Workloads integration tests prove the surface reads only the runtime projection. + +### Resource operator-state API exposes canonical policy with legacy compatibility + +`GET` and `PUT /api/resources/{id}/operator-state` add `monitoringMode` and +`lifecycleState` to the stable resource-state shape. The accepted vocabularies +are `normal|expected_offline|muted` and `active|retired`. The existing +`intentionallyOffline` field remains readable and writable for older clients: +when the enum is absent, true maps to expected-offline; when the enum is +present, it is authoritative and the response boolean is derived from it. +Invalid enum values fail with the existing stable operator-state validation +error. + +A successful mutation invokes alert reconciliation only after durable commit, +so the API cannot report a policy that has not landed and existing active +alerts leave attention promptly. Agent resource context, fleet context, and +the canonical capability manifest project both new fields. Retired lifecycle +is also an execution-time remediation lock in the shared action lifecycle. +`internal/api/resources_operator_state_test.go`, agent-context tests, manifest +tests, and the generated `cmd/pulse-mcp/README.md` pin this additive contract. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e662d68a7..9b6be07ff 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5968,3 +5968,15 @@ unresolved status would flash the default tab away from an admin on every load. `__tests__/adminOnlySettingsNavGates.test.ts` and `__tests__/useSettingsAccess.test.tsx` pin the complete named-capability, fail-closed mount, failed-status, deduplicated-load, and General-fallback rules. + +### Alert monitoring actions preserve domain ownership + +The Alerts overview may offer a compact per-resource Monitoring menu, but the +menu is an adapter over the canonical resource operator-state API. It must +preserve unrelated state on every write, distinguish availability-only +expected-offline from all-attention mute, and state that retirement changes +Pulse monitoring rather than deleting provider inventory. Resource detail and +alert surfaces use the same typed monitoring and lifecycle vocabulary and +provider-ownership presentation helper. They must not create local alert mute, +archive, or removal state. The menu remains keyboard reachable, uses ordinary +shared surface and border tokens, and retains usable controls at phone width. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index f81cca453..682abe7e2 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -2940,3 +2940,14 @@ Known and deliberately unchanged: each changed guest still triggers a full-file save, so one poll cycle over N changed guests performs N marshals and N atomic writes that serialize on the store mutex. Coalescing them is a behavioural change beyond the shutdown defect. + +### Monitoring projects canonical resource policy into alert evaluation + +The monitoring-owned operator-intent adapter projects `monitoringMode` and +`lifecycleState` together with maintenance timing and the legacy compatibility +boolean. It still resolves source-native references through canonical resource +identity before reading the store and fails open on missing, ambiguous, or +errored identity lookup. Monitoring does not reinterpret provider ownership or +invent lifecycle state; Alerts owns signal suppression and unified resources +owns persistence. `internal/monitoring/monitor_alert_intent_test.go` and the +alerts intent-policy proof pin this adapter boundary. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 30db06b77..ef7084668 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -2169,3 +2169,20 @@ reads, cancellation, and clean retry. `frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts` and `frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx` prove typed start-failure presentation and the accepted-run page transition. + +### Resource monitoring policy is actionable where noise appears + +The resource drawer replaces the overloaded intentionally-offline toggle with +canonical Monitoring and Lifecycle selectors. Normal, expected-offline, muted, +active, and retired copy states their exact Alerts, Patrol, inventory, and +automation effects. Provider-aware retirement copy makes clear that Proxmox, +Kubernetes, vSphere, and container-runtime inventory is not deleted by a Pulse +policy change and that the resource can be restored to active monitoring. + +Active alert cards expose the same canonical policy through a compact +Monitoring action. It reads the current resource state, preserves maintenance, +criticality, notes, remediation settings, and capability allowlists, then +writes through `@/api/resourceOperatorState`; no alert-local mute document is +created. `ResourceMonitoringPolicyAction.test.tsx` and the resource operator +state section tests pin provider copy, expected-offline persistence, retirement, +and compatibility preservation. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 29897e15e..18d92c672 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -2364,3 +2364,12 @@ enrichment. It filters healthy, disabled, non-workload, and coverage-free rows before serialization, so the response is no larger than the actionable banner input. Client presentation is a single pass over that bounded list and performs no per-source fetch or detail lookup. + +### Resource policy reads stay outside the Workloads hot path + +The Proxmox guest drawer mounts `ResourceOperatorStateSection` only for the +currently expanded workload. Its non-suspending query performs one canonical +operator-state read for that selected resource and does not add table-row, +hover-preview, interval, or websocket work. Saving monitoring or lifecycle +policy performs one mutation and one selected-resource refetch. The Workloads +list, selectors, windowing, and polling budgets remain unchanged. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index f697d0616..b6b6bc9f0 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2126,3 +2126,12 @@ because `RequireAdmin` intentionally leaves the update route reachable there. The frontend behavior is pinned by `App.architecture.test.ts`, and `TestContract_SecurityStatusSystemSettingsReadTracksSettingsReadScope` pins the capability against both `/api/system/settings` and `/api/updates/status`. + +### Resource policy identity resolution remains tenant scoped + +Operator-state reads and writes resolve source-native or superseded resource +references through the requesting tenant's canonical registry before touching +the tenant resource store. Runtime reconciliation may visit every live monitor, +but each alert manager resolves policy through its own tenant-scoped store, so a +matching provider ID in another organization cannot import the mutation. The +existing route scopes and authenticated actor attribution remain unchanged. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 2f2171f31..3a722bda9 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -5157,3 +5157,13 @@ restore, retention, resource-store, or recovery evidence. Additive RBAC schema upgrade preserves older identities and fails construction on migration errors; the security-privacy and api-contracts contracts own the authoritative shape and proof. + +### Resource monitoring lifecycle preserves recovery evidence + +Mutating `monitoringMode` or `lifecycleState` changes Pulse attention and +automated remediation admission only. Expected-offline, mute, and retirement do +not delete resource history, backups, snapshots, recovery points, retention +configuration, or provider inventory. A retired resource fails closed for +automated remediation through the shared action lifecycle, while recovery +evidence remains available for operator review and for restoration to active +monitoring. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 58b0cace6..93369207b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -4450,3 +4450,24 @@ teardown. `TestCachedResourceStoresHaveATenantReleasePath` in `internal/unifiedresources/code_standards_test.go` is a source-shape guard, because the leak is invisible at runtime until a tenant is deleted or a data directory is torn down. + +### Canonical resource monitoring and lifecycle policy + +`ResourceOperatorState` owns one durable per-resource monitoring posture and +lifecycle. `monitoringMode` is the closed vocabulary `normal`, +`expected_offline`, or `muted`; `lifecycleState` is `active` or `retired`. +Expected-offline is availability-specific, while muted removes all Alerts and +Patrol attention. Retired resources remain in canonical provider inventory and +retain history, but they also suppress attention and block automated +remediation until restored to active. Provider inventory deletion is not a +side effect of this policy. + +The SQLite row persists both enums. Schema migration promotes legacy +`intentionally_offline=1` rows to `monitoring_mode=expected_offline`, and the +legacy boolean remains only a derived wire compatibility projection. New +runtime consumers must use the enum helpers rather than assign a second meaning +to that boolean. Identity succession continues to rekey the same state row. +`internal/unifiedresources/resource_operator_state_test.go` and +`resource_operator_state_policy_test.go` pin normalization, validation, +round-trip persistence, attention suppression, and the retired remediation +lock. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 92cc058ec..79b01c0f3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,29 @@ { "version": 1, - "base_sha": "bcb172c1274b393f1bdd8d51b4fffb7f0585e4bd", - "verified_at": "2026-08-10T08:55:00Z", + "base_sha": "c45a950a74c11d299b1be6277a8b552e87f9c5d9", + "verified_at": "2026-08-11T10:30:03Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", - "frontend-modern/src/features/patrol/patrolControlPresentation.ts" + "frontend-modern/src/api/resourceOperatorState.ts", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx", + "frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx", + "frontend-modern/src/utils/resourceMonitoringPolicy.ts" ], "content_sha256": { - "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "da3ddd2c2fd5bf94e9642d178c8f60c361b21de3559e6085047e911539301fef", - "frontend-modern/src/features/patrol/patrolControlPresentation.ts": "1f395d06a16be436f5222cd36259a9585aaa2f7a036399fbfd4ee8b3c98bf12e" + "frontend-modern/src/api/resourceOperatorState.ts": "2b27d3dcc3415cc2a57b9bd201d06f04150ede3b29d91816ce3715a065eb8459", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "9b757063cbd6c0b97ab0057754d45ad2e2067ad58f4ec0b388c7ae28e51d8239", + "frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx": "3c5008b4db7027a7a08c8dff796a7546b5b343a522bf7439361467c0031318f3", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "f0339dc3c3fdd2e08f2c5fea75a62a0a5ecdfab833970653bf9f74982c4ba891", + "frontend-modern/src/features/alerts/AlertOverviewAlertCard.tsx": "83081abb871d76e5531bf66ddcddb47171c05c86c1d91249a0e9c6ef5dad73dd", + "frontend-modern/src/features/alerts/ResourceMonitoringPolicyAction.tsx": "287ff35169c4082f9cb8cac3e36bcbaaab9e343801c497e89da8602d9af276d2", + "frontend-modern/src/utils/resourceMonitoringPolicy.ts": "ba5133bbdeb57a236845f3ee946f0f0c13518e293810eec1ae7e4fab2ab3e2d5" }, "routes": [ - "/patrol (Needs attention workbench rows and attention detail drawer)" + "/alerts", + "/proxmox/overview" ], "viewports": [ { @@ -20,19 +31,22 @@ "height": 800 }, { - "width": 744, - "height": 1024 + "width": 390, + "height": 844 } ], "states": [ - "Attention rows (46 mock alerts, complete + unknown-freshness evidence) render resource name and relative time with no evidence label; the 'Evidence incomplete; timing unavailable' / 'Evidence timing unavailable' warning line is gone from every row.", - "Attention detail drawer shows the muted 'Evidence recorded' badge next to the state badge, with per-envelope observations reading 'Pulse Complete Confirmed - observed just now'.", - "Full-page text contains no 'timing unavailable' occurrence in any state exercised at either viewport.", - "Backend half verified via scratch instance API: /api/ai/patrol/attention returned all 44-46 mock rows with evidenceCompleteness=complete (previously provider-incident rows were partial via the legacy shim)." + "Active powered-off Proxmox guest alert with the Monitoring policy menu open and provider ownership copy visible.", + "Normal monitoring restored, powered-off detector alert reactivated, then Expected offline applied from that alert and the alert removed immediately.", + "Proxmox guest drawer with Monitoring and Lifecycle controls showing Expected offline and provider-owned retirement guidance.", + "Retired lifecycle selected with monitoring disabled and the retained-inventory, history, attention, and automation notice visible.", + "Mobile Alerts overview with the policy menu open, all four choices reachable, and main content clientWidth and scrollWidth both 362 pixels." ], "interactions": [ - "Ran a scratch mock-mode instance built from this working tree (backend + built dist) on port 7671 with the dev admin bypass.", - "Opened /patrol at 1280px, read the attention list rows, clicked the vaultwarden memory-limit row, and inspected the detail drawer badges and evidence observations.", - "Reloaded /patrol at 744px, re-checked the row metadata line, re-opened the detail drawer, and screenshotted the drawer showing 'Evidence recorded'." + "Opened the Monitoring menu from a live powered-off alert and confirmed Proxmox remains the inventory owner.", + "Applied Expected offline from the alert, observed the success notification, and confirmed the alert disappeared immediately while the active count decreased.", + "Opened dev-portal-01 in the Proxmox guest drawer, changed Monitoring between Normal and Expected offline, and saved through the canonical resource policy.", + "Changed Lifecycle to Retired, confirmed the retirement notice and disabled monitoring control, then restored Active while preserving Expected offline.", + "Repeated the Alerts policy-menu inspection at 390 by 844 pixels and confirmed there was no horizontal content overflow." ] } diff --git a/frontend-modern/src/api/__tests__/resourceOperatorState.test.ts b/frontend-modern/src/api/__tests__/resourceOperatorState.test.ts index 4ab4c0f98..d96814a5d 100644 --- a/frontend-modern/src/api/__tests__/resourceOperatorState.test.ts +++ b/frontend-modern/src/api/__tests__/resourceOperatorState.test.ts @@ -72,6 +72,8 @@ describe('resourceOperatorState api', () => { it('PUTs the canonical body shape and returns the read-after-write record', async () => { apiFetchJSONMock.mockResolvedValueOnce({ canonicalId: 'vm:101', + monitoringMode: 'expected_offline', + lifecycleState: 'active', intentionallyOffline: true, neverAutoRemediate: false, setAt: '2026-05-09T11:00:00Z', @@ -79,6 +81,8 @@ describe('resourceOperatorState api', () => { } satisfies ResourceOperatorState); const input: ResourceOperatorStateInput = { + monitoringMode: 'expected_offline', + lifecycleState: 'active', intentionallyOffline: true, neverAutoRemediate: false, autoRemediationPolicy: { @@ -100,7 +104,11 @@ describe('resourceOperatorState api', () => { // (setAt, setBy) — never echo the input verbatim. expect(result.setAt).toBe('2026-05-09T11:00:00Z'); expect(result.setBy).toBe('operator:richard'); + expect(result.monitoringMode).toBe('expected_offline'); + expect(result.lifecycleState).toBe('active'); expect(JSON.parse(apiFetchJSONMock.mock.calls[0][1]?.body as string)).toMatchObject({ + monitoringMode: 'expected_offline', + lifecycleState: 'active', autoRemediationPolicy: { enabled: true, capabilityNames: ['restart'], diff --git a/frontend-modern/src/api/resourceOperatorState.ts b/frontend-modern/src/api/resourceOperatorState.ts index ba590b110..34191853f 100644 --- a/frontend-modern/src/api/resourceOperatorState.ts +++ b/frontend-modern/src/api/resourceOperatorState.ts @@ -8,6 +8,8 @@ import { apiFetchJSON } from '@/utils/apiClient'; * for the suppression / refusal semantics each field drives. */ export type ResourceCriticality = 'high' | 'medium' | 'low' | ''; +export type ResourceMonitoringMode = 'normal' | 'expected_offline' | 'muted'; +export type ResourceLifecycleState = 'active' | 'retired'; export interface AutoRemediationWindow { timezone: string; @@ -28,6 +30,8 @@ export interface AutoRemediationPolicy { export interface ResourceOperatorState { canonicalId: string; + monitoringMode?: ResourceMonitoringMode; + lifecycleState?: ResourceLifecycleState; /** * When true, new findings raised against this resource get * auto-acknowledged with reason=expected_behavior — Patrol stops @@ -74,6 +78,13 @@ export type ResourceOperatorStateInput = Omit< 'canonicalId' | 'setAt' | 'setBy' >; +const normalizeResourceOperatorState = (state: ResourceOperatorState): ResourceOperatorState => ({ + ...state, + monitoringMode: + state.monitoringMode || (state.intentionallyOffline ? 'expected_offline' : 'normal'), + lifecycleState: state.lifecycleState || 'active', +}); + /** * Read the operator-set state for a resource. Resolves to null when * the server returns 404 (no entry recorded — the default no-state @@ -83,10 +94,11 @@ export async function getResourceOperatorState( resourceId: string, ): Promise { try { - return await apiFetchJSON( + const state = await apiFetchJSON( `/api/resources/${encodeURIComponent(resourceId)}/operator-state`, { cache: 'no-store' }, ); + return normalizeResourceOperatorState(state); } catch (err) { // The 404 response shape is `{ error: 'operator_state_not_set', ... }`. // Translating into null lets the caller treat "no state" as a clean @@ -113,7 +125,7 @@ export async function setResourceOperatorState( resourceId: string, state: ResourceOperatorStateInput, ): Promise { - return apiFetchJSON( + const persisted = await apiFetchJSON( `/api/resources/${encodeURIComponent(resourceId)}/operator-state`, { method: 'PUT', @@ -121,6 +133,7 @@ export async function setResourceOperatorState( headers: { 'Content-Type': 'application/json' }, }, ); + return normalizeResourceOperatorState(persisted); } /** diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index 0f37a414d..4265bb0d2 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -712,6 +712,8 @@ export const ResourceDetailDrawerOverviewTab: Component diff --git a/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx b/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx index b7ff9f9ff..a4a9c8a1e 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceOperatorStateSection.tsx @@ -3,6 +3,8 @@ import { Toggle } from '@/components/shared/Toggle'; import { notificationStore } from '@/stores/notifications'; import { type ResourceCriticality, + type ResourceLifecycleState, + type ResourceMonitoringMode, type ResourceOperatorState, type ResourceOperatorStateInput, clearResourceOperatorState, @@ -12,6 +14,7 @@ import { import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { formatRelativeTime } from '@/utils/format'; import type { ResourceCapability } from '@/types/resource'; +import { describeResourceInventoryOwnership } from '@/utils/resourceMonitoringPolicy'; /** * ResourceOperatorStateSection surfaces the operator-set per-resource @@ -31,6 +34,8 @@ import type { ResourceCapability } from '@/types/resource'; */ interface ResourceOperatorStateSectionProps { resourceId: string; + resourceType?: string; + platformType?: string; capabilities?: ResourceCapability[]; } @@ -61,7 +66,8 @@ export const ResourceOperatorStateSection: Component('normal'); + const [lifecycleState, setLifecycleState] = createSignal('active'); const [neverAutoRemediate, setNeverAutoRemediate] = createSignal(false); const [criticality, setCriticality] = createSignal(''); const [note, setNote] = createSignal(''); @@ -79,6 +85,9 @@ export const ResourceOperatorStateSection: Component capability.autoAuthorization && capability.autoAuthorization !== 'never', ), ); + const inventoryOwnership = createMemo(() => + describeResourceInventoryOwnership(props.resourceType, props.platformType), + ); const minuteToTime = (minute: number): string => { const normalized = Math.max(0, Math.min(1439, minute)); @@ -112,7 +121,10 @@ export const ResourceOperatorStateSection: Component { const current = persisted(); if (current === undefined) return; - setIntentionallyOffline(current?.intentionallyOffline ?? false); + setMonitoringMode( + current?.monitoringMode ?? (current?.intentionallyOffline ? 'expected_offline' : 'normal'), + ); + setLifecycleState(current?.lifecycleState ?? 'active'); setNeverAutoRemediate(current?.neverAutoRemediate ?? false); setCriticality(current?.criticality ?? ''); setNote(current?.note ?? ''); @@ -130,7 +142,9 @@ export const ResourceOperatorStateSection: Component { const current = persisted(); - const persistedOffline = current?.intentionallyOffline ?? false; + const persistedMonitoringMode = + current?.monitoringMode ?? (current?.intentionallyOffline ? 'expected_offline' : 'normal'); + const persistedLifecycleState = current?.lifecycleState ?? 'active'; const persistedLocked = current?.neverAutoRemediate ?? false; const persistedCriticality = current?.criticality ?? ''; const persistedNote = current?.note ?? ''; @@ -149,7 +163,8 @@ export const ResourceOperatorStateSection: Component { const current = persisted(); - setIntentionallyOffline(current?.intentionallyOffline ?? false); + setMonitoringMode( + current?.monitoringMode ?? (current?.intentionallyOffline ? 'expected_offline' : 'normal'), + ); + setLifecycleState(current?.lifecycleState ?? 'active'); setNeverAutoRemediate(current?.neverAutoRemediate ?? false); setCriticality(current?.criticality ?? ''); setNote(current?.note ?? ''); @@ -250,7 +270,8 @@ export const ResourceOperatorStateSection: Component

Operator overrides

- Tell Pulse how Patrol should treat this resource — suppress expected noise, prioritize - its findings, or lock it against automated remediation. + Control Alerts, Patrol, lifecycle, priority, and automated remediation from one + persisted resource policy.

@@ -781,21 +806,54 @@ export const ResourceOperatorStateSection: Component -
-
- -

- Suppress findings on this resource. Use when a workload is deprecated, a dev environment - is shut down on purpose, or a host is archived. -

-
- setIntentionallyOffline(e.currentTarget.checked)} - disabled={saving()} - /> +
+ + +
+ +
+ Retired resources remain in {inventoryOwnership().ownerLabel} inventory and keep their + Pulse history. All alert attention and automated remediation are disabled until the + lifecycle returns to Active. +
+
+
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx index 2fa723792..0efbfb167 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx @@ -58,7 +58,7 @@ describe('ResourceOperatorStateSection render with capabilityNames: null', () => // The section itself must be up (pre-fix, the throw escaped render // and nothing below the header survived). expect(screen.getByText('Operator overrides')).toBeTruthy(); - expect(screen.getByText('Intentionally offline')).toBeTruthy(); + expect(screen.getByText('Monitoring')).toBeTruthy(); expect(screen.getByText('Never auto-remediate')).toBeTruthy(); // "Clear all overrides" only renders for persisted() && !isDirty() — diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts index 2952ece87..a39347511 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts @@ -17,6 +17,11 @@ const supportDisclosureSource = readFileSync( 'utf-8', ); +const guestOverviewSource = readFileSync( + resolve(__dirname, '..', '..', 'Workloads', 'GuestDrawerOverview.tsx'), + 'utf-8', +); + describe('ResourceOperatorStateSection', () => { it('exposes operator-set controls bound to the canonical API client', () => { // The section is the operator's window into the per-resource state @@ -28,7 +33,10 @@ describe('ResourceOperatorStateSection', () => { expect(sectionSource).toContain('getResourceOperatorState'); expect(sectionSource).toContain('setResourceOperatorState'); expect(sectionSource).toContain('clearResourceOperatorState'); - expect(sectionSource).toContain('Intentionally offline'); + expect(sectionSource).toContain('Normal monitoring'); + expect(sectionSource).toContain('Expected offline'); + expect(sectionSource).toContain('Mute all attention'); + expect(sectionSource).toContain('Retired from monitoring'); expect(sectionSource).toContain('Never auto-remediate'); expect(sectionSource).toContain('Patrol priority'); expect(sectionSource).toContain('Operator note'); @@ -159,7 +167,11 @@ describe('ResourceOperatorStateSection', () => { expect(sectionSource).toContain('Cancel window'); // handleClearMaintenanceWindow must preserve toggles by reading // the current edit-state signals rather than nulling everything. - expect(sectionSource).toContain('intentionallyOffline: intentionallyOffline()'); + expect(sectionSource).toContain('monitoringMode: monitoringMode()'); + expect(sectionSource).toContain('lifecycleState: lifecycleState()'); + expect(sectionSource).toContain( + "intentionallyOffline: monitoringMode() === 'expected_offline'", + ); expect(sectionSource).toContain('neverAutoRemediate: neverAutoRemediate()'); expect(sectionSource).toContain('maintenanceStartAt: undefined,'); expect(sectionSource).toContain('maintenanceEndAt: undefined,'); @@ -225,3 +237,14 @@ describe('ResourceDetailDrawerOverviewTab integration', () => { expect(sectionSource).toContain(' 0}>'); }); }); + +describe('Proxmox guest drawer integration', () => { + it('exposes the same canonical policy with provider ownership context', () => { + expect(guestOverviewSource).toContain( + "from '@/components/Infrastructure/ResourceOperatorStateSection'", + ); + expect(guestOverviewSource).toContain(' { ); }); + it('mounts canonical resource policy only in the selected guest overview', () => { + expect(guestDrawerOverviewSource).toContain(' { it('opens Pulse Assistant with a canonical Proxmox LXC resource handoff', () => { aiChatStore.setEnabled(true); diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 61034e6e6..b1f35363f 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -8,6 +8,7 @@ import { DiscoveryProvenanceMarker } from '@/components/shared/DiscoveryProvenan import { InfoCardFrame } from '@/components/shared/InfoCardFrame'; import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField'; import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard'; +import { ResourceOperatorStateSection } from '@/components/Infrastructure/ResourceOperatorStateSection'; import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { formatBytes, formatUptime } from '@/utils/format'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; @@ -463,7 +464,12 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) {
-
+
+ { + const metadataPlatform = props.alert.metadata?.platformType; + if (typeof metadataPlatform === 'string' && metadataPlatform.trim()) return metadataPlatform; + const link = resourceLink(); + if (link.startsWith('/proxmox')) return 'proxmox'; + if (link.startsWith('/docker')) return 'docker'; + if (link.startsWith('/kubernetes')) return 'kubernetes'; + if (link.startsWith('/vmware')) return 'vmware'; + if (link.startsWith('/machines')) return 'agent'; + return undefined; + }; + + const resourceTypeForPolicy = (): string | undefined => { + const metadataType = props.alert.metadata?.resourceType; + if (typeof metadataType === 'string' && metadataType.trim()) return metadataType; + const resourceId = (props.alert.resourceId || '').toLowerCase(); + if (resourceId.startsWith('agent:')) return 'agent'; + if (resourceId.includes('docker')) return 'app-container'; + if (resourceId.includes('k8s') || resourceId.includes('kubernetes')) return 'pod'; + if (resourceLink() === '/proxmox/overview') return 'vm'; + return undefined; + }; + return (
@@ -155,6 +181,14 @@ export function AlertOverviewAlertCard(props: AlertOverviewAlertCardProps) { props.timelineState.expandedIncidents().has(alertKey()), )} + + + + describeResourceInventoryOwnership(props.resourceType, props.platformType); + const choices = (): PolicyChoice[] => [ + { + label: 'Normal monitoring', + description: 'Resume all Alerts and Patrol attention.', + monitoringMode: 'normal', + lifecycleState: 'active', + }, + { + label: 'Expected offline', + description: 'Hide availability noise but keep other monitoring active.', + monitoringMode: 'expected_offline', + lifecycleState: 'active', + }, + { + label: 'Mute all attention', + description: 'Stop Alerts and Patrol while keeping the resource visible.', + monitoringMode: 'muted', + lifecycleState: 'active', + }, + { + label: 'Retire from monitoring', + description: `${ownership().ownerLabel} keeps the inventory record. Pulse stops attention and automation.`, + lifecycleState: 'retired', + }, + ]; + + const applyChoice = async (choice: PolicyChoice) => { + if (saving()) return; + setSaving(true); + try { + const current = await getResourceOperatorState(props.resourceId); + const monitoringMode = + choice.monitoringMode ?? + current?.monitoringMode ?? + (current?.intentionallyOffline ? 'expected_offline' : 'normal'); + const lifecycleState = choice.lifecycleState ?? current?.lifecycleState ?? 'active'; + const input: ResourceOperatorStateInput = { + monitoringMode, + lifecycleState, + intentionallyOffline: monitoringMode === 'expected_offline', + neverAutoRemediate: current?.neverAutoRemediate ?? false, + autoRemediationPolicy: current?.autoRemediationPolicy ?? { + enabled: false, + capabilityNames: [], + }, + maintenanceStartAt: current?.maintenanceStartAt, + maintenanceEndAt: current?.maintenanceEndAt, + maintenanceReason: current?.maintenanceReason, + criticality: current?.criticality ?? '', + note: current?.note, + }; + await setResourceOperatorState(props.resourceId, input); + if (detailsRef) detailsRef.open = false; + notificationStore.success(`${choice.label} saved for ${props.resourceName}`); + } catch (error) { + notificationStore.error( + error instanceof Error ? error.message : 'Failed to update resource monitoring', + ); + } finally { + setSaving(false); + } + }; + + return ( +
event.stopPropagation()}> + + {saving() ? 'Saving…' : 'Monitoring'} + +
+

+ This changes Pulse policy only. {ownership().ownerLabel} remains the inventory owner. +

+ + {(choice) => ( + + )} + +
+
+ ); +} diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx index f0b19d452..5687d4c07 100644 --- a/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx @@ -84,6 +84,7 @@ describe('OverviewTab Last 24 Hours stat', () => { .closest('tr') ?.querySelector('[data-testid="alert-overview-stat-value"]'); expect(statValue?.textContent).toBe('1'); + expect(screen.getAllByText('Monitoring')).toHaveLength(2); }); it('shows 0 when all alerts are older than 24 hours', () => { diff --git a/frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx b/frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx new file mode 100644 index 000000000..a67fee7cd --- /dev/null +++ b/frontend-modern/src/features/alerts/__tests__/ResourceMonitoringPolicyAction.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const operatorStateMock = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), +})); + +vi.mock('@/api/resourceOperatorState', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getResourceOperatorState: operatorStateMock.get, + setResourceOperatorState: operatorStateMock.set, + }; +}); + +vi.mock('@/stores/notifications', () => ({ + notificationStore: { success: vi.fn(), error: vi.fn() }, +})); + +import { ResourceMonitoringPolicyAction } from '../ResourceMonitoringPolicyAction'; + +describe('ResourceMonitoringPolicyAction', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('persists expected-offline policy through the canonical resource state API', async () => { + operatorStateMock.get.mockResolvedValue(null); + operatorStateMock.set.mockResolvedValue({}); + render(() => ( + + )); + + fireEvent.click(screen.getByText('Monitoring')); + expect(screen.getByText(/Proxmox remains the inventory owner/)).toBeInTheDocument(); + fireEvent.click(screen.getByText('Expected offline')); + + await waitFor(() => { + expect(operatorStateMock.set).toHaveBeenCalledWith( + 'vm:101', + expect.objectContaining({ + monitoringMode: 'expected_offline', + lifecycleState: 'active', + intentionallyOffline: true, + }), + ); + }); + }); + + it('retires provider inventory without changing its previous monitoring mode', async () => { + operatorStateMock.get.mockResolvedValue({ + canonicalId: 'vm:101', + monitoringMode: 'expected_offline', + lifecycleState: 'active', + intentionallyOffline: true, + neverAutoRemediate: false, + autoRemediationPolicy: { enabled: false, capabilityNames: [] }, + setAt: '2026-08-11T09:00:00Z', + }); + operatorStateMock.set.mockResolvedValue({}); + render(() => ( + + )); + + fireEvent.click(screen.getByText('Monitoring')); + fireEvent.click(screen.getByText('Retire from monitoring')); + + await waitFor(() => { + expect(operatorStateMock.set).toHaveBeenCalledWith( + 'vm:101', + expect.objectContaining({ + monitoringMode: 'expected_offline', + lifecycleState: 'retired', + }), + ); + }); + }); +}); diff --git a/frontend-modern/src/utils/resourceMonitoringPolicy.ts b/frontend-modern/src/utils/resourceMonitoringPolicy.ts new file mode 100644 index 000000000..55f6557be --- /dev/null +++ b/frontend-modern/src/utils/resourceMonitoringPolicy.ts @@ -0,0 +1,63 @@ +export interface ResourceInventoryOwnership { + ownerLabel: string; + providerOwned: boolean; + retirementDescription: string; +} + +export function describeResourceInventoryOwnership( + resourceType?: string, + platformType?: string, +): ResourceInventoryOwnership { + const type = (resourceType || '').toLowerCase(); + const platform = (platformType || '').toLowerCase(); + + if ( + platform === 'proxmox' || + ['vm', 'system-container', 'oci-container', 'pbs', 'pmg'].includes(type) + ) { + return { + ownerLabel: 'Proxmox', + providerOwned: true, + retirementDescription: + 'Proxmox owns this inventory record. Retiring it stops Pulse attention and automation without deleting it from Proxmox. Restore active monitoring here at any time.', + }; + } + if (platform === 'kubernetes' || type.startsWith('k8s-') || type === 'pod') { + return { + ownerLabel: 'Kubernetes', + providerOwned: true, + retirementDescription: + 'Kubernetes owns this object. Retiring it stops Pulse attention and automation without deleting the object from the cluster.', + }; + } + if (platform === 'vmware') { + return { + ownerLabel: 'VMware vSphere', + providerOwned: true, + retirementDescription: + 'vSphere owns this inventory record. Retiring it changes Pulse monitoring only and does not delete anything from vCenter.', + }; + } + if (platform === 'docker' || type.startsWith('docker-') || type === 'app-container') { + return { + ownerLabel: 'container runtime', + providerOwned: true, + retirementDescription: + 'The container runtime owns this inventory record. Retiring it changes Pulse monitoring only and does not remove the runtime object.', + }; + } + if (type === 'agent' || platform === 'agent') { + return { + ownerLabel: 'Pulse agent', + providerOwned: false, + retirementDescription: + 'Retiring this machine stops Pulse attention and automation while keeping its history. Agent removal remains available from Machines.', + }; + } + return { + ownerLabel: 'source system', + providerOwned: true, + retirementDescription: + 'The source system owns this inventory record. Retiring it changes Pulse monitoring only and preserves the resource history.', + }; +} diff --git a/internal/actionlifecycle/service.go b/internal/actionlifecycle/service.go index 301e52c19..6a7bf27bc 100644 --- a/internal/actionlifecycle/service.go +++ b/internal/actionlifecycle/service.go @@ -1640,7 +1640,7 @@ func boundedActionAvailabilityText(value string, maxRunes int) string { } // validateExecutionPolicy enforces operator-set per-resource policy at the -// dispatch decision point, currently the NeverAutoRemediate lock. +// dispatch decision point, including explicit locks and retired lifecycle. func validateExecutionPolicy(store Store, record unified.ActionAuditRecord) error { if store == nil { return errors.New("action audit store unavailable") @@ -1653,7 +1653,7 @@ func validateExecutionPolicy(store Store, record unified.ActionAuditRecord) erro if err != nil || !found { return err } - if state.NeverAutoRemediate { + if state.BlocksRemediation() { return unified.ErrResourceRemediationLocked } return nil diff --git a/internal/actionlifecycle/service_test.go b/internal/actionlifecycle/service_test.go index 9016faf24..ecfec008d 100644 --- a/internal/actionlifecycle/service_test.go +++ b/internal/actionlifecycle/service_test.go @@ -1414,6 +1414,33 @@ func TestExecuteRefusesRemediationLockedResource(t *testing.T) { } } +func TestExecuteRefusesRetiredResourceWithoutDispatch(t *testing.T) { + now := time.Now().UTC() + env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) + + plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default")) + if err != nil { + t.Fatalf("Plan: %v", err) + } + if err := env.store.SetResourceOperatorState(unified.ResourceOperatorState{ + CanonicalID: "vm:42", + LifecycleState: unified.LifecycleStateRetired, + }); err != nil { + t.Fatalf("SetResourceOperatorState: %v", err) + } + + failed, err := env.service.Execute(context.Background(), "default", plan.ActionID, testActionActor("agent:test", "default"), "") + if !errors.Is(err, unified.ErrResourceRemediationLocked) { + t.Fatalf("error = %v, want ErrResourceRemediationLocked", err) + } + if env.executor.calls != 0 { + t.Fatalf("executor must not run for retired resources, calls = %d", env.executor.calls) + } + if failed.State != unified.ActionStateFailed { + t.Fatalf("retirement refusal must persist a failed audit, state = %q", failed.State) + } +} + func TestExecuteRefusesDriftedPlan(t *testing.T) { now := time.Now().UTC() env := newServiceEnv(t, testResource(now, unified.ApprovalNone)) diff --git a/internal/agentcapabilities/manifest.go b/internal/agentcapabilities/manifest.go index 0fe84378a..60528af29 100644 --- a/internal/agentcapabilities/manifest.go +++ b/internal/agentcapabilities/manifest.go @@ -115,7 +115,15 @@ func resourceCapabilitiesOutputSchema() json.RawMessage { func operatorStateOutputSchema() json.RawMessage { return agentObjectOutputSchema([]string{"canonicalId", "intentionallyOffline", "neverAutoRemediate", "setAt"}, map[string]any{ - "canonicalId": stringOption("Canonical Pulse resource id."), + "canonicalId": stringOption("Canonical Pulse resource id."), + "monitoringMode": map[string]any{ + "type": "string", "enum": []string{"normal", "expected_offline", "muted"}, + "description": "Canonical per-resource monitoring posture.", + }, + "lifecycleState": map[string]any{ + "type": "string", "enum": []string{"active", "retired"}, + "description": "Whether the resource remains operationally active in Pulse.", + }, "intentionallyOffline": booleanOption("Whether this resource is expected to be offline."), "neverAutoRemediate": booleanOption("Whether automated remediation must be refused for this resource."), "maintenanceStartAt": dateTimeOption("Maintenance window start time when set."), @@ -277,6 +285,14 @@ func updateNodeInputSchema() json.RawMessage { func operatorStateInputSchema() json.RawMessage { schema := agentObjectInputSchemaMap([]string{ResourceIDArgumentName, "intentionallyOffline", "neverAutoRemediate"}, map[string]any{ ResourceIDArgumentName: stringOption("Canonical resource id from get_fleet_context or get_resource_context. This path value wins over any body id."), + "monitoringMode": map[string]any{ + "type": "string", "enum": []string{"normal", "expected_offline", "muted"}, + "description": "Canonical monitoring posture. When supplied, this takes precedence over the intentionallyOffline compatibility field.", + }, + "lifecycleState": map[string]any{ + "type": "string", "enum": []string{"active", "retired"}, + "description": "Retired resources stay in provider inventory but leave alert attention and automated remediation.", + }, "intentionallyOffline": map[string]any{ "type": "boolean", "description": "Whether the resource is expected to be offline. This suppresses offline findings for this resource.", @@ -917,7 +933,7 @@ var canonicalManifest = Manifest{ { Name: GetOperatorStateCapabilityName, Title: "Get operator state", - Description: "Read the operator-set state for a resource (intentionally offline, never auto-remediate, maintenance window, criticality).", + Description: "Read the operator-set monitoring mode, lifecycle state, remediation lock, maintenance window, and criticality for a resource.", Category: "operator-state", Method: http.MethodGet, Path: OperatorStateCapabilityPath, diff --git a/internal/agentcapabilities/manifest_test.go b/internal/agentcapabilities/manifest_test.go index a2dc7a5cc..9d2d6f284 100644 --- a/internal/agentcapabilities/manifest_test.go +++ b/internal/agentcapabilities/manifest_test.go @@ -134,6 +134,29 @@ func TestCanonicalManifestUsesSharedOperatorStateVocabulary(t *testing.T) { t.Fatalf("%s path = %q, want %q", name, cap.Path, OperatorStateCapabilityPath) } } + + setCapability, _ := FindCapability(manifest.Capabilities, SetOperatorStateCapabilityName) + var inputSchema map[string]any + if err := json.Unmarshal(ToolInputSchema(setCapability), &inputSchema); err != nil { + t.Fatal(err) + } + inputProperties, _ := inputSchema["properties"].(map[string]any) + for _, field := range []string{"monitoringMode", "lifecycleState"} { + if _, ok := inputProperties[field]; !ok { + t.Fatalf("set_operator_state input schema missing %q: %v", field, inputProperties) + } + } + getCapability, _ := FindCapability(manifest.Capabilities, GetOperatorStateCapabilityName) + var outputSchema map[string]any + if err := json.Unmarshal(getCapability.OutputSchema, &outputSchema); err != nil { + t.Fatal(err) + } + outputProperties, _ := outputSchema["properties"].(map[string]any) + for _, field := range []string{"monitoringMode", "lifecycleState"} { + if _, ok := outputProperties[field]; !ok { + t.Fatalf("get_operator_state output schema missing %q: %v", field, outputProperties) + } + } } func TestCanonicalManifestUsesSharedFindingVocabulary(t *testing.T) { diff --git a/internal/agentcapabilities/markdown_test.go b/internal/agentcapabilities/markdown_test.go index c29226e59..2c2d31968 100644 --- a/internal/agentcapabilities/markdown_test.go +++ b/internal/agentcapabilities/markdown_test.go @@ -144,6 +144,13 @@ func TestMCPSurfaceContractMarkdownFallsBackForLegacyManifest(t *testing.T) { } } +func TestMCPToolInventoryDescribesCanonicalResourcePolicy(t *testing.T) { + got := MCPToolCapabilityInventoryMarkdown(CanonicalManifest()) + if !strings.Contains(got, "monitoring mode, lifecycle state, remediation lock") { + t.Fatalf("operator-state inventory omits canonical policy vocabulary:\n%s", got) + } +} + func TestMCPToolCapabilityInventoryMarkdownProjectsManifestTools(t *testing.T) { got := MCPToolCapabilityInventoryMarkdown(Manifest{ SurfaceContract: CanonicalManifest().SurfaceContract, diff --git a/internal/agentcontext/resource_context_sections.go b/internal/agentcontext/resource_context_sections.go index bd0f0e71f..2d3e028be 100644 --- a/internal/agentcontext/resource_context_sections.go +++ b/internal/agentcontext/resource_context_sections.go @@ -48,6 +48,8 @@ type Section struct { // stays independent of HTTP and storage owners. type OperatorState struct { IntentionallyOffline bool + MonitoringMode string + LifecycleState string NeverAutoRemediate bool MaintenanceWindowActive bool MaintenanceStartAt *time.Time @@ -638,9 +640,14 @@ func formatAgentRelationshipFact(rel unified.ResourceRelationship, policy *unifi func formatOperatorStateFact(state OperatorState) string { parts := []string{} - if state.IntentionallyOffline { + if state.MonitoringMode != "" { + parts = append(parts, "monitoring="+state.MonitoringMode) + } else if state.IntentionallyOffline { parts = append(parts, "intentionally offline") } + if state.LifecycleState != "" { + parts = append(parts, "lifecycle="+state.LifecycleState) + } if state.NeverAutoRemediate { parts = append(parts, "never auto-remediate") } diff --git a/internal/agentcontext/resource_context_sections_test.go b/internal/agentcontext/resource_context_sections_test.go index 2593f6738..c884c9759 100644 --- a/internal/agentcontext/resource_context_sections_test.go +++ b/internal/agentcontext/resource_context_sections_test.go @@ -85,7 +85,7 @@ func TestBuildResourceContextSectionsAppliesRedactionTrustAndFreshness(t *testin sections := BuildResourceContextSections(resource, nil, BuildOptions{ GeneratedAt: now, - OperatorState: &OperatorState{IntentionallyOffline: true, NeverAutoRemediate: true, Criticality: "high", NotePresent: true, SetAt: operatorSetAt}, + OperatorState: &OperatorState{MonitoringMode: "muted", LifecycleState: "retired", NeverAutoRemediate: true, Criticality: "high", NotePresent: true, SetAt: operatorSetAt}, ActiveFindingCount: 2, PendingApprovalCount: 1, RecentActionCount: 3, @@ -173,7 +173,7 @@ func TestBuildResourceContextSectionsAppliesRedactionTrustAndFreshness(t *testin if operatorState.ObservedAt == nil || !operatorState.ObservedAt.Equal(operatorSetAt) { t.Fatalf("operator state observedAt = %v, want %v", operatorState.ObservedAt, operatorSetAt) } - for _, want := range []string{"intentionally offline", "never auto-remediate", "criticality=high", "note present"} { + for _, want := range []string{"monitoring=muted", "lifecycle=retired", "never auto-remediate", "criticality=high", "note present"} { if !strings.Contains(operatorState.Value, want) { t.Fatalf("operator state value = %q, want %q", operatorState.Value, want) } diff --git a/internal/ai/findings.go b/internal/ai/findings.go index 7852ce1f1..b8aef36fa 100644 --- a/internal/ai/findings.go +++ b/internal/ai/findings.go @@ -822,6 +822,8 @@ type ResourceOperatorStateMaintenanceWindow struct { type ResourceOperatorStateProjection struct { MaintenanceWindow *ResourceOperatorStateMaintenanceWindow IntentionallyOffline bool + MonitoringMode string + LifecycleState string // NeverAutoRemediate is the operator's "do not act on this // resource" flag. The findings store does not consume it during // suppression (that's the action broker's concern), but the @@ -833,6 +835,26 @@ type ResourceOperatorStateProjection struct { Criticality string } +func (p ResourceOperatorStateProjection) monitoringSuppressionCause() string { + if strings.EqualFold(strings.TrimSpace(p.LifecycleState), "retired") { + return "resource_retired" + } + switch strings.ToLower(strings.TrimSpace(p.MonitoringMode)) { + case "muted": + return "monitoring_muted" + case "expected_offline": + return "expected_offline" + } + if p.IntentionallyOffline { + return "intentionally_offline" + } + return "" +} + +func (p ResourceOperatorStateProjection) suppressesFindings() bool { + return p.MaintenanceWindow != nil || p.monitoringSuppressionCause() != "" +} + // ResourceOperatorStateProvider is the narrow interface the findings // store consumes for per-resource operator intent. Implementations // adapt the unified-resources `ResourceOperatorState` shape into the @@ -1789,8 +1811,7 @@ func (s *FindingsStore) Add(f *Finding) bool { if !willFixReminderDue && existing.DismissedReason == "expected_behavior" && !existing.Suppressed { if cause := findOperatorStateDismissCause(existing); cause != "" { if operatorStateProviderPresent { - currentlySuppressing := operatorProjectionOK && - (operatorProjection.MaintenanceWindow != nil || operatorProjection.IntentionallyOffline) + currentlySuppressing := operatorProjectionOK && operatorProjection.suppressesFindings() operatorStateLifted = !currentlySuppressing } else { // No provider currently wired but the finding was @@ -1992,18 +2013,18 @@ func (s *FindingsStore) Add(f *Finding) bool { "maintenance_end_at": window.EndAt.Format(time.RFC3339), }) s.syncLoopStateLocked(f) - case projection.IntentionallyOffline: - // Operator has marked this resource as expected-to-be-offline, - // so any finding raised against it is by definition expected - // noise. Auto-dismiss with a clear note so the operator - // understands why future findings stayed quiet without - // expanding each row. + case projection.monitoringSuppressionCause() != "": + cause := projection.monitoringSuppressionCause() f.DismissedReason = "expected_behavior" f.AcknowledgedAt = &now - f.UserNote = "Resource is marked intentionally offline by the operator. Clear the flag on the resource detail surface to resume notifications." - s.appendLifecycleLocked(f, "dismissed", "Auto-acknowledged: resource is intentionally offline", f.LoopState, string(FindingLoopStateDismissed), map[string]string{ + if cause == "intentionally_offline" { + f.UserNote = "Resource is marked intentionally offline by the operator. Clear the flag on the resource detail surface to resume notifications." + } else { + f.UserNote = "Resource monitoring policy suppresses Patrol attention. Return monitoring and lifecycle to active defaults on the resource detail surface to resume findings." + } + s.appendLifecycleLocked(f, "dismissed", "Auto-acknowledged: resource monitoring policy suppresses attention", f.LoopState, string(FindingLoopStateDismissed), map[string]string{ "reason": "expected_behavior", - "operator_state_cause": "intentionally_offline", + "operator_state_cause": cause, }) s.syncLoopStateLocked(f) } diff --git a/internal/ai/findings_test.go b/internal/ai/findings_test.go index 8e42ff640..7a296cd4a 100644 --- a/internal/ai/findings_test.go +++ b/internal/ai/findings_test.go @@ -2246,6 +2246,8 @@ func TestFindingsStore_OperatorStateProjectionFor_ProxiesProvider(t *testing.T) store := NewFindingsStore() expected := ResourceOperatorStateProjection{ IntentionallyOffline: true, + MonitoringMode: "muted", + LifecycleState: "retired", NeverAutoRemediate: true, Criticality: "high", } @@ -2262,6 +2264,9 @@ func TestFindingsStore_OperatorStateProjectionFor_ProxiesProvider(t *testing.T) if got.IntentionallyOffline != expected.IntentionallyOffline { t.Errorf("IntentionallyOffline must round-trip; got %v want %v", got.IntentionallyOffline, expected.IntentionallyOffline) } + if got.MonitoringMode != expected.MonitoringMode || got.LifecycleState != expected.LifecycleState { + t.Errorf("canonical monitoring projection = (%q, %q), want (%q, %q)", got.MonitoringMode, got.LifecycleState, expected.MonitoringMode, expected.LifecycleState) + } if got.NeverAutoRemediate != expected.NeverAutoRemediate { t.Errorf("NeverAutoRemediate must round-trip — investigation runtime reads this projection too; got %v want %v", got.NeverAutoRemediate, expected.NeverAutoRemediate) } diff --git a/internal/ai/tools/action_audit.go b/internal/ai/tools/action_audit.go index f04c41d10..06b5920a8 100644 --- a/internal/ai/tools/action_audit.go +++ b/internal/ai/tools/action_audit.go @@ -1106,7 +1106,7 @@ func (e *PulseToolExecutor) isResourceRemediationLocked(resourceID string) (bool if !found { return false, nil } - return state.NeverAutoRemediate, nil + return state.BlocksRemediation(), nil } // checkRemediationLockForDispatch applies the operator NeverAutoRemediate diff --git a/internal/alerts/alert_store.go b/internal/alerts/alert_store.go index bc3a93e65..fa172d10c 100644 --- a/internal/alerts/alert_store.go +++ b/internal/alerts/alert_store.go @@ -105,6 +105,9 @@ func (m *Manager) setActiveAlertNoLock(storageKey string, alert *Alert) { return } backfillCanonicalIdentity(alert) + if suppressed, _ := m.operatorSuppressionForAlertNoLock(alert, time.Now().UTC()); suppressed { + return + } ensureOperationalContract(alert, time.Now()) requestedKey := storageKey storageKey = activeAlertStorageKey(alert, storageKey) diff --git a/internal/alerts/intent_policy.go b/internal/alerts/intent_policy.go index 6264313d2..8e6bc3c55 100644 --- a/internal/alerts/intent_policy.go +++ b/internal/alerts/intent_policy.go @@ -11,11 +11,34 @@ var ErrAlertIntentPolicyRevisionConflict = errors.New("alert_intent_policy_revis type OperatorIntentContext struct { IntentionallyOffline bool `json:"intentionallyOffline"` + MonitoringMode string `json:"monitoringMode"` + LifecycleState string `json:"lifecycleState"` MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"` MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"` MaintenanceReason string `json:"maintenanceReason,omitempty"` } +func (c OperatorIntentContext) suppressionForSignal(signal string) (bool, string) { + mode := strings.ToLower(strings.TrimSpace(c.MonitoringMode)) + if mode == "" && c.IntentionallyOffline { + mode = "expected_offline" + } + lifecycle := strings.ToLower(strings.TrimSpace(c.LifecycleState)) + if lifecycle == "retired" { + return true, "operator_retired" + } + if mode == "muted" { + return true, "operator_muted" + } + if mode == "expected_offline" { + switch strings.ToLower(strings.TrimSpace(signal)) { + case string(AlertIntentSignalOffline), string(AlertIntentSignalAvailability): + return true, "operator_expected_offline" + } + } + return false, "" +} + func (c OperatorIntentContext) MaintenanceActiveAt(now time.Time) bool { return c.MaintenanceStartAt != nil && c.MaintenanceEndAt != nil && !now.Before(*c.MaintenanceStartAt) && now.Before(*c.MaintenanceEndAt) @@ -174,7 +197,10 @@ func (m *Manager) resolveEffectiveIntentPolicyNoLock(resourceID, resourceType, s resourceID = strings.TrimSpace(resourceID) resourceType = strings.ToLower(strings.TrimSpace(resourceType)) signal = strings.ToLower(strings.TrimSpace(signal)) - effective := EffectiveAlertIntentPolicy{Sources: make(map[string]string)} + effective := EffectiveAlertIntentPolicy{ + HonorOperatorState: true, + Sources: make(map[string]string), + } if strings.HasPrefix(signal, "metric.") { metric := strings.TrimPrefix(signal, "metric.") @@ -245,7 +271,7 @@ func (m *Manager) resolveEffectiveIntentPolicyNoLock(resourceID, resourceType, s func (m *Manager) ResolveEffectiveIntentPolicy(resourceID, resourceType, signal string) EffectiveAlertIntentPolicy { if m == nil { - return EffectiveAlertIntentPolicy{Sources: map[string]string{"graceSeconds": "factory", "honorOperatorState": "factory"}} + return EffectiveAlertIntentPolicy{HonorOperatorState: true, Sources: map[string]string{"graceSeconds": "factory", "honorOperatorState": "factory"}} } m.mu.RLock() effective := m.resolveEffectiveIntentPolicyNoLock(resourceID, resourceType, signal) @@ -309,13 +335,6 @@ func (m *Manager) evaluateIntentNoLock(resourceID, resourceType, signal, trackin decision.Reason = "condition_clear" return decision } - if !effective.Explicit { - if m.clearIntentPendingNoLock(trackingKey) { - decision.StateChanged = true - } - decision.ShouldActivate = true - return decision - } observedAt := m.policyNow().UTC() tick := m.intentTickNoLock() state, exists := m.intentPending[trackingKey] @@ -357,15 +376,22 @@ func (m *Manager) evaluateIntentNoLock(resourceID, resourceType, signal, trackin } return decision } - if signal == string(AlertIntentSignalOffline) && operator.IntentionallyOffline { + if suppressed, reason := operator.suppressionForSignal(signal); suppressed { m.intentPending[trackingKey] = state decision.Pending = true decision.Suppressed = true - decision.Reason = "operator_intentionally_offline" + decision.Reason = reason return decision } } } + if !effective.Explicit { + if m.clearIntentPendingNoLock(trackingKey) { + decision.StateChanged = true + } + decision.ShouldActivate = true + return decision + } eligibleElapsed := time.Duration(effective.GraceSeconds) * time.Second if effective.BackupOffline != nil && effective.BackupOffline.Enabled && signal == string(AlertIntentSignalOffline) { @@ -521,7 +547,8 @@ func (m *Manager) PreviewIntentPolicy(request AlertIntentPolicyPreviewRequest) ( preview.Contexts = append(preview.Contexts, ctx) } if operatorFound { - active := operator.IntentionallyOffline || operator.MaintenanceActiveAt(now) + suppressed, _ := operator.suppressionForSignal(request.Signal) + active := suppressed || operator.MaintenanceActiveAt(now) ctx := AlertIntentPolicyPreviewContext{Kind: "operator_state", Active: active, Evidence: operator.MaintenanceReason} if operator.MaintenanceEndAt != nil { expiresAt := operator.MaintenanceEndAt.UTC() diff --git a/internal/alerts/intent_policy_test.go b/internal/alerts/intent_policy_test.go index 2a97a32fc..b0567c16a 100644 --- a/internal/alerts/intent_policy_test.go +++ b/internal/alerts/intent_policy_test.go @@ -194,6 +194,90 @@ func TestAlertIntentPreviewHonorsOperatorStateWithoutMutatingRuntime(t *testing. } } +func TestAlertIntentHonorsCanonicalResourcePolicyWithoutExplicitRules(t *testing.T) { + m := newTestManager(t) + m.SetOperatorIntentContextResolver(func(resourceID string, observedAt time.Time) (OperatorIntentContext, bool) { + return OperatorIntentContext{MonitoringMode: "expected_offline", LifecycleState: "active"}, true + }) + + preview, err := m.PreviewIntentPolicy(AlertIntentPolicyPreviewRequest{ + ResourceID: "vm:101", + ResourceType: "vm", + Signal: string(AlertIntentSignalOffline), + ConditionActive: true, + }) + if err != nil { + t.Fatalf("PreviewIntentPolicy() error = %v", err) + } + if preview.Status != "expected_transient" || preview.Reason != "operator_expected_offline" { + t.Fatalf("preview = %+v", preview) + } + if preview.Effective.Explicit { + t.Fatal("operator policy regression test must not rely on an explicit intent rule") + } +} + +func TestCanonicalResourcePolicyGatesAllAlertWritersAndReconcilesExisting(t *testing.T) { + m := newTestManager(t) + mode := "normal" + m.SetOperatorIntentContextResolver(func(resourceID string, observedAt time.Time) (OperatorIntentContext, bool) { + return OperatorIntentContext{MonitoringMode: mode, LifecycleState: "active"}, true + }) + + existing := &Alert{ID: "backup-vm-101", ResourceID: "vm:101", Type: "backup-age"} + m.mu.Lock() + m.setActiveAlertNoLock(existing.ID, existing) + m.mu.Unlock() + if got := len(m.GetActiveAlerts()); got != 1 { + t.Fatalf("active alerts before mute = %d, want 1", got) + } + + mode = "muted" + if cleared := m.ReconcileResourceOperatorState("vm:101"); cleared != 1 { + t.Fatalf("ReconcileResourceOperatorState() cleared = %d, want 1", cleared) + } + + m.mu.Lock() + m.setActiveAlertNoLock(existing.ID, existing) + _, reactivated := m.getActiveAlertNoLock(existing.ID) + m.mu.Unlock() + if reactivated { + t.Fatal("muted resource alert was reactivated through a noncanonical writer") + } +} + +func TestCanonicalResourcePolicyCannotEnterQuietHoursReplay(t *testing.T) { + m := newTestManager(t) + m.SetOperatorIntentContextResolver(func(resourceID string, observedAt time.Time) (OperatorIntentContext, bool) { + return OperatorIntentContext{MonitoringMode: "muted", LifecycleState: "active"}, true + }) + alert := &Alert{ + ID: "offline-vm-101", + ResourceID: "vm:101", + Type: "offline", + Level: AlertLevelWarning, + Metadata: map[string]interface{}{ + MetadataQuietHoursSuppressed: true, + MetadataQuietHoursSuppressionReason: "non-critical", + MetadataQuietHoursReplayAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }, + } + + if !m.ShouldSuppressNotification(alert) { + t.Fatal("muted resource firing notification must be dropped, not queued for replay") + } + if hasQuietHoursNotificationReplay(alert) { + t.Fatal("operator suppression must clear stale quiet-hours replay metadata") + } + markQuietHoursNotificationReplay(alert, "non-critical", time.Now().Add(time.Hour)) + if !m.ShouldSuppressResolvedNotification(alert) { + t.Fatal("muted resource recovery notification must stay suppressed despite replay metadata") + } + if hasQuietHoursNotificationReplay(alert) { + t.Fatal("operator-suppressed recovery must not remain queued for quiet-hours replay") + } +} + func TestLifecycleAlertStartsAtFirstIntentMatch(t *testing.T) { m := NewManagerWithDataDir(t.TempDir()) t.Cleanup(m.Stop) diff --git a/internal/alerts/notification_policy.go b/internal/alerts/notification_policy.go index 57ced54fa..cbfa8d19a 100644 --- a/internal/alerts/notification_policy.go +++ b/internal/alerts/notification_policy.go @@ -408,6 +408,9 @@ func (m *Manager) shouldSuppressNotification(alert *Alert) (bool, string) { if alert == nil { return false, "" } + if suppressed, reason := m.operatorSuppressionForAlertNoLock(alert, time.Now().UTC()); suppressed { + return true, reason + } if !m.isInQuietHours() { return false, "" @@ -520,6 +523,16 @@ func (m *Manager) ShouldSuppressNotification(alert *Alert) bool { m.mu.RLock() defer m.mu.RUnlock() + if suppressed, reason := m.operatorSuppressionForAlertNoLock(alert, time.Now().UTC()); suppressed { + clearQuietHoursNotificationReplay(alert) + log.Debug(). + Str("alertID", alert.ID). + Str("type", alert.Type). + Str("operatorPolicy", reason). + Msg("Notification suppressed by resource monitoring policy") + return true + } + suppressed, reason := m.shouldSuppressNotification(alert) if suppressed { replayAt := m.quietHoursReplayAt() @@ -557,6 +570,15 @@ func (m *Manager) ShouldSuppressResolvedNotification(alert *Alert) bool { Msg("Recovery notification suppressed for acknowledged alert") return true } + if suppressed, reason := m.operatorSuppressionForAlertNoLock(alert, time.Now().UTC()); suppressed { + clearQuietHoursNotificationReplay(alert) + log.Debug(). + Str("alertID", alert.ID). + Str("type", alert.Type). + Str("operatorPolicy", reason). + Msg("Recovery notification suppressed by resource monitoring policy") + return true + } quietHoursReplay := hasQuietHoursNotificationReplay(alert) if alert.LastNotified == nil && !quietHoursReplay { diff --git a/internal/alerts/resource_monitoring_policy.go b/internal/alerts/resource_monitoring_policy.go new file mode 100644 index 000000000..82096160b --- /dev/null +++ b/internal/alerts/resource_monitoring_policy.go @@ -0,0 +1,86 @@ +package alerts + +import ( + "strings" + "time" +) + +// alertIntentSignalForRecord maps every alert family onto the small canonical +// signal vocabulary consumed by per-resource monitoring policy. Unknown alert +// families deliberately remain the default signal so expected_offline cannot +// hide unrelated health, capacity, backup, or security problems. +func alertIntentSignalForRecord(alert *Alert) string { + if alert == nil { + return string(AlertIntentSignalDefault) + } + if quietHoursCategoryForAlert(alert) == "offline" { + if strings.EqualFold(strings.TrimSpace(alert.Type), "resource-incident") { + return string(AlertIntentSignalAvailability) + } + return string(AlertIntentSignalOffline) + } + return string(AlertIntentSignalDefault) +} + +func (m *Manager) operatorSuppressionForAlertNoLock(alert *Alert, now time.Time) (bool, string) { + if m == nil || alert == nil || m.operatorIntentResolver == nil { + return false, "" + } + resourceID := strings.TrimSpace(alert.ResourceID) + if resourceID == "" { + return false, "" + } + operator, found := m.operatorIntentResolver(resourceID, now.UTC()) + if !found { + return false, "" + } + if operator.MaintenanceActiveAt(now) { + return true, "operator_maintenance" + } + return operator.suppressionForSignal(alertIntentSignalForRecord(alert)) +} + +// ReconcileResourceOperatorState immediately resolves active alerts that the +// newly persisted resource policy suppresses. Detector writes are also gated +// in setActiveAlertNoLock, so resolved alerts cannot reappear while the policy +// remains active. The return value is the number of active records cleared. +func (m *Manager) ReconcileResourceOperatorState(resourceID string) int { + resourceID = strings.TrimSpace(resourceID) + if m == nil || resourceID == "" { + return 0 + } + + now := time.Now().UTC() + m.mu.Lock() + alertIDs := make([]string, 0) + for storageKey, alert := range m.activeAlerts { + if alert == nil { + continue + } + candidate := strings.TrimSpace(alert.ResourceID) + if candidate == "" { + continue + } + matches := candidate == resourceID + if !matches && m.resourceIntentResolver != nil { + if canonicalID, found := m.resourceIntentResolver(candidate); found { + matches = strings.TrimSpace(canonicalID) == resourceID + } + } + if !matches { + continue + } + if suppressed, _ := m.operatorSuppressionForAlertNoLock(alert, now); suppressed { + alertIDs = append(alertIDs, effectiveAlertID(alert, storageKey)) + } + } + m.mu.Unlock() + + cleared := 0 + for _, alertID := range alertIDs { + if m.ClearAlert(alertID) { + cleared++ + } + } + return cleared +} diff --git a/internal/api/agent_resource_context.go b/internal/api/agent_resource_context.go index 7b5293dab..c69d680e6 100644 --- a/internal/api/agent_resource_context.go +++ b/internal/api/agent_resource_context.go @@ -123,6 +123,8 @@ type AgentFleetResourceSummary struct { ResourceName string `json:"resourceName"` Technology string `json:"technology,omitempty"` IntentionallyOffline bool `json:"intentionallyOffline"` + MonitoringMode string `json:"monitoringMode"` + LifecycleState string `json:"lifecycleState"` NeverAutoRemediate bool `json:"neverAutoRemediate"` MaintenanceWindowActive bool `json:"maintenanceWindowActive"` Findings AgentFleetFindingCounts `json:"findings"` @@ -212,6 +214,8 @@ type AgentOperationsLoopStatus struct { // the underlying store type's JSON tags shift. type AgentResourceOperatorState struct { IntentionallyOffline bool `json:"intentionallyOffline"` + MonitoringMode string `json:"monitoringMode"` + LifecycleState string `json:"lifecycleState"` NeverAutoRemediate bool `json:"neverAutoRemediate"` MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"` MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"` @@ -644,6 +648,8 @@ func agentResourceOperatorStateForContext(state *AgentResourceOperatorState) *ag } return &agentcontext.OperatorState{ IntentionallyOffline: state.IntentionallyOffline, + MonitoringMode: state.MonitoringMode, + LifecycleState: state.LifecycleState, NeverAutoRemediate: state.NeverAutoRemediate, MaintenanceWindowActive: state.MaintenanceWindowActive, MaintenanceStartAt: state.MaintenanceStartAt, @@ -745,7 +751,9 @@ func (h *AgentContextHandler) HandleFleetContext(w http.ResponseWriter, r *http. } if state, found, opErr := store.GetResourceOperatorState(canonical); opErr == nil && found { summary.IntentionallyOffline = state.IntentionallyOffline - summary.NeverAutoRemediate = state.NeverAutoRemediate + summary.MonitoringMode = string(state.MonitoringMode) + summary.LifecycleState = string(state.LifecycleState) + summary.NeverAutoRemediate = state.BlocksRemediation() summary.MaintenanceWindowActive = state.IsInMaintenanceAt(now) } if h.findingsProvider != nil { @@ -1322,9 +1330,12 @@ func projectAgentResourceOperatorState( state unified.ResourceOperatorState, now time.Time, ) AgentResourceOperatorState { + state = unified.NormalizeResourceOperatorState(state) return AgentResourceOperatorState{ IntentionallyOffline: state.IntentionallyOffline, - NeverAutoRemediate: state.NeverAutoRemediate, + MonitoringMode: string(state.MonitoringMode), + LifecycleState: string(state.LifecycleState), + NeverAutoRemediate: state.BlocksRemediation(), MaintenanceStartAt: state.MaintenanceStartAt, MaintenanceEndAt: state.MaintenanceEndAt, MaintenanceReason: state.MaintenanceReason, diff --git a/internal/api/resources.go b/internal/api/resources.go index 6bd9a0b8c..44cfb0a4d 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -42,6 +42,13 @@ type ResourceHandlers struct { actionExecutionAuthorizer actionlifecycle.ExecutionAuthorizer actionRefreshPlanner actionlifecycle.RefreshPlanner discoveryReadiness ResourceDiscoveryReadinessProvider + operatorStateChanged func(orgID, resourceID string) +} + +// SetOperatorStateChanged installs the runtime reconciliation hook invoked +// after a canonical operator-state mutation commits successfully. +func (h *ResourceHandlers) SetOperatorStateChanged(callback func(orgID, resourceID string)) { + h.operatorStateChanged = callback } // ResourceDiscoveryReadinessProvider projects service-discovery state onto a diff --git a/internal/api/resources_operator_state.go b/internal/api/resources_operator_state.go index 1072ce8a6..56cd69195 100644 --- a/internal/api/resources_operator_state.go +++ b/internal/api/resources_operator_state.go @@ -19,6 +19,8 @@ import ( // type's evolution stays decoupled from the wire format. type resourceOperatorStateAPI struct { CanonicalID string `json:"canonicalId"` + MonitoringMode string `json:"monitoringMode"` + LifecycleState string `json:"lifecycleState"` IntentionallyOffline bool `json:"intentionallyOffline"` NeverAutoRemediate bool `json:"neverAutoRemediate"` AutoRemediationPolicy unified.AutoRemediationPolicy `json:"autoRemediationPolicy"` @@ -32,8 +34,11 @@ type resourceOperatorStateAPI struct { } func toResourceOperatorStateAPI(state unified.ResourceOperatorState) resourceOperatorStateAPI { + state = unified.NormalizeResourceOperatorState(state) return resourceOperatorStateAPI{ CanonicalID: state.CanonicalID, + MonitoringMode: string(state.MonitoringMode), + LifecycleState: string(state.LifecycleState), IntentionallyOffline: state.IntentionallyOffline, NeverAutoRemediate: state.NeverAutoRemediate, AutoRemediationPolicy: state.AutoRemediationPolicy, @@ -73,6 +78,7 @@ func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r } orgID := GetOrgID(r.Context()) + resourceID = h.resolveOperatorStateCanonicalID(orgID, resourceID) store, err := h.getStore(orgID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) @@ -103,6 +109,8 @@ func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r // (operator wrote vm:101 in the URL but vm:102 in the body). state := unified.ResourceOperatorState{ CanonicalID: resourceID, + MonitoringMode: unified.ResourceMonitoringMode(payload.MonitoringMode), + LifecycleState: unified.ResourceLifecycleState(payload.LifecycleState), IntentionallyOffline: payload.IntentionallyOffline, NeverAutoRemediate: payload.NeverAutoRemediate, AutoRemediationPolicy: payload.AutoRemediationPolicy, @@ -130,6 +138,9 @@ func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } + if h.operatorStateChanged != nil { + h.operatorStateChanged(orgID, resourceID) + } writeJSON(w, http.StatusOK, toResourceOperatorStateAPI(persisted)) case http.MethodDelete: @@ -141,6 +152,9 @@ func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) return } + if h.operatorStateChanged != nil { + h.operatorStateChanged(orgID, resourceID) + } w.WriteHeader(http.StatusNoContent) default: @@ -148,6 +162,26 @@ func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r } } +// resolveOperatorStateCanonicalID ensures every UI and agent entry point writes +// one policy record even when it starts from a source-native alert ID, a +// superseded ID, or another canonical identity alias. If the live registry +// cannot resolve the reference, preserving the normalized input retains the +// established API behavior for resources that are not currently inventoried. +func (h *ResourceHandlers) resolveOperatorStateCanonicalID(orgID, resourceID string) string { + resourceID = unified.CanonicalResourceID(resourceID) + if resourceID == "" { + return "" + } + registry, err := h.buildRegistry(orgID) + if err != nil || registry == nil { + return resourceID + } + if _, canonicalID, found := registry.GetByReference(resourceID); found { + return canonicalID + } + return resourceID +} + // extractOperatorStateResourceID pulls the canonical resource ID out of a // `/api/resources//operator-state` URL path. Tolerates a trailing // slash on the URL (defense-in-depth — Go 1.22 ServeMux normally rejects diff --git a/internal/api/resources_operator_state_test.go b/internal/api/resources_operator_state_test.go index 2d7f87ca9..753a3b5c4 100644 --- a/internal/api/resources_operator_state_test.go +++ b/internal/api/resources_operator_state_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) @@ -130,6 +131,90 @@ func TestHandleResourceOperatorState_PutPersistsAndGetReturns200(t *testing.T) { } } +func TestHandleResourceOperatorState_CanonicalPolicyTakesPrecedenceAndReconciles(t *testing.T) { + h := newOperatorStateHandlers(t) + var reconciledOrg, reconciledResource string + h.SetOperatorStateChanged(func(orgID, resourceID string) { + reconciledOrg, reconciledResource = orgID, resourceID + }) + + body := bytes.NewBufferString(`{ + "monitoringMode":"muted", + "lifecycleState":"retired", + "intentionallyOffline":true, + "neverAutoRemediate":false + }`) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/resources/vm:101/operator-state", body) + h.HandleResourceOperatorState(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT status = %d body=%s", rec.Code, rec.Body.String()) + } + var got resourceOperatorStateAPI + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.MonitoringMode != "muted" || got.LifecycleState != "retired" { + t.Fatalf("canonical policy response = %+v", got) + } + if got.IntentionallyOffline { + t.Fatal("compatibility boolean must derive from canonical monitoring mode") + } + if reconciledOrg != "default" || reconciledResource != "vm:101" { + t.Fatalf("reconciliation callback = (%q, %q)", reconciledOrg, reconciledResource) + } +} + +func TestHandleResourceOperatorState_ResolvesSourceAliasToCanonicalResource(t *testing.T) { + h := newOperatorStateHandlers(t) + now := time.Date(2026, 8, 11, 10, 0, 0, 0, time.UTC) + h.SetStateProvider(resourceUnifiedSeedProvider{ + snapshot: models.StateSnapshot{LastUpdate: now}, + resources: []unified.Resource{{ + ID: "system-container-canonical", + Type: unified.ResourceTypeSystemContainer, + Name: "dev-portal-01", + LastSeen: now, + SupersededCanonicalIDs: []string{"mock-cluster-pve3-115"}, + }}, + }) + + var reconciledResource string + h.SetOperatorStateChanged(func(_, resourceID string) { + reconciledResource = resourceID + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/resources/mock-cluster-pve3-115/operator-state", bytes.NewBufferString(`{ + "monitoringMode":"expected_offline", + "lifecycleState":"active" + }`)) + h.HandleResourceOperatorState(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PUT status = %d body=%s", rec.Code, rec.Body.String()) + } + + var got resourceOperatorStateAPI + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.CanonicalID != "system-container-canonical" { + t.Fatalf("canonical ID = %q", got.CanonicalID) + } + if reconciledResource != "system-container-canonical" { + t.Fatalf("reconciled resource = %q", reconciledResource) + } + store, err := h.getStore("default") + if err != nil { + t.Fatal(err) + } + if _, found, err := store.GetResourceOperatorState("mock-cluster-pve3-115"); err != nil || found { + t.Fatalf("source alias must not receive a duplicate record: found=%v err=%v", found, err) + } + if state, found, err := store.GetResourceOperatorState("system-container-canonical"); err != nil || !found || state.MonitoringMode != unified.MonitoringModeExpectedOffline { + t.Fatalf("canonical policy = %+v found=%v err=%v", state, found, err) + } +} + func TestResourceOperatorState_RecordsMaintenanceWindowLifecycleChanges(t *testing.T) { h := newOperatorStateHandlers(t) store, err := h.getStore("default") diff --git a/internal/api/router.go b/internal/api/router.go index 3d730c0c0..5d0961a1d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -540,6 +540,34 @@ func (r *Router) setupRoutes() { r.unifiedAgentHandlers.SetServerVersion(r.serverVersion) r.kubernetesAgentHandlers.SetRecoveryIngestor(r.recoveryHandlers) r.resourceHandlers = NewResourceHandlers(r.config) + r.resourceHandlers.SetOperatorStateChanged(func(_ string, resourceID string) { + seen := make(map[*monitoring.Monitor]struct{}) + reconcile := func(monitor *monitoring.Monitor) { + if monitor == nil { + return + } + if _, duplicate := seen[monitor]; duplicate { + return + } + seen[monitor] = struct{}{} + if reconciler, ok := any(monitor.GetAlertManager()).(interface { + ReconcileResourceOperatorState(string) int + }); ok { + reconciler.ReconcileResourceOperatorState(resourceID) + monitor.SyncAlertState() + } + } + + if r.mtMonitor != nil { + // The store mutation is already tenant scoped. Each live monitor's + // resolver reads its own tenant store, so visiting every live monitor + // cannot apply another tenant's policy. It does avoid selecting a newly + // initialized, empty tenant monitor while alerts still live on the + // legacy/default runtime during startup and development transitions. + r.mtMonitor.ForEachMonitor(reconcile) + } + reconcile(r.monitor) + }) actionOrgChecker := NewAuthorizationChecker(NewMultiTenantOrganizationLoader(r.multiTenant)) actionAuth := actionAuthority{authorizer: r.authorizer, orgChecker: actionOrgChecker} r.resourceHandlers.SetActionAuthorizers(actionAuth, actionAuth) @@ -2466,9 +2494,14 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool { } projection := ai.ResourceOperatorStateProjection{ IntentionallyOffline: state.IntentionallyOffline, + MonitoringMode: string(state.MonitoringMode), + LifecycleState: string(state.LifecycleState), NeverAutoRemediate: state.NeverAutoRemediate, Criticality: string(state.Criticality), } + if state.LifecycleState == unifiedresources.LifecycleStateRetired { + projection.NeverAutoRemediate = true + } if state.IsInMaintenanceAt(now) { projection.MaintenanceWindow = &ai.ResourceOperatorStateMaintenanceWindow{ StartAt: *state.MaintenanceStartAt, diff --git a/internal/monitoring/monitor_alert_intent.go b/internal/monitoring/monitor_alert_intent.go index 020ed1a4b..dab9b7429 100644 --- a/internal/monitoring/monitor_alert_intent.go +++ b/internal/monitoring/monitor_alert_intent.go @@ -50,6 +50,8 @@ func (m *Monitor) installOperatorIntentResolver(store ResourceStoreInterface) { } return alerts.OperatorIntentContext{ IntentionallyOffline: state.IntentionallyOffline, + MonitoringMode: string(state.MonitoringMode), + LifecycleState: string(state.LifecycleState), MaintenanceStartAt: state.MaintenanceStartAt, MaintenanceEndAt: state.MaintenanceEndAt, MaintenanceReason: state.MaintenanceReason, diff --git a/internal/monitoring/monitor_alert_intent_test.go b/internal/monitoring/monitor_alert_intent_test.go index 080f233e2..f14395bb4 100644 --- a/internal/monitoring/monitor_alert_intent_test.go +++ b/internal/monitoring/monitor_alert_intent_test.go @@ -4,10 +4,41 @@ import ( "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) +func TestInstallOperatorIntentResolverProjectsCanonicalResourcePolicy(t *testing.T) { + store := unifiedresources.NewMemoryStore() + registry := unifiedresources.NewRegistry(store) + registry.IngestResources([]unifiedresources.Resource{{ + ID: "vm:101", Type: unifiedresources.ResourceTypeVM, Name: "database", + }}) + if err := store.SetResourceOperatorState(unifiedresources.ResourceOperatorState{ + CanonicalID: "vm:101", + MonitoringMode: unifiedresources.MonitoringModeMuted, + LifecycleState: unifiedresources.LifecycleStateRetired, + }); err != nil { + t.Fatal(err) + } + + manager := alerts.NewManagerWithDataDir(t.TempDir()) + t.Cleanup(manager.Stop) + monitor := &Monitor{alertManager: manager} + monitor.installOperatorIntentResolver(unifiedresources.NewMonitorAdapter(registry)) + preview, err := manager.PreviewIntentPolicy(alerts.AlertIntentPolicyPreviewRequest{ + ResourceID: "vm:101", ResourceType: "vm", Signal: string(alerts.AlertIntentSignalOffline), ConditionActive: true, + }) + if err != nil { + t.Fatal(err) + } + if preview.Reason != "operator_retired" || preview.Status != "expected_transient" { + t.Fatalf("canonical operator policy preview = %+v", preview) + } +} + func TestResolveBackupIntentContextRequiresFreshActiveMatchingEvidence(t *testing.T) { now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) state := models.NewState() diff --git a/internal/unifiedresources/loop_reports_store.go b/internal/unifiedresources/loop_reports_store.go index 9249bada4..134b8c636 100644 --- a/internal/unifiedresources/loop_reports_store.go +++ b/internal/unifiedresources/loop_reports_store.go @@ -14,7 +14,8 @@ import ( // state row. Order is not guaranteed. func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorState, error) { rows, err := s.db.Query(` - SELECT canonical_id, intentionally_offline, never_auto_remediate, + SELECT canonical_id, monitoring_mode, lifecycle_state, + intentionally_offline, never_auto_remediate, maintenance_start_at, maintenance_end_at, maintenance_reason, criticality, note, set_at, set_by FROM resource_operator_state`) @@ -27,6 +28,8 @@ func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorSt for rows.Next() { var ( state ResourceOperatorState + monitoringMode string + lifecycleState string intentional int neverRemediate int startAt, endAt sql.NullTime @@ -37,6 +40,8 @@ func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorSt ) if err := rows.Scan( &state.CanonicalID, + &monitoringMode, + &lifecycleState, &intentional, &neverRemediate, &startAt, @@ -49,6 +54,8 @@ func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorSt ); err != nil { return nil, fmt.Errorf("scan resource operator state row: %w", err) } + state.MonitoringMode = ResourceMonitoringMode(monitoringMode) + state.LifecycleState = ResourceLifecycleState(lifecycleState) state.IntentionallyOffline = intentional != 0 state.NeverAutoRemediate = neverRemediate != 0 if startAt.Valid { @@ -71,7 +78,7 @@ func (s *SQLiteResourceStore) ListResourceOperatorStates() ([]ResourceOperatorSt if setBy.Valid { state.SetBy = setBy.String } - out = append(out, state) + out = append(out, NormalizeResourceOperatorState(state)) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate resource operator state rows: %w", err) diff --git a/internal/unifiedresources/resource_operator_state.go b/internal/unifiedresources/resource_operator_state.go index ea890a901..a43fb51bf 100644 --- a/internal/unifiedresources/resource_operator_state.go +++ b/internal/unifiedresources/resource_operator_state.go @@ -14,6 +14,28 @@ import ( // is the default ("no operator-set criticality"), distinct from "medium". type ResourceCriticality string +// ResourceMonitoringMode is the canonical per-resource alerting posture. It +// separates expected availability from an explicit all-signal mute so callers +// do not have to overload one boolean with incompatible meanings. +type ResourceMonitoringMode string + +const ( + MonitoringModeNormal ResourceMonitoringMode = "normal" + MonitoringModeExpectedOffline ResourceMonitoringMode = "expected_offline" + MonitoringModeMuted ResourceMonitoringMode = "muted" +) + +// ResourceLifecycleState records whether a provider-owned inventory item is +// still operationally active in Pulse. Retired resources remain discoverable +// because their owning provider may continue to report them, but Pulse removes +// them from alert attention and refuses automated remediation. +type ResourceLifecycleState string + +const ( + LifecycleStateActive ResourceLifecycleState = "active" + LifecycleStateRetired ResourceLifecycleState = "retired" +) + const ( CriticalityHigh ResourceCriticality = "high" CriticalityMedium ResourceCriticality = "medium" @@ -51,6 +73,22 @@ func IsValidCriticality(value string) bool { return false } +func IsValidMonitoringMode(value string) bool { + switch ResourceMonitoringMode(value) { + case MonitoringModeNormal, MonitoringModeExpectedOffline, MonitoringModeMuted: + return true + } + return false +} + +func IsValidLifecycleState(value string) bool { + switch ResourceLifecycleState(value) { + case LifecycleStateActive, LifecycleStateRetired: + return true + } + return false +} + // ResourceOperatorState captures operator-set per-resource intent that // modulates Patrol's behavior on findings against this resource. The shape // is intentionally narrow: every field encodes a specific operator intent @@ -70,11 +108,21 @@ type ResourceOperatorState struct { // unified-resources store; the API boundary trims and rejects empty. CanonicalID string `json:"canonicalId"` - // IntentionallyOffline marks the resource as expected-to-be-offline. - // Findings of the form "resource X is offline" against this resource - // will be auto-acknowledged with reason=intentionally_offline by the - // Patrol findings store. Other finding categories (high CPU, disk - // pressure on a still-mounted volume, etc.) are unaffected. + // MonitoringMode is the canonical alert and Patrol posture. Normal keeps + // all monitoring active, expected_offline suppresses availability noise, + // and muted suppresses all alert and finding attention while preserving + // inventory visibility. + MonitoringMode ResourceMonitoringMode `json:"monitoringMode"` + + // LifecycleState is active by default. Retired is an operator-owned + // lifecycle decision for resources that remain in provider inventory but + // should no longer participate in alerting or automated remediation. + LifecycleState ResourceLifecycleState `json:"lifecycleState"` + + // IntentionallyOffline is the compatibility projection for clients that + // predate MonitoringMode. NormalizeResourceOperatorState derives it from + // MonitoringMode, and maps legacy true writes to expected_offline when the + // new field is absent. Runtime policy must consume MonitoringMode instead. IntentionallyOffline bool `json:"intentionallyOffline"` // NeverAutoRemediate forbids Patrol from dispatching automated fixes @@ -130,7 +178,9 @@ type ResourceOperatorState struct { // the persistence layer MAY keep an audit row to track that the operator // explicitly cleared the state. func (s ResourceOperatorState) IsEmpty() bool { - return !s.IntentionallyOffline && + normalized := NormalizeResourceOperatorState(s) + return normalized.MonitoringMode == MonitoringModeNormal && + normalized.LifecycleState == LifecycleStateActive && !s.NeverAutoRemediate && !s.AutoRemediationPolicy.Enabled && len(s.AutoRemediationPolicy.CapabilityNames) == 0 && @@ -142,6 +192,28 @@ func (s ResourceOperatorState) IsEmpty() bool { strings.TrimSpace(s.Note) == "" } +// SuppressesAllAttention reports whether every Alerts and Patrol signal is +// outside the resource's current operator-owned monitoring posture. +func (s ResourceOperatorState) SuppressesAllAttention() bool { + s = NormalizeResourceOperatorState(s) + return s.MonitoringMode == MonitoringModeMuted || s.LifecycleState == LifecycleStateRetired +} + +// ExpectsOffline reports whether availability loss is expected while other +// monitoring signals remain eligible. +func (s ResourceOperatorState) ExpectsOffline() bool { + s = NormalizeResourceOperatorState(s) + return s.MonitoringMode == MonitoringModeExpectedOffline || s.SuppressesAllAttention() +} + +// BlocksRemediation reports whether automated action is incompatible with the +// operator's explicit state. Retirement is a lifecycle lock even when the +// legacy NeverAutoRemediate flag was not separately set. +func (s ResourceOperatorState) BlocksRemediation() bool { + s = NormalizeResourceOperatorState(s) + return s.NeverAutoRemediate || s.LifecycleState == LifecycleStateRetired +} + // IsInMaintenanceAt reports whether `now` falls within the configured // maintenance window. Returns false when no window is configured, when only // one of start/end is set (treated as no window), or when end <= start. @@ -173,12 +245,19 @@ var ErrResourceOperatorStateInvalid = errors.New("resource_operator_state_invali // is structural only — operator-set meaning (was the maintenance window // actually intended? is the note correct?) is the operator's call. func ValidateResourceOperatorState(state ResourceOperatorState) error { + state = NormalizeResourceOperatorState(state) if strings.TrimSpace(state.CanonicalID) == "" { return fmt.Errorf("%w: canonical_id is required", ErrResourceOperatorStateInvalid) } if !IsValidCriticality(string(state.Criticality)) { return fmt.Errorf("%w: criticality %q is not one of (high, medium, low, empty)", ErrResourceOperatorStateInvalid, state.Criticality) } + if !IsValidMonitoringMode(string(state.MonitoringMode)) { + return fmt.Errorf("%w: monitoring_mode %q is not one of (normal, expected_offline, muted)", ErrResourceOperatorStateInvalid, state.MonitoringMode) + } + if !IsValidLifecycleState(string(state.LifecycleState)) { + return fmt.Errorf("%w: lifecycle_state %q is not one of (active, retired)", ErrResourceOperatorStateInvalid, state.LifecycleState) + } if err := ValidateAutoRemediationPolicy(state.AutoRemediationPolicy); err != nil { return fmt.Errorf("%w: %v", ErrResourceOperatorStateInvalid, err) } @@ -201,6 +280,19 @@ func ValidateResourceOperatorState(state ResourceOperatorState) error { // NOT validate — call ValidateResourceOperatorState afterward. func NormalizeResourceOperatorState(state ResourceOperatorState) ResourceOperatorState { state.CanonicalID = strings.TrimSpace(state.CanonicalID) + state.MonitoringMode = ResourceMonitoringMode(strings.ToLower(strings.TrimSpace(string(state.MonitoringMode)))) + if state.MonitoringMode == "" { + if state.IntentionallyOffline { + state.MonitoringMode = MonitoringModeExpectedOffline + } else { + state.MonitoringMode = MonitoringModeNormal + } + } + state.IntentionallyOffline = state.MonitoringMode == MonitoringModeExpectedOffline + state.LifecycleState = ResourceLifecycleState(strings.ToLower(strings.TrimSpace(string(state.LifecycleState)))) + if state.LifecycleState == "" { + state.LifecycleState = LifecycleStateActive + } state.MaintenanceReason = strings.TrimSpace(state.MaintenanceReason) state.Note = strings.TrimSpace(state.Note) state.SetBy = strings.TrimSpace(state.SetBy) diff --git a/internal/unifiedresources/resource_operator_state_policy_test.go b/internal/unifiedresources/resource_operator_state_policy_test.go index 4b4dbf2c8..b08412a1c 100644 --- a/internal/unifiedresources/resource_operator_state_policy_test.go +++ b/internal/unifiedresources/resource_operator_state_policy_test.go @@ -64,7 +64,9 @@ func TestAutoRemediationPolicySQLiteRoundTrip(t *testing.T) { defer store.Close() state := ResourceOperatorState{ - CanonicalID: "docker:container:web", + CanonicalID: "docker:container:web", + MonitoringMode: MonitoringModeMuted, + LifecycleState: LifecycleStateRetired, AutoRemediationPolicy: AutoRemediationPolicy{ Enabled: true, CapabilityNames: []string{"restart"}, @@ -85,6 +87,9 @@ func TestAutoRemediationPolicySQLiteRoundTrip(t *testing.T) { if !got.AutoRemediationPolicy.Enabled || len(got.AutoRemediationPolicy.CapabilityNames) != 1 || got.AutoRemediationPolicy.Window == nil || got.AutoRemediationPolicy.Window.Timezone != "Europe/London" { t.Fatalf("round-trip policy = %#v", got.AutoRemediationPolicy) } + if got.MonitoringMode != MonitoringModeMuted || got.LifecycleState != LifecycleStateRetired { + t.Fatalf("round-trip monitoring policy = %+v", got) + } } func TestSQLiteOperatorStateNullPolicyColumnNormalizesOnRead(t *testing.T) { diff --git a/internal/unifiedresources/resource_operator_state_test.go b/internal/unifiedresources/resource_operator_state_test.go index 5d1e9c422..d80ced118 100644 --- a/internal/unifiedresources/resource_operator_state_test.go +++ b/internal/unifiedresources/resource_operator_state_test.go @@ -149,6 +149,49 @@ func TestValidateResourceOperatorState(t *testing.T) { }) } +func TestResourceOperatorStateMonitoringAndLifecyclePolicy(t *testing.T) { + t.Run("legacy intentionally offline normalizes to expected offline", func(t *testing.T) { + state := NormalizeResourceOperatorState(ResourceOperatorState{IntentionallyOffline: true}) + if state.MonitoringMode != MonitoringModeExpectedOffline || !state.IntentionallyOffline { + t.Fatalf("legacy normalization = %+v", state) + } + if state.LifecycleState != LifecycleStateActive { + t.Fatalf("default lifecycle = %q, want active", state.LifecycleState) + } + }) + + t.Run("canonical mode owns compatibility projection", func(t *testing.T) { + state := NormalizeResourceOperatorState(ResourceOperatorState{ + MonitoringMode: MonitoringModeMuted, + IntentionallyOffline: true, + }) + if state.IntentionallyOffline { + t.Fatal("muted mode must not remain projected as intentionally offline") + } + if !state.SuppressesAllAttention() { + t.Fatal("muted mode must suppress all attention") + } + }) + + t.Run("retired lifecycle suppresses attention and blocks remediation", func(t *testing.T) { + state := ResourceOperatorState{LifecycleState: LifecycleStateRetired} + if !state.SuppressesAllAttention() || !state.BlocksRemediation() { + t.Fatalf("retired policy not enforced: %+v", state) + } + }) + + t.Run("invalid enum values fail closed", func(t *testing.T) { + for _, state := range []ResourceOperatorState{ + {CanonicalID: "vm:101", MonitoringMode: "sometimes"}, + {CanonicalID: "vm:101", LifecycleState: "deleted"}, + } { + if err := ValidateResourceOperatorState(state); !errors.Is(err, ErrResourceOperatorStateInvalid) { + t.Fatalf("ValidateResourceOperatorState(%+v) error = %v", state, err) + } + } + }) +} + func TestNormalizeResourceOperatorState_TrimsAndLowersCriticality(t *testing.T) { got := NormalizeResourceOperatorState(ResourceOperatorState{ CanonicalID: " vm:101 ", diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index 6d3b71d4c..1a4dcbd63 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -540,6 +540,8 @@ func (s *SQLiteResourceStore) initSchema() error { CREATE TABLE IF NOT EXISTS resource_operator_state ( canonical_id TEXT PRIMARY KEY, + monitoring_mode TEXT NOT NULL DEFAULT 'normal', + lifecycle_state TEXT NOT NULL DEFAULT 'active', intentionally_offline INTEGER NOT NULL DEFAULT 0, never_auto_remediate INTEGER NOT NULL DEFAULT 0, auto_remediation_policy_json TEXT, @@ -642,10 +644,26 @@ func (s *SQLiteResourceStore) migrateResourceOperatorStateSchema() error { if err != nil { return err } - if _, ok := columns["auto_remediation_policy_json"]; !ok { - if _, err := s.db.Exec("ALTER TABLE resource_operator_state ADD COLUMN auto_remediation_policy_json TEXT"); err != nil { - return fmt.Errorf("add resource_operator_state.auto_remediation_policy_json column: %w", err) + definitions := map[string]string{ + "monitoring_mode": "TEXT NOT NULL DEFAULT 'normal'", + "lifecycle_state": "TEXT NOT NULL DEFAULT 'active'", + "auto_remediation_policy_json": "TEXT", + } + for name, definition := range definitions { + if _, ok := columns[name]; ok { + continue } + if _, err := s.db.Exec("ALTER TABLE resource_operator_state ADD COLUMN " + name + " " + definition); err != nil { + return fmt.Errorf("add resource_operator_state.%s column: %w", name, err) + } + } + // Rows written before monitoring_mode existed used the compatibility + // boolean as their source of truth. Promote those rows once so all future + // reads and writes use the canonical enum. + if _, err := s.db.Exec(`UPDATE resource_operator_state + SET monitoring_mode = 'expected_offline' + WHERE intentionally_offline = 1 AND monitoring_mode = 'normal'`); err != nil { + return fmt.Errorf("migrate resource_operator_state monitoring mode: %w", err) } return nil } @@ -2925,7 +2943,8 @@ func getResourceOperatorStateSQL(queryer resourceOperatorStateQueryRower, canoni return ResourceOperatorState{}, false, nil } row := queryer.QueryRow(` - SELECT canonical_id, intentionally_offline, never_auto_remediate, + SELECT canonical_id, monitoring_mode, lifecycle_state, + intentionally_offline, never_auto_remediate, auto_remediation_policy_json, maintenance_start_at, maintenance_end_at, maintenance_reason, criticality, note, set_at, set_by @@ -2943,6 +2962,8 @@ func getResourceOperatorStateSQL(queryer resourceOperatorStateQueryRower, canoni func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOperatorState, error) { var state ResourceOperatorState var ( + monitoringMode string + lifecycleState string intentional int neverRemediate int autoPolicyJSON sql.NullString @@ -2954,6 +2975,8 @@ func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOp ) if err := scanner.Scan( &state.CanonicalID, + &monitoringMode, + &lifecycleState, &intentional, &neverRemediate, &autoPolicyJSON, @@ -2967,6 +2990,8 @@ func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOp ); err != nil { return ResourceOperatorState{}, err } + state.MonitoringMode = ResourceMonitoringMode(monitoringMode) + state.LifecycleState = ResourceLifecycleState(lifecycleState) state.IntentionallyOffline = intentional != 0 state.NeverAutoRemediate = neverRemediate != 0 if autoPolicyJSON.Valid && strings.TrimSpace(autoPolicyJSON.String) != "" { @@ -2998,7 +3023,7 @@ func scanResourceOperatorState(scanner resourceOperatorStateScanner) (ResourceOp if setBy.Valid { state.SetBy = setBy.String } - return state, nil + return NormalizeResourceOperatorState(state), nil } // SetResourceOperatorState upserts the state row. Validates and @@ -3069,12 +3094,15 @@ func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState } _, err := execer.Exec(` INSERT INTO resource_operator_state ( - canonical_id, intentionally_offline, never_auto_remediate, + canonical_id, monitoring_mode, lifecycle_state, + intentionally_offline, never_auto_remediate, auto_remediation_policy_json, maintenance_start_at, maintenance_end_at, maintenance_reason, criticality, note, set_at, set_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(canonical_id) DO UPDATE SET + monitoring_mode = excluded.monitoring_mode, + lifecycle_state = excluded.lifecycle_state, intentionally_offline = excluded.intentionally_offline, never_auto_remediate = excluded.never_auto_remediate, auto_remediation_policy_json = excluded.auto_remediation_policy_json, @@ -3086,6 +3114,8 @@ func setResourceOperatorStateSQL(execer sqlExecutor, state ResourceOperatorState set_at = excluded.set_at, set_by = excluded.set_by`, state.CanonicalID, + state.MonitoringMode, + state.LifecycleState, intentional, neverRemediate, autoPolicyJSON,