mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Join Docker alert events with canonical resource history
Keep alert and inventory evidence together after container removal and restart without rewriting retained events or transferring approval and operator authority. Resolve exact source identities in the shared store and preserve event replay idempotency across old and current records. Verify actual retained homelab events, registered tool reads, lifecycle callbacks, tenant isolation, race behaviour and responsive evidence views.
This commit is contained in:
@@ -1886,3 +1886,52 @@ The provider warning stayed visible and no retry, route switch or provider
|
||||
request was made. This is captured-response rendering, not real-model diagnosis
|
||||
or server persistence qualification. The exact scoped worker hook gates delivery
|
||||
through PR #1935.
|
||||
|
||||
|
||||
## Shared Docker history identity, 2026-09-06
|
||||
|
||||
The preceding incident-read correction was pushed as
|
||||
`580a246981c76b401e9007f5ac65c89355b64c6d` in PR #1935. Its live retained
|
||||
records established the identity split addressed here.
|
||||
|
||||
The canonical fix belongs to the shared monitor/store boundary. Exact full
|
||||
Docker container references resolve through current registry identity. Retained
|
||||
bindings survive inventory removal, and deterministic source-specific identities
|
||||
allow legacy records to be found after restart. Names and abbreviated IDs are
|
||||
not sufficient evidence. A small organization-scoped history alias index joins
|
||||
readable records without rewriting event IDs, timestamps or metadata. Existing
|
||||
canonical succession machinery was deliberately not used for these aliases
|
||||
because it also moves operator state and action indexes. History matching must
|
||||
not transfer authority. The alias index follows journal retention and separate
|
||||
store connections read fresh bindings.
|
||||
|
||||
Focused regression and race proofs passed on pulse-dev with Go1.26.8 and
|
||||
GOMAXPROCS4. Full unifiedresources, monitoring and tools packages passed in
|
||||
37.826s, 79.866s and 59.584s. They cover real alert-manager callbacks, recovery
|
||||
after inventory removal, restart, replay, same-name controls, tenant isolation,
|
||||
unchanged operator/approval records and registered Assistant tool reads. Scoped
|
||||
history lookup measured 0.261–0.275ms with one alias and 0.317–0.336ms with
|
||||
20,000 unrelated aliases, at 6,280 bytes and 94 allocations per read. These are
|
||||
worker microbenchmarks, not fleet or frontend performance qualification.
|
||||
|
||||
The verified worker Pro binary has SHA256
|
||||
`bb6d1508a5b4d23943c37dfc42198f132c0139805dcd1891ee18aca0a9f9dd54`.
|
||||
It was installed into the local development stack and restarted healthy. Both
|
||||
canonical and legacy timeline API queries now return the same seven retained
|
||||
records for the removed storage fixture, including the original fired/resolved
|
||||
records with exact unchanged content. The complete registered-tool rendering
|
||||
matrix passed at `/patrol`, 1440x1000, 900x1000 and 390x1000. Six captured cases
|
||||
include migrated history, bounded and empty results, and unavailable/failed
|
||||
reads. Hover/focus, Enter/Space, deepest scrolling, Escape and controlled-session
|
||||
reload preserved exact inputs, outputs and completed/failed states. Pixels were
|
||||
inspected at all three widths. Source and binary hashes remained fixed. Private
|
||||
receipts are `tmp/patrol-history-identity/browser/receipt.json` and
|
||||
`live-history-proof.json`. Controlled session responses qualify rendering, not
|
||||
server persistence or model diagnosis. The cached provider refusal remained
|
||||
unchanged. No model request or infrastructure fault was made. The exact scoped
|
||||
worker hook remains the delivery gate for PR #1935.
|
||||
|
||||
This history correction does not qualify the failed ordinary storage diagnosis,
|
||||
partial recovery claim, installed tmpfs collector, autonomous provider, approved
|
||||
and rejected action outcomes, or independent Pro environments. The unused legacy
|
||||
recorder/coordinator still needs retirement with its archives preserved.
|
||||
|
||||
@@ -10201,7 +10201,7 @@
|
||||
},
|
||||
{
|
||||
"id": "patrol-assistant-customer-outcome-qualification",
|
||||
"summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The exact scoped worker hook gates delivery through PR1935. Live reads of the removed storage fixture confirm that health alert fired/resolved records remain under the legacy Docker ID while creation/removal use the canonical app-container ID. This is partial incident-history remediation. The shared timeline writer and retained identity/history contract must repair that split, including removed resources, without an Assistant-local ID rewrite. The unused legacy recorder/coordinator startup needs retirement while preserving saved archives. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain enforced. Separate paid-provider approval is pending, with no paid request or policy bypass. Ordinary Assistant is not autonomous qualification. Required local work still includes reliable interpretation, config-read/model retest, storage/backup and approved/rejected action outcomes. Independent volunteered Pro environments remain a separate wider-readiness gate.",
|
||||
"summary": "The redesign goal remains open, with its executable plan and detailed receipts in docs/qualification/PATROL_ASSISTANT_CUSTOMER_JOURNEY.md. Patrol owns investigation and Assistant continues the same issue. Observations, hypotheses, accepted proposals, execution and independently verified outcomes remain distinct. The recorded baseline contains 127 paid installations, 71 Patrol-enabled, 23 with Assistant calls and fourteen verified resolutions from one installation. Schema17 outcome/provider/cost fields had no adoption. These do not establish representative success, false-alarm or missed-problem rates. Shared provenance/history/risk fixes and removal of proposal-as-proof and proxy completion policy landed through PR1928/1929. PR1934 merged canonical tool/transcript identity. PR1935 contains measurement-presence, capacity, canonical config-read, command-connectivity and tmpfs collection/query corrections through 6e18777d30f30b498def39d30016a697cabc4ea7. Exact worker hooks and named browser matrices passed, with remote landing pending. Real ordinary storage diagnosis failed by ruling out pressure without filesystem capacity evidence. Same-session recovery correctly identified present health and alert resolution but overstated continuous control health. Independent fault-intact, recovery, two-pass cleanup and persisted transcript checks passed. The current incident history correction replaces the primary legacy recorder read with the existing organization-pinned canonical resource timeline, preserving source/time semantics, bounded history, empty coverage and failed-read distinctions. Explicit legacy archive reads remain resource-bound. Its registered-tool SQLite, full tools package, focused race and final-source browser proofs pass. The primary history read was pushed as 580a246 in PR1935. The shared monitor/store identity correction now joins exact full Docker references with canonical container history through an organization-scoped history-only alias index. Original event content and approval/operator authority remain unchanged. Callback, removal/restart, replay, tenant/control and registered-tool tests, full affected packages, race checks and scoped lookup benchmarks passed. Final worker-build API proof returns the same seven retained homelab records under canonical and legacy queries, preserving the original fired/resolved events exactly. Six-case Playwright proof at desktop/intermediate/mobile widths passed. The exact scoped worker hook gates this delivery through PR1935. The unused legacy recorder/coordinator startup needs retirement while preserving saved archives. Installed tmpfs collector and real-model interpretation remain unqualified. Other named residuals include unsupported filters, typed compatibility canonical-ID lookup, legacy direction availability, Docker-host history and responsive mount details. Claude Max explicitly refused autonomous Patrol readiness. Cached refusal and API409 enforcement remain enforced. Separate paid-provider approval is pending, with no paid request or policy bypass. Ordinary Assistant is not autonomous qualification. Required local work still includes reliable interpretation, config-read/model retest, storage/backup and approved/rejected action outcomes. Independent volunteered Pro environments remain a separate wider-readiness gate.",
|
||||
"owner": "project-owner",
|
||||
"status": "planned",
|
||||
"recorded_at": "2026-09-05",
|
||||
|
||||
@@ -87,6 +87,15 @@ existing cache policy. The legacy untyped-error fallback is unchanged.
|
||||
Retaining cached inventory does not establish a successful poll or fresh
|
||||
backup evidence.
|
||||
|
||||
Docker alert lifecycle events pass through the shared resource history identity
|
||||
writer. A full Docker source reference must reach the same canonical container
|
||||
history as inventory changes, including recovery after inventory removal and
|
||||
restart. Existing alert lifecycle event IDs remain unchanged so replay cannot
|
||||
duplicate retained events. Same-name containers and abbreviated IDs must not
|
||||
join another container's history. The real alert-manager callback path is
|
||||
covered by `TestDockerAlertTimelineUsesCanonicalHistoryIdentity` in
|
||||
`internal/monitoring/monitor_alert_handling_test.go`.
|
||||
|
||||
Verification: `TestPollPBSBackups_PreservesCacheOnTransientDatastoreError` and
|
||||
`TestPollPBSBackups_DropsStaleCacheOnTerminalDatastoreError` in
|
||||
`internal/monitoring/monitor_backups_readstate_test.go` exercise actual HTTP
|
||||
|
||||
@@ -668,14 +668,14 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/patrol_scope_tools.go",
|
||||
"rationale": "the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary",
|
||||
"subsystems": [
|
||||
"ai-runtime",
|
||||
"api-contracts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/patrol_scope_tools.go",
|
||||
"rationale": "the typed Patrol resource-scope evidence mapping is both the AI runtime least-manifest projection contract and the canonical API/agent vocabulary that keeps scoped Watch and investigation tool reductions aligned across the Pulse and enterprise boundary",
|
||||
"subsystems": [
|
||||
"ai-runtime",
|
||||
"api-contracts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "internal/agentcapabilities/projection.go",
|
||||
"rationale": "the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases",
|
||||
"subsystems": [
|
||||
@@ -1458,8 +1458,8 @@
|
||||
"internal/hostagent/action_runner_client_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agent-privilege-helper-runtime",
|
||||
{
|
||||
"id": "agent-privilege-helper-runtime",
|
||||
"label": "typed no-network agent privilege helper proof",
|
||||
"match_prefixes": [
|
||||
"internal/agenthelper/"
|
||||
@@ -1471,11 +1471,11 @@
|
||||
"test_prefixes": [
|
||||
"internal/agenthelper/"
|
||||
],
|
||||
"exact_files": [
|
||||
"cmd/pulse-agent-helper/main_test.go",
|
||||
"internal/agenthelper/container_inventory_test.go",
|
||||
"internal/agenthelper/update_activation_test.go"
|
||||
]
|
||||
"exact_files": [
|
||||
"cmd/pulse-agent-helper/main_test.go",
|
||||
"internal/agenthelper/container_inventory_test.go",
|
||||
"internal/agenthelper/update_activation_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "native-pve-action-qualification",
|
||||
@@ -1736,9 +1736,9 @@
|
||||
"internal/hostagent/docker_lifecycle_test.go",
|
||||
"internal/hostagent/issue1595_sas_collection_test.go",
|
||||
"internal/hostagent/observer_delivery_test.go",
|
||||
"internal/hostagent/package_updates_test.go",
|
||||
"internal/hostagent/privilege_helper_client_test.go",
|
||||
"internal/hostagent/send_report_test.go",
|
||||
"internal/hostagent/package_updates_test.go",
|
||||
"internal/hostagent/privilege_helper_client_test.go",
|
||||
"internal/hostagent/send_report_test.go",
|
||||
"internal/hostagent/smartctl_standby_guard_test.go",
|
||||
"internal/hostagent/storage_cleanup_test.go",
|
||||
"internal/hostagent/unraid_test.go",
|
||||
@@ -1754,11 +1754,11 @@
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"scripts/installtests/agent_state_dir_lifecycle_test.go",
|
||||
"scripts/installtests/install_sh_test.go",
|
||||
"scripts/installtests/safe_profile_migration_test.go"
|
||||
]
|
||||
"exact_files": [
|
||||
"scripts/installtests/agent_state_dir_lifecycle_test.go",
|
||||
"scripts/installtests/install_sh_test.go",
|
||||
"scripts/installtests/safe_profile_migration_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "windows-agent-installer-runtime",
|
||||
@@ -3220,10 +3220,10 @@
|
||||
"test_prefixes": [
|
||||
"frontend-modern/src/api/__tests__/"
|
||||
],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/types/api.ts",
|
||||
"internal/api/action_runner_credentials_test.go",
|
||||
"internal/api/ai_handlers_more_test.go",
|
||||
"exact_files": [
|
||||
"frontend-modern/src/types/api.ts",
|
||||
"internal/api/action_runner_credentials_test.go",
|
||||
"internal/api/ai_handlers_more_test.go",
|
||||
"internal/api/ai_handlers_patrol_actions_additional_test.go",
|
||||
"internal/api/alerting/external_probe_notifications_test.go",
|
||||
"internal/api/audit_handlers_test.go",
|
||||
@@ -4803,14 +4803,14 @@
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"pulse-enterprise:scripts/validate-pro-release-line_test.sh",
|
||||
"scripts/installtests/backfill_release_assets_test.go",
|
||||
"scripts/installtests/build_release_assets_test.go",
|
||||
"scripts/installtests/release_ldflags_test.go",
|
||||
"scripts/installtests/safe_profile_migration_test.go",
|
||||
"scripts/release_control/secure_runtime_attestation_v6_test.py",
|
||||
"scripts/release_control/secure_runtime_attestation_v7_test.py"
|
||||
"exact_files": [
|
||||
"pulse-enterprise:scripts/validate-pro-release-line_test.sh",
|
||||
"scripts/installtests/backfill_release_assets_test.go",
|
||||
"scripts/installtests/build_release_assets_test.go",
|
||||
"scripts/installtests/release_ldflags_test.go",
|
||||
"scripts/installtests/safe_profile_migration_test.go",
|
||||
"scripts/release_control/secure_runtime_attestation_v6_test.py",
|
||||
"scripts/release_control/secure_runtime_attestation_v7_test.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -5056,8 +5056,8 @@
|
||||
"tests/integration/tests/16-dev-runtime-recovery.spec.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "shell-installer-runtime",
|
||||
{
|
||||
"id": "shell-installer-runtime",
|
||||
"label": "shell installer runtime proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
@@ -5065,11 +5065,11 @@
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"scripts/installtests/agent_state_dir_lifecycle_test.go",
|
||||
"scripts/installtests/install_sh_test.go",
|
||||
"scripts/installtests/safe_profile_migration_test.go"
|
||||
]
|
||||
"exact_files": [
|
||||
"scripts/installtests/agent_state_dir_lifecycle_test.go",
|
||||
"scripts/installtests/install_sh_test.go",
|
||||
"scripts/installtests/safe_profile_migration_test.go"
|
||||
]
|
||||
}
|
||||
],
|
||||
"match_files": null
|
||||
@@ -5461,21 +5461,21 @@
|
||||
"frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "compact-info-card-consumers",
|
||||
"label": "compact information card consumer proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "workload-presentation-helpers",
|
||||
{
|
||||
"id": "compact-info-card-consumers",
|
||||
"label": "compact information card consumer proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "workload-presentation-helpers",
|
||||
"label": "workload presentation helper proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
@@ -6485,11 +6485,11 @@
|
||||
"frontend-modern/src/components/Settings/useRBACFeatureGateState.ts",
|
||||
"frontend-modern/src/components/Settings/useRolesPanelState.ts",
|
||||
"frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts",
|
||||
"frontend-modern/src/types/rbac.ts",
|
||||
"frontend-modern/src/types/rbac.ts",
|
||||
"frontend-modern/src/utils/organizationRolePresentation.ts",
|
||||
"frontend-modern/src/utils/organizationSettingsPresentation.ts",
|
||||
"frontend-modern/src/utils/orgUtils.ts",
|
||||
"frontend-modern/src/utils/rbacPresentation.ts",
|
||||
"frontend-modern/src/utils/rbacPresentation.ts",
|
||||
"internal/api/access_control_handlers.go",
|
||||
"internal/api/enterprise_extension_rbac_admin.go",
|
||||
"internal/api/org_handlers.go",
|
||||
@@ -6514,8 +6514,8 @@
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"frontend-modern/src/api/orgs.ts",
|
||||
"frontend-modern/src/api/rbac.ts",
|
||||
"frontend-modern/src/types/rbac.ts"
|
||||
"frontend-modern/src/api/rbac.ts",
|
||||
"frontend-modern/src/types/rbac.ts"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
@@ -6604,8 +6604,8 @@
|
||||
"frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts",
|
||||
"frontend-modern/src/utils/organizationRolePresentation.ts",
|
||||
"frontend-modern/src/utils/organizationSettingsPresentation.ts",
|
||||
"frontend-modern/src/utils/orgUtils.ts",
|
||||
"frontend-modern/src/utils/rbacPresentation.ts"
|
||||
"frontend-modern/src/utils/orgUtils.ts",
|
||||
"frontend-modern/src/utils/rbacPresentation.ts"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
@@ -6619,8 +6619,8 @@
|
||||
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/organizationRolePresentation.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/organizationSettingsPresentation.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/orgUtils.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/rbacPresentation.test.ts"
|
||||
"frontend-modern/src/utils/__tests__/orgUtils.test.ts",
|
||||
"frontend-modern/src/utils/__tests__/rbacPresentation.test.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -7411,7 +7411,7 @@
|
||||
"pkg/audit/sqlite_logger.go",
|
||||
"pkg/auth/agent_credentials.go",
|
||||
"pkg/auth/rbac.go",
|
||||
"pkg/auth/rbac_manager.go",
|
||||
"pkg/auth/rbac_manager.go",
|
||||
"pkg/auth/sqlite_manager.go",
|
||||
"pkg/extensions/audit_admin.go",
|
||||
"pkg/server/server.go",
|
||||
@@ -7682,7 +7682,7 @@
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"pkg/auth/rbac.go",
|
||||
"pkg/auth/rbac_manager.go",
|
||||
"pkg/auth/rbac_manager.go",
|
||||
"pkg/auth/sqlite_manager.go",
|
||||
"pkg/server/server.go"
|
||||
],
|
||||
@@ -7693,7 +7693,7 @@
|
||||
"internal/api/rbac_tenant_provider_test.go",
|
||||
"internal/api/security_regression_test.go",
|
||||
"pkg/auth/rbac_manager_test.go",
|
||||
"pkg/auth/sqlite_manager_queryplan_test.go",
|
||||
"pkg/auth/sqlite_manager_queryplan_test.go",
|
||||
"pkg/auth/sqlite_manager_test.go",
|
||||
"pkg/server/server_test.go"
|
||||
]
|
||||
@@ -8086,6 +8086,7 @@
|
||||
"internal/unifiedresources/adapter_coverage_test.go",
|
||||
"internal/unifiedresources/adapters_test.go",
|
||||
"internal/unifiedresources/ceph_pool_health_contract_test.go",
|
||||
"internal/unifiedresources/history_identity_test.go",
|
||||
"internal/unifiedresources/host_storage_cleanup_test.go",
|
||||
"internal/unifiedresources/monitor_adapter_read_state_test.go",
|
||||
"internal/unifiedresources/views_test.go"
|
||||
@@ -8483,6 +8484,7 @@
|
||||
"exact_files": [
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/unifiedresources/availability_link_test.go",
|
||||
"internal/unifiedresources/history_identity_test.go",
|
||||
"internal/unifiedresources/kubernetes_registry_test.go",
|
||||
"internal/unifiedresources/pbs_pmg_registry_test.go",
|
||||
"internal/unifiedresources/registry_merge_policy_test.go",
|
||||
@@ -8520,6 +8522,19 @@
|
||||
"internal/unifiedresources/action_policy_provenance_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resource-history-identity",
|
||||
"label": "resource history identity and authority isolation proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"internal/unifiedresources/history_identity.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"internal/unifiedresources/history_identity_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unified-resource-runtime-support",
|
||||
"label": "unified resource runtime support proof",
|
||||
|
||||
@@ -4812,6 +4812,17 @@ recent-change slice plus facet counts it actually renders. The store now also
|
||||
owns a `resource_changes` persistence table with `RecordChange` and
|
||||
`GetRecentChanges` methods so change history is queryable by canonical ID and
|
||||
time window.
|
||||
Docker alert source references containing an exact full container ID resolve at
|
||||
`MonitorAdapter.RecordChange` through the current registry, then a retained
|
||||
history binding, then the deterministic source-specific container identity.
|
||||
Names and shortened IDs cannot establish this binding. `history_identity.go`
|
||||
owns a history-only alias index in the organization-scoped resource store.
|
||||
Legacy event rows retain their IDs, resource references and timestamps. Reads
|
||||
expand aliases and canonical predecessor eras without changing operator state,
|
||||
action requests, approvals, links or exclusions. Separate monitor, API and
|
||||
Assistant store handles must see current persisted aliases. Missing identity
|
||||
storage is an error, not evidence of empty history. Retention removes an alias
|
||||
only after neither identity has retained journal records.
|
||||
That same shared timeline vocabulary now includes the `activity` change kind
|
||||
for provider-read breadcrumbs such as VMware tasks and events, plus the
|
||||
`vmware_adapter` source-adapter token for canonical provenance drill-down.
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "6e18777d30f30b498def39d30016a697cabc4ea7",
|
||||
"verified_at": "2026-09-06T14:26:55.462Z",
|
||||
"base_sha": "580a246981c76b401e9007f5ac65c89355b64c6d",
|
||||
"verified_at": "2026-09-06T15:06:56.468Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [],
|
||||
"content_sha256": {},
|
||||
"backend_content_sha256": {
|
||||
"internal/unifiedresources/history_identity.go": "3934ac49acce29b16dd671769719112208be053a8658c678f798645a6a38395d",
|
||||
"internal/unifiedresources/monitor_adapter.go": "5b51089bac8e85e7c776956c17471599ec15add4e5e8502cd8687c4f788fcdb1",
|
||||
"internal/unifiedresources/store.go": "018176a4762b06adf52c78128f8351ca93819cbfa10bf2242535fef3d7c59191",
|
||||
"internal/ai/tools/tools_knowledge.go": "d98ea1bab481acce5eee4988d74d364dc73c4068ca33725d843b54117cc9cc85",
|
||||
"internal/ai/tools/executor.go": "c476d6aec1f7925467f1547f0219e6c6265639a62b397649adc82a8ed87b7e4a"
|
||||
},
|
||||
"binary_sha256": "4929aeb869db54122bc5525352d3126c0e9fa7c4847e3fdfa741500600b5e00d",
|
||||
"binary_sha256": "bb6d1508a5b4d23943c37dfc42198f132c0139805dcd1891ee18aca0a9f9dd54",
|
||||
"rendering_content_sha256": {
|
||||
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c",
|
||||
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371",
|
||||
@@ -33,13 +36,13 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Five captured registered-tool results: retained canonical lifecycle, bounded/truncated history, empty retained history, unavailable store, and failed store read.",
|
||||
"Observation and optional occurrence timestamps, source metadata, empty arrays, coverage limitations and failed tool status survive rendering.",
|
||||
"Existing provider route warning remains visible. No retry, route switch, model request or infrastructure mutation was performed."
|
||||
"Six captured registered-tool results: migrated legacy Docker fired event plus canonical recovery, retained lifecycle, bounded history, empty history, unavailable store and failed read.",
|
||||
"Exact event IDs, original resource references, observation/occurrence times and source metadata survive expansion. Empty coverage and failed tool states remain distinct.",
|
||||
"Actual removed homelab container canonical and legacy timeline APIs return the same seven records, preserving both previously captured fired/resolved records exactly. Provider refusal remains enforced."
|
||||
],
|
||||
"interactions": [
|
||||
"Hover/focus and Enter expansion, exact input/output comparison, deepest output scroll and pixel inspection at desktop/intermediate/mobile widths, Space collapse and Escape.",
|
||||
"Full reload, controlled session selection, reopen each result and compare exact retained input/output and completed/failed state.",
|
||||
"Final verified Pro build installed locally, healthy restart, fixed source/binary hashes before and after. Private receipt: tmp/patrol-canonical-history/browser/receipt.json. Captured response/session fixtures qualify rendering, not real-model diagnosis or backend persistence."
|
||||
"Hover/focus, Enter expansion, exact input/output comparison, deepest scrolling, Space collapse and Escape at /patrol, 1440x1000, 900x1000 and 390x1000. Actual pixels inspected at all three widths.",
|
||||
"Full reload, controlled session selection, reopening all six results and exact retained input/output and completed/failed comparison.",
|
||||
"Final verified worker Pro build installed locally and restarted healthy. Source/binary hashes fixed before and after. Private proof: tmp/patrol-history-identity/browser/receipt.json and live-history-proof.json. Captured tool/session rendering does not qualify real-model diagnosis or server persistence. No provider call or fault injection."
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,38 @@ import (
|
||||
|
||||
type failedIncidentHistoryStore struct{ unifiedresources.ResourceStore }
|
||||
|
||||
func TestIncidentHistoryRetainsLegacyDockerLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := unifiedresources.NewSQLiteResourceStore(dir, "incident-test")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, store.Close()) })
|
||||
container := strings.Repeat("b", 64)
|
||||
legacy := "docker:tower/" + container
|
||||
canonical := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, "tower/container/"+container)
|
||||
start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
|
||||
occurred := start.Add(-time.Minute)
|
||||
fired := unifiedresources.ResourceChange{ID: "legacy-fired", ResourceID: legacy, ObservedAt: start.Add(time.Minute), OccurredAt: &occurred, Kind: unifiedresources.ChangeAlertFired, SourceType: unifiedresources.SourceHeuristic, Reason: "Container unhealthy"}
|
||||
require.NoError(t, store.RecordChange(fired))
|
||||
require.NoError(t, store.Close())
|
||||
store, err = unifiedresources.NewSQLiteResourceStore(dir, "incident-test")
|
||||
require.NoError(t, err)
|
||||
resolved := unifiedresources.ResourceChange{ID: "canonical-resolved", ResourceID: canonical, ObservedAt: start.Add(3 * time.Minute), Kind: unifiedresources.ChangeAlertResolved, SourceType: unifiedresources.SourceHeuristic}
|
||||
require.NoError(t, store.RecordChange(resolved))
|
||||
exec := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: store})
|
||||
input := map[string]interface{}{"action": "incidents", "resource_id": canonical, "since": start.Format(time.RFC3339), "limit": float64(50)}
|
||||
result, err := exec.registry.Execute(context.Background(), exec, agentcapabilities.PulseKnowledgeToolName, input)
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.IsError, result.Content)
|
||||
var got struct {
|
||||
Events []unifiedresources.ResourceChange `json:"events"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &got))
|
||||
require.Equal(t, []unifiedresources.ResourceChange{resolved, fired}, got.Events)
|
||||
capture, err := json.Marshal(map[string]any{"case": "migrated Docker lifecycle", "input": input, "result": result})
|
||||
require.NoError(t, err)
|
||||
t.Logf("INCIDENT_EVIDENCE %s", capture)
|
||||
}
|
||||
|
||||
func (failedIncidentHistoryStore) GetRecentChanges(string, time.Time, int) ([]unifiedresources.ResourceChange, error) {
|
||||
return nil, errors.New("history store unavailable")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,8 +17,78 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
||||
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDockerAlertTimelineUsesCanonicalHistoryIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := unifiedresources.NewSQLiteResourceStore(dir, "default")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, store.Close()) })
|
||||
manager := alerts.NewManagerWithDataDir(t.TempDir())
|
||||
t.Cleanup(manager.Stop)
|
||||
config := manager.GetConfig()
|
||||
config.Enabled = true
|
||||
config.ActivationState = alerts.ActivationPending
|
||||
config.TimeThresholds = map[string]int{}
|
||||
config.SuppressionWindow = 0
|
||||
manager.UpdateConfig(config)
|
||||
containerID := strings.Repeat("f", 64)
|
||||
host := models.DockerHost{ID: "history-host", Hostname: "history-host", LastSeen: time.Now(), Containers: []models.DockerContainer{
|
||||
{ID: containerID, Name: "worker", State: "running", Health: "unhealthy"},
|
||||
{ID: strings.Repeat("a", 64), Name: "worker", State: "running", Health: "healthy"},
|
||||
}}
|
||||
registry := unifiedresources.NewRegistry(store)
|
||||
registry.IngestSnapshot(models.StateSnapshot{DockerHosts: []models.DockerHost{host}})
|
||||
adapter := unifiedresources.NewMonitorAdapter(registry)
|
||||
monitor := &Monitor{alertManager: manager, resourceStore: adapter}
|
||||
manager.SubscribeLifecycleCallback(monitor.handleAlertLifecycleEvent)
|
||||
manager.CheckDockerHost(host)
|
||||
canonicalID := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, host.ID+"/container/"+containerID)
|
||||
filters := unifiedresources.ResourceChangeFilters{Kinds: []unifiedresources.ChangeKind{unifiedresources.ChangeAlertFired, unifiedresources.ChangeAlertResolved}}
|
||||
changes, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 1)
|
||||
require.Equal(t, unifiedresources.ChangeAlertFired, changes[0].Kind)
|
||||
require.Equal(t, canonicalID, changes[0].ResourceID)
|
||||
var fired alerts.Alert
|
||||
for _, alert := range manager.GetActiveAlerts() {
|
||||
if alert.Type == "docker-container-health" {
|
||||
fired = alert
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, fired.ID)
|
||||
// Recovery is emitted after the monitored container has left the registry.
|
||||
// Its exact retained source binding must still select the original resource.
|
||||
monitor.resourceStore = unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(store))
|
||||
host.Containers[0].Health = "healthy"
|
||||
manager.CheckDockerHost(host)
|
||||
changes, err = store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, changes, 2)
|
||||
require.Equal(t, unifiedresources.ChangeAlertResolved, changes[0].Kind)
|
||||
require.Equal(t, canonicalID, changes[0].ResourceID)
|
||||
monitor.recordAlertTimelineChange(&fired, unifiedresources.ChangeAlertFired, fired.StartTime, "")
|
||||
monitor.recordAlertTimelineChange(&fired, unifiedresources.ChangeAlertResolved, *changes[0].OccurredAt, "")
|
||||
again, err := store.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, changes, again)
|
||||
controlID := unifiedresources.SourceSpecificID(unifiedresources.ResourceTypeAppContainer, unifiedresources.SourceDocker, host.ID+"/container/"+host.Containers[1].ID)
|
||||
control, err := store.GetRecentChangesFiltered(controlID, time.Time{}, 10, filters)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, control)
|
||||
require.NoError(t, store.Close())
|
||||
restarted, err := unifiedresources.NewSQLiteResourceStore(dir, "default")
|
||||
require.NoError(t, err)
|
||||
defer restarted.Close()
|
||||
afterRestart, err := restarted.GetRecentChangesFiltered(canonicalID, time.Time{}, 10, filters)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, changes, afterRestart)
|
||||
encoded, err := json.Marshal(afterRestart)
|
||||
require.NoError(t, err)
|
||||
t.Logf("DOCKER_HISTORY_LIFECYCLE %s", encoded)
|
||||
}
|
||||
|
||||
func TestMonitor_HandleAlertFired_Extra(t *testing.T) {
|
||||
// 1. Alert is nil
|
||||
m1 := &Monitor{}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// legacyDockerHistoryIdentity accepts the source identifier emitted by Docker
|
||||
// alerts only when it contains a complete container ID. Names and short IDs
|
||||
// cannot establish durable identity after inventory removal.
|
||||
func legacyDockerHistoryIdentity(ref string) (sourceID, canonicalID string, ok bool) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if !strings.HasPrefix(ref, "docker:") {
|
||||
return "", "", false
|
||||
}
|
||||
host, container, found := strings.Cut(strings.TrimPrefix(ref, "docker:"), "/")
|
||||
if !found || host == "" || strings.TrimSpace(host) != host || len(container) != 64 || strings.ToLower(container) != container {
|
||||
return "", "", false
|
||||
}
|
||||
if _, err := hex.DecodeString(container); err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
sourceID = host + "/container/" + container
|
||||
if host == "container" { // DockerResourceID's explicit hostless form.
|
||||
sourceID = container
|
||||
}
|
||||
return sourceID, SourceSpecificID(ResourceTypeAppContainer, SourceDocker, sourceID), true
|
||||
}
|
||||
|
||||
// resourceHistoryIdentityWriter binds a source reference to the canonical
|
||||
// resource of one event. It affects history lookup only, never operator state,
|
||||
// action requests, approvals or execution identities.
|
||||
type resourceHistoryIdentityWriter interface {
|
||||
RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error
|
||||
ResolveHistorySourceIdentity(sourceID string) (string, bool, error)
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) ResolveHistorySourceIdentity(sourceID string) (string, bool, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(`SELECT canonical_id FROM resource_history_aliases WHERE source_id = ?`, sourceID).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
return id, err == nil, err
|
||||
}
|
||||
|
||||
func (m *MemoryStore) ResolveHistorySourceIdentity(sourceID string) (string, bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
id, ok := m.historyAliases[sourceID]
|
||||
return id, ok, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error {
|
||||
sourceID = CanonicalResourceID(sourceID)
|
||||
canonicalID := CanonicalResourceID(change.ResourceID)
|
||||
if sourceID == "" || canonicalID == "" || sourceID == canonicalID {
|
||||
return s.RecordChange(change)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin resource history identity: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Registry resolution is authoritative when available. Rebinding a source
|
||||
// reference affects subsequent history reads without rewriting past events.
|
||||
if _, err := tx.Exec(`INSERT INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?)
|
||||
ON CONFLICT(source_id) DO UPDATE SET canonical_id = excluded.canonical_id`, sourceID, canonicalID); err != nil {
|
||||
return fmt.Errorf("record resource history identity: %w", err)
|
||||
}
|
||||
if err := recordChangeSQL(tx, change, s.resourceChangesHasTimestamp); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (m *MemoryStore) RecordChangeWithSourceIdentity(change ResourceChange, sourceID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.historyAliases == nil {
|
||||
m.historyAliases = make(map[string]string)
|
||||
}
|
||||
if sourceID = CanonicalResourceID(sourceID); sourceID != "" && sourceID != change.ResourceID {
|
||||
m.historyAliases[sourceID] = change.ResourceID
|
||||
}
|
||||
return m.recordChangeLocked(change)
|
||||
}
|
||||
|
||||
// migrateResourceHistoryAliases adds an index for exact legacy Docker event
|
||||
// identities, including resources already removed from live inventory. The
|
||||
// event rows and all authority-bearing tables remain unchanged.
|
||||
func (s *SQLiteResourceStore) migrateResourceHistoryAliases() error {
|
||||
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS resource_history_aliases (
|
||||
source_id TEXT PRIMARY KEY, canonical_id TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_history_aliases_canonical ON resource_history_aliases(canonical_id);`); err != nil {
|
||||
return fmt.Errorf("initialize resource history identities: %w", err)
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT DISTINCT canonical_id FROM resource_changes WHERE canonical_id GLOB 'docker:*'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read legacy resource history identities: %w", err)
|
||||
}
|
||||
aliases := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var sourceID string
|
||||
if err := rows.Scan(&sourceID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if _, canonicalID, ok := legacyDockerHistoryIdentity(sourceID); ok {
|
||||
aliases[sourceID] = canonicalID
|
||||
}
|
||||
}
|
||||
readErr := rows.Err()
|
||||
rows.Close() // The store has one connection. Release it before writing.
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for sourceID, canonicalID := range aliases {
|
||||
if _, err := s.db.Exec(`INSERT OR IGNORE INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?)`, sourceID, canonicalID); err != nil {
|
||||
return fmt.Errorf("index legacy resource history identity: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandHistoryAliases reads persisted identity bindings each time so separate
|
||||
// monitor, API and Assistant store handles see new bindings immediately. The
|
||||
// indexed traversal is restricted to the requested identities, not the fleet.
|
||||
func (s *SQLiteResourceStore) expandHistoryAliases(ids []string) ([]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return ids, nil
|
||||
}
|
||||
seeds := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
seeds[i], args[i] = "(?)", id
|
||||
}
|
||||
rows, err := s.db.Query(`WITH RECURSIVE history_ids(id) AS (
|
||||
VALUES `+strings.Join(seeds, ",")+`
|
||||
UNION SELECT a.canonical_id FROM resource_history_aliases a JOIN history_ids h ON a.source_id = h.id
|
||||
UNION SELECT a.source_id FROM resource_history_aliases a JOIN history_ids h ON a.canonical_id = h.id
|
||||
UNION SELECT s.old_canonical_id FROM canonical_id_successions s JOIN history_ids h ON s.new_canonical_id = h.id
|
||||
) SELECT id FROM history_ids`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read resource history identities: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var expanded []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expanded = append(expanded, id)
|
||||
}
|
||||
return expanded, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package unifiedresources
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHistoryIdentityLegacyDockerReference(t *testing.T) {
|
||||
container := strings.Repeat("a", 64)
|
||||
for _, ref := range []string{"docker:host/" + container, "docker:container/" + container} {
|
||||
source, id, ok := legacyDockerHistoryIdentity(ref)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, SourceSpecificID(ResourceTypeAppContainer, SourceDocker, source), id)
|
||||
}
|
||||
for _, ref := range []string{"docker:host/worker", "docker:host/" + container[:12], "docker:host/" + strings.ToUpper(container), "docker:/" + container, "docker:host/" + strings.Repeat("z", 64), "docker:host", "vm:host/" + container} {
|
||||
_, _, ok := legacyDockerHistoryIdentity(ref)
|
||||
require.False(t, ok, ref)
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the actual scoped history read as the unrelated identity index grows.
|
||||
// Timing is reported for qualification, without a machine-dependent pass threshold.
|
||||
func BenchmarkHistoryIdentityQuery(b *testing.B) {
|
||||
for _, size := range []int{1, 20000} {
|
||||
b.Run(fmt.Sprintf("aliases-%d", size), func(b *testing.B) {
|
||||
store, err := NewSQLiteResourceStore(b.TempDir(), "benchmark")
|
||||
require.NoError(b, err)
|
||||
b.Cleanup(func() { require.NoError(b, store.Close()) })
|
||||
tx, err := store.db.Begin()
|
||||
require.NoError(b, err)
|
||||
for i := 0; i < size; i++ {
|
||||
_, err := tx.Exec(`INSERT INTO resource_history_aliases (source_id, canonical_id) VALUES (?, ?)`, fmt.Sprintf("legacy-%d", i), fmt.Sprintf("app-container-%d", i))
|
||||
require.NoError(b, err)
|
||||
require.NoError(b, recordChangeSQL(tx, ResourceChange{ID: fmt.Sprintf("event-%d", i), ResourceID: fmt.Sprintf("legacy-%d", i), ObservedAt: time.Now(), Kind: ChangeAlertFired}, store.resourceChangesHasTimestamp))
|
||||
}
|
||||
require.NoError(b, tx.Commit())
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
got, err := store.GetRecentChanges("app-container-0", time.Time{}, 50)
|
||||
if err != nil || len(got) != 1 {
|
||||
b.Fatalf("scoped history: count=%d err=%v", len(got), err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryIdentityMigrationPreservesEventsAndAuthority(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := NewSQLiteResourceStore(dir, "default")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, store.Close()) })
|
||||
legacy := "docker:tower/" + strings.Repeat("b", 64)
|
||||
_, canonical, _ := legacyDockerHistoryIdentity(legacy)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
event := ResourceChange{ID: "legacy-fired", ResourceID: legacy, ObservedAt: now, Kind: ChangeAlertFired, SourceType: SourcePulseDiff, Reason: "container unhealthy", Metadata: map[string]any{"alert_id": "health-test"}}
|
||||
require.NoError(t, store.RecordChange(event))
|
||||
require.NoError(t, store.SetResourceOperatorState(ResourceOperatorState{CanonicalID: legacy, NeverAutoRemediate: true, Note: "keep authority binding"}))
|
||||
_, err = store.db.Exec(`INSERT INTO action_audits (id, action_id, canonical_id, request_id, created_at, updated_at, state, request_json, plan_json)
|
||||
VALUES ('history-action', 'history-action', ?, 'request-1', ?, ?, 'pending', '{"binding":"original"}', '{}')`, legacy, now, now)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, store.Close())
|
||||
// No inventory survives this restart. The legacy full ID still identifies
|
||||
// the same container, and the original event is never rewritten.
|
||||
store, err = NewSQLiteResourceStore(dir, "default")
|
||||
require.NoError(t, err)
|
||||
for _, id := range []string{legacy, canonical} {
|
||||
got, err := store.GetRecentChanges(id, now.Add(-time.Minute), 10)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []ResourceChange{event}, got)
|
||||
count, err := store.CountRecentChanges(id, now.Add(-time.Minute))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
kinds, err := store.CountRecentChangesByKind(id, now.Add(-time.Minute))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, kinds[ChangeAlertFired])
|
||||
}
|
||||
state, found, err := store.GetResourceOperatorState(legacy)
|
||||
require.NoError(t, err)
|
||||
require.True(t, found)
|
||||
require.True(t, state.NeverAutoRemediate)
|
||||
require.Equal(t, "keep authority binding", state.Note)
|
||||
_, found, err = store.GetResourceOperatorState(canonical)
|
||||
require.NoError(t, err)
|
||||
require.False(t, found)
|
||||
var actionID, request, eventID string
|
||||
require.NoError(t, store.db.QueryRow(`SELECT canonical_id, request_json FROM action_audits WHERE id = 'history-action'`).Scan(&actionID, &request))
|
||||
require.Equal(t, legacy, actionID)
|
||||
require.Equal(t, `{"binding":"original"}`, request)
|
||||
require.NoError(t, store.db.QueryRow(`SELECT canonical_id FROM resource_changes WHERE id = 'legacy-fired'`).Scan(&eventID))
|
||||
require.Equal(t, legacy, eventID)
|
||||
}
|
||||
|
||||
func TestHistoryIdentitySeparateHandlesSeeBindingAndReplay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writer, err := NewSQLiteResourceStore(dir, "default")
|
||||
require.NoError(t, err)
|
||||
defer writer.Close()
|
||||
reader, err := NewSQLiteResourceStore(dir, "default")
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
legacy := "docker:tower/" + strings.Repeat("c", 64)
|
||||
_, canonical, _ := legacyDockerHistoryIdentity(legacy)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
old := ResourceChange{ID: "first", ResourceID: legacy, ObservedAt: now, Kind: ChangeAlertFired, SourceType: SourcePulseDiff}
|
||||
require.NoError(t, writer.RecordChange(old))
|
||||
got, err := reader.GetRecentChanges(canonical, time.Time{}, 10)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
replayed := old
|
||||
replayed.ResourceID = canonical
|
||||
require.NoError(t, writer.RecordChangeWithSourceIdentity(replayed, legacy))
|
||||
second := ResourceChange{ID: "second", ResourceID: canonical, ObservedAt: now.Add(time.Second), Kind: ChangeAlertResolved, SourceType: SourcePulseDiff}
|
||||
require.NoError(t, writer.RecordChangeWithSourceIdentity(second, legacy))
|
||||
require.NoError(t, writer.RecordChangeWithSourceIdentity(second, legacy))
|
||||
for _, id := range []string{legacy, canonical} {
|
||||
got, err := reader.GetRecentChanges(id, time.Time{}, 10)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []ResourceChange{second, old}, got)
|
||||
}
|
||||
// The same source identifier in a different organization cannot see this binding.
|
||||
other, err := NewSQLiteResourceStore(dir, "other-org")
|
||||
require.NoError(t, err)
|
||||
defer other.Close()
|
||||
got, err = other.GetRecentChanges(canonical, time.Time{}, 10)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestHistoryIdentityMonitorAdapterUsesExactContainerIdentity(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
container := strings.Repeat("d", 64)
|
||||
host := models.DockerHost{ID: "tower", Hostname: "tower", LastSeen: time.Now(), Containers: []models.DockerContainer{{ID: container, Name: "worker", State: "running"}}}
|
||||
registry := NewRegistry(store)
|
||||
registry.IngestSnapshot(models.StateSnapshot{DockerHosts: []models.DockerHost{host}})
|
||||
adapter := NewMonitorAdapter(registry)
|
||||
legacy := "docker:tower/" + container
|
||||
_, canonical, _ := legacyDockerHistoryIdentity(legacy)
|
||||
for i, ref := range []string{legacy, "docker:tower/worker", "docker:tower/" + container[:12]} {
|
||||
require.NoError(t, adapter.RecordChange(ResourceChange{ID: ref, ResourceID: ref, Kind: ChangeAlertFired, ObservedAt: time.Now().Add(time.Duration(i) * time.Second)}))
|
||||
}
|
||||
got, err := store.GetRecentChanges(canonical, time.Time{}, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, canonical, got[0].ResourceID)
|
||||
// A retained authoritative binding survives loss of the registry.
|
||||
require.NoError(t, store.RecordChangeWithSourceIdentity(ResourceChange{ID: "binding", ResourceID: "app-container-retained", ObservedAt: time.Now()}, legacy))
|
||||
removed := NewMonitorAdapter(NewRegistry(store))
|
||||
require.NoError(t, removed.RecordChange(ResourceChange{ID: "after-removal", ResourceID: legacy, Kind: ChangeAlertResolved, ObservedAt: time.Now()}))
|
||||
got, err = store.GetRecentChanges("app-container-retained", time.Time{}, 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
require.Equal(t, "app-container-retained", got[0].ResourceID)
|
||||
}
|
||||
|
||||
func TestHistoryIdentityRetentionAndUnavailableLookup(t *testing.T) {
|
||||
store, err := NewSQLiteResourceStore(t.TempDir(), "default")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, store.Close()) })
|
||||
legacy := "docker:tower/" + strings.Repeat("e", 64)
|
||||
_, canonical, _ := legacyDockerHistoryIdentity(legacy)
|
||||
require.NoError(t, store.RecordChangeWithSourceIdentity(ResourceChange{ID: "expired", ResourceID: canonical, ObservedAt: time.Now().Add(-2 * resourceChangesRetention)}, legacy))
|
||||
store.pruneOldRecords()
|
||||
_, found, err := store.ResolveHistorySourceIdentity(legacy)
|
||||
require.NoError(t, err)
|
||||
require.False(t, found)
|
||||
require.NoError(t, store.Close())
|
||||
_, err = store.GetRecentChanges(canonical, time.Time{}, 10)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -111,6 +111,33 @@ func (a *MonitorAdapter) RecordChange(change ResourceChange) error {
|
||||
if registry == nil || registry.store == nil {
|
||||
return nil
|
||||
}
|
||||
sourceRef := change.ResourceID
|
||||
if sourceID, derivedID, ok := legacyDockerHistoryIdentity(sourceRef); ok {
|
||||
// Use exact source identity when inventory is present, including any
|
||||
// canonical identity merge. Full Docker IDs remain derivable after removal.
|
||||
registry.mu.RLock()
|
||||
resolvedID := registry.bySource[SourceDocker][sourceID]
|
||||
registry.mu.RUnlock()
|
||||
if resolvedID != "" {
|
||||
change.ResourceID = resolvedID
|
||||
} else {
|
||||
change.ResourceID = derivedID
|
||||
if history, ok := registry.store.(resourceHistoryIdentityWriter); ok {
|
||||
id, found, err := history.ResolveHistorySourceIdentity(sourceRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
change.ResourceID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sourceRef != change.ResourceID {
|
||||
if writer, ok := registry.store.(resourceHistoryIdentityWriter); ok {
|
||||
return writer.RecordChangeWithSourceIdentity(change, sourceRef)
|
||||
}
|
||||
}
|
||||
return registry.store.RecordChange(change)
|
||||
}
|
||||
|
||||
|
||||
@@ -609,6 +609,9 @@ func (s *SQLiteResourceStore) initSchema() error {
|
||||
if err := s.ensureResourceChangesIndexes(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateResourceHistoryAliases(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.migrateResourceIdentitiesSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1428,6 +1431,13 @@ func (s *SQLiteResourceStore) pruneOldRecords() {
|
||||
} else if affected > 0 {
|
||||
totalDeleted += affected
|
||||
}
|
||||
// History-only aliases need not outlive all records for either identifier.
|
||||
// This never removes canonical identity pins or authority-bearing state.
|
||||
if _, err := s.db.Exec(`DELETE FROM resource_history_aliases
|
||||
WHERE NOT EXISTS (SELECT 1 FROM resource_changes WHERE canonical_id = resource_history_aliases.source_id)
|
||||
AND NOT EXISTS (SELECT 1 FROM resource_changes WHERE canonical_id = resource_history_aliases.canonical_id)`); err != nil {
|
||||
log.Printf("unifiedresources: failed to prune resource history identities: %v", err)
|
||||
}
|
||||
|
||||
res, err = s.db.Exec(
|
||||
`DELETE FROM action_audits WHERE created_at < ?`,
|
||||
@@ -1614,10 +1624,10 @@ func (s *SQLiteResourceStore) queryResourceIdentityPins() ([]ResourceIdentityPin
|
||||
// history. Resources without pins (Proxmox guests, record-declared eras)
|
||||
// merge through the durable canonical_id_successions record instead. Unknown
|
||||
// IDs expand to themselves.
|
||||
func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) []string {
|
||||
func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) ([]string, error) {
|
||||
canonicalID = CanonicalResourceID(canonicalID)
|
||||
if canonicalID == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s.identityPinMu.Lock()
|
||||
@@ -1631,7 +1641,7 @@ func (s *SQLiteResourceStore) resourceChangeIDSet(canonicalID string) []string {
|
||||
pins := s.identityPinCache
|
||||
s.identityPinMu.Unlock()
|
||||
|
||||
return expandResourceChangeIDs(canonicalID, pins, s.successionMap())
|
||||
return s.expandHistoryAliases(expandResourceChangeIDs(canonicalID, pins, s.successionMap()))
|
||||
}
|
||||
|
||||
func expandResourceChangeIDs(canonicalID string, pins []ResourceIdentityPin, successors map[string]string) []string {
|
||||
@@ -1757,7 +1767,11 @@ func (s *SQLiteResourceStore) GetRecentChangesFiltered(canonicalID string, since
|
||||
conditions := []string{}
|
||||
canonicalID = CanonicalResourceID(canonicalID)
|
||||
if canonicalID != "" {
|
||||
conditions, args = appendRecentChangeResourceCondition(conditions, args, s.resourceChangeIDSet(canonicalID), filters.IncludeRelated)
|
||||
ids, err := s.resourceChangeIDSet(canonicalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conditions, args = appendRecentChangeResourceCondition(conditions, args, ids, filters.IncludeRelated)
|
||||
} else {
|
||||
conditions = append(conditions, observedAtExpr+" >= ?")
|
||||
args = append(args, since)
|
||||
@@ -1882,8 +1896,12 @@ func (s *SQLiteResourceStore) CountRecentChanges(canonicalID string, since time.
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) CountRecentChangesFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (int, error) {
|
||||
ids, err := s.resourceChangeIDSet(canonicalID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
query, args := buildRecentChangeCountQuery(
|
||||
s.resourceChangeIDSet(canonicalID),
|
||||
ids,
|
||||
since,
|
||||
filters,
|
||||
"SELECT COUNT(*) FROM resource_changes",
|
||||
@@ -1907,8 +1925,12 @@ func (s *SQLiteResourceStore) CountRecentChangesByKind(canonicalID string, since
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) CountRecentChangesByKindFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeKind]int, error) {
|
||||
ids, err := s.resourceChangeIDSet(canonicalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query, args := buildRecentChangeCountQuery(
|
||||
s.resourceChangeIDSet(canonicalID),
|
||||
ids,
|
||||
since,
|
||||
filters,
|
||||
"SELECT COALESCE(kind, ''), COUNT(*) FROM resource_changes",
|
||||
@@ -1950,9 +1972,13 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceType(canonicalID string,
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) CountRecentChangesBySourceTypeFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceType]int, error) {
|
||||
ids, err := s.resourceChangeIDSet(canonicalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceTypeExpr := s.resourceChangesSourceTypeExpr()
|
||||
query, args := buildRecentChangeCountQuery(
|
||||
s.resourceChangeIDSet(canonicalID),
|
||||
ids,
|
||||
since,
|
||||
filters,
|
||||
"SELECT "+sourceTypeExpr+", COUNT(*) FROM resource_changes",
|
||||
@@ -1994,9 +2020,13 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapter(canonicalID stri
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapterFiltered(canonicalID string, since time.Time, filters ResourceChangeFilters) (map[ChangeSourceAdapter]int, error) {
|
||||
ids, err := s.resourceChangeIDSet(canonicalID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceAdapterExpr := s.resourceChangesSourceAdapterExpr()
|
||||
query, args := buildRecentChangeCountQuery(
|
||||
s.resourceChangeIDSet(canonicalID),
|
||||
ids,
|
||||
since,
|
||||
filters,
|
||||
"SELECT "+sourceAdapterExpr+", COUNT(*) FROM resource_changes",
|
||||
@@ -3352,6 +3382,7 @@ type MemoryStore struct {
|
||||
loopReports map[string]LoopReport
|
||||
identityPins map[string]ResourceIdentityPin
|
||||
canonicalSuccessions map[string]string
|
||||
historyAliases map[string]string
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
@@ -3422,7 +3453,11 @@ func (m *MemoryStore) resourceChangeIDSetLocked(canonicalID string) []string {
|
||||
for _, pin := range m.identityPins {
|
||||
pins = append(pins, pin)
|
||||
}
|
||||
return expandResourceChangeIDs(canonicalID, pins, m.canonicalSuccessions)
|
||||
if resolved := m.historyAliases[canonicalID]; resolved != "" {
|
||||
canonicalID = resolved
|
||||
}
|
||||
ids := expandResourceChangeIDs(canonicalID, pins, m.canonicalSuccessions)
|
||||
return appendSupersededChangeIDs(ids, m.historyAliases)
|
||||
}
|
||||
|
||||
func (m *MemoryStore) AddLink(link ResourceLink) error {
|
||||
|
||||
@@ -4464,6 +4464,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/unifiedresources/availability_link_test.go",
|
||||
"internal/unifiedresources/history_identity_test.go",
|
||||
"internal/unifiedresources/kubernetes_registry_test.go",
|
||||
"internal/unifiedresources/pbs_pmg_registry_test.go",
|
||||
"internal/unifiedresources/registry_merge_policy_test.go",
|
||||
@@ -4494,6 +4495,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
[
|
||||
"internal/monitoring/issue1595_collection_trust_test.go",
|
||||
"internal/unifiedresources/availability_link_test.go",
|
||||
"internal/unifiedresources/history_identity_test.go",
|
||||
"internal/unifiedresources/kubernetes_registry_test.go",
|
||||
"internal/unifiedresources/pbs_pmg_registry_test.go",
|
||||
"internal/unifiedresources/registry_merge_policy_test.go",
|
||||
|
||||
Reference in New Issue
Block a user