From aabef9afd5f05b66828a08028b26a07cd4691071 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 20 May 2026 19:30:55 +0100 Subject: [PATCH] Add canonical platform scopes for runtime workloads Teach unified resources to emit platformScopes so runtime workloads can belong to both Docker and their owning platform. Update workload filters to use platform scopes and keep TrueNAS app containers scoped to TrueNAS. --- docs/release-control/v6/internal/status.json | 4 +- .../subsystems/frontend-primitives.md | 6 + .../subsystems/performance-and-scalability.md | 8 + .../internal/subsystems/storage-recovery.md | 6 + .../internal/subsystems/unified-resources.md | 15 ++ .../useWorkloadFilterOptions.test.ts | 12 +- .../__tests__/workloadRouteModel.test.ts | 2 + .../__tests__/workloadSelectors.test.ts | 43 +++++- .../Workloads/useWorkloadFilterOptions.ts | 5 +- .../Workloads/workloadRouteModel.ts | 3 +- .../components/Workloads/workloadSelectors.ts | 8 +- .../__tests__/useUnifiedResources.test.ts | 62 ++++++++ .../src/hooks/__tests__/useWorkloads.test.ts | 22 ++- .../src/hooks/useUnifiedResources.ts | 15 +- frontend-modern/src/hooks/useWorkloads.ts | 21 ++- frontend-modern/src/types/resource.ts | 1 + frontend-modern/src/types/workloads.ts | 3 + .../__tests__/resourceStateAdapters.test.ts | 37 +++++ .../utils/__tests__/sourcePlatforms.test.ts | 15 ++ .../src/utils/resourceStateAdapters.ts | 26 +++- frontend-modern/src/utils/sourcePlatforms.ts | 20 +++ frontend-modern/src/utils/workloads.ts | 17 +++ .../canonical_identity_test.go | 28 +++- internal/unifiedresources/clone.go | 1 + internal/unifiedresources/platform_scopes.go | 139 ++++++++++++++++++ .../unifiedresources/platform_scopes_test.go | 67 +++++++++ internal/unifiedresources/policy_metadata.go | 1 + internal/unifiedresources/registry_test.go | 16 ++ internal/unifiedresources/types.go | 1 + 29 files changed, 567 insertions(+), 37 deletions(-) create mode 100644 internal/unifiedresources/platform_scopes.go create mode 100644 internal/unifiedresources/platform_scopes_test.go diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index df2718fc3..28d7d69dc 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -6934,7 +6934,7 @@ }, { "id": "platform-support-model-v1", - "summary": "Pulse v6 platform support is governed by one canonical model: `docker` and `kubernetes` are first-class top-level platforms, first-class platforms declare primary ingestion, canonical resource projections, and support-floor surfaces, and runtime variants such as `podman` plus transport helpers are not top-level platforms.", + "summary": "Pulse v6 platform support is governed by one canonical model: owning platform surfaces declare primary ingestion, canonical resource projections, and support-floor surfaces; `docker` is a first-class container-runtime lens and workload surface rather than an exclusive owning platform; `kubernetes` remains a first-class orchestrator platform; runtime variants such as `podman` plus transport helpers are not top-level platforms.", "kind": "architecture", "decided_at": "2026-03-30", "subsystem_ids": [ @@ -7192,7 +7192,7 @@ }, { "id": "platform-page-membership-overlap-v1", - "summary": "Pulse v6 primary IA remains platform-first rather than a generic mixed systems page: a single physical or agent-backed system may appear on multiple platform pages when it participates in multiple platform contracts, and each page must show only the workloads and native resources relevant to that platform. Host identity stays separate from runtime membership, so an Unraid server that reports Docker containers can appear on the container-runtime page while still reading as an Unraid-profiled host rather than a generic Docker server.", + "summary": "Pulse v6 primary IA remains platform-first rather than a generic mixed systems page: a single physical or agent-backed system may appear on multiple platform pages when it participates in multiple platform contracts, and each page must show only the workloads and native resources relevant to that platform. Workload platform membership is a canonical resource contract, not a page-local platformType guess: Docker/Podman containers reported from a Proxmox host or LXC may appear in both the Proxmox Workloads table and the container-runtime lens, while TrueNAS apps that expose Docker-compatible runtime metadata remain TrueNAS-owned and do not become Docker-managed action targets. Host identity stays separate from runtime membership, so an Unraid server that reports Docker containers can appear on the container-runtime page while still reading as an Unraid-profiled host rather than a generic Docker server.", "kind": "architecture", "decided_at": "2026-05-17", "subsystem_ids": [ diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d73076fca..f040e5f56 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -739,6 +739,12 @@ AI runtime. so client-side family grouping behaves identically against mock fixtures and live backends that leave `platformType` empty on a subset of canonical resource types. + Workload page membership must use the canonical `platformScopes` list when + present instead of treating `platformType` as exclusive ownership. A Docker + or Podman app-container can therefore be visible on both the container + runtime lens and its owning platform page, such as Proxmox when the runtime + is detected inside a PVE LXC, while TrueNAS app containers stay scoped to + TrueNAS even when their runtime metadata uses the shared Docker facet. 8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, host-profile labels, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest. The generic `docker` source-platform label is "Docker / Podman" in shared selectors, badges, and filter options so v5 Docker users can find the runtime surface while Podman-backed rows are not mislabeled as Docker-only; "Container runtime" remains the governed platform family, not the primary customer-facing label. Identity colour is semantic, not page-local decoration: shared source/platform badges, host identity badges, and container runtime badges must use the shared presentation helpers so Docker remains on the Docker/Podman blue runtime tone, Podman uses its distinct runtime tone, Proxmox PVE remains orange, and those meanings do not drift across table rows, filters, drawers, or platform pages. Agent host-profile entries, including Unraid, stay in the generated `agentHostProfiles` projection and shared wrapper helpers; frontend primitives may render those labels for Pulse Agent install/identity copy but must not add them to the first-class platform union. The generated host-profile projection also carries runtime platform fallback metadata for shared explanation and parity with backend normalization, but diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 2d49e50a7..5e25ebd96 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -158,6 +158,14 @@ regression protection. 1. Add performance budgets through SLO or contract tests 2. Add query-plan guardrails for DB-backed hot paths 3. Optimize hot paths only when backed by benchmarks or proven query issues + Workload platform-scope filtering and option derivation are part of the + workload table hot path. Shared selectors in + `frontend-modern/src/components/Workloads/workloadSelectors.ts`, + `frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts`, + `frontend-modern/src/components/Workloads/workloadRouteModel.ts`, and + `frontend-modern/src/hooks/useWorkloads.ts` must consume canonical + `platformScopes`; page surfaces must not rebuild platform membership with + ad hoc source or runtime scans. 4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path. The same rule applies to public setup-script lifecycle routes: `/api/auto-register` and `/api/auto-unregister` may bypass the global auth wall so their handlers can diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index aaba432fa..4ab796c98 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -114,6 +114,12 @@ Storage/recovery remediation or restore-adjacent workflows may consume contract. This subsystem must not create a storage-local approval policy, stale-plan hash, blast-radius model, or execution protocol outside `internal/api/actions.go` and `internal/actionplanner/planner.go`. +Storage/recovery surfaces may consume unified-resource `platformScopes` as +read-only platform membership context, but they must not reinterpret runtime +scope overlap as storage or recovery ownership. A Docker workload that also +belongs to an owning platform remains governed by the resource, policy, and +backup capability contracts exposed by unified resources and the shared API +boundary. Successful action plans also remain API-owned audit facts before storage/recovery surfaces consume them: approval-required plans must persist as `pending_approval` with initial lifecycle evidence, and retry/idempotency diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index a3f76bad1..59442359d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -155,6 +155,14 @@ Swarm capability surfaces. types, and the helper falls back through the existing source-platform normalization so client-side grouping behaves identically against mock fixtures and live backends. + `Resource.PlatformScopes` is the backend-owned platform page membership + contract for unified resources. It is derived by unified resources through + `RefreshPlatformScopes`, transported through `useUnifiedResources`, + `useWorkloads`, and `sourcePlatforms.ts`, and consumed by workload filters + without page-local reinterpretation. `platformType` remains the primary + display/source family; `platformScopes` is the overlap set used when a + runtime workload belongs to both Docker and an owning infrastructure + platform. 17. `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. App-container Discovery targets are part of that shared payload contract. Backend resources must expose frontend-facing `resourceType=app-container` @@ -1830,6 +1838,13 @@ temperature, uptime, container counts, update state, command metadata, and Swarm local-state/control evidence through `ResourceDockerMeta`; Docker platform pages must consume those canonical fields rather than keeping page-local host-runtime shape aliases. +Canonical resources now also carry `platformScopes`, the normalized +platform-page membership list consumed by workload filters. `platformType` +remains the primary display/source family, while `platformScopes` captures +overlap: Docker/Podman resources reported from a Proxmox LXC carry both +`proxmox-pve` and `docker`, but TrueNAS app containers that reuse +`DockerData` for runtime metadata remain scoped to `truenas` and must not be +promoted to Docker-managed action targets. The same facet bundle now also returns grouped recent-change counts by canonical change kind, so the detail drawer can surface the distribution of state transitions, restarts, config updates, and anomalies without diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadFilterOptions.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadFilterOptions.test.ts index 6e7eacecf..2336fc5d6 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadFilterOptions.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadFilterOptions.test.ts @@ -39,6 +39,7 @@ describe('useWorkloadFilterOptions', () => { type: 'app-container', workloadType: 'app-container', platformType: 'docker', + platformScopes: ['proxmox-pve', 'docker'], contextLabel: 'docker-prod-a', dockerHostId: 'docker-prod-a', containerRuntime: 'docker', @@ -70,6 +71,7 @@ describe('useWorkloadFilterOptions', () => { string | null >(null); const [selectedNode] = createSignal(null); + const [platformScope, setPlatformScope] = createSignal('docker'); const { result } = renderHook(() => useWorkloadFilterOptions({ @@ -77,7 +79,7 @@ describe('useWorkloadFilterOptions', () => { isWorkloadsRoute: () => false, allowEmbeddedScopeFilters: () => true, viewMode, - platformScope: () => 'docker', + platformScope, containerRuntime, selectedPlatform, selectedNode, @@ -95,5 +97,13 @@ describe('useWorkloadFilterOptions', () => { { value: 'docker-prod-a', label: 'docker-prod-a' }, ]); expect(result.containerRuntimeOptions()).toEqual(['docker']); + + setPlatformScope('proxmox-pve'); + + expect(result.workloadNodeOptions()).toEqual([ + { value: 'docker-prod-a', label: 'docker-prod-a' }, + { value: 'core-fabric-pve-prod-a', label: 'pve-prod-a' }, + ]); + expect(result.containerRuntimeOptions()).toEqual(['docker']); }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts index 36f6a2d9d..872861165 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadRouteModel.test.ts @@ -171,6 +171,7 @@ describe('workloadRouteModel', () => { type: 'app-container', workloadType: 'app-container', platformType: 'docker', + platformScopes: ['proxmox-pve', 'docker'], }), makeGuest({ id: 'pod-a', @@ -183,6 +184,7 @@ describe('workloadRouteModel', () => { ), ).toEqual([ { value: 'truenas', label: 'TrueNAS' }, + { value: 'proxmox-pve', label: 'PVE' }, { value: 'docker', label: 'Docker / Podman' }, ]); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts index ad8b87425..bc51a892c 100644 --- a/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/workloadSelectors.test.ts @@ -270,23 +270,34 @@ describe('workloadSelectors', () => { expect(withK8sContext.map((g) => g.name)).toEqual(['k8s-a']); }); - it('filters workloads by canonical platform type', () => { + it('filters workloads by canonical platform scopes', () => { const guests = [ makeGuest(1, { name: 'nextcloud', type: 'app-container', workloadType: 'app-container', platformType: 'truenas', + platformScopes: ['truenas'], + containerRuntime: 'docker', }), makeGuest(2, { name: 'grafana', type: 'app-container', workloadType: 'app-container', platformType: 'docker', + platformScopes: ['proxmox-pve', 'docker'], + containerRuntime: 'docker', + }), + makeGuest(3, { + name: 'vm-101', + type: 'vm', + workloadType: 'vm', + platformType: 'proxmox-pve', + platformScopes: ['proxmox-pve'], }), ]; - const result = filterWorkloads({ + const truenasResult = filterWorkloads({ guests, viewMode: 'app-container', statusMode: 'all', @@ -297,7 +308,33 @@ describe('workloadSelectors', () => { selectedKubernetesContext: null, }); - expect(result.map((guest) => guest.name)).toEqual(['nextcloud']); + expect(truenasResult.map((guest) => guest.name)).toEqual(['nextcloud']); + + const proxmoxResult = filterWorkloads({ + guests, + viewMode: 'app-container', + statusMode: 'all', + searchTerm: '', + selectedNode: null, + selectedHostHint: null, + selectedPlatform: 'proxmox-pve', + selectedKubernetesContext: null, + }); + + expect(proxmoxResult.map((guest) => guest.name)).toEqual(['grafana']); + + const dockerResult = filterWorkloads({ + guests, + viewMode: 'app-container', + statusMode: 'all', + searchTerm: '', + selectedNode: null, + selectedHostHint: null, + selectedPlatform: 'docker', + selectedKubernetesContext: null, + }); + + expect(dockerResult.map((guest) => guest.name)).toEqual(['grafana']); }); it('filters app containers by Docker host id from related-workload links', () => { diff --git a/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts b/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts index f9472b0a1..4ddc35201 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadFilterOptions.ts @@ -2,6 +2,7 @@ import { createMemo, type Accessor } from 'solid-js'; import type { WorkloadGuest, ViewMode } from '@/types/workloads'; import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; +import { workloadMatchesPlatformScope } from '@/utils/workloads'; import type { WorkloadsToolbarFilterConfig } from './workloadsFilterModel'; import { buildWorkloadsContainerRuntimeOptions, @@ -43,9 +44,7 @@ export function useWorkloadFilterOptions(options: WorkloadsWorkloadFilterOptions } return options .allGuests() - .filter( - (guest) => normalizeSourcePlatformQueryValue(guest.platformType || '') === normalizedScope, - ); + .filter((guest) => workloadMatchesPlatformScope(guest, normalizedScope)); }); const workloadNodeOptions = createMemo(() => buildWorkloadNodeOptions(platformScopedGuests())); diff --git a/frontend-modern/src/components/Workloads/workloadRouteModel.ts b/frontend-modern/src/components/Workloads/workloadRouteModel.ts index 7fdd2b1e0..2ae05143e 100644 --- a/frontend-modern/src/components/Workloads/workloadRouteModel.ts +++ b/frontend-modern/src/components/Workloads/workloadRouteModel.ts @@ -1,5 +1,6 @@ import type { WorkloadGuest, ViewMode } from '@/types/workloads'; import { + getWorkloadPlatformScopes, normalizeWorkloadViewModeParam, resolveWorkloadType, workloadMatchesViewMode, @@ -112,7 +113,7 @@ export const buildWorkloadsPlatformOptions = ( buildSourcePlatformOptions( guests .filter((guest) => workloadMatchesViewMode(resolveWorkloadType(guest), viewMode)) - .map((guest) => guest.platformType || ''), + .flatMap((guest) => getWorkloadPlatformScopes(guest)), ).map((option) => ({ value: option.key, label: option.label, diff --git a/frontend-modern/src/components/Workloads/workloadSelectors.ts b/frontend-modern/src/components/Workloads/workloadSelectors.ts index be216d962..079b6ad4c 100644 --- a/frontend-modern/src/components/Workloads/workloadSelectors.ts +++ b/frontend-modern/src/components/Workloads/workloadSelectors.ts @@ -11,6 +11,7 @@ import { isContainerWorkloadViewMode, getCanonicalWorkloadId, resolveWorkloadType, + workloadMatchesPlatformScope, workloadMatchesViewMode, } from '@/utils/workloads'; import { getWorkloadTypePresentation } from '@/utils/workloadTypePresentation'; @@ -62,6 +63,7 @@ const workloadSearchCandidates = (guest: WorkloadGuest): Array normalizeSourcePlatformQueryValue(g.platformType || '') === normalizedPlatform, - ); + if (normalizedPlatform && normalizedPlatform !== 'all') { + guests = guests.filter((g) => workloadMatchesPlatformScope(g, normalizedPlatform)); } const normalizedRuntime = (containerRuntime || '').trim().toLowerCase(); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 2ab86541f..1c6e4bb6e 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -452,6 +452,68 @@ describe('useUnifiedResources', () => { dispose(); }); + it('preserves backend platform scopes and falls back to source-derived membership', async () => { + apiFetchMock.mockResolvedValueOnce( + resourceResponse([ + { + ...v2Resource, + id: 'docker-container-frigate-141', + type: 'app-container', + name: 'frigate', + sources: ['docker'], + platformScopes: ['proxmox-pve', 'docker'], + docker: { + hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141', + containerId: 'frigate', + runtime: 'docker', + }, + }, + { + ...v2Resource, + id: 'truenas-app-nextcloud', + type: 'app-container', + name: 'nextcloud', + sources: ['truenas'], + truenas: { + hostname: 'truenas-main', + }, + docker: { + containerId: 'nextcloud', + runtime: 'docker', + }, + }, + ]), + ); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources(); + }); + + await waitForValue( + () => Boolean(result!.resources().find((resource) => resource.id === 'docker-container-frigate-141')), + true, + ); + + const dockerResource = result!.resources().find( + (resource) => resource.id === 'docker-container-frigate-141', + ); + const truenasResource = result!.resources().find( + (resource) => resource.id === 'truenas-app-nextcloud', + ); + + expect(dockerResource?.platformScopes).toEqual(['proxmox-pve', 'docker']); + expect((dockerResource?.platformData as Record)?.platformScopes).toEqual([ + 'proxmox-pve', + 'docker', + ]); + expect(truenasResource?.platformScopes).toEqual(['truenas']); + + dispose(); + }); + it('skips unified resource hydration while disabled and resumes when enabled', async () => { let dispose = () => {}; let result: ReturnType | undefined; diff --git a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts index 8d0b2b7d9..d793a4a83 100644 --- a/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts +++ b/frontend-modern/src/hooks/__tests__/useWorkloads.test.ts @@ -235,6 +235,22 @@ describe('useWorkloads', () => { image: 'ix-nextcloud:latest', }, }, + { + ...sampleResource, + id: 'docker-container-frigate-141', + type: 'app-container', + name: 'frigate', + status: 'running', + sources: ['docker'], + platformScopes: ['proxmox-pve', 'docker'], + parentName: 'homepage-docker.lab', + docker: { + containerId: 'frigate', + hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141', + runtime: 'docker', + image: 'ghcr.io/blakeblackshear/frigate:stable', + }, + }, { ...sampleResource, id: 'vmware-vm-crm', @@ -280,16 +296,20 @@ describe('useWorkloads', () => { }); await flushAsync(); - await waitForWorkloadCount(() => result!.workloads().length, 6); + await waitForWorkloadCount(() => result!.workloads().length, 7); const byName = new Map(result!.workloads().map((workload) => [workload.name, workload])); expect(byName.get('vm-101')?.platformType).toBe('proxmox-pve'); expect(byName.get('vm-pbs')?.platformType).toBe('proxmox-pbs'); expect(byName.get('vm-pmg')?.platformType).toBe('proxmox-pmg'); expect(byName.get('nextcloud')?.platformType).toBe('truenas'); + expect(byName.get('nextcloud')?.platformScopes).toEqual(['truenas']); expect(byName.get('nextcloud')?.id).toBe('app-container:truenas-main:nextcloud'); expect(byName.get('nextcloud')?.containerId).toBe('nextcloud-ctr'); expect(byName.get('nextcloud')?.dockerHostId).toBeUndefined(); + expect(byName.get('frigate')?.platformType).toBe('docker'); + expect(byName.get('frigate')?.platformScopes).toEqual(['proxmox-pve', 'docker']); + expect(byName.get('frigate')?.dockerHostId).toBe('proxmox-lxc-docker:pve-a:node-a:141'); expect(byName.get('crm-app-01')?.platformType).toBe('vmware-vsphere'); expect(byName.get('crm-app-01')?.node).toBe('esxi-01'); expect(byName.get('crm-app-01')?.instance).toBe('Compute-A'); diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 8da8141d3..7a044a7de 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -32,6 +32,7 @@ import { getPreferredNormalizedPlatformId } from '@/utils/resourceIdentity'; import { getExplicitResourceClusterName } from '@/utils/agentResources'; import { mergeCanonicalResourceSnapshot } from '@/utils/resourceStateAdapters'; import { + normalizeSourcePlatformScopes, resolvePlatformTypeFromSources, resolveSourceTypeFromSources, } from '@/utils/sourcePlatforms'; @@ -47,8 +48,7 @@ const UNIFIED_RESOURCES_CACHE_MAX_AGE_MS = 15_000; const UNIFIED_RESOURCES_WS_DEBOUNCE_MS = 800; const UNIFIED_RESOURCES_WS_MIN_REFETCH_INTERVAL_MS = 2_500; const UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS = 1_200; -const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS = - UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250; +const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS = UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250; type APIMetricValue = { value?: number; @@ -140,6 +140,7 @@ type APIResource = { lastSeen?: string; parentName?: string; sources?: string[]; + platformScopes?: string[]; sourceStatus?: Record; identity?: { machineId?: string; @@ -636,6 +637,7 @@ const toResource = (v2: APIResource): Resource => { const platformType = resolvePlatformTypeFromSources(sources) || (resourceType === 'network-endpoint' ? 'availability' : 'agent'); + const platformScopes = normalizeSourcePlatformScopes(v2.platformScopes, platformType); const discoveryResourceType = resolveDiscoveryResourceType(v2.discoveryTarget?.resourceType); const discoveryAgentId = v2.discoveryTarget?.agentId; @@ -657,6 +659,7 @@ const toResource = (v2: APIResource): Resource => { displayName: name, platformId, platformType, + platformScopes, sourceType: resolveSourceTypeFromSources(sources), parentId: v2.parentId, parentName: v2.parentName, @@ -738,6 +741,7 @@ const toResource = (v2: APIResource): Resource => { facetCounts: v2.facetCounts, platformData: { sources, + platformScopes, sourceStatus: v2.sourceStatus, proxmox: v2.proxmox, agent: v2.agent, @@ -1195,8 +1199,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { } }; - const shouldPreferWsInitialHydration = () => - prefersWsInitialHydration && !cacheEntry.hasSnapshot; + const shouldPreferWsInitialHydration = () => prefersWsInitialHydration && !cacheEntry.hasSnapshot; const hasWsInitialHydrationSnapshot = () => wsStore.connected() && wsStore.initialDataReceived() && Array.isArray(wsStore.state.resources); @@ -1400,9 +1403,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { scopeVersion += 1; const nextCacheEntry = seedUnifiedResourcesCacheFromAllResources( - getUnifiedResourcesCacheEntry( - buildScopedUnifiedResourcesCacheKey(cacheKey, nextOrgScope), - ), + getUnifiedResourcesCacheEntry(buildScopedUnifiedResourcesCacheKey(cacheKey, nextOrgScope)), cacheKey, query, nextOrgScope, diff --git a/frontend-modern/src/hooks/useWorkloads.ts b/frontend-modern/src/hooks/useWorkloads.ts index 17e6c8ed7..888c35220 100644 --- a/frontend-modern/src/hooks/useWorkloads.ts +++ b/frontend-modern/src/hooks/useWorkloads.ts @@ -1,13 +1,11 @@ -import { - onCleanup, - createEffect, - createSignal, - type Accessor, -} from 'solid-js'; +import { onCleanup, createEffect, createSignal, type Accessor } from 'solid-js'; import { apiFetchJSON, getOrgID } from '@/utils/apiClient'; import { normalizeOrgScope } from '@/utils/orgScope'; import { eventBus } from '@/stores/events'; -import { resolvePlatformTypeFromSources } from '@/utils/sourcePlatforms'; +import { + normalizeSourcePlatformScopes, + resolvePlatformTypeFromSources, +} from '@/utils/sourcePlatforms'; import { canonicalDiscoveryResourceType } from '@/utils/discoveryTarget'; import { normalizeDiskArray } from '@/utils/format'; import { @@ -59,6 +57,7 @@ type APIResource = { status?: string; lastSeen?: string; sources?: string[]; + platformScopes?: string[]; identity?: { machineId?: string; hostnames?: string[]; @@ -336,6 +335,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { const workloadType = resolveWorkloadType(resource.type); if (!workloadType) return null; const platformType = resolvePlatformTypeFromSources(resource.sources); + const platformScopes = normalizeSourcePlatformScopes(resource.platformScopes, platformType); const dockerManagedAppContainer = isDockerManagedAppContainer({ workloadType, type: resource.type ?? '', @@ -476,8 +476,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { lock: '', lastSeen: toIsoString(resource.lastSeen), isOci: workloadType === 'system-container' ? (resource.proxmox?.isOci ?? false) : false, - osTemplate: - workloadType === 'system-container' ? resource.proxmox?.osTemplate : undefined, + osTemplate: workloadType === 'system-container' ? resource.proxmox?.osTemplate : undefined, workloadType, displayId, containerId: appContainerRuntimeId || undefined, @@ -512,6 +511,7 @@ const mapResourceToWorkload = (resource: APIResource): WorkloadGuest | null => { dockerHostId: dockerManagedAppContainer ? resource.docker?.hostSourceId : undefined, kubernetesAgentId: workloadType === 'pod' ? resource.kubernetes?.agentId : undefined, platformType, + platformScopes, discoveryTarget, }; }; @@ -739,8 +739,7 @@ export function useWorkloads(enabled: Accessor = () => true) { void loadWorkloads(nextOrgScope, { force: true, showLoading: nextCacheEntry.workloads.length === 0, - }) - .catch(() => undefined); + }).catch(() => undefined); } }); onCleanup(unsubscribeOrgSwitch); diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 305579f54..27bf2bcca 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -726,6 +726,7 @@ export interface Resource { // Platform/Source platformId: string; platformType: PlatformType; + platformScopes?: string[]; sourceType: SourceType; sources?: string[]; diff --git a/frontend-modern/src/types/workloads.ts b/frontend-modern/src/types/workloads.ts index ac746a8b8..27d3ed7b3 100644 --- a/frontend-modern/src/types/workloads.ts +++ b/frontend-modern/src/types/workloads.ts @@ -17,6 +17,9 @@ export type WorkloadGuest = (VM | Container) & { /** Cluster name from Proxmox (for badge display in workloads table). */ clusterName?: string; platformType?: string; + // Canonical platform-page membership. A runtime workload may belong to more + // than one platform scope, e.g. Docker inside a Proxmox LXC. + platformScopes?: string[]; // For app-container workloads, this is underlying runtime telemetry // ("docker", "podman", etc.), not the owning platform. containerRuntime?: string; diff --git a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts index ff78ca141..cd89d1d13 100644 --- a/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts +++ b/frontend-modern/src/utils/__tests__/resourceStateAdapters.test.ts @@ -284,6 +284,43 @@ describe('resourceStateAdapters nodeFromResource', () => { }); }); + it('preserves canonical platform scopes across realtime resource merges', () => { + const existing = { + id: 'docker-container-frigate-141', + type: 'app-container', + name: 'frigate', + displayName: 'frigate', + platformId: 'frigate', + platformType: 'docker', + platformScopes: ['proxmox-pve', 'docker'], + sourceType: 'api', + sources: ['docker'], + status: 'online', + lastSeen: Date.now() - 1_000, + platformData: { + sources: ['docker'], + docker: { + hostSourceId: 'proxmox-lxc-docker:pve-a:node-a:141', + containerId: 'frigate', + runtime: 'docker', + }, + }, + } as Resource; + + const [resource] = mergeCanonicalResourceSnapshot( + [ + { + ...existing, + lastSeen: Date.now(), + platformScopes: undefined, + } as Resource, + ], + [existing], + ); + + expect(resource.platformScopes).toEqual(['proxmox-pve', 'docker']); + }); + it('canonicalizes agentless availability realtime resources as availability endpoints', () => { const [resource] = mergeCanonicalResourceSnapshot( [ diff --git a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts index 01d3cc5f2..8f61efbb1 100644 --- a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts +++ b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getSourcePlatformLabel, getSourcePlatformPresentation, + normalizeSourcePlatformScopes, readSourcePlatformFlags, normalizeSourcePlatformQueryValue, normalizeSourcePlatformKey, @@ -85,6 +86,20 @@ describe('sourcePlatforms', () => { }); }); + describe('normalizeSourcePlatformScopes', () => { + it('canonicalizes, dedupes, and ignores non-scope tokens', () => { + expect( + normalizeSourcePlatformScopes(['docker', 'pve', 'docker', ' all ', '', null], 'truenas'), + ).toEqual(['docker', 'proxmox-pve']); + }); + + it('falls back to the resolved platform when backend scopes are absent', () => { + expect(normalizeSourcePlatformScopes(undefined, 'truenas')).toEqual(['truenas']); + expect(normalizeSourcePlatformScopes([], 'proxmox')).toEqual(['proxmox-pve']); + expect(normalizeSourcePlatformScopes([], undefined)).toEqual([]); + }); + }); + describe('readSourcePlatformFlags', () => { it('tracks canonical source families from mixed source arrays', () => { expect(readSourcePlatformFlags(['agent', 'pbs', 'docker', 'custom-source'])).toEqual({ diff --git a/frontend-modern/src/utils/resourceStateAdapters.ts b/frontend-modern/src/utils/resourceStateAdapters.ts index 2e5b893d0..7282f6027 100644 --- a/frontend-modern/src/utils/resourceStateAdapters.ts +++ b/frontend-modern/src/utils/resourceStateAdapters.ts @@ -31,6 +31,7 @@ import { getPreferredResourceHostname, } from '@/utils/resourceIdentity'; import { + normalizeSourcePlatformScopes, normalizeSourcePlatformKey, resolvePlatformTypeFromSources, resolveSourceTypeFromSources, @@ -92,6 +93,10 @@ const readStringArray = (value: unknown): string[] | undefined => { return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined; }; +const readExplicitPlatformScopes = (resource: Resource): string[] | undefined => + readStringArray(getResourceRecord(resource).platformScopes) ?? + readStringArray(asRecord(resource.platformData)?.platformScopes); + const normalizeSourceToken = (value: string): string => normalizeSourcePlatformKey(value) || value.trim().toLowerCase(); @@ -546,7 +551,7 @@ const coalesceRealtimeResourceSnapshot = (resources: Resource[]): Resource[] => const indexByHostKey = new Map(); for (const rawResource of resources) { - const resource = canonicalizeRealtimeResource(rawResource); + const resource = canonicalizeRealtimeResource(rawResource, { synthesizePlatformScopes: false }); const hostKey = getHostResourceMergeKey(resource); if (!hostKey) { coalesced.push(resource); @@ -587,18 +592,29 @@ const hasAvailabilityFacet = ( ); }; -export const canonicalizeRealtimeResource = (resource: Resource): Resource => { +export const canonicalizeRealtimeResource = ( + resource: Resource, + options: { synthesizePlatformScopes?: boolean } = {}, +): Resource => { const platformData = canonicalizeLegacyPlatformData(resource); const platformRecord = asRecord(platformData); const sources = getCanonicalSourceList(resource, platformData); const platformType = resolvePlatformTypeFromSources(sources) || (hasAvailabilityFacet(resource, platformData) ? 'availability' : resource.platformType); + const explicitPlatformScopes = readExplicitPlatformScopes(resource); + const platformScopes = + explicitPlatformScopes !== undefined + ? normalizeSourcePlatformScopes(explicitPlatformScopes, platformType) + : options.synthesizePlatformScopes === false + ? undefined + : normalizeSourcePlatformScopes(undefined, platformType); const sourceType = sources && sources.length > 0 ? resolveSourceTypeFromSources(sources) : resource.sourceType; const normalizedBase = { ...resource, platformType, + platformScopes, sourceType, platformData, }; @@ -645,7 +661,7 @@ const mergeCanonicalSourceFacet = ( export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): Resource => { if (!existing) { - return incoming; + return canonicalizeRealtimeResource(incoming); } const existingCanonical = canonicalizeRealtimeResource(existing); const incomingSources = getCanonicalSourceList(incoming, incoming.platformData); @@ -653,6 +669,10 @@ export const mergeCanonicalResource = (incoming: Resource, existing?: Resource): ...existingCanonical, ...incoming, clusterId: incoming.clusterId ?? existingCanonical.clusterId, + platformScopes: normalizeSourcePlatformScopes( + incoming.platformScopes ?? existingCanonical.platformScopes, + incoming.platformType ?? existingCanonical.platformType, + ), discoveryTarget: incoming.discoveryTarget ?? existingCanonical.discoveryTarget, metricsTarget: incoming.metricsTarget ?? existingCanonical.metricsTarget, canonicalIdentity: mergeCanonicalIdentity( diff --git a/frontend-modern/src/utils/sourcePlatforms.ts b/frontend-modern/src/utils/sourcePlatforms.ts index 40176e482..633a612ed 100644 --- a/frontend-modern/src/utils/sourcePlatforms.ts +++ b/frontend-modern/src/utils/sourcePlatforms.ts @@ -79,6 +79,26 @@ export const normalizeSourcePlatformQueryValue = (value: string | null | undefin return normalizeSourcePlatformKey(normalized) || normalized; }; +export const normalizeSourcePlatformScopes = ( + values?: readonly (string | null | undefined)[] | null, + fallbackPlatform?: string | null | undefined, +): string[] => { + const scopes: string[] = []; + const seen = new Set(); + const add = (value: string | null | undefined): void => { + const normalized = normalizeSourcePlatformQueryValue(value); + if (!normalized || normalized === 'all' || seen.has(normalized)) return; + seen.add(normalized); + scopes.push(normalized); + }; + + values?.forEach(add); + if (scopes.length === 0) { + add(fallbackPlatform); + } + return scopes; +}; + export const readSourcePlatformFlags = (sources?: string[]): SourcePlatformFlags => { const flags: SourcePlatformFlags = { hasAgent: false, diff --git a/frontend-modern/src/utils/workloads.ts b/frontend-modern/src/utils/workloads.ts index 9eb525195..608def345 100644 --- a/frontend-modern/src/utils/workloads.ts +++ b/frontend-modern/src/utils/workloads.ts @@ -9,6 +9,10 @@ import type { Resource, ResourceDiscoveryTarget } from '@/types/resource'; import type { MetricResourceKind } from '@/utils/metricsKeys'; import { canonicalizeFrontendResourceType } from '@/utils/resourceTypeCompat'; import { canonicalDiscoveryResourceType } from '@/utils/discoveryTarget'; +import { + normalizeSourcePlatformQueryValue, + normalizeSourcePlatformScopes, +} from '@/utils/sourcePlatforms'; /** * Resolve a raw type string (from API or backend) to a semantic WorkloadType. @@ -73,6 +77,19 @@ export const workloadMatchesViewMode = ( return workloadType === viewMode; }; +export const getWorkloadPlatformScopes = ( + guest: Pick, +): string[] => normalizeSourcePlatformScopes(guest.platformScopes, guest.platformType); + +export const workloadMatchesPlatformScope = ( + guest: Pick, + platformScope?: string | null, +): boolean => { + const normalizedScope = normalizeSourcePlatformQueryValue(platformScope); + if (!normalizedScope || normalizedScope === 'all') return true; + return getWorkloadPlatformScopes(guest).includes(normalizedScope); +}; + export const resolveWorkloadType = ( guest: Pick, ): WorkloadType => { diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go index c084f3687..d222b0817 100644 --- a/internal/unifiedresources/canonical_identity_test.go +++ b/internal/unifiedresources/canonical_identity_test.go @@ -1,6 +1,9 @@ package unifiedresources -import "testing" +import ( + "reflect" + "testing" +) func TestRefreshCanonicalIdentityPrefersTargetsAndCanonicalHostData(t *testing.T) { resource := Resource{ @@ -291,6 +294,29 @@ func TestRefreshCanonicalIdentityFeedsPolicyMetadata(t *testing.T) { } } +func TestRefreshCanonicalMetadataDerivesRuntimePlatformScopes(t *testing.T) { + resource := Resource{ + ID: "docker-container-frigate-141", + Type: ResourceTypeAppContainer, + Name: "frigate", + Sources: []DataSource{SourceDocker}, + Docker: &DockerData{ + HostSourceID: "proxmox-lxc-docker:pve-a:node-a:141", + ContainerID: "frigate", + Runtime: "docker", + }, + } + + RefreshCanonicalMetadata(&resource) + + if resource.Canonical == nil { + t.Fatalf("expected canonical identity") + } + if got := resource.PlatformScopes; !reflect.DeepEqual(got, []string{"proxmox-pve", "docker"}) { + t.Fatalf("platform scopes = %#v, want proxmox-pve + docker", got) + } +} + func TestRefreshCanonicalIdentityIgnoresProxmoxPoolAsPlatformIdentity(t *testing.T) { resource := Resource{ ID: "vm-101", diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index c6730c1b5..a2184619a 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -22,6 +22,7 @@ func cloneResource(in *Resource) Resource { out := *in out.DiscoveryTarget = cloneDiscoveryTarget(in.DiscoveryTarget) out.MetricsTarget = cloneMetricsTarget(in.MetricsTarget) + out.PlatformScopes = cloneStringSlice(in.PlatformScopes) out.Sources = cloneDataSourceSlice(in.Sources) out.SourceStatus = cloneSourceStatusMap(in.SourceStatus) out.Identity = cloneResourceIdentity(in.Identity) diff --git a/internal/unifiedresources/platform_scopes.go b/internal/unifiedresources/platform_scopes.go new file mode 100644 index 000000000..ac5fbcbf9 --- /dev/null +++ b/internal/unifiedresources/platform_scopes.go @@ -0,0 +1,139 @@ +package unifiedresources + +import ( + "sort" + "strings" +) + +const proxmoxLXCDockerHostSourcePrefix = "proxmox-lxc-docker:" + +var canonicalPlatformScopeOrder = []string{ + "agent", + "truenas", + "proxmox-pve", + "proxmox-pbs", + "proxmox-pmg", + "docker", + "kubernetes", + "vmware-vsphere", + "availability", +} + +// RefreshPlatformScopes derives the canonical platform-page membership for a +// resource. Platform scopes are intentionally separate from the primary display +// platform: runtime resources such as Docker containers can belong to both the +// runtime lens and the platform that owns the host/guest they run on. +func RefreshPlatformScopes(resource *Resource) { + if resource == nil { + return + } + + scopes := make(map[string]struct{}, len(resource.Sources)+2) + for _, source := range resource.Sources { + addPlatformScope(scopes, platformScopeForSource(source)) + } + + addPlatformScopesForFacets(scopes, *resource) + if shouldAddDockerPlatformScope(*resource) { + addPlatformScope(scopes, "docker") + } + + if resource.Docker != nil { + hostSourceID := strings.TrimSpace(resource.Docker.HostSourceID) + if strings.HasPrefix(hostSourceID, proxmoxLXCDockerHostSourcePrefix) { + addPlatformScope(scopes, "proxmox-pve") + addPlatformScope(scopes, "docker") + } + } + + resource.PlatformScopes = orderPlatformScopes(scopes) +} + +func platformScopeForSource(source DataSource) string { + switch source { + case SourceAgent: + return "agent" + case SourceProxmox: + return "proxmox-pve" + case SourceDocker: + return "docker" + case SourcePBS: + return "proxmox-pbs" + case SourcePMG: + return "proxmox-pmg" + case SourceK8s: + return "kubernetes" + case SourceTrueNAS: + return "truenas" + case SourceVMware: + return "vmware-vsphere" + case SourceAvailability: + return "availability" + default: + return "" + } +} + +func addPlatformScopesForFacets(scopes map[string]struct{}, resource Resource) { + if resource.Agent != nil { + addPlatformScope(scopes, "agent") + } + if resource.TrueNAS != nil { + addPlatformScope(scopes, "truenas") + } + if resource.Proxmox != nil { + addPlatformScope(scopes, "proxmox-pve") + } + if resource.PBS != nil { + addPlatformScope(scopes, "proxmox-pbs") + } + if resource.PMG != nil { + addPlatformScope(scopes, "proxmox-pmg") + } + if resource.Kubernetes != nil { + addPlatformScope(scopes, "kubernetes") + } + if resource.VMware != nil { + addPlatformScope(scopes, "vmware-vsphere") + } + if resource.Availability != nil || CanonicalResourceType(resource.Type) == ResourceTypeNetworkEndpoint { + addPlatformScope(scopes, "availability") + } +} + +func shouldAddDockerPlatformScope(resource Resource) bool { + if resource.Docker == nil { + return false + } + if resource.TrueNAS != nil || hasDataSource(resource.Sources, SourceTrueNAS) { + return false + } + return true +} + +func addPlatformScope(scopes map[string]struct{}, scope string) { + scope = strings.ToLower(strings.TrimSpace(scope)) + if scope == "" { + return + } + scopes[scope] = struct{}{} +} + +func orderPlatformScopes(scopes map[string]struct{}) []string { + if len(scopes) == 0 { + return nil + } + out := make([]string, 0, len(scopes)) + for _, scope := range canonicalPlatformScopeOrder { + if _, ok := scopes[scope]; ok { + out = append(out, scope) + delete(scopes, scope) + } + } + unknownStart := len(out) + for scope := range scopes { + out = append(out, scope) + } + sort.Strings(out[unknownStart:]) + return out +} diff --git a/internal/unifiedresources/platform_scopes_test.go b/internal/unifiedresources/platform_scopes_test.go new file mode 100644 index 000000000..ca03c2cbf --- /dev/null +++ b/internal/unifiedresources/platform_scopes_test.go @@ -0,0 +1,67 @@ +package unifiedresources + +import "testing" + +func TestRefreshPlatformScopes_DerivesSourceMembership(t *testing.T) { + resource := Resource{ + ID: "node-1", + Type: ResourceTypeAgent, + Name: "pve-node", + Sources: []DataSource{SourceAgent, SourceProxmox}, + Agent: &AgentData{Hostname: "pve-node"}, + Proxmox: &ProxmoxData{NodeName: "pve-node"}, + } + + RefreshPlatformScopes(&resource) + + assertStringSliceEqual(t, resource.PlatformScopes, []string{"agent", "proxmox-pve"}) +} + +func TestRefreshPlatformScopes_ProxmoxLXCDockerRuntimeBelongsToRuntimeAndOwningPlatform(t *testing.T) { + resource := Resource{ + ID: "docker-container-frigate-141", + Type: ResourceTypeAppContainer, + Name: "frigate", + Sources: []DataSource{SourceDocker}, + Docker: &DockerData{ + HostSourceID: "proxmox-lxc-docker:pve-a:node-a:141", + ContainerID: "frigate", + Runtime: "docker", + }, + } + + RefreshPlatformScopes(&resource) + + assertStringSliceEqual(t, resource.PlatformScopes, []string{"proxmox-pve", "docker"}) +} + +func TestRefreshPlatformScopes_TrueNASAppContainerKeepsOwningPlatform(t *testing.T) { + resource := Resource{ + ID: "app-container:truenas-main:nextcloud", + Type: ResourceTypeAppContainer, + Name: "nextcloud", + Sources: []DataSource{SourceTrueNAS}, + TrueNAS: &TrueNASData{Hostname: "truenas-main"}, + Docker: &DockerData{ + ContainerID: "nextcloud", + Image: "ix-nextcloud:latest", + Runtime: "docker", + }, + } + + RefreshPlatformScopes(&resource) + + assertStringSliceEqual(t, resource.PlatformScopes, []string{"truenas"}) +} + +func assertStringSliceEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("slice length = %d, want %d; got %#v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("slice[%d] = %q, want %q; got %#v", i, got[i], want[i], got) + } + } +} diff --git a/internal/unifiedresources/policy_metadata.go b/internal/unifiedresources/policy_metadata.go index 86322d49d..8fcbd9e8f 100644 --- a/internal/unifiedresources/policy_metadata.go +++ b/internal/unifiedresources/policy_metadata.go @@ -93,6 +93,7 @@ func RefreshCanonicalMetadata(resource *Resource) { } RefreshCanonicalIdentity(resource) + RefreshPlatformScopes(resource) RefreshPolicyMetadata(resource) } diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index cbf95ab9a..1565df017 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -404,6 +404,19 @@ func TestResourceRegistry_IngestResourcesSeedsHostScopedDockerContainerMappings( assertSourceTarget("docker-container-frigate-105", host105+"/container/frigate") assertSourceTarget("docker-container-frigate-141", host141+"/container/frigate") + + scopesByHost := make(map[string][]string, len(resources)) + for _, resource := range resources { + if resource.Docker == nil { + t.Fatalf("docker container resource missing Docker payload: %#v", resource) + } + scopesByHost[resource.Docker.HostSourceID] = resource.PlatformScopes + } + for _, host := range []string{host105, host141} { + if got := scopesByHost[host]; !reflect.DeepEqual(got, []string{"proxmox-pve", "docker"}) { + t.Fatalf("host %q platform scopes = %#v, want proxmox-pve + docker", host, got) + } + } } func TestResourceRegistry_IngestResourcesDerivesClusterWorkloadParentFromSeededProxmoxNode(t *testing.T) { @@ -3724,6 +3737,9 @@ func TestRegistryIngestKeepsDockerContainersScopedToTheirHost(t *testing.T) { if resource.Name != "frigate" { t.Fatalf("unexpected container name: %q", resource.Name) } + if !reflect.DeepEqual(resource.PlatformScopes, []string{"proxmox-pve", "docker"}) { + t.Fatalf("platform scopes = %#v, want proxmox-pve + docker", resource.PlatformScopes) + } hostsSeen[strings.TrimSpace(resource.Docker.HostSourceID)] = struct{}{} } for _, want := range []string{ diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index db6c02fec..da7b905dd 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -23,6 +23,7 @@ type Resource struct { Canonical *CanonicalIdentity `json:"canonicalIdentity,omitempty"` Policy *ResourcePolicy `json:"policy,omitempty"` AISafeSummary string `json:"aiSafeSummary,omitempty"` + PlatformScopes []string `json:"platformScopes,omitempty"` Sources []DataSource `json:"sources"` SourceStatus map[DataSource]SourceStatus `json:"sourceStatus,omitempty"`