diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index b93d6dc08..0e267cd46 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -4990,3 +4990,10 @@ restart. `internal/api/host_agent_removal_lifecycle_integration_test.go`, `internal/monitoring/monitor_host_agent_removal_lifecycle_test.go`, and the existing `scripts/installtests/agent_state_dir_lifecycle_test.go`, `install_sh_test.go`, and `install_ps1_test.go` are the production-path proofs. + +Config reload must also hand the replacement monitor's tenant-scoped guest, +Docker, and host metadata stores to shared API/config persistence consumers. +That handoff is lifecycle continuity only: it prevents post-reload agent +reports and resource projection from observing an orphaned metadata cache, and +does not add a report shape, credential, command, or remote-configuration +authority. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5929522a6..ada7c6330 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -6719,3 +6719,9 @@ status/history read remains bounded and cannot trigger a replacement run. `internal/api/ai_handlers_patrol_actions_additional_test.go`, and `frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts` are the focused acceptance, API, and cold-start timing proofs. + +Assistant web-interface URL updates resolve metadata providers per organization +from the active tenant monitor. Provider replacement refreshes both the default +service and already-created tenant services after monitor reload, so no AI +service retains an orphaned cache or writes a tenant's URL through another +organization's guest, Docker, or host metadata store. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 3c5c84ccc..87f689e28 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -36,6 +36,7 @@ WWN only when the match is unique and the hardware identifier is usable. 1. `internal/api/contract_test.go` 1a. `internal/api/platform_connection_shared.go` 1b. `internal/api/metadata_handlers_shared.go` + 1c. `internal/config/persistence_metadata_accessors.go` 2. `internal/api/resources.go` 3. `internal/api/discovery_handlers.go` 4. `internal/api/alerts.go` @@ -1820,7 +1821,16 @@ a new API state machine, queue contract, or verification-accounting field. be forgotten. `internal/api/metadata_handlers_shared.go` owns the guest/docker metadata GET/PUT payload semantics (empty object instead of null, zero record instead of 404 — pinned by - `TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404`). Deploy + `TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404`). Guest, + Docker, and host metadata handlers must resolve the active tenant monitor's + live metadata stores. `ConfigPersistence`, export/import, entitlement usage, + and reload-time router wiring must share those store + instances rather than retaining independent in-memory caches of the same + files; a successful API write must therefore be visible to backend resource + projection and configuration export immediately. Assistant URL updates must + resolve a provider per organization and refresh existing services after a + monitor replacement; a default provider must not be shared across tenants. + Deploy preflights and jobs share `handleDeployJobStatus` / `handleDeployJobEvents` in `internal/api/deploy_handlers.go`; recovery points series/facets share `parseRecoveryListPointsOptions`; docker and diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index a8830c90f..b1c6ac9c7 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -221,6 +221,11 @@ data cannot wrap into a fabricated healthy value. 58. `internal/monitoring/proxmox_action_observer.go` 59. `internal/monitoring/agent_fleet_doctor.go` 60. `internal/config/host_continuity.go` +61. `internal/monitoring/docker_metadata_migration.go` +62. `internal/monitoring/kubernetes_metadata_migration.go` +63. `internal/monitoring/metadata_stores.go` +63a. `internal/config/docker_metadata.go` +63b. `internal/config/guest_metadata.go` ## Shared Boundaries @@ -1495,6 +1500,26 @@ Docker metadata and legacy app-container guest metadata to that key when a Docker report is ingested, then prefer that stable guest key when projecting unified app-container custom URLs. Runtime container IDs remain action and metric identities, not the persistent URL metadata identity. +When a container retains its runtime ID but changes normalized name, monitoring +must move stable guest and Docker metadata to the new name and remove the +obsolete name key after a successful or already-resolved destination. Rename +migration must snapshot all sources before writing so swaps do not exchange +URLs accidentally, and ambiguous normalized source or target names must fail +closed. A later unrelated container that reuses the old name must not inherit +the renamed container's URL. +Kubernetes pod, Deployment, and Service web-interface metadata uses +`k8s-workload::::` as its stable logical +identity. Monitoring must migrate a current legacy unified-resource key, plus +the legacy `k8s::pod:` key for pods, when that resource is +observed. Runtime UIDs remain discovery and metrics coordinates. Every scope +component is required so a URL cannot cross cluster, namespace, or kind +boundaries, and an existing empty stable record is an intentional clear that +must block legacy fallback. +The monitor-owned guest, Docker, and host metadata stores are the live +in-memory authority for projection and migration. API, config export/import, +tenant usage, Assistant URL discovery, and reload paths must share those exact +tenant-scoped store instances rather than opening parallel caches over the +same files. The same applies to proxmox topology coordinates exposed through typed views: node, cluster, and instance accessors must return canonical trimmed values so monitoring consumers do not fork topology grouping or labeling on `" pve-a "` 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 4c3019062..67f67b8a3 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -168,7 +168,8 @@ admission records a stable refusal without invoking executor or network code. WebSocket startup, so a delayed or blocked first stream snapshot cannot hide server-owned platform scopes; auth-mode branches must not fork additional state probes or perform the same hydration twice. -17. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. +17. `frontend-modern/src/utils/workloads.ts` shared with `unified-resources`: the stable workload metadata identity helper is both a unified-resource persistence boundary and a workloads hot-path lookup boundary. +18. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. Governed action decisions preserve SQLite and MemoryStore parity through one shared pure append command. Every accepted approval advances a monotonic @@ -1997,3 +1998,11 @@ The completed Operational Trust path preserves these explicit bounds: covered by query-plan and retention tests; 6. Operational Trust telemetry uses bounded enumerated labels only and never introduces resource-cardinality labels. + +Workload URL persistence remains lookup-only on the hot path. Docker and +Kubernetes stable metadata candidates are derived synchronously from platform +identity already present on each workload; the workload hook may carry the +cluster scope needed for that calculation, but must not add per-row requests, +runtime-UID scans, or a second metadata cache. Kubernetes persistence uses the +fully scoped cluster, kind, namespace, and name identity; runtime pod UIDs are +legacy fallback coordinates only. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index c49721eae..e1d224ab1 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -507,6 +507,14 @@ "unified-resources" ] }, + { + "path": "frontend-modern/src/utils/workloads.ts", + "rationale": "the stable workload metadata identity helper is both a unified-resource persistence boundary and a workloads hot-path lookup boundary", + "subsystems": [ + "performance-and-scalability", + "unified-resources" + ] + }, { "path": "internal/agentcapabilities/action_target.go", "rationale": "the Pulse Intelligence governed action target type and resource-to-action-target mapping vocabulary are both the Assistant approval/runtime routing contract and the canonical API/agent target contract for governed actions", @@ -1880,6 +1888,7 @@ ], "exact_files": [ "internal/api/ai_handler_test.go", + "internal/api/ai_handlers_more_test.go", "internal/api/ai_handlers_patrol_actions_additional_test.go", "internal/api/ai_handlers_test.go", "internal/api/ai_intelligence_handlers_test.go", @@ -2462,6 +2471,7 @@ "frontend-modern/src/utils/apiTokenPresentation.ts", "frontend-modern/src/utils/infrastructureSettingsPresentation.ts", "internal/api/setup_script_render.go", + "internal/config/persistence_metadata_accessors.go", "internal/websocket/hub.go", "pkg/aicontracts/action_broker.go", "pkg/aicontracts/fix_execution.go", @@ -2807,6 +2817,20 @@ "internal/api/alerts_endpoints_test.go" ] }, + { + "id": "metadata-persistence-cache-authority", + "label": "metadata persistence live-store authority proof", + "match_prefixes": [], + "match_files": [ + "internal/config/persistence_metadata_accessors.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/api/metadata_handlers_test.go", + "internal/config/persistence_metadata_test.go" + ] + }, { "id": "backend-payload-contracts", "label": "backend API payload proof", @@ -2820,9 +2844,11 @@ ], "exact_files": [ "frontend-modern/src/types/api.ts", + "internal/api/ai_handlers_more_test.go", "internal/api/ai_handlers_patrol_actions_additional_test.go", "internal/api/contract_test.go", "internal/api/host_agent_removal_lifecycle_integration_test.go", + "internal/api/metadata_handlers_test.go", "internal/api/patrol_autopilot_test.go" ] }, @@ -5132,6 +5158,8 @@ ], "owned_files": [ "docker-entrypoint.sh", + "internal/config/docker_metadata.go", + "internal/config/guest_metadata.go", "internal/config/host_continuity.go", "internal/dockeragent/collect.go", "internal/dockeragent/docker_client.go", @@ -5200,6 +5228,7 @@ "internal/config/host_continuity_test.go", "internal/monitoring/issue1485_unraid_lifecycle_test.go", "internal/monitoring/issue1595_collection_trust_test.go", + "internal/monitoring/monitor_docker_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", "internal/monitoring/monitor_package_updates_test.go", @@ -5528,10 +5557,15 @@ "match_prefixes": [ "internal/monitoring/" ], - "match_files": [], + "match_files": [ + "internal/config/docker_metadata.go", + "internal/config/guest_metadata.go" + ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "internal/config/docker_metadata_test.go", + "internal/config/guest_metadata_test.go", "internal/monitoring/availability_poller_test.go", "internal/monitoring/availability_udp_test.go", "internal/monitoring/canonical_guardrails_test.go", @@ -5540,6 +5574,7 @@ "internal/monitoring/monitor_alert_intent_test.go", "internal/monitoring/monitor_alert_override_migration_test.go", "internal/monitoring/monitor_backups_readstate_test.go", + "internal/monitoring/monitor_docker_test.go", "internal/monitoring/monitor_host_agent_removal_lifecycle_test.go", "internal/monitoring/monitor_host_agents_test.go", "internal/unifiedresources/code_standards_test.go" @@ -7013,6 +7048,7 @@ "frontend-modern/src/utils/serviceHealthPresentation.ts", "frontend-modern/src/utils/sourcePlatforms.ts", "frontend-modern/src/utils/sourceTypePresentation.ts", + "frontend-modern/src/utils/workloads.ts", "frontend-modern/src/utils/workloadTypePresentation.ts", "internal/api/resources.go", "internal/platformsupport/manifest_generated.go" @@ -7183,6 +7219,7 @@ "frontend-modern/src/routing/resourceLinks.ts", "frontend-modern/src/types/resource.ts", "frontend-modern/src/utils/resourceIdentity.ts", + "frontend-modern/src/utils/workloads.ts", "internal/api/resources.go" ], "allow_same_subsystem_tests": false, @@ -7246,6 +7283,7 @@ "frontend-modern/src/stores/__tests__/websocket-unified.test.ts", "frontend-modern/src/types/__tests__/resource.test.ts", "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", + "frontend-modern/src/utils/__tests__/workloads.test.ts", "internal/unifiedresources/code_standards_test.go" ] }, diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index c20957a88..8322ebbc0 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -1551,3 +1551,10 @@ accepts only a non-empty reason and bounded future expiry. Action offer and planning require the `ai_autofix` entitlement in addition to current RBAC, evidence, resource-capability, executor-readiness, approval, and action-policy checks. + +Guest, Docker, and host metadata reads, writes, and reloads must resolve the +active request tenant's monitor-owned store. A default-tenant cache must never +be reused to satisfy another organization, including Assistant-authored URL +updates, and stable workload identities must retain their full host or cluster, +namespace, kind, and name scope so a saved URL cannot cross a tenant or +resource boundary. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 10a9f1a94..518c2f1f9 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2278,6 +2278,11 @@ fields and nullable mode/kind metadata before presenting canonical item labels, while storage detail drawers and filter controls must route summary series IDs, source tones, and disk metrics through the shared storage helpers instead of reconstructing them from local table state. +Config export/import remains adjacent persistence infrastructure rather than a +storage/recovery authority. Guest metadata import must reload the active +tenant monitor's live store so recovery and resource projections observe one +post-import state; it must not leave a parallel persistence cache or reload the +default tenant on behalf of another organization. Host-agent removal tombstones in `internal/config/host_continuity.go` are an adjacent agent-lifecycle security journal, not storage or recovery inventory. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index cc8d19d6a..96a89f58f 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -128,6 +128,7 @@ life is derived. 58. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerServiceModel.ts` 59. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts` 60. `frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts` +60a. `frontend-modern/src/utils/workloads.ts` 61. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts` 62. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts` 63. `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts` @@ -470,6 +471,15 @@ the resource name through the shared `WebInterfaceNameLink` primitive. Proxmox host table columns are governed by `proxmoxHostTableModel.ts`; that model must not reintroduce a separate `Web` column or move the web-interface launch back into a page-local icon cell. +Persisted workload URLs must also use the same stable identity in the workload +and infrastructure drawers. Docker app containers use +`app-container::name:`; Kubernetes pods, Deployments, +and Services use +`k8s-workload::::`. Runtime container IDs, +pod UIDs, and generated registry IDs remain fallback migration, action, +discovery, or metrics coordinates and must not be the primary metadata write +key. Missing cluster/host, namespace, kind, or name scope must fail closed to +the legacy resource key rather than constructing a broadened stable key. Product-originated resource references may arrive as registered unified resource IDs, source-specific IDs, or canonical identity aliases. The unified-resource registry owns resolving those references through @@ -630,7 +640,8 @@ container inventory table. display/source family; `platformScopes` is the overlap set used when a runtime workload belongs to both Docker and an owning infrastructure platform. -18. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. +18. `frontend-modern/src/utils/workloads.ts` shared with `performance-and-scalability`: the stable workload metadata identity helper is both a unified-resource persistence boundary and a workloads hot-path lookup boundary. +19. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. `/api/resources` type filters must accept URL-encoded comma-separated lists from browser query builders exactly like literal comma separators, so Docker / Podman runtime pages do not lose `docker-host` inventory while requesting diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index 488f69e3c..a4dc60b70 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -296,6 +296,7 @@ describe('toDiscoveryConfig', () => { ...baseResource(), id: 'resource:pod:hash-1', type: 'pod', + name: 'checkout', platformType: 'kubernetes', clusterId: 'cluster-a', kubernetes: { @@ -319,7 +320,7 @@ describe('toDiscoveryConfig', () => { resourceId: 'pod-uid-1', hostname: 'stale-hostname', metadataKind: 'guest', - metadataId: 'resource:pod:hash-1', + metadataId: 'k8s-workload:cluster-a:pod:default:checkout', targetLabel: 'workload', }); }); @@ -329,6 +330,7 @@ describe('toDiscoveryConfig', () => { ...baseResource(), id: 'resource:pod:hash-2', type: 'pod', + name: 'checkout', platformType: 'kubernetes', kubernetes: { agentId: 'k8s-agent-1', @@ -353,7 +355,7 @@ describe('toDiscoveryConfig', () => { resourceId: 'pod-uid-2', hostname: 'stale-hostname', metadataKind: 'guest', - metadataId: 'resource:pod:hash-2', + metadataId: 'k8s-workload:cluster-a:pod:default:checkout', targetLabel: 'workload', }); }); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDiscoveryModel.branchcov.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDiscoveryModel.branchcov.test.ts index 3908020b5..b28e34bb3 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDiscoveryModel.branchcov.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDiscoveryModel.branchcov.test.ts @@ -527,7 +527,7 @@ describe('toDiscoveryConfig — main switch (no usable explicit target)', () => }); describe('pod / k8s-deployment / k8s-service cases', () => { - it('maps a bare pod to a workload config with resource.id as resourceId', () => { + it('falls back to resource.id for a pod without complete stable metadata scope', () => { const resource = baseResource({ type: 'pod', id: 'pod-1', name: 'pod-1' }); expect(toDiscoveryConfig(resource)).toEqual({ resourceType: 'pod', @@ -550,6 +550,25 @@ describe('toDiscoveryConfig — main switch (no usable explicit target)', () => expect(toDiscoveryConfig(resource)?.resourceId).toBe('uid-1'); }); + it('uses stable logical metadata identity for Kubernetes workload kinds', () => { + for (const type of ['pod', 'k8s-deployment', 'k8s-service'] as const) { + const resource = baseResource({ + type, + id: `runtime-${type}`, + name: 'checkout', + kubernetes: { + clusterId: 'cluster-a', + namespace: 'payments', + podUid: type === 'pod' ? 'pod-uid-old' : undefined, + } as Resource['kubernetes'], + }); + + expect(toDiscoveryConfig(resource)?.metadataId).toBe( + `k8s-workload:cluster-a:${type === 'pod' ? 'pod' : type.slice(4)}:payments:checkout`, + ); + } + }); + it('derives resourceId from namespace/podName when podUid is absent', () => { const resource = baseResource({ type: 'pod', diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts index 28095dbd5..d3444f632 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts @@ -14,7 +14,11 @@ import { getPreferredInfrastructureDisplayName, getPreferredResourceHostname, } from '@/utils/resourceIdentity'; -import { buildAppContainerMetadataId, getCanonicalWorkloadIdForResource } from '@/utils/workloads'; +import { + buildAppContainerMetadataId, + buildKubernetesWorkloadMetadataId, + getCanonicalWorkloadIdForResource, +} from '@/utils/workloads'; export type DiscoveryConfig = { resourceType: DiscoveryResourceType; @@ -38,6 +42,7 @@ type DockerPlatformData = { }; type KubernetesPlatformData = { + clusterId?: string; agentId?: string; namespace?: string; podName?: string; @@ -102,7 +107,7 @@ const getDockerContainerMetadataId = ( const getMetadataTarget = ( resource: Resource, - resourceType: DiscoveryResourceType, + resourceType: Resource['type'], platformData: PlatformData | undefined, ): Pick => { if (resourceType === 'app-container') { @@ -133,6 +138,28 @@ const getMetadataTarget = ( } } + if ( + resourceType === 'pod' || + resourceType === 'k8s-deployment' || + resourceType === 'k8s-service' + ) { + const kubernetesPlatformData = platformData?.kubernetes; + const stableMetadataId = buildKubernetesWorkloadMetadataId({ + kubernetesClusterId: + asString(resource.kubernetes?.clusterId) || asString(kubernetesPlatformData?.clusterId), + kind: resourceType, + namespace: + asString(resource.kubernetes?.namespace) || asString(kubernetesPlatformData?.namespace), + name: asString(resource.name), + }); + if (stableMetadataId) { + return { + metadataKind: 'guest', + metadataId: stableMetadataId, + }; + } + } + // Guest metadata is keyed by the canonical workload id shared with the // workloads surfaces (`instance:node:vmid` for PVE guests, resource id // otherwise — also the key v5 upgrades carry over). Saving under any other @@ -330,8 +357,7 @@ export const toDiscoveryConfig = (resource: Resource): DiscoveryConfig | null => agentId: workloadAgentId, resourceId: kubernetesResourceId || resource.id, hostname, - metadataKind: 'guest', - metadataId: resource.id, + ...getMetadataTarget(resource, canonicalResourceType, platformData), targetLabel: 'workload', }; default: diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index b08cc4432..a8bdf6d92 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -467,6 +467,7 @@ describe('useWorkloads', () => { type: 'pod', name: 'pod-1', kubernetes: { + clusterId: 'cluster-id-a', clusterName: 'cluster-a', context: 'cluster-context', namespace: 'default', @@ -490,6 +491,7 @@ describe('useWorkloads', () => { expect(result!.workloads()[0]?.contextLabel).toBe('cluster-a'); expect(result!.workloads()[0]?.instance).toBe('cluster-a'); + expect(result!.workloads()[0]?.kubernetesClusterId).toBe('cluster-id-a'); dispose(); }); diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index c6bbb76c7..c0e375112 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -557,6 +557,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { ? (resource.docker?.hostname || '').trim() || undefined : undefined, kubernetesAgentId: workloadType === 'pod' ? resource.kubernetes?.agentId : undefined, + kubernetesClusterId: workloadType === 'pod' ? resource.kubernetes?.clusterId : undefined, platformType, platformScopes, discoveryTarget, diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index 927027b7e..daa3f19ec 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -40,6 +40,9 @@ export type WorkloadGuest = (VM | Container) & { dockerHostName?: string; // Kubernetes agent ID (when available) — preferred for actionable operations. kubernetesAgentId?: string; + // Stable Kubernetes cluster identity used with namespace, kind, and name + // for persisted workload metadata. Runtime pod UIDs remain separate. + kubernetesClusterId?: string; // Canonical discovery ownership. API-backed platforms such as TrueNAS should // only expose Discovery when the unified resource contract supplies a target. discoveryTarget?: ResourceDiscoveryTarget; diff --git a/frontend-modern/src/utils/__tests__/workloads.test.ts b/frontend-modern/src/utils/__tests__/workloads.test.ts index c4b18ff56..98ae2eaf1 100644 --- a/frontend-modern/src/utils/__tests__/workloads.test.ts +++ b/frontend-modern/src/utils/__tests__/workloads.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + buildKubernetesWorkloadMetadataId, buildCanonicalNodeScopedWorkloadId, canonicalizeWorkloadFilterType, getDiscoveryResourceTypeForWorkload, @@ -294,6 +295,59 @@ describe('getWorkloadMetadataId', () => { 'app-container:truenas-main:nextcloud', ]); }); + + it('uses cluster, kind, namespace, and name for Kubernetes pod URL identity', () => { + const oldPod = { + id: 'k8s:cluster-a:pod:pod-uid-old', + name: 'checkout', + type: 'pod', + workloadType: 'pod' as const, + kubernetesClusterId: 'cluster-a', + namespace: 'payments', + instance: '', + node: '', + vmid: 0, + }; + const recreatedPod = { + ...oldPod, + id: 'k8s:cluster-a:pod:pod-uid-new', + }; + + expect(buildKubernetesWorkloadMetadataId(oldPod)).toBe( + 'k8s-workload:cluster-a:pod:payments:checkout', + ); + expect(getWorkloadMetadataIdCandidates(oldPod)).toEqual([ + 'k8s-workload:cluster-a:pod:payments:checkout', + 'k8s:cluster-a:pod:pod-uid-old', + ]); + expect(getWorkloadMetadataId(recreatedPod)).toBe( + 'k8s-workload:cluster-a:pod:payments:checkout', + ); + }); + + it('does not build a Kubernetes URL identity without every isolation scope', () => { + expect( + buildKubernetesWorkloadMetadataId({ + kubernetesClusterId: '', + namespace: 'payments', + name: 'checkout', + }), + ).toBeNull(); + expect( + buildKubernetesWorkloadMetadataId({ + kubernetesClusterId: 'cluster-a', + namespace: '', + name: 'checkout', + }), + ).toBeNull(); + expect( + buildKubernetesWorkloadMetadataId({ + kubernetesClusterId: 'cluster-a', + namespace: 'payments', + name: '', + }), + ).toBeNull(); + }); }); describe('getCanonicalWorkloadIdForResource', () => { diff --git a/frontend-modern/src/utils/workloads.ts b/frontend-modern/src/utils/workloads.ts index cad2e2e9e..806c8d0e0 100644 --- a/frontend-modern/src/utils/workloads.ts +++ b/frontend-modern/src/utils/workloads.ts @@ -173,10 +173,48 @@ export const buildAppContainerMetadataId = ({ return `app-container:${hostId}:name:${containerName}`; }; +type KubernetesWorkloadMetadataIdentity = { + kubernetesClusterId?: string | null; + kind?: string | null; + namespace?: string | null; + name?: string | null; +}; + +export const buildKubernetesWorkloadMetadataId = ({ + kubernetesClusterId, + kind = 'pod', + namespace, + name, +}: KubernetesWorkloadMetadataIdentity): string | null => { + const clusterId = (kubernetesClusterId || '').trim(); + const workloadNamespace = (namespace || '').trim(); + const workloadName = (name || '').trim(); + const rawKind = (kind || '').trim().toLowerCase(); + const workloadKind = + rawKind === 'pod' + ? 'pod' + : rawKind === 'k8s-deployment' || rawKind === 'deployment' + ? 'deployment' + : rawKind === 'k8s-service' || rawKind === 'service' + ? 'service' + : ''; + if (!clusterId || !workloadKind || !workloadNamespace || !workloadName) return null; + return `k8s-workload:${clusterId}:${workloadKind}:${workloadNamespace}:${workloadName}`; +}; + export const getWorkloadMetadataIdCandidates = ( guest: Pick< WorkloadGuest, - 'id' | 'workloadType' | 'type' | 'instance' | 'node' | 'vmid' | 'dockerHostId' | 'name' + | 'id' + | 'workloadType' + | 'type' + | 'instance' + | 'node' + | 'vmid' + | 'dockerHostId' + | 'kubernetesClusterId' + | 'namespace' + | 'name' >, ): string[] => { const canonicalId = getCanonicalWorkloadId(guest); @@ -186,6 +224,10 @@ export const getWorkloadMetadataIdCandidates = ( const appContainerId = buildAppContainerMetadataId(guest); if (appContainerId) candidates.push(appContainerId); } + if (resolveWorkloadType(guest) === 'pod') { + const kubernetesWorkloadId = buildKubernetesWorkloadMetadataId(guest); + if (kubernetesWorkloadId) candidates.push(kubernetesWorkloadId); + } if (canonicalId) candidates.push(canonicalId); @@ -195,7 +237,16 @@ export const getWorkloadMetadataIdCandidates = ( export const getWorkloadMetadataId = ( guest: Pick< WorkloadGuest, - 'id' | 'workloadType' | 'type' | 'instance' | 'node' | 'vmid' | 'dockerHostId' | 'name' + | 'id' + | 'workloadType' + | 'type' + | 'instance' + | 'node' + | 'vmid' + | 'dockerHostId' + | 'kubernetesClusterId' + | 'namespace' + | 'name' >, ): string => getWorkloadMetadataIdCandidates(guest)[0] || getCanonicalWorkloadId(guest); diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 3d01a12d8..1f05bb9b9 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -69,6 +69,7 @@ type AISettingsHandler struct { policyMutation func(func() error) error patrolAutopilotPolicy func() unifiedresources.PatrolAutopilotServerPolicy metadataProvider ai.MetadataProvider + metadataProviderFactory func(orgID string) ai.MetadataProvider patrolThresholdProvider ai.ThresholdProvider metricsHistoryProvider ai.MetricsHistoryProvider baselineStore *ai.BaselineStore @@ -237,6 +238,7 @@ type aiSettingsProviderSnapshot struct { defaultAIService *ai.Service stateProvider ai.StateProvider metadataProvider ai.MetadataProvider + metadataProviderFactory func(orgID string) ai.MetadataProvider patrolThresholdProvider ai.ThresholdProvider metricsHistoryProvider ai.MetricsHistoryProvider baselineStore *ai.BaselineStore @@ -268,6 +270,7 @@ func (h *AISettingsHandler) providerSnapshot() aiSettingsProviderSnapshot { defaultAIService: h.defaultAIService, stateProvider: h.stateProvider, metadataProvider: h.metadataProvider, + metadataProviderFactory: h.metadataProviderFactory, patrolThresholdProvider: h.patrolThresholdProvider, metricsHistoryProvider: h.metricsHistoryProvider, baselineStore: h.baselineStore, @@ -415,6 +418,10 @@ func (h *AISettingsHandler) GetAIService(ctx context.Context) *ai.Service { if svc, exists = h.aiServices[orgID]; exists { return svc } + // A provider factory can be replaced while this service is waiting on + // aiServicesMu during monitor reload. Refresh after acquiring the creation + // lock so the new service cannot retain the pre-reload metadata store. + providers = h.providerSnapshot() // Create new service for this tenant persistence, err := mtPersistence.GetPersistence(orgID) @@ -449,8 +456,8 @@ func (h *AISettingsHandler) GetAIService(ctx context.Context) *ai.Service { if provider := h.unifiedResourceProviderForOrg(orgID); provider != nil { svc.SetUnifiedResourceProvider(provider) } - if providers.metadataProvider != nil { - svc.SetMetadataProvider(providers.metadataProvider) + if metadataProvider := providers.metadataProviderForOrg(orgID); metadataProvider != nil { + svc.SetMetadataProvider(metadataProvider) } if orgID == "default" { if providers.patrolThresholdProvider != nil { @@ -752,10 +759,21 @@ func (h *AISettingsHandler) SetUnifiedResourceProvider(urp ai.UnifiedResourcePro } } +func (p aiSettingsProviderSnapshot) metadataProviderForOrg(orgID string) ai.MetadataProvider { + if p.metadataProviderFactory != nil { + return p.metadataProviderFactory(orgID) + } + return p.metadataProvider +} + // SetMetadataProvider sets the metadata provider for AI URL discovery func (h *AISettingsHandler) SetMetadataProvider(mp ai.MetadataProvider) { + if h == nil { + return + } h.stateMu.Lock() h.metadataProvider = mp + h.metadataProviderFactory = nil defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { @@ -769,6 +787,42 @@ func (h *AISettingsHandler) SetMetadataProvider(mp ai.MetadataProvider) { } } +// SetMetadataProviderFactory scopes URL metadata updates to each AI service's +// organization and refreshes providers already created before a monitor reload. +func (h *AISettingsHandler) SetMetadataProviderFactory( + factory func(orgID string) ai.MetadataProvider, +) { + if h == nil { + return + } + h.stateMu.Lock() + h.metadataProvider = nil + h.metadataProviderFactory = factory + defaultAIService := h.defaultAIService + h.stateMu.Unlock() + + if defaultAIService != nil { + var provider ai.MetadataProvider + if factory != nil { + provider = factory("default") + } + defaultAIService.SetMetadataProvider(provider) + } + + h.aiServicesMu.Lock() + defer h.aiServicesMu.Unlock() + for orgID, svc := range h.aiServices { + if svc == nil { + continue + } + var provider ai.MetadataProvider + if factory != nil { + provider = factory(orgID) + } + svc.SetMetadataProvider(provider) + } +} + // StartPatrol starts the background AI patrol service func (h *AISettingsHandler) StartPatrol(ctx context.Context) { h.GetAIService(ctx).StartPatrol(ctx) diff --git a/internal/api/ai_handlers_more_test.go b/internal/api/ai_handlers_more_test.go index f916ac847..e70f04774 100644 --- a/internal/api/ai_handlers_more_test.go +++ b/internal/api/ai_handlers_more_test.go @@ -24,6 +24,58 @@ func (stubMetadataProvider) SetGuestURL(string, string) error { return nil } func (stubMetadataProvider) SetDockerURL(string, string) error { return nil } func (stubMetadataProvider) SetHostURL(string, string) error { return nil } +type recordingMetadataProvider struct { + guestURLs map[string]string +} + +func (p *recordingMetadataProvider) SetGuestURL(id, url string) error { + p.guestURLs[id] = url + return nil +} + +func (*recordingMetadataProvider) SetDockerURL(string, string) error { return nil } +func (*recordingMetadataProvider) SetHostURL(string, string) error { return nil } + +func TestAISettingsHandlerMetadataProviderFactoryScopesExistingServices(t *testing.T) { + handler := NewAISettingsHandler(config.NewMultiTenantPersistence(t.TempDir()), nil, nil) + tenantContext := context.WithValue(context.Background(), OrgIDContextKey, "tenant-a") + + defaultService := handler.GetAIService(context.Background()) + tenantService := handler.GetAIService(tenantContext) + if defaultService == nil || tenantService == nil { + t.Fatal("expected default and tenant AI services") + } + + providers := map[string]*recordingMetadataProvider{} + handler.SetMetadataProviderFactory(func(orgID string) ai.MetadataProvider { + provider := &recordingMetadataProvider{guestURLs: make(map[string]string)} + providers[orgID] = provider + return provider + }) + + if err := defaultService.SetResourceURL("vm", "shared-id", "https://default.internal"); err != nil { + t.Fatalf("set default URL: %v", err) + } + if err := tenantService.SetResourceURL("vm", "shared-id", "https://tenant.internal"); err != nil { + t.Fatalf("set tenant URL: %v", err) + } + if got := providers["default"].guestURLs["shared-id"]; got != "https://default.internal" { + t.Fatalf("default provider URL = %q", got) + } + if got := providers["tenant-a"].guestURLs["shared-id"]; got != "https://tenant.internal" { + t.Fatalf("tenant provider URL = %q", got) + } + + newTenantContext := context.WithValue(context.Background(), OrgIDContextKey, "tenant-b") + newTenantService := handler.GetAIService(newTenantContext) + if err := newTenantService.SetResourceURL("vm", "shared-id", "https://new-tenant.internal"); err != nil { + t.Fatalf("set new tenant URL: %v", err) + } + if got := providers["tenant-b"].guestURLs["shared-id"]; got != "https://new-tenant.internal" { + t.Fatalf("new tenant provider URL = %q", got) + } +} + type stubThresholdProvider struct{} func (stubThresholdProvider) GetNodeCPUThreshold() float64 { return 80 } diff --git a/internal/api/config_export_import_handlers.go b/internal/api/config_export_import_handlers.go index b75a86304..8dd43e51b 100644 --- a/internal/api/config_export_import_handlers.go +++ b/internal/api/config_export_import_handlers.go @@ -186,7 +186,7 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque // Reload guest metadata from disk. if h.guestMetadataHandler != nil { - if err := h.guestMetadataHandler.Reload(); err != nil { + if err := h.guestMetadataHandler.Reload(r.Context()); err != nil { log.Warn().Err(err).Msg("Failed to reload guest metadata after import") } else { log.Info().Msg("Reloaded guest metadata after import") diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 9dd93b49c..848b6f8ed 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -332,6 +332,7 @@ func (h *ConfigHandlers) getContextState(ctx context.Context) (*config.Config, * // and setup state. if orgID == "default" && (defaultConfig != nil || defaultPersistence != nil || defaultMonitor != nil) { + bindMonitorMetadataStores(defaultPersistence, defaultMonitor) return defaultConfig, defaultPersistence, defaultMonitor } @@ -345,6 +346,7 @@ func (h *ConfigHandlers) getContextState(ctx context.Context) (*config.Config, * if mtPersistence != nil { p, _ = mtPersistence.GetPersistence(orgID) } + bindMonitorMetadataStores(p, m) return cfg, p, m } else { if orgID == "default" { @@ -368,6 +370,20 @@ func (h *ConfigHandlers) getContextState(ctx context.Context) (*config.Config, * return nil, nil, nil } +func bindMonitorMetadataStores( + persistence *config.ConfigPersistence, + monitor *monitoring.Monitor, +) { + if persistence == nil || monitor == nil { + return + } + persistence.SetMetadataStores( + monitor.GuestMetadataStore(), + monitor.DockerMetadataStore(), + monitor.HostMetadataStore(), + ) +} + func (h *ConfigHandlers) getConfig(ctx context.Context) *config.Config { c, _, _ := h.getContextState(ctx) return c diff --git a/internal/api/docker_metadata.go b/internal/api/docker_metadata.go index d100f036e..99890a54e 100644 --- a/internal/api/docker_metadata.go +++ b/internal/api/docker_metadata.go @@ -18,6 +18,7 @@ const ( // DockerMetadataHandler handles Docker resource metadata operations type DockerMetadataHandler struct { mtPersistence *config.MultiTenantPersistence + storeResolver func(context.Context) *config.DockerMetadataStore } // NewDockerMetadataHandler creates a new Docker metadata handler @@ -27,7 +28,20 @@ func NewDockerMetadataHandler(mtPersistence *config.MultiTenantPersistence) *Doc } } +// SetStoreResolver makes API reads and writes use the active monitor's store. +// The persistence-backed store remains the initialization/test fallback. +func (h *DockerMetadataHandler) SetStoreResolver( + resolver func(context.Context) *config.DockerMetadataStore, +) { + h.storeResolver = resolver +} + func (h *DockerMetadataHandler) getStore(ctx context.Context) *config.DockerMetadataStore { + if h != nil && h.storeResolver != nil { + if store := h.storeResolver(ctx); store != nil { + return store + } + } orgID := "default" if ctx != nil { if requestOrgID := GetOrgID(ctx); requestOrgID != "" { diff --git a/internal/api/guest_metadata.go b/internal/api/guest_metadata.go index c59824cd1..07eafd8de 100644 --- a/internal/api/guest_metadata.go +++ b/internal/api/guest_metadata.go @@ -12,6 +12,7 @@ import ( // GuestMetadataHandler handles guest metadata operations type GuestMetadataHandler struct { mtPersistence *config.MultiTenantPersistence + storeResolver func(context.Context) *config.GuestMetadataStore } // NewGuestMetadataHandler creates a new guest metadata handler @@ -21,7 +22,20 @@ func NewGuestMetadataHandler(mtPersistence *config.MultiTenantPersistence) *Gues } } +// SetStoreResolver makes API reads and writes use the active monitor's store. +// The persistence-backed store remains the initialization/test fallback. +func (h *GuestMetadataHandler) SetStoreResolver( + resolver func(context.Context) *config.GuestMetadataStore, +) { + h.storeResolver = resolver +} + func (h *GuestMetadataHandler) getStore(ctx context.Context) *config.GuestMetadataStore { + if h != nil && h.storeResolver != nil { + if store := h.storeResolver(ctx); store != nil { + return store + } + } // Default to "default" org if none specified (though middleware should always set it) orgID := "default" if ctx != nil { @@ -33,15 +47,12 @@ func (h *GuestMetadataHandler) getStore(ctx context.Context) *config.GuestMetada return p.GetGuestMetadataStore() } -// Reload reloads the guest metadata from disk -func (h *GuestMetadataHandler) Reload() error { - // For multi-tenant, we might need to reload all loaded stores? - // Or we just rely on lazy loading. - // Since stores are cached in ConfigPersistence, we currently don't have an easy way to iterate all. - // But stores load on init. Reload() method on store might be needed if modified on disk externally. - // For now, this is a no-op or TODO for multi-tenant deep reload. - // Actually, we can get "default" store and reload it for legacy compat. - return h.getStore(context.Background()).Load() +// Reload reloads the request tenant's guest metadata from disk. +func (h *GuestMetadataHandler) Reload(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + return h.getStore(ctx).Load() } // Store returns the underlying metadata store for the default tenant (Legacy support) diff --git a/internal/api/guest_metadata_additional_test.go b/internal/api/guest_metadata_additional_test.go index ba4ce059d..9caa140c4 100644 --- a/internal/api/guest_metadata_additional_test.go +++ b/internal/api/guest_metadata_additional_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/config" @@ -10,7 +11,7 @@ func TestGuestMetadataHandler_Reload(t *testing.T) { mtp := config.NewMultiTenantPersistence(t.TempDir()) handler := NewGuestMetadataHandler(mtp) - if err := handler.Reload(); err != nil { + if err := handler.Reload(context.Background()); err != nil { t.Fatalf("Reload error: %v", err) } } diff --git a/internal/api/host_metadata.go b/internal/api/host_metadata.go index 13ca06987..0096f3c31 100644 --- a/internal/api/host_metadata.go +++ b/internal/api/host_metadata.go @@ -28,6 +28,7 @@ func hostMetadataPathParts(path string) (agentID string, isCollection bool, ok b // HostMetadataHandler handles agent metadata operations. type HostMetadataHandler struct { mtPersistence *config.MultiTenantPersistence + storeResolver func(context.Context) *config.HostMetadataStore } // NewHostMetadataHandler creates a new host metadata handler @@ -37,7 +38,20 @@ func NewHostMetadataHandler(mtPersistence *config.MultiTenantPersistence) *HostM } } +// SetStoreResolver makes API reads and writes use the active monitor's store. +// The persistence-backed store remains the initialization/test fallback. +func (h *HostMetadataHandler) SetStoreResolver( + resolver func(context.Context) *config.HostMetadataStore, +) { + h.storeResolver = resolver +} + func (h *HostMetadataHandler) getStore(ctx context.Context) *config.HostMetadataStore { + if h != nil && h.storeResolver != nil { + if store := h.storeResolver(ctx); store != nil { + return store + } + } orgID := "default" if ctx != nil { if requestOrgID := GetOrgID(ctx); requestOrgID != "" { diff --git a/internal/api/metadata_handlers_test.go b/internal/api/metadata_handlers_test.go index 9d3c3572c..ed2841e45 100644 --- a/internal/api/metadata_handlers_test.go +++ b/internal/api/metadata_handlers_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -146,3 +147,112 @@ func TestHostMetadataHandler(t *testing.T) { t.Fatalf("unexpected metadata via agent alias: %+v", meta) } } + +func TestMetadataHandlersUseLiveMonitorStoresForImmediateRoundTrips(t *testing.T) { + fallbackPersistence := config.NewMultiTenantPersistence(t.TempDir()) + defaultPersistence, err := fallbackPersistence.GetPersistence("default") + if err != nil { + t.Fatalf("get default persistence: %v", err) + } + + t.Run("guest", func(t *testing.T) { + livePath := t.TempDir() + liveStore := config.NewGuestMetadataStore(livePath, nil) + defaultPersistence.SetMetadataStores(liveStore, nil, nil) + handler := NewGuestMetadataHandler(fallbackPersistence) + resolvedOrgID := "" + handler.SetStoreResolver(func(ctx context.Context) *config.GuestMetadataStore { + resolvedOrgID = GetOrgID(ctx) + return liveStore + }) + + req := httptest.NewRequest( + http.MethodPut, + "/api/guests/metadata/app-container:host:name:app", + strings.NewReader(`{"customUrl":"https://app.internal"}`), + ) + resp := httptest.NewRecorder() + handler.HandleUpdateMetadata(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("update status = %d, body = %s", resp.Code, resp.Body.String()) + } + if meta := liveStore.Get("app-container:host:name:app"); meta == nil || meta.CustomURL != "https://app.internal" { + t.Fatalf("live guest store metadata = %#v", meta) + } + if defaultPersistence.GetGuestMetadataStore() != liveStore { + t.Fatal("persistence and monitor guest stores do not share one live instance") + } + + // A config import writes the same file out of band, then Reload must + // refresh the monitor-owned in-memory store rather than a parallel + // persistence cache. + importWriter := config.NewGuestMetadataStore(livePath, nil) + if err := importWriter.Set("imported", &config.GuestMetadata{ + CustomURL: "https://imported.internal", + }); err != nil { + t.Fatalf("write imported guest metadata: %v", err) + } + tenantContext := context.WithValue(context.Background(), OrgIDContextKey, "tenant-a") + if err := handler.Reload(tenantContext); err != nil { + t.Fatalf("reload live guest metadata: %v", err) + } + if resolvedOrgID != "tenant-a" { + t.Fatalf("reload resolved org %q, want tenant-a", resolvedOrgID) + } + if meta := liveStore.Get("imported"); meta == nil || meta.CustomURL != "https://imported.internal" { + t.Fatalf("reloaded live guest store metadata = %#v", meta) + } + }) + + t.Run("docker", func(t *testing.T) { + liveStore := config.NewDockerMetadataStore(t.TempDir(), nil) + defaultPersistence.SetMetadataStores(nil, liveStore, nil) + handler := NewDockerMetadataHandler(fallbackPersistence) + handler.SetStoreResolver(func(context.Context) *config.DockerMetadataStore { + return liveStore + }) + + req := httptest.NewRequest( + http.MethodPut, + "/api/docker/metadata/docker-host:container:runtime-id", + strings.NewReader(`{"customUrl":"https://container.internal"}`), + ) + resp := httptest.NewRecorder() + handler.HandleUpdateMetadata(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("update status = %d, body = %s", resp.Code, resp.Body.String()) + } + if meta := liveStore.Get("docker-host:container:runtime-id"); meta == nil || meta.CustomURL != "https://container.internal" { + t.Fatalf("live Docker store metadata = %#v", meta) + } + if defaultPersistence.GetDockerMetadataStore() != liveStore { + t.Fatal("persistence and monitor Docker stores do not share one live instance") + } + }) + + t.Run("host", func(t *testing.T) { + liveStore := config.NewHostMetadataStore(t.TempDir(), nil) + defaultPersistence.SetMetadataStores(nil, nil, liveStore) + handler := NewHostMetadataHandler(fallbackPersistence) + handler.SetStoreResolver(func(context.Context) *config.HostMetadataStore { + return liveStore + }) + + req := httptest.NewRequest( + http.MethodPut, + "/api/agents/metadata/agent-live", + strings.NewReader(`{"customUrl":"https://agent.internal"}`), + ) + resp := httptest.NewRecorder() + handler.HandleUpdateMetadata(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("update status = %d, body = %s", resp.Code, resp.Body.String()) + } + if meta := liveStore.Get("agent-live"); meta == nil || meta.CustomURL != "https://agent.internal" { + t.Fatalf("live host store metadata = %#v", meta) + } + if defaultPersistence.GetHostMetadataStore() != liveStore { + t.Fatal("persistence and monitor host stores do not share one live instance") + } + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index a998f342a..8afd66cae 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -432,7 +432,26 @@ func (r *Router) setupRoutes() { r.configHandlers = NewConfigHandlers(r.multiTenant, r.mtMonitor, r.reloadFunc, r.wsHub, guestMetadataHandler, r.reloadSystemSettings) if r.monitor != nil { r.configHandlers.SetMonitor(r.monitor) + r.bindDefaultMetadataStores(r.monitor) } + guestMetadataHandler.SetStoreResolver(func(ctx context.Context) *config.GuestMetadataStore { + if monitor := r.configHandlers.getMonitor(ctx); monitor != nil { + return monitor.GuestMetadataStore() + } + return nil + }) + dockerMetadataHandler.SetStoreResolver(func(ctx context.Context) *config.DockerMetadataStore { + if monitor := r.configHandlers.getMonitor(ctx); monitor != nil { + return monitor.DockerMetadataStore() + } + return nil + }) + hostMetadataHandler.SetStoreResolver(func(ctx context.Context) *config.HostMetadataStore { + if monitor := r.configHandlers.getMonitor(ctx); monitor != nil { + return monitor.HostMetadataStore() + } + return nil + }) r.configHandlers.SetConfig(r.config) r.configHandlers.SetMockModeChangeHook(r.syncPlatformSupplementalProviders) r.trueNASHandlers = &TrueNASHandlers{ @@ -755,14 +774,9 @@ func (r *Router) setupRoutes() { } else { log.Warn().Msg("[Router] unified resource provider is nil, cannot inject unified resource provider") } - // Inject metadata provider for AI URL discovery feature - // This allows AI to set resource URLs when it discovers web services - metadataProvider := NewMetadataProvider( - guestMetadataHandler.Store(), - dockerMetadataHandler.Store(), - hostMetadataHandler.Store(), - ) - r.aiSettingsHandler.SetMetadataProvider(metadataProvider) + // Inject tenant-scoped metadata providers for AI URL discovery. Existing + // tenant services are refreshed when Router.SetMonitor replaces a monitor. + r.configureMetadataProviderFactory() // Wire the per-tenant AI narrator, fleet narrator, and Patrol // findings provider into reporting. The AI service implements all @@ -1483,6 +1497,8 @@ func (r *Router) startLifecycleWorker(worker func()) { // SetMonitor updates the router and associated handlers with a new monitor instance. func (r *Router) SetMonitor(m *monitoring.Monitor) { r.monitor = m + r.bindDefaultMetadataStores(m) + r.configureMetadataProviderFactory() if r.alertHandlers != nil { r.alertHandlers.SetMonitor(NewAlertMonitorWrapper(m)) } @@ -1541,6 +1557,72 @@ func (r *Router) SetMonitor(m *monitoring.Monitor) { } } +func (r *Router) bindDefaultMetadataStores(monitor *monitoring.Monitor) { + if r == nil || monitor == nil { + return + } + if r.persistence != nil { + r.persistence.SetMetadataStores( + monitor.GuestMetadataStore(), + monitor.DockerMetadataStore(), + monitor.HostMetadataStore(), + ) + } + if r.multiTenant != nil { + if persistence, err := r.multiTenant.GetPersistence("default"); err == nil { + persistence.SetMetadataStores( + monitor.GuestMetadataStore(), + monitor.DockerMetadataStore(), + monitor.HostMetadataStore(), + ) + } + } +} + +func (r *Router) configureMetadataProviderFactory() { + if r == nil || r.aiSettingsHandler == nil { + return + } + r.aiSettingsHandler.SetMetadataProviderFactory(func(orgID string) ai.MetadataProvider { + orgID = strings.TrimSpace(orgID) + if orgID == "" { + orgID = "default" + } + + var monitor *monitoring.Monitor + if orgID == "default" { + monitor = r.monitor + } else if r.mtMonitor != nil { + monitor, _ = r.mtMonitor.GetMonitor(orgID) + } + if monitor != nil { + return NewMetadataProvider( + monitor.GuestMetadataStore(), + monitor.DockerMetadataStore(), + monitor.HostMetadataStore(), + ) + } + + if r.multiTenant != nil { + if persistence, err := r.multiTenant.GetPersistence(orgID); err == nil && persistence != nil { + return NewMetadataProvider( + persistence.GetGuestMetadataStore(), + persistence.GetDockerMetadataStore(), + persistence.GetHostMetadataStore(), + ) + } + } + if orgID == "default" && r.persistence != nil { + return NewMetadataProvider( + r.persistence.GetGuestMetadataStore(), + r.persistence.GetDockerMetadataStore(), + r.persistence.GetHostMetadataStore(), + ) + } + return nil + }) +} + func (r *Router) configureProxmoxGuestDockerDetection(m *monitoring.Monitor) { if r == nil || m == nil { return diff --git a/internal/api/router_helpers.go b/internal/api/router_helpers.go index afa89efd2..6382d946b 100644 --- a/internal/api/router_helpers.go +++ b/internal/api/router_helpers.go @@ -115,9 +115,11 @@ func (r *Router) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) { if mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil { r.monitor = m + r.bindDefaultMetadataStores(m) } mtm.SetMonitorInitializer(r.configureMonitorDependencies) } + r.configureMetadataProviderFactory() // Wire tenant state provider to resource handlers if r.resourceHandlers != nil { diff --git a/internal/api/router_set_monitor_agents_test.go b/internal/api/router_set_monitor_agents_test.go index b196c52b9..ca4172040 100644 --- a/internal/api/router_set_monitor_agents_test.go +++ b/internal/api/router_set_monitor_agents_test.go @@ -37,13 +37,17 @@ func TestRouterSetMonitor_UpdatesAgentHandlers(t *testing.T) { // fallback, which is exactly the field SetMonitor must refresh. kubernetesHandlers := NewKubernetesAgentHandlers(nil, monitor1, nil) dockerHandlers := NewDockerAgentHandlers(nil, monitor1, nil, cfg) + multiTenantPersistence := config.NewMultiTenantPersistence(tempDir) // Minimal router, as in TestReloadSystemSettings_AppliesWebhookCIDRsToNewMonitor: // SetMonitor only touches the handlers that are non-nil. router := &Router{ config: cfg, + persistence: config.NewConfigPersistence(tempDir), + multiTenant: multiTenantPersistence, kubernetesAgentHandlers: kubernetesHandlers, dockerAgentHandlers: dockerHandlers, + aiSettingsHandler: NewAISettingsHandler(multiTenantPersistence, nil, nil), } router.SetMonitor(monitor2) @@ -55,4 +59,26 @@ func TestRouterSetMonitor_UpdatesAgentHandlers(t *testing.T) { if got := dockerHandlers.getMonitor(ctx); got != monitor2 { t.Fatal("docker agent handlers still resolve the pre-reload monitor after Router.SetMonitor") } + if got := router.persistence.GetGuestMetadataStore(); got != monitor2.GuestMetadataStore() { + t.Fatal("config persistence still resolves the pre-reload guest metadata store") + } + if got := router.persistence.GetDockerMetadataStore(); got != monitor2.DockerMetadataStore() { + t.Fatal("config persistence still resolves the pre-reload Docker metadata store") + } + if got := router.persistence.GetHostMetadataStore(); got != monitor2.HostMetadataStore() { + t.Fatal("config persistence still resolves the pre-reload host metadata store") + } + if err := router.aiSettingsHandler.GetAIService(ctx).SetResourceURL( + "vm", + "instance:node:100", + "https://guest.internal", + ); err != nil { + t.Fatalf("AI metadata update after monitor reload: %v", err) + } + if meta := monitor2.GuestMetadataStore().Get("instance:node:100"); meta == nil || meta.CustomURL != "https://guest.internal" { + t.Fatalf("replacement monitor guest metadata = %#v", meta) + } + if meta := monitor1.GuestMetadataStore().Get("instance:node:100"); meta != nil { + t.Fatalf("pre-reload monitor received URL update: %#v", meta) + } } diff --git a/internal/api/subscription_entitlements.go b/internal/api/subscription_entitlements.go index d83512596..aa3243e20 100644 --- a/internal/api/subscription_entitlements.go +++ b/internal/api/subscription_entitlements.go @@ -8,6 +8,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" ) // EntitlementPayload is the normalized entitlement response for frontend consumption. @@ -183,8 +184,10 @@ func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlem // Count canonical top-level monitored systems from monitor state. var monitorResolved bool + var resolvedMonitor *monitoring.Monitor if h.mtMonitor != nil { if monitor, err := h.mtMonitor.GetMonitor(orgID); err == nil && monitor != nil { + resolvedMonitor = monitor state := monitor.MonitoredSystemUsage() if state.Available { usage.MonitoredSystems = int64(state.Count) @@ -197,6 +200,7 @@ func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlem } } if !monitorResolved && orgID == "default" && h.monitor != nil { + resolvedMonitor = h.monitor state := h.monitor.MonitoredSystemUsage() if state.Available { usage.MonitoredSystems = int64(state.Count) @@ -208,7 +212,9 @@ func (h *LicenseHandlers) entitlementUsageSnapshot(ctx context.Context) entitlem } // Guest metadata for guest limit tracking. - if h.mtPersistence != nil { + if resolvedMonitor != nil && resolvedMonitor.GuestMetadataStore() != nil { + usage.Guests = int64(len(resolvedMonitor.GuestMetadataStore().GetAll())) + } else if h.mtPersistence != nil { if persistence, err := h.mtPersistence.GetPersistence(orgID); err == nil && persistence != nil { if guestStore := persistence.GetGuestMetadataStore(); guestStore != nil { usage.Guests = int64(len(guestStore.GetAll())) diff --git a/internal/config/docker_metadata.go b/internal/config/docker_metadata.go index 618bf6910..7bdf2602a 100644 --- a/internal/config/docker_metadata.go +++ b/internal/config/docker_metadata.go @@ -273,3 +273,53 @@ func (s *DockerMetadataStore) ReplaceAll(metadata map[string]*DockerMetadata) er return s.save() } + +// UpdateAll applies one atomic in-memory container-metadata mutation and +// persists it once while preserving host metadata. Concurrent Set/Delete +// calls cannot be lost between snapshot and replacement. +func (s *DockerMetadataStore) UpdateAll( + update func(map[string]*DockerMetadata) bool, +) error { + if update == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + working := make(map[string]*DockerMetadata, len(s.metadata)) + for id, meta := range s.metadata { + if meta == nil { + continue + } + clone := *meta + if len(meta.Tags) > 0 { + clone.Tags = append([]string(nil), meta.Tags...) + } + if len(meta.Notes) > 0 { + clone.Notes = append([]string(nil), meta.Notes...) + } + working[id] = &clone + } + if !update(working) { + return nil + } + for id, meta := range working { + if meta == nil { + delete(working, id) + continue + } + meta.ID = id + if meta.Tags == nil { + meta.Tags = []string{} + } + } + + previous := s.metadata + s.metadata = working + if err := s.save(); err != nil { + s.metadata = previous + return err + } + return nil +} diff --git a/internal/config/docker_metadata_test.go b/internal/config/docker_metadata_test.go index 04a4fe2c2..cca6a6d6b 100644 --- a/internal/config/docker_metadata_test.go +++ b/internal/config/docker_metadata_test.go @@ -145,3 +145,32 @@ func TestDockerMetadataStore_Save_Error(t *testing.T) { err := store.Set("c1", &DockerMetadata{ID: "c1"}) assert.Error(t, err) } + +func TestDockerMetadataStoreUpdateAllPreservesHostMetadata(t *testing.T) { + store := NewDockerMetadataStore(t.TempDir(), nil) + require.NoError(t, store.Set("old", &DockerMetadata{CustomURL: "https://old.internal"})) + require.NoError(t, store.SetHostMetadata("host", &DockerHostMetadata{ + CustomURL: "https://host.internal", + })) + + require.NoError(t, store.UpdateAll(func(metadata map[string]*DockerMetadata) bool { + metadata["new"] = metadata["old"] + delete(metadata, "old") + return true + })) + + assert.Nil(t, store.Get("old")) + require.NotNil(t, store.Get("new")) + assert.Equal(t, "new", store.Get("new").ID) + assert.Equal(t, "https://old.internal", store.Get("new").CustomURL) + require.NotNil(t, store.GetHostMetadata("host")) + assert.Equal(t, "https://host.internal", store.GetHostMetadata("host").CustomURL) + + reloaded := NewDockerMetadataStore(store.dataPath, nil) + assert.Nil(t, reloaded.Get("old")) + require.NotNil(t, reloaded.Get("new")) + assert.Equal(t, "new", reloaded.Get("new").ID) + assert.Equal(t, "https://old.internal", reloaded.Get("new").CustomURL) + require.NotNil(t, reloaded.GetHostMetadata("host")) + assert.Equal(t, "https://host.internal", reloaded.GetHostMetadata("host").CustomURL) +} diff --git a/internal/config/guest_metadata.go b/internal/config/guest_metadata.go index f086b76a6..c9390e121 100644 --- a/internal/config/guest_metadata.go +++ b/internal/config/guest_metadata.go @@ -147,6 +147,9 @@ func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node stri s.mu.Lock() defer s.mu.Unlock() + if current := s.metadata[guestID]; current != nil { + return cloneGuestMetadata(current) + } legacyMeta := s.metadata[legacyID] if legacyMeta == nil { return cloneGuestMetadata(s.metadata[guestID]) @@ -164,6 +167,8 @@ func (s *GuestMetadataStore) GetWithLegacyMigration(guestID, instance, node stri // Persist while holding the store lock. save() assumes locked access. if err := s.save(); err != nil { log.Error().Err(err).Msg("Failed to save guest metadata after migration") + s.metadata[legacyID] = legacyMeta + delete(s.metadata, guestID) } return cloneGuestMetadata(migrated) @@ -257,3 +262,44 @@ func (s *GuestMetadataStore) ReplaceAll(metadata map[string]*GuestMetadata) erro return s.save() } + +// UpdateAll applies one atomic in-memory mutation and persists it once. +// The callback receives a deep-cloned working set and returns whether it +// changed. Concurrent Set/Delete calls cannot be lost between snapshot and +// replacement. +func (s *GuestMetadataStore) UpdateAll( + update func(map[string]*GuestMetadata) bool, +) error { + if update == nil { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + working := make(map[string]*GuestMetadata, len(s.metadata)) + for id, meta := range s.metadata { + working[id] = cloneGuestMetadata(meta) + } + if !update(working) { + return nil + } + for id, meta := range working { + if meta == nil { + delete(working, id) + continue + } + meta.ID = id + if meta.Tags == nil { + meta.Tags = []string{} + } + } + + previous := s.metadata + s.metadata = working + if err := s.save(); err != nil { + s.metadata = previous + return err + } + return nil +} diff --git a/internal/config/guest_metadata_test.go b/internal/config/guest_metadata_test.go index e9718e33a..9133f4270 100644 --- a/internal/config/guest_metadata_test.go +++ b/internal/config/guest_metadata_test.go @@ -652,3 +652,61 @@ func TestGuestMetadataStore_GetWithLegacyMigration_ConcurrentMigration(t *testin t.Error("New ID should exist after migration") } } + +func TestGuestMetadataStoreUpdateAllAppliesOneWorkingSet(t *testing.T) { + store := NewGuestMetadataStore(t.TempDir(), nil) + if err := store.Set("old", &GuestMetadata{CustomURL: "https://old.internal"}); err != nil { + t.Fatalf("seed metadata: %v", err) + } + + if err := store.UpdateAll(func(metadata map[string]*GuestMetadata) bool { + metadata["new"] = metadata["old"] + delete(metadata, "old") + return true + }); err != nil { + t.Fatalf("UpdateAll: %v", err) + } + + if store.Get("old") != nil { + t.Fatal("old metadata key still exists") + } + if meta := store.Get("new"); meta == nil || meta.ID != "new" || meta.CustomURL != "https://old.internal" { + t.Fatalf("new metadata = %#v", meta) + } + + reloaded := NewGuestMetadataStore(store.dataPath, nil) + if reloaded.Get("old") != nil { + t.Fatal("persisted old metadata key still exists") + } + if meta := reloaded.Get("new"); meta == nil || meta.ID != "new" || meta.CustomURL != "https://old.internal" { + t.Fatalf("persisted new metadata = %#v", meta) + } +} + +func TestGuestMetadataStoreUpdateAllRollsBackFailedPersistence(t *testing.T) { + store := NewGuestMetadataStore(t.TempDir(), nil) + if err := store.Set("old", &GuestMetadata{CustomURL: "https://old.internal"}); err != nil { + t.Fatalf("seed metadata: %v", err) + } + + badPath := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(badPath, []byte("occupied"), 0o600); err != nil { + t.Fatalf("create invalid data path: %v", err) + } + store.dataPath = badPath + + err := store.UpdateAll(func(metadata map[string]*GuestMetadata) bool { + metadata["new"] = metadata["old"] + delete(metadata, "old") + return true + }) + if err == nil { + t.Fatal("expected persistence error") + } + if meta := store.Get("old"); meta == nil || meta.CustomURL != "https://old.internal" { + t.Fatalf("rolled-back old metadata = %#v", meta) + } + if meta := store.Get("new"); meta != nil { + t.Fatalf("failed update remained in memory: %#v", meta) + } +} diff --git a/internal/config/persistence_metadata_accessors.go b/internal/config/persistence_metadata_accessors.go index fadbc6e23..7cbce9875 100644 --- a/internal/config/persistence_metadata_accessors.go +++ b/internal/config/persistence_metadata_accessors.go @@ -1,5 +1,30 @@ package config +// SetMetadataStores makes persistence consumers share the active monitor's +// in-memory metadata stores. The stores already persist to this scope's data +// directory; sharing the instances prevents API, export/import, and monitor +// projection from observing different caches of the same files. +func (c *ConfigPersistence) SetMetadataStores( + guest *GuestMetadataStore, + docker *DockerMetadataStore, + host *HostMetadataStore, +) { + if c == nil { + return + } + c.metadataMu.Lock() + defer c.metadataMu.Unlock() + if guest != nil { + c.guestMetadataStore = guest + } + if docker != nil { + c.dockerMetadataStore = docker + } + if host != nil { + c.hostMetadataStore = host + } +} + // GetGuestMetadataStore returns the guest metadata store, creating it if necessary func (c *ConfigPersistence) GetGuestMetadataStore() *GuestMetadataStore { c.metadataMu.Lock() diff --git a/internal/config/persistence_metadata_test.go b/internal/config/persistence_metadata_test.go index 34fbbbad4..c1e0817a6 100644 --- a/internal/config/persistence_metadata_test.go +++ b/internal/config/persistence_metadata_test.go @@ -31,3 +31,16 @@ func TestConfigPersistence_LoadDockerMetadata(t *testing.T) { // Ensure we can use the store assert.Empty(t, store.GetAll()) } + +func TestConfigPersistenceSharesActiveMetadataStores(t *testing.T) { + cp := config.NewConfigPersistence(t.TempDir()) + guest := config.NewGuestMetadataStore(t.TempDir(), nil) + docker := config.NewDockerMetadataStore(t.TempDir(), nil) + host := config.NewHostMetadataStore(t.TempDir(), nil) + + cp.SetMetadataStores(guest, docker, host) + + assert.Same(t, guest, cp.GetGuestMetadataStore()) + assert.Same(t, docker, cp.GetDockerMetadataStore()) + assert.Same(t, host, cp.GetHostMetadataStore()) +} diff --git a/internal/monitoring/docker_metadata_migration.go b/internal/monitoring/docker_metadata_migration.go index 955061c8b..dff8f75e7 100644 --- a/internal/monitoring/docker_metadata_migration.go +++ b/internal/monitoring/docker_metadata_migration.go @@ -34,44 +34,48 @@ func (m *Monitor) CopyDockerContainerMetadata(hostID, oldContainerID, newContain oldKey := dockerContainerRuntimeMetadataKey(hostID, oldContainerID) newKey := dockerContainerRuntimeMetadataKey(hostID, newContainerID) - oldMeta := m.dockerMetadataStore.Get(oldKey) - if oldMeta == nil { - return nil - } - if oldMeta.CustomURL == "" && oldMeta.Description == "" && len(oldMeta.Tags) == 0 && len(oldMeta.Notes) == 0 { - return nil - } + return m.dockerMetadataStore.UpdateAll(func(metadata map[string]*config.DockerMetadata) bool { + oldMeta := metadata[oldKey] + if oldMeta == nil { + return false + } + if oldMeta.CustomURL == "" && oldMeta.Description == "" && len(oldMeta.Tags) == 0 && len(oldMeta.Notes) == 0 { + return false + } - newMeta := m.dockerMetadataStore.Get(newKey) - var merged config.DockerMetadata - if newMeta != nil { - merged = *newMeta - } + newMeta := metadata[newKey] + var merged config.DockerMetadata + if newMeta != nil { + merged = *newMeta + } - // Merge missing fields from old -> new, so we don't clobber any metadata already present under the new ID. - if merged.CustomURL == "" { - merged.CustomURL = oldMeta.CustomURL - } - if merged.Description == "" { - merged.Description = oldMeta.Description - } - if len(merged.Tags) == 0 && len(oldMeta.Tags) > 0 { - merged.Tags = append([]string(nil), oldMeta.Tags...) - } - if len(merged.Notes) == 0 && len(oldMeta.Notes) > 0 { - merged.Notes = append([]string(nil), oldMeta.Notes...) - } + // Merge missing fields from old -> new, so a concurrent or already + // persisted value under the recreated runtime ID always wins. + if merged.CustomURL == "" { + merged.CustomURL = oldMeta.CustomURL + } + if merged.Description == "" { + merged.Description = oldMeta.Description + } + if len(merged.Tags) == 0 && len(oldMeta.Tags) > 0 { + merged.Tags = append([]string(nil), oldMeta.Tags...) + } + if len(merged.Notes) == 0 && len(oldMeta.Notes) > 0 { + merged.Notes = append([]string(nil), oldMeta.Notes...) + } - // Avoid an unnecessary disk write if nothing changed. - if newMeta != nil && - merged.CustomURL == newMeta.CustomURL && - merged.Description == newMeta.Description && - slices.Equal(merged.Tags, newMeta.Tags) && - slices.Equal(merged.Notes, newMeta.Notes) { - return nil - } + if newMeta != nil && + merged.CustomURL == newMeta.CustomURL && + merged.Description == newMeta.Description && + slices.Equal(merged.Tags, newMeta.Tags) && + slices.Equal(merged.Notes, newMeta.Notes) { + return false + } - return m.dockerMetadataStore.Set(newKey, &merged) + merged.ID = newKey + metadata[newKey] = &merged + return true + }) } func dockerContainerRuntimeMetadataKey(hostID, containerID string) string { @@ -148,24 +152,28 @@ func copyDockerMetadataAliasIfTargetMissing(store *config.DockerMetadataStore, s if store == nil || strings.TrimSpace(sourceKey) == "" || strings.TrimSpace(targetKey) == "" { return nil } - if store.Get(targetKey) != nil { - return nil - } - source := store.Get(sourceKey) - if source == nil { - return nil - } - clone := &config.DockerMetadata{ - CustomURL: source.CustomURL, - Description: source.Description, - } - if len(source.Tags) > 0 { - clone.Tags = append([]string(nil), source.Tags...) - } - if len(source.Notes) > 0 { - clone.Notes = append([]string(nil), source.Notes...) - } - return store.Set(targetKey, clone) + return store.UpdateAll(func(metadata map[string]*config.DockerMetadata) bool { + if metadata[targetKey] != nil { + return false + } + source := metadata[sourceKey] + if source == nil { + return false + } + clone := &config.DockerMetadata{ + ID: targetKey, + CustomURL: source.CustomURL, + Description: source.Description, + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + metadata[targetKey] = clone + return true + }) } func copyGuestMetadataAliasIfTargetMissing( @@ -177,26 +185,248 @@ func copyGuestMetadataAliasIfTargetMissing( if store == nil || strings.TrimSpace(sourceKey) == "" || strings.TrimSpace(targetKey) == "" { return nil } - if store.Get(targetKey) != nil { + return store.UpdateAll(func(metadata map[string]*config.GuestMetadata) bool { + if metadata[targetKey] != nil { + return false + } + source := metadata[sourceKey] + if source == nil { + return false + } + clone := &config.GuestMetadata{ + ID: targetKey, + CustomURL: source.CustomURL, + Description: source.Description, + LastKnownName: normalizeDockerContainerMetadataIdentity(containerName), + LastKnownType: "app-container", + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + metadata[targetKey] = clone + return true + }) +} + +type dockerStableMetadataMove struct { + sourceKey string + targetKey string + targetName string +} + +func dockerContainerStableMetadataMoves( + hostID string, + previousContainers []models.DockerContainer, + currentContainers []models.DockerContainer, + keyForName func(string, string) string, +) []dockerStableMetadataMove { + previousByID := make(map[string]models.DockerContainer, len(previousContainers)) + ambiguousIDs := make(map[string]struct{}) + previousNameCounts := make(map[string]int, len(previousContainers)) + for _, container := range previousContainers { + containerID := strings.TrimSpace(container.ID) + if name := normalizeDockerContainerMetadataIdentity(container.Name); name != "" { + previousNameCounts[keyForName(hostID, name)]++ + } + if containerID == "" { + continue + } + if _, exists := previousByID[containerID]; exists { + ambiguousIDs[containerID] = struct{}{} + delete(previousByID, containerID) + continue + } + if _, ambiguous := ambiguousIDs[containerID]; ambiguous { + continue + } + previousByID[containerID] = container + } + + currentNameCounts := make(map[string]int, len(currentContainers)) + for _, container := range currentContainers { + if name := normalizeDockerContainerMetadataIdentity(container.Name); name != "" { + currentNameCounts[keyForName(hostID, name)]++ + } + } + + sourceCounts := make(map[string]int) + targetCounts := make(map[string]int) + candidates := make([]dockerStableMetadataMove, 0) + for _, current := range currentContainers { + containerID := strings.TrimSpace(current.ID) + if containerID == "" { + continue + } + previous, ok := previousByID[containerID] + if !ok { + continue + } + previousName := normalizeDockerContainerMetadataIdentity(previous.Name) + currentName := normalizeDockerContainerMetadataIdentity(current.Name) + if previousName == "" || currentName == "" || previousName == currentName { + continue + } + sourceKey := keyForName(hostID, previousName) + targetKey := keyForName(hostID, currentName) + if sourceKey == "" || targetKey == "" || sourceKey == targetKey { + continue + } + candidates = append(candidates, dockerStableMetadataMove{ + sourceKey: sourceKey, + targetKey: targetKey, + targetName: currentName, + }) + sourceCounts[sourceKey]++ + targetCounts[targetKey]++ + } + + moves := make([]dockerStableMetadataMove, 0, len(candidates)) + for _, move := range candidates { + if sourceCounts[move.sourceKey] == 1 && + targetCounts[move.targetKey] == 1 && + previousNameCounts[move.sourceKey] == 1 && + currentNameCounts[move.targetKey] == 1 { + moves = append(moves, move) + } + } + return moves +} + +func applyStableMetadataMoves[T any]( + moves []dockerStableMetadataMove, + updateAll func(func(map[string]*T) bool) error, + cloneForTarget func(*T, dockerStableMetadataMove) *T, +) error { + if len(moves) == 0 { return nil } - source := store.Get(sourceKey) - if source == nil { - return nil + return updateAll(func(metadata map[string]*T) bool { + sourceSnapshots := make(map[string]*T, len(moves)) + sourceKeys := make(map[string]struct{}, len(moves)) + targetKeys := make(map[string]struct{}, len(moves)) + for _, move := range moves { + sourceKeys[move.sourceKey] = struct{}{} + targetKeys[move.targetKey] = struct{}{} + if _, loaded := sourceSnapshots[move.sourceKey]; !loaded { + sourceSnapshots[move.sourceKey] = metadata[move.sourceKey] + } + } + + changed := false + for _, move := range moves { + source := sourceSnapshots[move.sourceKey] + if source == nil { + continue + } + target := metadata[move.targetKey] + _, targetIsMovingSource := sourceKeys[move.targetKey] + if target != nil && !targetIsMovingSource { + // The destination already has intentional name-scoped metadata. + // Preserve it rather than attaching a renamed container's metadata + // to a name that may represent a different logical resource. + continue + } + metadata[move.targetKey] = cloneForTarget(source, move) + changed = true + } + + for _, move := range moves { + if sourceSnapshots[move.sourceKey] == nil { + continue + } + if _, sourceIsAnotherTarget := targetKeys[move.sourceKey]; sourceIsAnotherTarget { + continue + } + delete(metadata, move.sourceKey) + changed = true + } + return changed + }) +} + +func (m *Monitor) migrateDockerContainerMetadataForRenamedContainers( + hostID string, + previousContainers []models.DockerContainer, + currentContainers []models.DockerContainer, +) { + if m == nil { + return } - clone := &config.GuestMetadata{ - CustomURL: source.CustomURL, - Description: source.Description, - LastKnownName: normalizeDockerContainerMetadataIdentity(containerName), - LastKnownType: "app-container", + hostID = strings.TrimSpace(hostID) + if hostID == "" || len(previousContainers) == 0 || len(currentContainers) == 0 { + return } - if len(source.Tags) > 0 { - clone.Tags = append([]string(nil), source.Tags...) + + if m.guestMetadataStore != nil { + moves := dockerContainerStableMetadataMoves( + hostID, + previousContainers, + currentContainers, + dockerAppContainerMetadataKey, + ) + err := applyStableMetadataMoves( + moves, + m.guestMetadataStore.UpdateAll, + func(source *config.GuestMetadata, move dockerStableMetadataMove) *config.GuestMetadata { + clone := &config.GuestMetadata{ + CustomURL: source.CustomURL, + Description: source.Description, + LastKnownName: move.targetName, + LastKnownType: "app-container", + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + return clone + }, + ) + if err != nil { + log.Warn(). + Err(err). + Str("dockerHostID", hostID). + Int("renameCount", len(moves)). + Msg("Failed to move guest metadata after Docker container rename") + } } - if len(source.Notes) > 0 { - clone.Notes = append([]string(nil), source.Notes...) + + if m.dockerMetadataStore != nil { + moves := dockerContainerStableMetadataMoves( + hostID, + previousContainers, + currentContainers, + dockerContainerNameMetadataKey, + ) + err := applyStableMetadataMoves( + moves, + m.dockerMetadataStore.UpdateAll, + func(source *config.DockerMetadata, _ dockerStableMetadataMove) *config.DockerMetadata { + clone := &config.DockerMetadata{ + CustomURL: source.CustomURL, + Description: source.Description, + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + return clone + }, + ) + if err != nil { + log.Warn(). + Err(err). + Str("dockerHostID", hostID). + Int("renameCount", len(moves)). + Msg("Failed to move Docker metadata after container rename") + } } - return store.Set(targetKey, clone) } func (m *Monitor) migrateCurrentDockerContainerMetadataToStableIdentities( @@ -260,19 +490,26 @@ func (m *Monitor) migrateCurrentDockerContainerMetadataToStableIdentities( source = m.dockerMetadataStore.Get(dockerContainerNameMetadataKey(hostID, containerName)) } if source != nil { - clone := &config.GuestMetadata{ - CustomURL: source.CustomURL, - Description: source.Description, - LastKnownName: containerName, - LastKnownType: "app-container", - } - if len(source.Tags) > 0 { - clone.Tags = append([]string(nil), source.Tags...) - } - if len(source.Notes) > 0 { - clone.Notes = append([]string(nil), source.Notes...) - } - if err := m.guestMetadataStore.Set(stableKey, clone); err != nil { + if err := m.guestMetadataStore.UpdateAll(func(metadata map[string]*config.GuestMetadata) bool { + if metadata[stableKey] != nil { + return false + } + clone := &config.GuestMetadata{ + ID: stableKey, + CustomURL: source.CustomURL, + Description: source.Description, + LastKnownName: containerName, + LastKnownType: "app-container", + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + metadata[stableKey] = clone + return true + }); err != nil { log.Warn(). Err(err). Str("dockerHostID", hostID). diff --git a/internal/monitoring/kubernetes_metadata_migration.go b/internal/monitoring/kubernetes_metadata_migration.go new file mode 100644 index 000000000..2d059a0d2 --- /dev/null +++ b/internal/monitoring/kubernetes_metadata_migration.go @@ -0,0 +1,128 @@ +package monitoring + +import ( + "fmt" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rs/zerolog/log" +) + +func kubernetesWorkloadMetadataKey(resource unifiedresources.Resource) string { + if resource.Kubernetes == nil { + return "" + } + + clusterID := strings.TrimSpace(resource.Kubernetes.ClusterID) + namespace := strings.TrimSpace(resource.Kubernetes.Namespace) + name := strings.TrimSpace(resource.Name) + if clusterID == "" || namespace == "" || name == "" { + return "" + } + + var kind string + switch unifiedresources.CanonicalResourceType(resource.Type) { + case unifiedresources.ResourceTypePod: + kind = "pod" + case unifiedresources.ResourceTypeK8sDeployment: + kind = "deployment" + case unifiedresources.ResourceTypeK8sService: + kind = "service" + default: + return "" + } + + return fmt.Sprintf("k8s-workload:%s:%s:%s:%s", clusterID, kind, namespace, name) +} + +func kubernetesWorkloadLegacyMetadataKeys(resource unifiedresources.Resource) []string { + candidates := []string{strings.TrimSpace(resource.ID)} + if resource.Kubernetes != nil && + unifiedresources.CanonicalResourceType(resource.Type) == unifiedresources.ResourceTypePod { + clusterID := strings.TrimSpace(resource.Kubernetes.ClusterID) + podUID := strings.TrimSpace(resource.Kubernetes.PodUID) + if clusterID != "" && podUID != "" { + candidates = append(candidates, fmt.Sprintf("k8s:%s:pod:%s", clusterID, podUID)) + } + } + + out := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + if candidate == "" { + continue + } + if _, exists := seen[candidate]; exists { + continue + } + seen[candidate] = struct{}{} + out = append(out, candidate) + } + return out +} + +func cloneKubernetesGuestMetadata( + source *config.GuestMetadata, + resource unifiedresources.Resource, +) *config.GuestMetadata { + clone := &config.GuestMetadata{ + CustomURL: source.CustomURL, + Description: source.Description, + LastKnownName: strings.TrimSpace(resource.Name), + LastKnownType: string(unifiedresources.CanonicalResourceType(resource.Type)), + } + if len(source.Tags) > 0 { + clone.Tags = append([]string(nil), source.Tags...) + } + if len(source.Notes) > 0 { + clone.Notes = append([]string(nil), source.Notes...) + } + return clone +} + +func (m *Monitor) kubernetesWorkloadCustomURL(resource unifiedresources.Resource) (string, bool) { + if m == nil || m.guestMetadataStore == nil { + return "", false + } + + stableKey := kubernetesWorkloadMetadataKey(resource) + if stableKey == "" { + return "", false + } + var ( + customURL string + found bool + migratedLegacy string + ) + err := m.guestMetadataStore.UpdateAll(func(metadata map[string]*config.GuestMetadata) bool { + if meta := metadata[stableKey]; meta != nil { + customURL = strings.TrimSpace(meta.CustomURL) + found = true + return false + } + for _, legacyKey := range kubernetesWorkloadLegacyMetadataKeys(resource) { + meta := metadata[legacyKey] + if meta == nil { + continue + } + customURL = strings.TrimSpace(meta.CustomURL) + found = true + migratedLegacy = legacyKey + clone := cloneKubernetesGuestMetadata(meta, resource) + clone.ID = stableKey + metadata[stableKey] = clone + return true + } + return false + }) + if err != nil { + log.Warn(). + Err(err). + Str("resourceID", resource.ID). + Str("legacyMetadataKey", migratedLegacy). + Str("stableMetadataKey", stableKey). + Msg("Failed to migrate Kubernetes workload metadata to stable logical identity") + } + return customURL, found +} diff --git a/internal/monitoring/metadata_stores.go b/internal/monitoring/metadata_stores.go new file mode 100644 index 000000000..3f72ce71d --- /dev/null +++ b/internal/monitoring/metadata_stores.go @@ -0,0 +1,30 @@ +package monitoring + +import "github.com/rcourtman/pulse-go-rewrite/internal/config" + +// GuestMetadataStore returns the live guest metadata store used by resource +// projection, migration, and monitoring decisions. +func (m *Monitor) GuestMetadataStore() *config.GuestMetadataStore { + if m == nil { + return nil + } + return m.guestMetadataStore +} + +// DockerMetadataStore returns the live Docker metadata store used by resource +// projection, migration, and monitoring decisions. +func (m *Monitor) DockerMetadataStore() *config.DockerMetadataStore { + if m == nil { + return nil + } + return m.dockerMetadataStore +} + +// HostMetadataStore returns the live host metadata store used by resource +// projection and monitoring decisions. +func (m *Monitor) HostMetadataStore() *config.HostMetadataStore { + if m == nil { + return nil + } + return m.hostMetadataStore +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index b1a8ba49d..c92f010fb 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -4008,7 +4008,7 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS unifiedView := m.currentUnifiedStateView() metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState) broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources) - broadcastResources = m.applyDockerMetadataToUnifiedResources(broadcastResources) + broadcastResources = m.applyPersistedMetadataToUnifiedResources(broadcastResources) frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver) frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot) if !unifiedView.freshness.IsZero() { @@ -4618,7 +4618,7 @@ func (m *Monitor) UnifiedResourceSnapshot() ([]unifiedresources.Resource, time.T // REST /api/resources seeds its registry from this snapshot. Apply the // same user-metadata hydration (container customUrl) as the websocket // broadcast path, so the two payload shapes cannot drift. - return m.applyDockerMetadataToUnifiedResources(view.resources), view.freshness + return m.applyPersistedMetadataToUnifiedResources(view.resources), view.freshness } // GetUnifiedReadState returns a typed unified read-state provider when the @@ -5347,12 +5347,12 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend { store := m.resourceStore m.mu.RUnlock() return convertResourcesForBroadcast( - m.applyDockerMetadataToUnifiedResources(m.getUnifiedResourcesForBroadcast()), + m.applyPersistedMetadataToUnifiedResources(m.getUnifiedResourcesForBroadcast()), broadcastMetricsTargetResolver(store), ) } -func (m *Monitor) applyDockerMetadataToUnifiedResources(resources []unifiedresources.Resource) []unifiedresources.Resource { +func (m *Monitor) applyPersistedMetadataToUnifiedResources(resources []unifiedresources.Resource) []unifiedresources.Resource { if len(resources) == 0 || m == nil { return resources } @@ -5361,16 +5361,29 @@ func (m *Monitor) applyDockerMetadataToUnifiedResources(resources []unifiedresou copy(out, resources) for i := range out { resource := &out[i] - if resource.Type != unifiedresources.ResourceTypeAppContainer || strings.TrimSpace(resource.CustomURL) != "" || resource.Docker == nil { + if strings.TrimSpace(resource.CustomURL) != "" { continue } - hostID := strings.TrimSpace(resource.Docker.HostSourceID) - containerID := strings.TrimSpace(resource.Docker.ContainerID) - if hostID == "" { - continue - } - if customURL, ok := m.dockerAppContainerCustomURL(*resource, hostID, containerID); ok { - resource.CustomURL = customURL + + switch unifiedresources.CanonicalResourceType(resource.Type) { + case unifiedresources.ResourceTypeAppContainer: + if resource.Docker == nil { + continue + } + hostID := strings.TrimSpace(resource.Docker.HostSourceID) + containerID := strings.TrimSpace(resource.Docker.ContainerID) + if hostID == "" { + continue + } + if customURL, ok := m.dockerAppContainerCustomURL(*resource, hostID, containerID); ok { + resource.CustomURL = customURL + } + case unifiedresources.ResourceTypePod, + unifiedresources.ResourceTypeK8sDeployment, + unifiedresources.ResourceTypeK8sService: + if customURL, ok := m.kubernetesWorkloadCustomURL(*resource); ok { + resource.CustomURL = customURL + } } } return out diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 44574ae8c..4ca95fc78 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -1969,6 +1969,7 @@ func (m *Monitor) ApplyDockerReport(report agentsdocker.Report, tokenRecord *con } if hasPrevious { + m.migrateDockerContainerMetadataForRenamedContainers(identifier, previous.Containers(), host.Containers) m.migrateDockerContainerMetadataForRecreatedContainers(identifier, previous.Containers(), host.Containers) } m.migrateCurrentDockerContainerMetadataToStableIdentities(identifier, host.Containers) diff --git a/internal/monitoring/monitor_docker_test.go b/internal/monitoring/monitor_docker_test.go index 42682f715..afdfc60e8 100644 --- a/internal/monitoring/monitor_docker_test.go +++ b/internal/monitoring/monitor_docker_test.go @@ -511,12 +511,196 @@ func TestApplyDockerReportMigratesGuestMetadataToStableContainerName(t *testing. }, } - got := monitor.applyDockerMetadataToUnifiedResources(resources) + got := monitor.applyPersistedMetadataToUnifiedResources(resources) if got[0].CustomURL != "https://app.internal" { t.Fatalf("CustomURL after recreate gap = %q, want https://app.internal", got[0].CustomURL) } } +func TestApplyDockerReportMovesStableMetadataOnRenameWithoutLeakingToReusedName(t *testing.T) { + monitor := newTestMonitor(t) + + baseTimestamp := time.Now().UTC() + report := agentsdocker.Report{ + Agent: agentsdocker.AgentInfo{ + ID: "agent-rename", + Version: "1.0.0", + IntervalSeconds: 30, + }, + Host: agentsdocker.HostInfo{ + Hostname: "docker-host-rename", + MachineID: "machine-rename", + }, + Containers: []agentsdocker.Container{ + {ID: "container-stable", Name: "app"}, + }, + Timestamp: baseTimestamp, + } + + host, err := monitor.ApplyDockerReport(report, nil) + if err != nil { + t.Fatalf("first ApplyDockerReport failed: %v", err) + } + oldKey := dockerAppContainerMetadataKey(host.ID, "app") + if err := monitor.guestMetadataStore.Set(oldKey, &config.GuestMetadata{ + CustomURL: "https://app.internal", + LastKnownName: "app", + LastKnownType: "app-container", + }); err != nil { + t.Fatalf("seed stable guest metadata: %v", err) + } + oldDockerKey := dockerContainerNameMetadataKey(host.ID, "app") + if err := monitor.dockerMetadataStore.Set(oldDockerKey, &config.DockerMetadata{ + CustomURL: "https://app-drawer.internal", + }); err != nil { + t.Fatalf("seed stable Docker metadata: %v", err) + } + + report.Timestamp = baseTimestamp.Add(30 * time.Second) + report.Containers = []agentsdocker.Container{ + {ID: "container-stable", Name: "renamed-app"}, + } + if _, err := monitor.ApplyDockerReport(report, nil); err != nil { + t.Fatalf("renamed-container ApplyDockerReport failed: %v", err) + } + + newKey := dockerAppContainerMetadataKey(host.ID, "renamed-app") + if meta := monitor.guestMetadataStore.Get(newKey); meta == nil || meta.CustomURL != "https://app.internal" { + t.Fatalf("renamed stable metadata = %#v, want preserved URL", meta) + } + if meta := monitor.guestMetadataStore.Get(oldKey); meta != nil { + t.Fatalf("old stable metadata key still exists after rename: %#v", meta) + } + newDockerKey := dockerContainerNameMetadataKey(host.ID, "renamed-app") + if meta := monitor.dockerMetadataStore.Get(newDockerKey); meta == nil || meta.CustomURL != "https://app-drawer.internal" { + t.Fatalf("renamed stable Docker metadata = %#v, want preserved URL", meta) + } + if meta := monitor.dockerMetadataStore.Get(oldDockerKey); meta != nil { + t.Fatalf("old stable Docker metadata key still exists after rename: %#v", meta) + } + + report.Timestamp = baseTimestamp.Add(60 * time.Second) + report.Containers = []agentsdocker.Container{ + {ID: "container-stable", Name: "renamed-app"}, + {ID: "container-unrelated", Name: "app"}, + } + if _, err := monitor.ApplyDockerReport(report, nil); err != nil { + t.Fatalf("reused-name ApplyDockerReport failed: %v", err) + } + + resources := []unifiedresources.Resource{ + { + ID: dockerAppContainerLegacyResourceID(host.ID, "container-stable"), + Type: unifiedresources.ResourceTypeAppContainer, + Name: "renamed-app", + Docker: &unifiedresources.DockerData{ + HostSourceID: host.ID, + ContainerID: "container-stable", + }, + }, + { + ID: dockerAppContainerLegacyResourceID(host.ID, "container-unrelated"), + Type: unifiedresources.ResourceTypeAppContainer, + Name: "app", + Docker: &unifiedresources.DockerData{ + HostSourceID: host.ID, + ContainerID: "container-unrelated", + }, + }, + } + got := monitor.applyPersistedMetadataToUnifiedResources(resources) + if got[0].CustomURL != "https://app.internal" { + t.Fatalf("renamed container CustomURL = %q, want preserved URL", got[0].CustomURL) + } + if got[1].CustomURL != "" { + t.Fatalf("unrelated container reusing old name inherited CustomURL %q", got[1].CustomURL) + } +} + +func TestDockerContainerStableMetadataMovesFailClosedOnAmbiguousNames(t *testing.T) { + moves := dockerContainerStableMetadataMoves( + "docker-host", + []models.DockerContainer{ + {ID: "container-a", Name: "/app"}, + {ID: "container-b", Name: "app"}, + }, + []models.DockerContainer{ + {ID: "container-a", Name: "renamed-app"}, + {ID: "container-b", Name: "app"}, + }, + dockerAppContainerMetadataKey, + ) + if len(moves) != 0 { + t.Fatalf("ambiguous normalized source names produced moves: %#v", moves) + } +} + +func TestMigrateDockerContainerMetadataForRenamedContainersPreservesNameSwapOwnership(t *testing.T) { + monitor := newTestMonitor(t) + hostID := "docker-host-swap" + if err := monitor.guestMetadataStore.Set( + dockerAppContainerMetadataKey(hostID, "blue"), + &config.GuestMetadata{CustomURL: "https://blue.internal"}, + ); err != nil { + t.Fatalf("seed blue metadata: %v", err) + } + if err := monitor.guestMetadataStore.Set( + dockerAppContainerMetadataKey(hostID, "green"), + &config.GuestMetadata{CustomURL: "https://green.internal"}, + ); err != nil { + t.Fatalf("seed green metadata: %v", err) + } + + monitor.migrateDockerContainerMetadataForRenamedContainers( + hostID, + []models.DockerContainer{ + {ID: "container-blue", Name: "blue"}, + {ID: "container-green", Name: "green"}, + }, + []models.DockerContainer{ + {ID: "container-blue", Name: "green"}, + {ID: "container-green", Name: "blue"}, + }, + ) + + if meta := monitor.guestMetadataStore.Get(dockerAppContainerMetadataKey(hostID, "green")); meta == nil || meta.CustomURL != "https://blue.internal" { + t.Fatalf("container-blue metadata after swap = %#v", meta) + } + if meta := monitor.guestMetadataStore.Get(dockerAppContainerMetadataKey(hostID, "blue")); meta == nil || meta.CustomURL != "https://green.internal" { + t.Fatalf("container-green metadata after swap = %#v", meta) + } +} + +func TestMigrateDockerContainerMetadataForRenamedContainersPreservesExistingDestination(t *testing.T) { + monitor := newTestMonitor(t) + hostID := "docker-host-destination" + oldKey := dockerAppContainerMetadataKey(hostID, "old-name") + newKey := dockerAppContainerMetadataKey(hostID, "reserved-name") + if err := monitor.guestMetadataStore.Set(oldKey, &config.GuestMetadata{ + CustomURL: "https://old.internal", + }); err != nil { + t.Fatalf("seed old metadata: %v", err) + } + if err := monitor.guestMetadataStore.Set(newKey, &config.GuestMetadata{ + CustomURL: "https://reserved.internal", + }); err != nil { + t.Fatalf("seed destination metadata: %v", err) + } + + monitor.migrateDockerContainerMetadataForRenamedContainers( + hostID, + []models.DockerContainer{{ID: "container", Name: "old-name"}}, + []models.DockerContainer{{ID: "container", Name: "reserved-name"}}, + ) + + if meta := monitor.guestMetadataStore.Get(newKey); meta == nil || meta.CustomURL != "https://reserved.internal" { + t.Fatalf("destination metadata = %#v", meta) + } + if meta := monitor.guestMetadataStore.Get(oldKey); meta != nil { + t.Fatalf("obsolete source could leak onto later name reuse: %#v", meta) + } +} + func TestApplyDockerReportFoldsRuntimeKeyDockerMetadataIntoStableGuestKey(t *testing.T) { monitor := newTestMonitor(t) @@ -575,7 +759,7 @@ func TestApplyDockerReportFoldsRuntimeKeyDockerMetadataIntoStableGuestKey(t *tes }, }, } - got := monitor.applyDockerMetadataToUnifiedResources(resources) + got := monitor.applyPersistedMetadataToUnifiedResources(resources) if got[0].CustomURL != "https://wud.internal" { t.Fatalf("projected CustomURL = %q, want https://wud.internal", got[0].CustomURL) } @@ -643,7 +827,7 @@ func TestApplyDockerReportKeepsClearedStableGuestKeyOverRuntimeDockerMetadata(t }, }, } - got := monitor.applyDockerMetadataToUnifiedResources(resources) + got := monitor.applyPersistedMetadataToUnifiedResources(resources) if got[0].CustomURL != "" { t.Fatalf("projected CustomURL = %q, want empty (cleared)", got[0].CustomURL) } @@ -716,12 +900,12 @@ func TestApplyDockerMetadataToUnifiedResourcesAddsContainerCustomURL(t *testing. }, } - got := monitor.applyDockerMetadataToUnifiedResources(resources) + got := monitor.applyPersistedMetadataToUnifiedResources(resources) if got[0].CustomURL != "https://app.internal" { t.Fatalf("CustomURL = %q, want migrated Docker metadata URL", got[0].CustomURL) } if resources[0].CustomURL != "" { - t.Fatalf("applyDockerMetadataToUnifiedResources mutated input CustomURL to %q", resources[0].CustomURL) + t.Fatalf("applyPersistedMetadataToUnifiedResources mutated input CustomURL to %q", resources[0].CustomURL) } } @@ -745,7 +929,7 @@ func TestApplyDockerMetadataToUnifiedResourcesKeepsResourceCustomURL(t *testing. }, } - got := monitor.applyDockerMetadataToUnifiedResources(resources) + got := monitor.applyPersistedMetadataToUnifiedResources(resources) if got[0].CustomURL != "https://resource.internal" { t.Fatalf("CustomURL = %q, want existing resource URL to win", got[0].CustomURL) } @@ -771,7 +955,7 @@ func TestApplyDockerMetadataToUnifiedResourcesUsesStableDockerMetadata(t *testin }, } - got := monitor.applyDockerMetadataToUnifiedResources(resources) + got := monitor.applyPersistedMetadataToUnifiedResources(resources) if got[0].CustomURL != "https://stable-docker.internal" { t.Fatalf("CustomURL = %q, want stable Docker metadata URL", got[0].CustomURL) } @@ -802,12 +986,185 @@ func TestApplyDockerMetadataToUnifiedResourcesStableGuestMetadataBlocksLegacyFal }, } - got := monitor.applyDockerMetadataToUnifiedResources(resources) + got := monitor.applyPersistedMetadataToUnifiedResources(resources) if got[0].CustomURL != "" { t.Fatalf("CustomURL = %q, want stable empty guest metadata to block legacy fallback", got[0].CustomURL) } } +func TestApplyPersistedMetadataToUnifiedResourcesMigratesKubernetesURLToStableLogicalIdentity(t *testing.T) { + monitor := newTestMonitor(t) + legacyPodID := "k8s:cluster-a:pod:pod-uid-old" + if err := monitor.guestMetadataStore.Set(legacyPodID, &config.GuestMetadata{ + CustomURL: "https://checkout.internal", + }); err != nil { + t.Fatalf("seed legacy pod metadata: %v", err) + } + + oldPod := unifiedresources.Resource{ + ID: "resource:pod:old", + Type: unifiedresources.ResourceTypePod, + Name: "checkout", + Kubernetes: &unifiedresources.K8sData{ + ClusterID: "cluster-a", + Namespace: "payments", + ResourceKind: "Pod", + PodUID: "pod-uid-old", + }, + } + got := monitor.applyPersistedMetadataToUnifiedResources([]unifiedresources.Resource{oldPod}) + if got[0].CustomURL != "https://checkout.internal" { + t.Fatalf("legacy pod CustomURL = %q, want migrated URL", got[0].CustomURL) + } + + recreatedPod := oldPod + recreatedPod.ID = "resource:pod:new" + recreatedPod.Kubernetes = &unifiedresources.K8sData{ + ClusterID: "cluster-a", + Namespace: "payments", + ResourceKind: "Pod", + PodUID: "pod-uid-new", + } + got = monitor.applyPersistedMetadataToUnifiedResources([]unifiedresources.Resource{recreatedPod}) + if got[0].CustomURL != "https://checkout.internal" { + t.Fatalf("recreated pod CustomURL = %q, want stable logical URL", got[0].CustomURL) + } + + unrelated := []unifiedresources.Resource{ + { + ID: "resource:pod:other-cluster", + Type: unifiedresources.ResourceTypePod, + Name: "checkout", + Kubernetes: &unifiedresources.K8sData{ + ClusterID: "cluster-b", + Namespace: "payments", + ResourceKind: "Pod", + PodUID: "pod-uid-other-cluster", + }, + }, + { + ID: "resource:pod:other-namespace", + Type: unifiedresources.ResourceTypePod, + Name: "checkout", + Kubernetes: &unifiedresources.K8sData{ + ClusterID: "cluster-a", + Namespace: "staging", + ResourceKind: "Pod", + PodUID: "pod-uid-other-namespace", + }, + }, + { + ID: "resource:deployment:same-name", + Type: unifiedresources.ResourceTypeK8sDeployment, + Name: "checkout", + Kubernetes: &unifiedresources.K8sData{ + ClusterID: "cluster-a", + Namespace: "payments", + ResourceKind: "Deployment", + ResourceUID: "deployment-uid", + }, + }, + { + ID: "resource:pod:different-name", + Type: unifiedresources.ResourceTypePod, + Name: "checkout-canary", + Kubernetes: &unifiedresources.K8sData{ + ClusterID: "cluster-a", + Namespace: "payments", + ResourceKind: "Pod", + PodUID: "pod-uid-different-name", + }, + }, + } + got = monitor.applyPersistedMetadataToUnifiedResources(unrelated) + for i := range got { + if got[i].CustomURL != "" { + t.Fatalf("unrelated resource %d inherited CustomURL %q", i, got[i].CustomURL) + } + } +} + +func TestApplyPersistedMetadataToUnifiedResourcesMigratesKubernetesControllersToStableLogicalIdentity(t *testing.T) { + for _, testCase := range []struct { + name string + resourceType unifiedresources.ResourceType + kind string + }{ + { + name: "deployment", + resourceType: unifiedresources.ResourceTypeK8sDeployment, + kind: "Deployment", + }, + { + name: "service", + resourceType: unifiedresources.ResourceTypeK8sService, + kind: "Service", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + monitor := newTestMonitor(t) + oldResourceID := "resource:" + testCase.name + ":old" + if err := monitor.guestMetadataStore.Set(oldResourceID, &config.GuestMetadata{ + CustomURL: "https://" + testCase.name + ".internal", + }); err != nil { + t.Fatalf("seed legacy metadata: %v", err) + } + + resource := unifiedresources.Resource{ + ID: oldResourceID, + Type: testCase.resourceType, + Name: "checkout", + Kubernetes: &unifiedresources.K8sData{ + ClusterID: "cluster-a", + Namespace: "payments", + ResourceKind: testCase.kind, + ResourceUID: testCase.name + "-uid-old", + }, + } + got := monitor.applyPersistedMetadataToUnifiedResources([]unifiedresources.Resource{resource}) + if got[0].CustomURL != "https://"+testCase.name+".internal" { + t.Fatalf("legacy %s CustomURL = %q", testCase.name, got[0].CustomURL) + } + + resource.ID = "resource:" + testCase.name + ":new" + resource.Kubernetes.ResourceUID = testCase.name + "-uid-new" + got = monitor.applyPersistedMetadataToUnifiedResources([]unifiedresources.Resource{resource}) + if got[0].CustomURL != "https://"+testCase.name+".internal" { + t.Fatalf("recreated %s CustomURL = %q", testCase.name, got[0].CustomURL) + } + }) + } +} + +func TestApplyPersistedMetadataToUnifiedResourcesKubernetesStableClearBlocksLegacyFallback(t *testing.T) { + monitor := newTestMonitor(t) + resource := unifiedresources.Resource{ + ID: "resource:pod:current", + Type: unifiedresources.ResourceTypePod, + Name: "checkout", + Kubernetes: &unifiedresources.K8sData{ + ClusterID: "cluster-a", + Namespace: "payments", + ResourceKind: "Pod", + PodUID: "pod-uid-current", + }, + } + stableKey := "k8s-workload:cluster-a:pod:payments:checkout" + if err := monitor.guestMetadataStore.Set(stableKey, &config.GuestMetadata{CustomURL: ""}); err != nil { + t.Fatalf("seed cleared stable pod metadata: %v", err) + } + if err := monitor.guestMetadataStore.Set("k8s:cluster-a:pod:pod-uid-current", &config.GuestMetadata{ + CustomURL: "https://stale.internal", + }); err != nil { + t.Fatalf("seed legacy pod metadata: %v", err) + } + + got := monitor.applyPersistedMetadataToUnifiedResources([]unifiedresources.Resource{resource}) + if got[0].CustomURL != "" { + t.Fatalf("CustomURL = %q, want stable empty Kubernetes metadata to block legacy fallback", got[0].CustomURL) + } +} + func TestApplyDockerReportComputesContainerNetworkAndDiskRates(t *testing.T) { monitor := newTestMonitor(t) baseTime := time.Now().UTC() diff --git a/internal/monitoring/multi_tenant_monitor.go b/internal/monitoring/multi_tenant_monitor.go index 9bad36342..7fd6a372b 100644 --- a/internal/monitoring/multi_tenant_monitor.go +++ b/internal/monitoring/multi_tenant_monitor.go @@ -225,6 +225,11 @@ func (mtm *MultiTenantMonitor) GetMonitor(orgID string) (*Monitor, error) { if err != nil { return nil, fmt.Errorf("failed to create monitor for org %s: %w", orgID, err) } + tenantPersistence.SetMetadataStores( + monitor.GuestMetadataStore(), + monitor.DockerMetadataStore(), + monitor.HostMetadataStore(), + ) // Set org ID for tenant isolation // This enables tenant-scoped WebSocket broadcasts