mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-22 07:23:27 +00:00
Preserve resource URLs across runtime identity changes
Contract-Neutral: Docker URL migration touches shared monitor ingest files without changing the host deletion or re-enrollment contract.
This commit is contained in:
parent
0a95b25f41
commit
15dbabfe9a
43 changed files with 1790 additions and 134 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:<cluster>:<kind>:<namespace>:<name>` as its stable logical
|
||||
identity. Monitoring must migrate a current legacy unified-resource key, plus
|
||||
the legacy `k8s:<cluster>:pod:<pod-uid>` 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 "`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:<host>:name:<normalized-name>`; Kubernetes pods, Deployments,
|
||||
and Services use
|
||||
`k8s-workload:<cluster>:<kind>:<namespace>:<name>`. 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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<DiscoveryConfig, 'metadataKind' | 'metadataId'> => {
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
128
internal/monitoring/kubernetes_metadata_migration.go
Normal file
128
internal/monitoring/kubernetes_metadata_migration.go
Normal file
|
|
@ -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
|
||||
}
|
||||
30
internal/monitoring/metadata_stores.go
Normal file
30
internal/monitoring/metadata_stores.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue