From 7da942226a60a324e2f8b250bdbf8a780ab3828c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 22:28:24 +0100 Subject: [PATCH] Clarify Pulse Agent host profile support Separate first-class platform support from Pulse Agent host profiles and classify Unraid as an agent-backed host profile while preserving it as presentation-only platform vocabulary. --- .../internal/PLATFORM_SUPPORT_MANIFEST.json | 22 ++ .../v6/internal/PLATFORM_SUPPORT_MODEL.md | 53 ++- .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/api-contracts.md | 6 + .../subsystems/frontend-primitives.md | 2 +- .../internal/subsystems/storage-recovery.md | 8 +- .../internal/subsystems/unified-resources.md | 6 +- .../src/api/__tests__/connections.test.ts | 6 +- frontend-modern/src/api/connections.ts | 1 + .../ConnectionEditor/AddressProbeStep.tsx | 7 +- .../ConnectionEditor/ConnectionEditor.tsx | 7 +- .../Settings/InfrastructureSourcePicker.tsx | 9 +- .../InfrastructureSourcePicker.test.tsx | 21 ++ .../InfrastructureWorkspace.test.tsx | 3 +- .../__tests__/settingsArchitecture.test.ts | 2 +- .../__tests__/useConnectionsLedger.test.ts | 3 +- .../Settings/connectionsTableModel.ts | 21 +- .../Settings/useConnectionsLedger.ts | 2 +- ...frastructureOnboardingPresentation.test.ts | 20 +- .../utils/__tests__/sourcePlatforms.test.ts | 11 + .../infrastructureOnboardingPresentation.ts | 61 ++- .../platformSupportManifest.generated.ts | 65 +++- .../src/utils/platformSupportManifest.ts | 54 +++ internal/api/connections_aggregator.go | 26 +- internal/api/connections_aggregator_test.go | 39 +- internal/api/connections_types.go | 1 + internal/api/contract_test.go | 5 +- internal/hostagent/agent.go | 2 + internal/hostagent/agent_new_test.go | 35 ++ internal/hostagent/agent_test.go | 5 + internal/hostagent/os_identity.go | 46 ++- .../mock/platform_support_contract_test.go | 352 +++++++++++++++++- ...nerate_platform_support_frontend_module.py | 203 +++++++++- 33 files changed, 1040 insertions(+), 69 deletions(-) create mode 100644 frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json index 89c9134ab..bb538591f 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json @@ -9,6 +9,28 @@ "docker", "kubernetes" ], + "agent_host_profiles": [ + { + "id": "unraid", + "family": "Unraid", + "governance_state": "supported", + "readiness_stage": "supported", + "host_identity_tokens": [ + "unraid" + ], + "support_floor": { + "setup": "supported", + "visibility": "supported", + "workloads": "n/a", + "storage": "supported", + "recovery": "n/a", + "alerts": "supported", + "assistant_read": "supported", + "assistant_control": "read-only" + }, + "storage_family": "onprem" + } + ], "platforms": [ { "id": "agent", diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md index 61e9daff1..1fb459d28 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md @@ -33,6 +33,11 @@ through platform-by-platform improvisation. intentionally merges API-backed and agent-backed truth. 7. Platform work must project into canonical shared resources first. Do not add provider-local top-level resource types by default. +8. Agent host and appliance profiles are governed separately from first-class + platforms. + A profile may describe supported Pulse Agent deployment on a specific host + or appliance family, but it does not promote that family into + `PLATFORM_TYPE_KEYS` or first-class platform status. ## Platform Categories @@ -84,6 +89,25 @@ Examples: 2. a unified agent on a TrueNAS appliance enriching a `truenas` system that is already supported through the API-backed poller +### Agent Host Profile + +An agent host profile is a governed compatibility profile for running Pulse +Agent on a specific host or appliance family. + +It may reuse source/platform vocabulary for identity matching, but it is not a +platform id, does not carry `PLATFORM_TYPE_KEYS` membership, and does not +replace first-class platform support. + +Agent host profiles must define: + +1. canonical profile id +2. profile family or label +3. governance state +4. readiness stage +5. host identity tokens +6. support-floor row +7. optional storage family + ## Canonical Ingestion Modes ### API-backed @@ -221,6 +245,20 @@ Rules: 5. `azure` 6. `gcp` +Unraid remains presentation-only platform vocabulary so source/platform labels +stay available for compatibility, but its governed Pulse Agent support is +tracked below as an agent host profile instead of as a first-class platform. + +### Agent Host Profiles + +Support floor fields are recorded in this order: `setup`, `visibility`, +`workloads`, `storage`, `recovery`, `alerts`, `assistant_read`, +`assistant_control`. + +| Profile | Family | Governance | Readiness | Host identity tokens | Storage family | Support floor | +| --- | --- | --- | --- | --- | --- | --- | +| `unraid` | `Unraid` | `supported` | `supported` | `unraid` | `onprem` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` | + ### Current support rows Support floor fields are recorded in this order: `setup`, `visibility`, @@ -248,13 +286,14 @@ paths, no canonical projections, and `n/a` for every support-floor field. supported, admitted, and presentation-only platform vocabulary declared here, plus the canonical platform-family, readiness-stage, primary-mode, onboarding-path, projection, and support-floor classification for supported -and admitted platforms. Tests and shared frontend vocabulary may consume that -manifest, and the tracked frontend projection in -`frontend-modern/src/utils/platformSupportManifest.generated.ts` must be -generated from it, but neither projection may introduce platform ids or -governance states, platform families, readiness stages, primary modes, -onboarding paths, projections, or support-floor claims that are not declared in -this document. +and admitted platforms, and the separate machine-readable agent host profile +classification for supported host/appliance profiles. Tests and shared +frontend vocabulary may consume that manifest, and the tracked frontend +projection in `frontend-modern/src/utils/platformSupportManifest.generated.ts` +must be generated from it, but neither projection may introduce platform ids, +host profile ids, governance states, families, readiness stages, primary +modes, onboarding paths, projections, support-floor claims, or host identity +tokens that are not declared in this document. ### Runtime variants diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6985d5e34..59c4ceba5 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -175,6 +175,11 @@ those facts into source-strategy copy, but it must not turn an admitted `first-lab-ready` platform such as VMware into a product-level supported claim, invent platform-local projections, or classify assistant control beyond the manifest's support-floor row. +That same lifecycle-owned helper must keep first-class platform APIs separate +from governed Pulse Agent host profiles. Host/appliance compatibility such as +Unraid is presented as an agent install/profile path sourced from the manifest +`agent_host_profiles` section, not as `PLATFORM_TYPE_KEYS` membership or a +peer API-backed platform. The lifecycle-owned infrastructure source manager also owns platform/system grouping as source-management content, but not its table band presentation: `frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx` must diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4430c7786..af189fe4b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -792,6 +792,12 @@ the canonical monitored-system blocked payload. kernel, architecture, and command capability, so settings surfaces can render recognizable standalone-host identity without a second inventory fetch or frontend-local host reconciliation rules. + Appliance-specific Pulse Agent compatibility is an additive host-profile + fact on that same identity payload. For Unraid and similar host profiles, + `agentIdentity.platform` remains the canonical runtime platform such as + `linux`, while `agentIdentity.hostProfile` carries the governed profile id + such as `unraid`; frontend clients must not re-promote those profile ids + into first-class platform types. `pulse fleet connections` may read that same `GET /api/connections` payload as a deterministic CLI adapter for agent-ready operations, but it must remain a read-only view over the canonical connections ledger rather diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d89f166a6..34ae153d8 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -607,7 +607,7 @@ frontend primitive boundary. must derive primary issue labels and summaries from explicit incidents, storage risk summaries, or storage-risk reasons so healthy rows do not render impact text as a warning. -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, 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. +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. 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. 9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. When a page keeps summary charts visible below the desktop breakpoint, it must use the shared `stickyDesktopOnly` mode instead of adding page-local media queries, so wrapped two-column summaries scroll as normal content and only become sticky once the large-screen layout is active. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders. 10. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision. 11. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index e48ec5f67..cf18fc051 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -508,9 +508,11 @@ bypass the API fail-closed execution gate. systems, duplicate recovery inventory rows, or a storage-local ownership model. The same shared `/api/connections` contract also owns compact `agentIdentity` facts for agent-backed rows; storage and recovery may read - that metadata when they need to label a represented host, but they must not - rebuild OS/endpoint identity from recovery inventory or alias heuristics. - taxonomy. When that grouped platform row is a Proxmox cluster, storage and + that metadata, including host-profile ids such as `unraid`, when they need + to label a represented host, but they must not rebuild OS/endpoint identity + from recovery inventory or alias heuristics, or reinterpret an agent + host-profile id as a storage provider platform. + When that grouped platform row is a Proxmox cluster, storage and recovery must also treat the backend-authored cluster moniker as the canonical row identity instead of re-expanding cluster-member agents into sibling host rows or per-node storage owners. If the grouped row carries diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index eb5078ac2..832834808 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -611,7 +611,11 @@ must stay generated from `frontend-modern/src/types/resource.ts` must derive `PlatformType` from that generated supported-plus-admitted projection rather than hand-maintaining a second platform union that can drift from the governed manifest or re-admit -presentation-only labels by mistake. +presentation-only labels by mistake. Agent host profiles are generated beside +that platform projection for shared identity/presentation use only; a profile +such as Unraid may label a Pulse Agent host, but it must not enter +`PlatformType`, `PLATFORM_TYPE_KEYS`, unified-resource source filters, or +canonical top-level platform identity. That same shared source boundary also applies when unified seeds and supplemental providers coexist. If a canonical unified-resource seed omits an owned supplemental source such as TrueNAS or VMware, the shared resource API diff --git a/frontend-modern/src/api/__tests__/connections.test.ts b/frontend-modern/src/api/__tests__/connections.test.ts index 1db288188..985dc8095 100644 --- a/frontend-modern/src/api/__tests__/connections.test.ts +++ b/frontend-modern/src/api/__tests__/connections.test.ts @@ -165,7 +165,8 @@ describe('ConnectionsAPI', () => { source: 'agent', agentIdentity: { hostname: 'tower', - platform: 'unraid', + platform: 'linux', + hostProfile: 'unraid', osName: 'Unraid', osVersion: '7.1.0', kernelVersion: '6.12.0', @@ -183,7 +184,8 @@ describe('ConnectionsAPI', () => { expect(result.connections[0]).toMatchObject({ agentIdentity: { hostname: 'tower', - platform: 'unraid', + platform: 'linux', + hostProfile: 'unraid', osName: 'Unraid', osVersion: '7.1.0', kernelVersion: '6.12.0', diff --git a/frontend-modern/src/api/connections.ts b/frontend-modern/src/api/connections.ts index 180cb6f2e..96a51150f 100644 --- a/frontend-modern/src/api/connections.ts +++ b/frontend-modern/src/api/connections.ts @@ -65,6 +65,7 @@ export interface ConnectionFleetGovernance { export interface ConnectionAgentIdentity { hostname?: string; platform?: string; + hostProfile?: string; osName?: string; osVersion?: string; kernelVersion?: string; diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx index 2384c2ad8..9cb46f9af 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx @@ -1,6 +1,7 @@ import { Component, For, Show } from 'solid-js'; import type { ProbeCandidate } from '@/api/connections'; import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form'; +import { getInfrastructureAgentHostProfileSupportText } from '@/utils/infrastructureOnboardingPresentation'; import type { CompletedProbePhase, ConnectionEditorState } from './useConnectionEditor'; import { CONNECTION_TYPE_LABELS } from './useConnectionEditor'; @@ -70,7 +71,9 @@ export const AddressProbeStep: Component = (props) => {
Pick a supported product from the catalog below, or if this is } + fallback={ + Pick a supported product from the catalog below, or if this is + } > , or if this is - a Linux, macOS, Windows, FreeBSD, or Unraid host,{' '} + this is one of the supported {getInfrastructureAgentHostProfileSupportText()},{' '} install Pulse Agent instead} diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx index 45dd702c1..89058d6ff 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx @@ -6,7 +6,10 @@ import { createConnectionEditorState, type ConnectionEditorState, } from './useConnectionEditor'; -import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation'; +import { + getInfrastructureAgentHostProfileSupportText, + getInfrastructureAutoDetectLabels, +} from '@/utils/infrastructureOnboardingPresentation'; export type ConnectionEditorMode = 'add' | 'edit'; @@ -128,7 +131,7 @@ export const ConnectionEditor: Component = (props) => { > Install Pulse Agent {' '} - for Linux, macOS, Windows, FreeBSD, or Unraid hosts. + for {getInfrastructureAgentHostProfileSupportText()}.

{strategy.label} - + - Available now + {governanceBadge}
diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx new file mode 100644 index 000000000..f8e33d161 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourcePicker.test.tsx @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { InfrastructureSourcePicker } from '../InfrastructureSourcePicker'; + +describe('InfrastructureSourcePicker', () => { + afterEach(() => { + cleanup(); + }); + + it('labels admitted VMware as first-lab-ready and keeps Pulse Agent on the host profile path', () => { + render(() => ); + + expect(screen.getByText('First lab ready')).toBeInTheDocument(); + expect(screen.queryByText('Available now')).toBeNull(); + expect( + screen.getByText( + 'Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles where you want low-overhead node-local telemetry.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 4c6829bb2..0b762f301 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -729,7 +729,8 @@ describe('InfrastructureWorkspace', () => { agentVersion: '6.0.2', agentIdentity: { hostname: 'tower', - platform: 'unraid', + platform: 'linux', + hostProfile: 'unraid', osName: 'Unraid', osVersion: '7.1.0', kernelVersion: '6.12.0', diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 8a7e1a76c..d3016bd01 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -536,7 +536,7 @@ describe('settings architecture guardrails', () => { expect(addressProbeStepSource).toContain('Probe address'); expect(addressProbeStepSource).toContain('install Pulse Agent instead'); expect(addressProbeStepSource).toContain('Choose a source type instead'); - expect(addressProbeStepSource).toContain('Linux, macOS, Windows, FreeBSD, or Unraid host'); + expect(addressProbeStepSource).toContain('getInfrastructureAgentHostProfileSupportText'); expect(addressProbeStepSource).toContain('supported API-backed platform'); expect(connectionEditorStateSource).toContain('ConnectionsAPI.probe(value)'); diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts index 46a65e920..2472fc6af 100644 --- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts @@ -35,7 +35,8 @@ describe('useConnectionsLedger', () => { }, agentIdentity: { hostname: 'tower', - platform: 'unraid', + platform: 'linux', + hostProfile: 'unraid', osName: 'Unraid', osVersion: '7.1.0', kernelVersion: '6.12.0', diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index 9b8ade658..b3b0c67ec 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -1,4 +1,4 @@ -import type { Connection } from '@/api/connections'; +import type { Connection, ConnectionAgentIdentity } from '@/api/connections'; import type { ConnectionType } from '@/api/connections'; import type { ConnectionFleetAdapterHealth, @@ -11,6 +11,7 @@ import type { ConnectionFleetUpdateStatus, ConnectionFleetVersionDrift, } from '@/api/connections'; +import { getAgentHostProfileFamily } from '@/utils/platformSupportManifest'; export const lastActivityTextFromLastSeen = (lastSeen?: string | null): string => { if (!lastSeen) return 'No activity yet'; @@ -30,6 +31,10 @@ export const lastActivityTextFromLastSeen = (lastSeen?: string | null): string = export const connectionLastActivityText = (connection: Connection): string => lastActivityTextFromLastSeen(connection.lastSeen); +type ConnectionAgentIdentityPresentation = ConnectionAgentIdentity & { + hostProfile?: string | null; +}; + const prettifyPlatform = (platform?: string | null): string | null => { const normalized = platform?.trim().toLowerCase(); if (!normalized) return null; @@ -50,6 +55,16 @@ const prettifyPlatform = (platform?: string | null): string | null => { } }; +const connectionAgentHostProfileLabel = ( + identity?: ConnectionAgentIdentityPresentation | null, +): string | null => { + const hostProfile = identity?.hostProfile?.trim(); + if (hostProfile) { + return getAgentHostProfileFamily(hostProfile) ?? prettifyPlatform(hostProfile); + } + return prettifyPlatform(identity?.platform ?? identity?.osName); +}; + const isIPv4Literal = (value: string): boolean => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value); const firstDistinctAgentIPv4Alias = (connection: Connection): string | null => { @@ -74,11 +89,13 @@ const firstDistinctAgentIPv4Alias = (connection: Connection): string | null => { }; export const connectionAgentIdentitySummary = (connection: Connection): string | null => { + const hostProfile = connectionAgentHostProfileLabel(connection.agentIdentity); const osName = connection.agentIdentity?.osName?.trim(); const osVersion = connection.agentIdentity?.osVersion?.trim(); + if (hostProfile && osVersion) return `${hostProfile} ${osVersion}`; if (osName && osVersion) return `${osName} ${osVersion}`; if (osName) return osName; - return prettifyPlatform(connection.agentIdentity?.platform); + return hostProfile; }; export const connectionAgentEndpointDisplay = (connection: Connection): string | null => { diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index 483059172..7b36797b1 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -31,7 +31,7 @@ export const CONNECTION_TYPE_LABELS: Record = { vmware: 'VMware vCenter', truenas: 'TrueNAS SCALE', availability: 'Network Endpoint', - agent: 'Pulse Unified Agent', + agent: 'Pulse Agent', docker: 'Docker', kubernetes: 'Kubernetes', }; diff --git a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts index 7290d9879..38cc091f6 100644 --- a/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/infrastructureOnboardingPresentation.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + getInfrastructureAgentHostProfileSupportText, getInfrastructureCoverageCompleteActionPresentation, getInfrastructureApiProductsByGovernanceState, getInfrastructureAutoDetectLabels, getInfrastructureEmptyStateDetail, + getInfrastructureGovernanceBadgeLabel, getInfrastructureEmptyStateSummary, + INFRASTRUCTURE_ONBOARDING_PATHS, getInfrastructureOnboardingProductPresentation, getInfrastructureSourceManagerProducts, getInfrastructureSourcePickerGroups, @@ -37,7 +40,11 @@ describe('infrastructureOnboardingPresentation', () => { const agent = getInfrastructureOnboardingProductPresentation('agent'); const pve = getInfrastructureOnboardingProductPresentation('pve'); - expect(agent.bestFor).toContain('full node-local telemetry'); + expect(getInfrastructureAgentHostProfileSupportText()).toBe( + 'Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles', + ); + expect(agent.bestFor).toContain('host/appliance profiles'); + expect(agent.bestFor).toContain('low-overhead node-local telemetry'); expect(agent.coverage).toContain('Low-overhead host telemetry'); expect(agent.catalogDescription).toContain('Low-overhead host telemetry'); expect(agent.sourceStrategy).toBe('agent'); @@ -55,6 +62,8 @@ describe('infrastructureOnboardingPresentation', () => { expect(getInfrastructureSourceStrategyPresentation('agent')).toMatchObject({ label: 'Agent telemetry', summary: 'Pulse Agent', + detail: + 'Installs Pulse Agent for Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles, local services, Docker, and Kubernetes.', }); expect(getInfrastructureSourceStrategyPresentation('api-agent')).toMatchObject({ label: 'API first', @@ -73,6 +82,8 @@ describe('infrastructureOnboardingPresentation', () => { primaryMode: 'api-backed', canonicalProjections: ['network-endpoint'], }); + expect(INFRASTRUCTURE_ONBOARDING_PATHS.api.title).toBe('Connect platform API'); + expect(INFRASTRUCTURE_ONBOARDING_PATHS.agent.title).toBe('Install Pulse Agent'); }); it('keeps supported API products separate from the admitted VMware path', () => { @@ -89,6 +100,13 @@ describe('infrastructureOnboardingPresentation', () => { expect( getInfrastructureApiProductsByGovernanceState('admitted').map((product) => product.label), ).toEqual(['VMware vCenter']); + expect( + getInfrastructureGovernanceBadgeLabel( + getInfrastructureOnboardingProductPresentation('vmware').governanceState, + getInfrastructureOnboardingProductPresentation('vmware').readinessStage, + ), + ).toBe('First lab ready'); + expect(getInfrastructureGovernanceBadgeLabel('supported', 'supported')).toBeNull(); }); it('derives picker groups, auto-detect copy, and landing summaries from the shared helper', () => { diff --git a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts index f02e3584b..25d105d1f 100644 --- a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts +++ b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts @@ -9,6 +9,10 @@ import { resolveSourceTypeFromSources, } from '@/utils/sourcePlatforms'; import { + AGENT_HOST_PROFILE_IDS, + PLATFORM_TYPE_KEYS, + PRESENTATION_ONLY_PLATFORM_IDS, + getAgentHostProfileFamily, getSourcePlatformCanonicalProjections, getSourcePlatformReadinessStage, getSourcePlatformSupportFloor, @@ -139,6 +143,13 @@ describe('sourcePlatforms', () => { assistantControl: 'read-only', }); }); + + it('keeps Unraid as an agent host profile instead of a platform type', () => { + expect(AGENT_HOST_PROFILE_IDS).toEqual(['unraid']); + expect(getAgentHostProfileFamily('unraid')).toBe('Unraid'); + expect(PLATFORM_TYPE_KEYS).not.toContain('unraid'); + expect(PRESENTATION_ONLY_PLATFORM_IDS).toContain('unraid'); + }); }); describe('resolvePlatformTypeFromSources', () => { diff --git a/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts b/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts index 967eaaf03..02610220f 100644 --- a/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts +++ b/frontend-modern/src/utils/infrastructureOnboardingPresentation.ts @@ -1,5 +1,6 @@ import type { ConnectionType } from '@/api/connections'; import { + SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES, getSourcePlatformManifestEntry, type PlatformGovernanceState, type PlatformPrimaryMode, @@ -72,6 +73,45 @@ export interface InfrastructureCoverageCompleteActionPresentation { detail: string; } +const INFRASTRUCTURE_AGENT_RUNTIME_PLATFORM_LABELS = [ + 'Linux', + 'macOS', + 'Windows', + 'FreeBSD', +] as const; + +const formatJoinedLabelList = (labels: readonly string[]): string => { + if (labels.length === 0) return ''; + if (labels.length === 1) return labels[0]; + if (labels.length === 2) return `${labels[0]} and ${labels[1]}`; + return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`; +}; + +const getInfrastructureAgentHostProfileLabels = (): readonly string[] => { + const labels = [ + ...INFRASTRUCTURE_AGENT_RUNTIME_PLATFORM_LABELS, + ...SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES.filter( + (profile) => profile.governanceState === 'supported', + ).map((profile) => profile.family), + ]; + return Array.from(new Set(labels)); +}; + +export const getInfrastructureAgentHostProfileSupportText = (): string => + `${formatJoinedLabelList(getInfrastructureAgentHostProfileLabels())} host/appliance profiles`; + +export const getInfrastructureGovernanceBadgeLabel = ( + governanceState: PlatformGovernanceState, + readinessStage: PlatformReadinessStage, +): string | null => { + if (governanceState === 'supported') return null; + if (governanceState === 'admitted') { + return readinessStage === 'first-lab-ready' ? 'First lab ready' : 'Admitted'; + } + if (governanceState === 'presentation-only') return 'Presentation only'; + return null; +}; + const SOURCE_STRATEGY_PRESENTATION: Record< InfrastructureSourceStrategy, InfrastructureSourceStrategyPresentation @@ -84,7 +124,7 @@ const SOURCE_STRATEGY_PRESENTATION: Record< agent: { label: 'Agent telemetry', summary: 'Pulse Agent', - detail: 'Installs Pulse Agent for host telemetry, local services, Docker, and Kubernetes.', + detail: `Installs Pulse Agent for ${getInfrastructureAgentHostProfileSupportText()}, local services, Docker, and Kubernetes.`, }, 'api-agent': { label: 'API first', @@ -105,8 +145,7 @@ const PRODUCT_PRESENTATION: Record< > = { agent: { label: 'Pulse Agent', - bestFor: - 'Linux, macOS, Windows, FreeBSD, and compatible hosts such as Unraid. Recommended on each machine where you want full node-local telemetry.', + bestFor: `${getInfrastructureAgentHostProfileSupportText()} where you want low-overhead node-local telemetry.`, coverage: 'Low-overhead host telemetry, SMART, services, Docker, and Kubernetes', catalogDescription: 'Low-overhead host telemetry, services, Docker, Kubernetes', sourceStrategy: 'agent', @@ -232,7 +271,7 @@ export const INFRASTRUCTURE_ONBOARDING_PATHS: Record< InfrastructureOnboardingPathPresentation > = { api: { - title: 'Connect a supported platform', + title: 'Connect platform API', description: 'Use a management API when the platform exposes one. Pulse validates the endpoint, requests credentials, and then starts collecting platform inventory and health.', bestFor: 'TrueNAS, Proxmox, and the current VMware vCenter integration path', @@ -240,10 +279,8 @@ export const INFRASTRUCTURE_ONBOARDING_PATHS: Record< }, agent: { title: 'Install Pulse Agent', - description: - 'Use the agent when you want low-overhead machine telemetry, or when the system does not expose a management API Pulse can connect to directly.', - bestFor: - 'Linux, macOS, Windows, FreeBSD, and compatible hosts such as Unraid. Recommended on each machine where you want full node-local telemetry.', + description: `Use the agent when you want low-overhead machine telemetry for ${getInfrastructureAgentHostProfileSupportText()}, or when the system does not expose a management API Pulse can connect to directly.`, + bestFor: `${getInfrastructureAgentHostProfileSupportText()} where you want full node-local telemetry.`, coverage: 'Low-overhead CPU temperature, disk SMART, services, network metrics, Docker, and Kubernetes telemetry', }, @@ -255,13 +292,7 @@ export const INFRASTRUCTURE_AGENT_DISCOVERY_LABELS = [ 'Kubernetes', ] as const; -export const INFRASTRUCTURE_AGENT_HOST_LABELS = [ - 'Linux', - 'macOS', - 'Windows', - 'FreeBSD', - 'Unraid', -] as const; +export const INFRASTRUCTURE_AGENT_HOST_LABELS = getInfrastructureAgentHostProfileLabels(); const SOURCE_PICKER_GROUPS: InfrastructureSourcePickerGroupPresentation[] = [ { diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts index cfa66e8d8..e56a97069 100644 --- a/frontend-modern/src/utils/platformSupportManifest.generated.ts +++ b/frontend-modern/src/utils/platformSupportManifest.generated.ts @@ -1,11 +1,11 @@ // This file is generated by scripts/release_control/generate_platform_support_frontend_module.py. // Do not edit by hand. // Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json -// Source SHA256: 60952e07f032d1a607075c2163c44ffbd5144617fd2e04a5ff1a9ff78490c353 +// Source SHA256: 48a9ef03ba53e39c676b8a3aaf3b608ffd4c2c0269c2e1e485170a006eadfe21 export const PLATFORM_SUPPORT_MANIFEST_SOURCE = { path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json', - sha256: '60952e07f032d1a607075c2163c44ffbd5144617fd2e04a5ff1a9ff78490c353', + sha256: '48a9ef03ba53e39c676b8a3aaf3b608ffd4c2c0269c2e1e485170a006eadfe21', } as const; export const PLATFORM_SUPPORT_MANIFEST = { schemaVersion: 1, @@ -18,6 +18,26 @@ export const PLATFORM_SUPPORT_MANIFEST = { 'docker', 'kubernetes', ], + agentHostProfiles: [ + { + id: 'unraid', + family: 'Unraid', + governanceState: 'supported', + readinessStage: 'supported', + hostIdentityTokens: ['unraid'], + supportFloor: { + setup: 'supported', + visibility: 'supported', + workloads: 'n/a', + storage: 'supported', + recovery: 'n/a', + alerts: 'supported', + assistantRead: 'supported', + assistantControl: 'read-only', + }, + storageFamily: 'onprem', + }, + ], platforms: [ { id: 'agent', @@ -358,6 +378,9 @@ export const PLATFORM_SUPPORT_MANIFEST = { ], } as const; export const SOURCE_PLATFORM_MANIFEST_ENTRIES = PLATFORM_SUPPORT_MANIFEST.platforms; +export const SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES = + PLATFORM_SUPPORT_MANIFEST.agentHostProfiles; +export const AGENT_HOST_PROFILE_IDS = ['unraid'] as const; export const SUPPORTED_PLATFORM_IDS = [ 'agent', 'docker', @@ -473,6 +496,33 @@ export const SOURCE_PLATFORM_ONBOARDING_PATH_KEYS = [ 'install-workspace', 'platform-connections', ] as const; +export const SOURCE_AGENT_HOST_PROFILE_FAMILY = { + unraid: 'Unraid', +} as const; +export const SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE = { + unraid: 'supported', +} as const; +export const SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE = { + unraid: 'supported', +} as const; +export const SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS = { + unraid: ['unraid'], +} as const; +export const SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR = { + unraid: { + setup: 'supported', + visibility: 'supported', + workloads: 'n/a', + storage: 'supported', + recovery: 'n/a', + alerts: 'supported', + assistantRead: 'supported', + assistantControl: 'read-only', + }, +} as const; +export const SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY = { + unraid: 'onprem', +} as const; export const SOURCE_PLATFORM_FAMILY = { agent: 'Pulse-managed host', docker: 'Container runtime', @@ -783,6 +833,17 @@ export type PlatformSupportFloor = export type PlatformSupportFloorValue = PlatformSupportFloor[keyof PlatformSupportFloor]; export type GeneratedSourcePlatformOnboardingPath = (typeof SOURCE_PLATFORM_ONBOARDING_PATH_KEYS)[number]; +export type GeneratedAgentHostProfileId = (typeof AGENT_HOST_PROFILE_IDS)[number]; +export type GeneratedAgentHostProfileManifestEntry = + (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]; +export type AgentHostProfileGovernanceState = + (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['governanceState']; +export type AgentHostProfileReadinessStage = + (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['readinessStage']; +export type AgentHostProfileSupportFloor = + (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['supportFloor']; +export type AgentHostProfileSupportFloorValue = + AgentHostProfileSupportFloor[keyof AgentHostProfileSupportFloor]; export type SourcePlatformStorageFamily = (typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['storageFamily']; export type GeneratedPlatformType = (typeof PLATFORM_TYPE_KEYS)[number]; diff --git a/frontend-modern/src/utils/platformSupportManifest.ts b/frontend-modern/src/utils/platformSupportManifest.ts index 54048930d..666da2abe 100644 --- a/frontend-modern/src/utils/platformSupportManifest.ts +++ b/frontend-modern/src/utils/platformSupportManifest.ts @@ -1,11 +1,19 @@ import { ADMITTED_PLATFORM_IDS, + AGENT_HOST_PROFILE_IDS, DEFAULT_INFRASTRUCTURE_SOURCE_ORDER, KNOWN_SOURCE_PLATFORM_KEYS, PLATFORM_TYPE_KEYS, PLATFORM_SUPPORT_MANIFEST_SOURCE, PRESENTATION_ONLY_PLATFORM_IDS, PLATFORM_SUPPORT_MANIFEST, + SOURCE_AGENT_HOST_PROFILE_FAMILY, + SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE, + SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS, + SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES, + SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE, + SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY, + SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR, SOURCE_PLATFORM_CANONICAL_PROJECTIONS, SOURCE_PLATFORM_ONBOARDING_PATH_KEYS, SOURCE_PLATFORM_ONBOARDING_PATHS, @@ -20,6 +28,12 @@ import { SOURCE_PLATFORM_STORAGE_FAMILY, SOURCE_PLATFORM_SUPPORT_FLOOR, SUPPORTED_PLATFORM_IDS, + type AgentHostProfileGovernanceState, + type AgentHostProfileReadinessStage, + type AgentHostProfileSupportFloor, + type AgentHostProfileSupportFloorValue, + type GeneratedAgentHostProfileId, + type GeneratedAgentHostProfileManifestEntry, type GeneratedKnownSourcePlatform, type GeneratedSourcePlatformOnboardingPath, type GeneratedSourcePlatformManifestEntry, @@ -33,7 +47,13 @@ import { } from '@/utils/platformSupportManifest.generated'; export type SourcePlatformManifestEntry = GeneratedSourcePlatformManifestEntry; +export type SourceAgentHostProfileManifestEntry = GeneratedAgentHostProfileManifestEntry; export type { + AgentHostProfileGovernanceState, + AgentHostProfileReadinessStage, + AgentHostProfileSupportFloor, + AgentHostProfileSupportFloorValue, + GeneratedAgentHostProfileId as AgentHostProfileId, GeneratedKnownSourcePlatform, GeneratedSourcePlatformOnboardingPath as SourcePlatformOnboardingPath, PlatformPrimaryMode, @@ -48,16 +68,32 @@ export type { const entriesById = new Map( SOURCE_PLATFORM_MANIFEST_ENTRIES.map((platform) => [platform.id, platform] as const), ); +const agentHostProfileEntriesById = new Map( + SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES.map((profile) => [profile.id, profile] as const), +); +const agentHostProfileEntriesByToken = new Map( + SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES.flatMap((profile) => + profile.hostIdentityTokens.map((token) => [token, profile] as const), + ), +); const EMPTY_ONBOARDING_PATHS: readonly GeneratedSourcePlatformOnboardingPath[] = []; export { ADMITTED_PLATFORM_IDS, + AGENT_HOST_PROFILE_IDS, DEFAULT_INFRASTRUCTURE_SOURCE_ORDER, KNOWN_SOURCE_PLATFORM_KEYS, PLATFORM_SUPPORT_MANIFEST_SOURCE, PLATFORM_TYPE_KEYS, PRESENTATION_ONLY_PLATFORM_IDS, PLATFORM_SUPPORT_MANIFEST, + SOURCE_AGENT_HOST_PROFILE_FAMILY, + SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE, + SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS, + SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES, + SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE, + SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY, + SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR, SOURCE_PLATFORM_CANONICAL_PROJECTIONS, SOURCE_PLATFORM_ONBOARDING_PATH_KEYS, SOURCE_PLATFORM_ONBOARDING_PATHS, @@ -84,6 +120,24 @@ export const getSourcePlatformManifestEntry = ( return entriesById.get(platformId) || null; }; +export const getAgentHostProfileManifestEntry = ( + value: string | null | undefined, +): SourceAgentHostProfileManifestEntry | null => { + const normalized = (value || '').trim().toLowerCase(); + if (!normalized) return null; + + return ( + agentHostProfileEntriesById.get(normalized) || + agentHostProfileEntriesByToken.get(normalized) || + null + ); +}; + +export const getAgentHostProfileFamily = (value: string | null | undefined): string | null => { + const manifestProfile = getAgentHostProfileManifestEntry(value); + return manifestProfile?.family ?? null; +}; + export const getSourcePlatformStorageFamily = ( value: string | null | undefined, ): SourcePlatformStorageFamily | null => { diff --git a/internal/api/connections_aggregator.go b/internal/api/connections_aggregator.go index aa866c149..77f563b89 100644 --- a/internal/api/connections_aggregator.go +++ b/internal/api/connections_aggregator.go @@ -462,9 +462,11 @@ func connectionFleetRemoteControl(conn Connection) string { } func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity { + hostProfile := connectionAgentHostProfileForHost(host) identity := &ConnectionAgentIdentity{ Hostname: strings.TrimSpace(host.Hostname), - Platform: strings.TrimSpace(host.Platform), + Platform: connectionAgentPlatformForHost(host, hostProfile), + HostProfile: hostProfile, OSName: strings.TrimSpace(host.OSName), OSVersion: strings.TrimSpace(host.OSVersion), KernelVersion: strings.TrimSpace(host.KernelVersion), @@ -474,6 +476,7 @@ func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity { } if identity.Hostname == "" && identity.Platform == "" && + identity.HostProfile == "" && identity.OSName == "" && identity.OSVersion == "" && identity.KernelVersion == "" && @@ -485,6 +488,27 @@ func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity { return identity } +func connectionAgentPlatformForHost(host models.Host, hostProfile string) string { + platform := strings.TrimSpace(host.Platform) + if hostProfile == "unraid" && (platform == "" || strings.EqualFold(platform, "unraid")) { + return "linux" + } + return platform +} + +func connectionAgentHostProfileForHost(host models.Host) string { + if host.Unraid != nil { + return "unraid" + } + if strings.EqualFold(strings.TrimSpace(host.OSName), "unraid") { + return "unraid" + } + if strings.EqualFold(strings.TrimSpace(host.Platform), "unraid") { + return "unraid" + } + return "" +} + func connectionHostAliasesForAgent(host models.Host, name, address string) []string { values := []string{name, address, host.Hostname, host.ReportIP} for _, iface := range host.NetworkInterfaces { diff --git a/internal/api/connections_aggregator_test.go b/internal/api/connections_aggregator_test.go index 58cb12561..80f658ae3 100644 --- a/internal/api/connections_aggregator_test.go +++ b/internal/api/connections_aggregator_test.go @@ -257,7 +257,8 @@ func TestBuildConnections_AgentHostAliasesIncludeReportedIdentityHints(t *testin got[0].AgentIdentity, &ConnectionAgentIdentity{ Hostname: "pi", - Platform: "unraid", + Platform: "linux", + HostProfile: "unraid", OSName: "Unraid", OSVersion: "7.1.0", KernelVersion: "6.12.0", @@ -270,6 +271,42 @@ func TestBuildConnections_AgentHostAliasesIncludeReportedIdentityHints(t *testin } } +func TestBuildConnections_AgentHostProfileFromUnraidStorageFacts(t *testing.T) { + now := time.Now() + in := aggregatorInputs{ + hosts: []models.Host{ + { + ID: "tower", + Hostname: "tower", + Platform: "linux", + Unraid: &models.HostUnraidStorage{ArrayStarted: true}, + LastSeen: now, + AgentVersion: "6.12.10", + CommandsEnabled: false, + }, + }, + now: now, + } + + got := buildConnections(in) + if len(got) != 1 { + t.Fatalf("expected 1 connection, got %d", len(got)) + } + identity := got[0].AgentIdentity + if identity == nil { + t.Fatal("expected agent identity metadata") + } + if identity.HostProfile != "unraid" { + t.Fatalf("host profile = %q, want %q", identity.HostProfile, "unraid") + } + if identity.Platform != "linux" { + t.Fatalf("platform = %q, want %q", identity.Platform, "linux") + } + if identity.OSName != "" { + t.Fatalf("os name = %q, want empty when source identity is absent", identity.OSName) + } +} + func TestBuildConnections_AgentVersionUpdateAvailability(t *testing.T) { now := time.Now() in := aggregatorInputs{ diff --git a/internal/api/connections_types.go b/internal/api/connections_types.go index 64745fd05..fb2e8cbe4 100644 --- a/internal/api/connections_types.go +++ b/internal/api/connections_types.go @@ -81,6 +81,7 @@ type ConnectionError struct { type ConnectionAgentIdentity struct { Hostname string `json:"hostname,omitempty"` Platform string `json:"platform,omitempty"` + HostProfile string `json:"hostProfile,omitempty"` OSName string `json:"osName,omitempty"` OSVersion string `json:"osVersion,omitempty"` KernelVersion string `json:"kernelVersion,omitempty"` diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index f217abfc0..c5c8408da 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -12826,7 +12826,8 @@ func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) { Source: ConnectionSourceAgent, AgentIdentity: &ConnectionAgentIdentity{ Hostname: "host-1", - Platform: "unraid", + Platform: "linux", + HostProfile: "unraid", OSName: "Unraid", OSVersion: "7.1.0", KernelVersion: "6.12.0", @@ -12859,7 +12860,7 @@ func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) { t.Fatalf("marshal agent Connection: %v", err) } - want := `{"id":"agent:host-1","type":"agent","name":"host-1","address":"host-1","hostAliases":["host-1","192.168.0.2"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-04-22T12:00:00Z","source":"agent","agentIdentity":{"hostname":"host-1","platform":"unraid","osName":"Unraid","osVersion":"7.1.0","kernelVersion":"6.12.0","architecture":"x86_64","reportIp":"192.168.0.2","commandsEnabled":true},"agentVersion":"6.0.0","expectedAgentVersion":"6.0.2","agentUpdateAvailable":true,"fleet":{"enrollmentState":"enrolled","livenessState":"active","versionDrift":"behind","adapterHealth":"healthy","configRollout":"reported","credentialStatus":"verified","updateStatus":"update-available","remoteControl":"enabled"},"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}` + want := `{"id":"agent:host-1","type":"agent","name":"host-1","address":"host-1","hostAliases":["host-1","192.168.0.2"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-04-22T12:00:00Z","source":"agent","agentIdentity":{"hostname":"host-1","platform":"linux","hostProfile":"unraid","osName":"Unraid","osVersion":"7.1.0","kernelVersion":"6.12.0","architecture":"x86_64","reportIp":"192.168.0.2","commandsEnabled":true},"agentVersion":"6.0.0","expectedAgentVersion":"6.0.2","agentUpdateAvailable":true,"fleet":{"enrollmentState":"enrolled","livenessState":"active","versionDrift":"behind","adapterHealth":"healthy","configRollout":"reported","credentialStatus":"verified","updateStatus":"update-available","remoteControl":"enabled"},"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}` assertJSONSnapshot(t, body, want) } diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index 6765c0d32..c5fb33744 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -997,6 +997,8 @@ func normalisePlatform(platform string) string { switch platform { case "darwin": return "macos" + case "unraid": + return "linux" default: return platform } diff --git a/internal/hostagent/agent_new_test.go b/internal/hostagent/agent_new_test.go index 450bd46b5..2e46ddb9c 100644 --- a/internal/hostagent/agent_new_test.go +++ b/internal/hostagent/agent_new_test.go @@ -478,6 +478,41 @@ Platform = QNAP t.Fatalf("osVersion = %q, want %q", agent.osVersion, "5.2.0") } }) + + t.Run("unraid from version file", func(t *testing.T) { + mc := &mockCollector{ + goos: "linux", + hostInfoFn: func(context.Context) (*gohost.InfoStat, error) { + return &gohost.InfoStat{ + Hostname: "unraid", + HostID: "hid", + Platform: "linux", + PlatformFamily: "linux", + PlatformVersion: "", + KernelArch: runtime.GOARCH, + }, nil + }, + readFileFn: func(name string) ([]byte, error) { + switch name { + case "/etc/unraid-version": + return []byte("Unraid OS 7.1.0\n"), nil + default: + return nil, os.ErrNotExist + } + }, + } + + agent, err := New(Config{APIToken: "token", LogLevel: zerolog.InfoLevel, Collector: mc}) + if err != nil { + t.Fatalf("New: %v", err) + } + if agent.osName != "Unraid" { + t.Fatalf("osName = %q, want %q", agent.osName, "Unraid") + } + if agent.osVersion != "7.1.0" { + t.Fatalf("osVersion = %q, want %q", agent.osVersion, "7.1.0") + } + }) } func TestNew_UsesCustomCABundleForHTTPTransport(t *testing.T) { diff --git a/internal/hostagent/agent_test.go b/internal/hostagent/agent_test.go index 5e265dec9..05db4875a 100644 --- a/internal/hostagent/agent_test.go +++ b/internal/hostagent/agent_test.go @@ -50,6 +50,11 @@ func TestNormalisePlatform(t *testing.T) { platform: "freebsd", expected: "freebsd", }, + { + name: "unraid reports linux platform", + platform: "unraid", + expected: "linux", + }, { name: "empty string", platform: "", diff --git a/internal/hostagent/os_identity.go b/internal/hostagent/os_identity.go index fad3f5afd..51744f751 100644 --- a/internal/hostagent/os_identity.go +++ b/internal/hostagent/os_identity.go @@ -1,6 +1,11 @@ package hostagent -import "strings" +import ( + "regexp" + "strings" +) + +var unraidVersionPattern = regexp.MustCompile(`\b\d+(?:\.\d+)+(?:[-+._][A-Za-z0-9]+)*\b|\b\d+\b`) func resolveHostOSIdentity(collector SystemCollector, osName, osVersion string) (string, string) { currentName := strings.TrimSpace(osName) @@ -24,6 +29,13 @@ func resolveHostOSIdentity(collector SystemCollector, osName, osVersion string) return name, strings.TrimSpace(version) } + if name, version, ok := detectUnraidOSIdentity(collector); ok { + if version == "" { + version = currentVersion + } + return name, strings.TrimSpace(version) + } + return currentName, currentVersion } @@ -138,6 +150,38 @@ func detectQNAPOSIdentity(collector SystemCollector) (string, string, bool) { return "", "", false } +func detectUnraidOSIdentity(collector SystemCollector) (string, string, bool) { + data, err := collector.ReadFile(hostAgentUnraidVersionPath) + if err != nil { + if _, statErr := collector.Stat(hostAgentUnraidVersionPath); statErr != nil { + return "", "", false + } + return "Unraid", "", true + } + + version := cleanUnraidVersion(string(data)) + return "Unraid", version, true +} + +func cleanUnraidVersion(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if match := unraidVersionPattern.FindString(line); match != "" { + return match + } + } + + return "" +} + func parseAssignmentConfig(content string) map[string]string { parsed := make(map[string]string) diff --git a/internal/mock/platform_support_contract_test.go b/internal/mock/platform_support_contract_test.go index 601d19c78..7cdecf2d5 100644 --- a/internal/mock/platform_support_contract_test.go +++ b/internal/mock/platform_support_contract_test.go @@ -14,15 +14,27 @@ import ( ) var ( - numberedPlatformListRE = regexp.MustCompile("^\\d+\\.\\s+`([^`]+)`") - currentSupportMatrixRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|") - currentSupportSetupRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|\\s+([^|]+?)\\s+\\|") + numberedPlatformListRE = regexp.MustCompile("^\\d+\\.\\s+`([^`]+)`") + currentSupportMatrixRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|") + currentSupportSetupRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|\\s+([^|]+?)\\s+\\|") + agentHostProfileTableRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|") ) type platformSupportManifest struct { - SchemaVersion int `json:"schema_version"` - DefaultInfrastructureSourceOrder []string `json:"default_infrastructure_source_order"` - Platforms []platformSupportManifestEntry `json:"platforms"` + SchemaVersion int `json:"schema_version"` + DefaultInfrastructureSourceOrder []string `json:"default_infrastructure_source_order"` + AgentHostProfiles []agentHostProfileManifestEntry `json:"agent_host_profiles"` + Platforms []platformSupportManifestEntry `json:"platforms"` +} + +type agentHostProfileManifestEntry struct { + ID string `json:"id"` + Family string `json:"family"` + GovernanceState string `json:"governance_state"` + ReadinessStage string `json:"readiness_stage"` + HostIdentityTokens []string `json:"host_identity_tokens"` + SupportFloor map[string]string `json:"support_floor"` + StorageFamily string `json:"storage_family"` } type platformSupportManifestEntry struct { @@ -47,6 +59,7 @@ func TestPlatformSupportManifestMatchesSupportModel(t *testing.T) { classified := parsePlatformListSection(t, model, "### First-class platforms") admitted := parsePlatformListSection(t, model, "### Admitted platforms (not yet supported)") presentationOnly := parsePlatformListSection(t, model, "### Presentation-only platform vocabulary") + agentHostProfiles := parseAgentHostProfileSection(t, model, "### Agent Host Profiles") matrix := parseCurrentSupportMatrixPlatforms(t, model) if diff := diffPlatformSets(classified, matrix); diff != "" { @@ -71,6 +84,52 @@ func TestPlatformSupportManifestMatchesSupportModel(t *testing.T) { ); diff != "" { t.Fatalf("supported onboarding-path manifest drifted from the canonical support model:\n%s", diff) } + if diff := diffPlatformSets( + sortedAgentHostProfileIDs(agentHostProfiles), + manifestAgentHostProfileIDsByState(t, manifest, "supported"), + ); diff != "" { + t.Fatalf("agent host profile manifest drifted from the canonical support model:\n%s", diff) + } + if diff := diffPlatformFieldMap( + agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string { + return []string{profile.Family} + }), + manifestAgentHostProfileFamiliesByState(t, manifest, "supported"), + ); diff != "" { + t.Fatalf("agent host profile family drifted from the canonical support model:\n%s", diff) + } + if diff := diffPlatformFieldMap( + agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string { + return []string{profile.GovernanceState} + }), + manifestAgentHostProfileGovernanceStatesByState(t, manifest, "supported"), + ); diff != "" { + t.Fatalf("agent host profile governance drifted from the canonical support model:\n%s", diff) + } + if diff := diffPlatformFieldMap( + agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string { + return []string{profile.ReadinessStage} + }), + manifestAgentHostProfileReadinessStagesByState(t, manifest, "supported"), + ); diff != "" { + t.Fatalf("agent host profile readiness drifted from the canonical support model:\n%s", diff) + } + if diff := diffPlatformFieldMap( + agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string { + return append([]string(nil), profile.HostIdentityTokens...) + }), + manifestAgentHostProfileHostIdentityTokensByState(t, manifest, "supported"), + ); diff != "" { + t.Fatalf("agent host profile host-identity tokens drifted from the canonical support model:\n%s", diff) + } + if diff := diffPlatformFieldMap( + agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string { + return []string{profile.StorageFamily} + }), + manifestAgentHostProfileStorageFamiliesByState(t, manifest, "supported"), + ); diff != "" { + t.Fatalf("agent host profile storage-family drifted from the canonical support model:\n%s", diff) + } if diff := diffPlatformSets(classified, manifest.DefaultInfrastructureSourceOrder); diff != "" { t.Fatalf("default infrastructure source ordering drifted from the canonical supported platform set:\n%s", diff) } @@ -167,6 +226,57 @@ func TestVMwareFixturesRemainAdmittedButNotSupported(t *testing.T) { } } +func TestUnraidRemainsInPresentationOnlyVocabularyAndAgentHostProfiles(t *testing.T) { + model := loadPlatformSupportModel(t) + manifest := loadPlatformSupportManifest(t) + supported := manifestPlatformsByState(t, manifest, "supported") + admitted := manifestPlatformsByState(t, manifest, "admitted") + presentationOnly := manifestPlatformsByState(t, manifest, "presentation-only") + profiles := parseAgentHostProfileSection(t, model, "### Agent Host Profiles") + + if containsPlatform(supported, "unraid") { + t.Fatal("unraid must not appear in the current supported platform set") + } + if containsPlatform(admitted, "unraid") { + t.Fatal("unraid must not appear in the admitted platform set") + } + if !containsPlatform(presentationOnly, "unraid") { + t.Fatal("expected unraid to remain presentation-only platform vocabulary") + } + if !containsPlatform(sortedAgentHostProfileIDs(profiles), "unraid") { + t.Fatal("expected unraid to remain a supported agent host profile") + } + + profile := requireAgentHostProfileManifestEntry(t, manifest, "unraid") + if profile.GovernanceState != "supported" { + t.Fatalf("unraid governance state = %q, want supported", profile.GovernanceState) + } + if profile.ReadinessStage != "supported" { + t.Fatalf("unraid readiness stage = %q, want supported", profile.ReadinessStage) + } + if diff := diffPlatformSets([]string{"unraid"}, profile.HostIdentityTokens); diff != "" { + t.Fatalf("unraid host identity tokens drifted from the host-profile model:\n%s", diff) + } + if profile.StorageFamily != "onprem" { + t.Fatalf("unraid storage family = %q, want onprem", profile.StorageFamily) + } + if got := profile.SupportFloor["assistant_control"]; got != "read-only" { + t.Fatalf("unraid assistant control support floor = %q, want read-only", got) + } + if got := profile.SupportFloor["storage"]; got != "supported" { + t.Fatalf("unraid storage support floor = %q, want supported", got) + } + if !strings.Contains(model, "### Agent Host Profiles") { + t.Fatal("expected platform support model to keep an agent host profiles section") + } + if !strings.Contains( + model, + "| `unraid` | `Unraid` | `supported` | `supported` | `unraid` | `onprem` |", + ) { + t.Fatal("expected platform support model to keep the unraid host-profile row") + } +} + func assertAgentMockCoverage(t *testing.T, graph FixtureGraph) { t.Helper() @@ -495,6 +605,236 @@ func manifestPlatformOnboardingPaths(manifest platformSupportManifest) map[strin return rows } +type agentHostProfileSectionEntry struct { + Family string + GovernanceState string + ReadinessStage string + HostIdentityTokens []string + StorageFamily string +} + +func parseAgentHostProfileSection( + t *testing.T, + model string, + heading string, +) map[string]agentHostProfileSectionEntry { + t.Helper() + + entries := make(map[string]agentHostProfileSectionEntry) + inSection := false + + for _, raw := range strings.Split(model, "\n") { + line := strings.TrimSpace(raw) + switch { + case line == heading: + inSection = true + continue + case !inSection: + continue + case strings.HasPrefix(line, "### ") || strings.HasPrefix(line, "## "): + return requireNonEmptyAgentHostProfileMap(t, entries, heading) + case line == "" || strings.HasPrefix(line, "| ---"): + continue + } + + matches := agentHostProfileTableRowRE.FindStringSubmatch(line) + if len(matches) != 7 { + continue + } + + entries[matches[1]] = agentHostProfileSectionEntry{ + Family: matches[2], + GovernanceState: matches[3], + ReadinessStage: matches[4], + HostIdentityTokens: parseInlineTokenList(matches[5]), + StorageFamily: matches[6], + } + } + + return requireNonEmptyAgentHostProfileMap(t, entries, heading) +} + +func parseInlineTokenList(value string) []string { + tokens := make([]string, 0, 1) + for _, token := range strings.Split(value, ",") { + token = strings.TrimSpace(token) + token = strings.Trim(token, "`") + if token == "" { + continue + } + tokens = append(tokens, token) + } + return tokens +} + +func sortedAgentHostProfileIDs(profiles map[string]agentHostProfileSectionEntry) []string { + ids := make([]string, 0, len(profiles)) + for id := range profiles { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func agentHostProfileFieldMap( + profiles map[string]agentHostProfileSectionEntry, + selector func(agentHostProfileSectionEntry) []string, +) map[string][]string { + rows := make(map[string][]string, len(profiles)) + for id, profile := range profiles { + rows[id] = append([]string(nil), selector(profile)...) + } + return rows +} + +func requireNonEmptyAgentHostProfileMap( + t *testing.T, + entries map[string]agentHostProfileSectionEntry, + heading string, +) map[string]agentHostProfileSectionEntry { + t.Helper() + + if len(entries) == 0 { + t.Fatalf("expected %s to declare at least one agent host profile", heading) + } + return entries +} + +func manifestAgentHostProfileIDsByState( + t *testing.T, + manifest platformSupportManifest, + governanceState string, +) []string { + t.Helper() + + ids := make([]string, 0) + for _, profile := range manifest.AgentHostProfiles { + if strings.TrimSpace(profile.ID) == "" { + t.Fatal("expected agent host profile ids to be non-empty") + } + if strings.TrimSpace(profile.GovernanceState) == "" { + t.Fatalf("expected agent host profile manifest to classify %s", profile.ID) + } + if profile.GovernanceState == governanceState { + ids = append(ids, profile.ID) + } + } + + return requireNonEmptyPlatformList( + t, + ids, + fmt.Sprintf("manifest agent host profiles with state %s", governanceState), + ) +} + +func manifestAgentHostProfileFieldMap( + t *testing.T, + manifest platformSupportManifest, + governanceState string, + selector func(agentHostProfileManifestEntry) []string, + label string, +) map[string][]string { + t.Helper() + + rows := make(map[string][]string) + for _, profile := range manifest.AgentHostProfiles { + if profile.GovernanceState != governanceState { + continue + } + rows[profile.ID] = append([]string(nil), selector(profile)...) + } + + return requireNonEmptyPlatformFieldMap(t, rows, label) +} + +func manifestAgentHostProfileFamiliesByState( + t *testing.T, + manifest platformSupportManifest, + governanceState string, +) map[string][]string { + return manifestAgentHostProfileFieldMap( + t, + manifest, + governanceState, + func(profile agentHostProfileManifestEntry) []string { return []string{profile.Family} }, + fmt.Sprintf("manifest agent host profile families with state %s", governanceState), + ) +} + +func manifestAgentHostProfileGovernanceStatesByState( + t *testing.T, + manifest platformSupportManifest, + governanceState string, +) map[string][]string { + return manifestAgentHostProfileFieldMap( + t, + manifest, + governanceState, + func(profile agentHostProfileManifestEntry) []string { return []string{profile.GovernanceState} }, + fmt.Sprintf("manifest agent host profile governance with state %s", governanceState), + ) +} + +func manifestAgentHostProfileReadinessStagesByState( + t *testing.T, + manifest platformSupportManifest, + governanceState string, +) map[string][]string { + return manifestAgentHostProfileFieldMap( + t, + manifest, + governanceState, + func(profile agentHostProfileManifestEntry) []string { return []string{profile.ReadinessStage} }, + fmt.Sprintf("manifest agent host profile readiness with state %s", governanceState), + ) +} + +func manifestAgentHostProfileHostIdentityTokensByState( + t *testing.T, + manifest platformSupportManifest, + governanceState string, +) map[string][]string { + return manifestAgentHostProfileFieldMap( + t, + manifest, + governanceState, + func(profile agentHostProfileManifestEntry) []string { + return append([]string(nil), profile.HostIdentityTokens...) + }, + fmt.Sprintf("manifest agent host profile identity tokens with state %s", governanceState), + ) +} + +func manifestAgentHostProfileStorageFamiliesByState( + t *testing.T, + manifest platformSupportManifest, + governanceState string, +) map[string][]string { + return manifestAgentHostProfileFieldMap( + t, + manifest, + governanceState, + func(profile agentHostProfileManifestEntry) []string { return []string{profile.StorageFamily} }, + fmt.Sprintf("manifest agent host profile storage families with state %s", governanceState), + ) +} + +func requireAgentHostProfileManifestEntry( + t *testing.T, + manifest platformSupportManifest, + profileID string, +) agentHostProfileManifestEntry { + t.Helper() + + for _, profile := range manifest.AgentHostProfiles { + if profile.ID == profileID { + return profile + } + } + t.Fatalf("expected agent host profile manifest entry for %s", profileID) + return agentHostProfileManifestEntry{} +} + func requireManifestPlatform(t *testing.T, manifest platformSupportManifest, platformID string) platformSupportManifestEntry { t.Helper() diff --git a/scripts/release_control/generate_platform_support_frontend_module.py b/scripts/release_control/generate_platform_support_frontend_module.py index 30810b354..a214b4ef8 100755 --- a/scripts/release_control/generate_platform_support_frontend_module.py +++ b/scripts/release_control/generate_platform_support_frontend_module.py @@ -35,6 +35,8 @@ SUPPORT_FLOOR_FIELDS = ( "assistant_control", ) VALID_SUPPORT_FLOOR_VALUES = {"supported", "augmentation-only", "read-only", "n/a"} +VALID_AGENT_HOST_PROFILE_GOVERNANCE_STATES = {"supported", "presentation-only"} +VALID_AGENT_HOST_PROFILE_READINESS_STAGES = {"supported", "presentation-only"} def require_dict(value: Any, label: str) -> dict[str, Any]: @@ -73,15 +75,125 @@ def unique_preserve_order(values: list[str]) -> list[str]: return ordered +def normalize_support_floor(support_floor_record: Any, label: str) -> dict[str, str]: + record = require_dict(support_floor_record, label) + support_floor: dict[str, str] = {} + for field in SUPPORT_FLOOR_FIELDS: + value = require_string(record.get(field), f"{label}.{field}") + if value not in VALID_SUPPORT_FLOOR_VALUES: + raise ValueError( + f"expected {label}.{field} to be one of {sorted(VALID_SUPPORT_FLOOR_VALUES)}" + ) + camel_field = field.split("_")[0] + "".join(part.title() for part in field.split("_")[1:]) + support_floor[camel_field] = value + return support_floor + + def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: schema_version = raw_manifest.get("schema_version") if not isinstance(schema_version, int) or schema_version < 1: raise ValueError("expected schema_version to be a positive integer") + raw_agent_host_profiles = raw_manifest.get("agent_host_profiles") + if not isinstance(raw_agent_host_profiles, list) or not raw_agent_host_profiles: + raise ValueError("expected agent_host_profiles to be a non-empty array") + raw_platforms = raw_manifest.get("platforms") if not isinstance(raw_platforms, list) or not raw_platforms: raise ValueError("expected platforms to be a non-empty array") + agent_host_profiles: list[dict[str, Any]] = [] + agent_host_profile_ids: list[str] = [] + for index, raw_profile in enumerate(raw_agent_host_profiles): + record = require_dict(raw_profile, f"agent_host_profiles[{index}]") + profile_id = require_lowercase_identifier(record.get("id"), f"agent_host_profiles[{index}].id") + if profile_id in agent_host_profile_ids: + raise ValueError(f"duplicate agent host profile id {profile_id}") + + governance_state = require_string( + record.get("governance_state"), f"agent_host_profiles[{index}].governance_state" + ) + if governance_state not in VALID_AGENT_HOST_PROFILE_GOVERNANCE_STATES: + raise ValueError( + "expected agent_host_profiles[" + f"{index}].governance_state to be one of " + f"{sorted(VALID_AGENT_HOST_PROFILE_GOVERNANCE_STATES)}" + ) + + readiness_stage = require_string( + record.get("readiness_stage"), f"agent_host_profiles[{index}].readiness_stage" + ) + if readiness_stage not in VALID_AGENT_HOST_PROFILE_READINESS_STAGES: + raise ValueError( + "expected agent_host_profiles[" + f"{index}].readiness_stage to be one of " + f"{sorted(VALID_AGENT_HOST_PROFILE_READINESS_STAGES)}" + ) + if governance_state == "supported" and readiness_stage != "supported": + raise ValueError( + f"supported agent host profile {profile_id} must use readiness_stage supported" + ) + if governance_state == "presentation-only" and readiness_stage != "presentation-only": + raise ValueError( + "presentation-only agent host profile " + f"{profile_id} must use readiness_stage presentation-only" + ) + + host_identity_tokens = unique_preserve_order( + [ + require_lowercase_identifier( + token, + f"agent_host_profiles[{index}].host_identity_tokens", + ) + for token in require_string_list( + record.get("host_identity_tokens"), + f"agent_host_profiles[{index}].host_identity_tokens", + ) + ] + ) + if governance_state == "supported" and not host_identity_tokens: + raise ValueError( + f"supported agent host profile {profile_id} must declare host identity tokens" + ) + if governance_state == "presentation-only" and host_identity_tokens: + raise ValueError( + f"presentation-only agent host profile {profile_id} must not declare host identity tokens" + ) + + support_floor = normalize_support_floor( + record.get("support_floor"), + f"agent_host_profiles[{index}].support_floor", + ) + if governance_state == "presentation-only" and any( + value != "n/a" for value in support_floor.values() + ): + raise ValueError( + f"presentation-only agent host profile {profile_id} support floor must be n/a" + ) + + storage_family = require_string( + record.get("storage_family"), f"agent_host_profiles[{index}].storage_family" + ) + if storage_family not in VALID_STORAGE_FAMILIES: + raise ValueError( + "expected agent_host_profiles[" + f"{index}].storage_family to be one of " + f"{sorted(VALID_STORAGE_FAMILIES)}" + ) + + agent_host_profiles.append( + { + "id": profile_id, + "family": require_string(record.get("family"), f"agent_host_profiles[{index}].family"), + "governanceState": governance_state, + "readinessStage": readiness_stage, + "hostIdentityTokens": host_identity_tokens, + "supportFloor": support_floor, + "storageFamily": storage_family, + } + ) + agent_host_profile_ids.append(profile_id) + platforms: list[dict[str, Any]] = [] known_ids: set[str] = set() alias_map: dict[str, str] = {} @@ -175,20 +287,10 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: if governance_state == "presentation-only" and canonical_projections: raise ValueError(f"presentation-only platform {platform_id} must not declare projections") - support_floor_record = require_dict(record.get("support_floor"), f"platforms[{index}].support_floor") - support_floor: dict[str, str] = {} - for field in SUPPORT_FLOOR_FIELDS: - value = require_string( - support_floor_record.get(field), - f"platforms[{index}].support_floor.{field}", - ) - if value not in VALID_SUPPORT_FLOOR_VALUES: - raise ValueError( - f"expected platforms[{index}].support_floor.{field} to be one of " - f"{sorted(VALID_SUPPORT_FLOOR_VALUES)}" - ) - camel_field = field.split("_")[0] + "".join(part.title() for part in field.split("_")[1:]) - support_floor[camel_field] = value + support_floor = normalize_support_floor( + record.get("support_floor"), + f"platforms[{index}].support_floor", + ) if governance_state == "presentation-only" and any(value != "n/a" for value in support_floor.values()): raise ValueError(f"presentation-only platform {platform_id} support floor must be n/a") @@ -274,6 +376,24 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: readiness_stage_by_id = { platform["id"]: platform["readinessStage"] for platform in platforms } + agent_host_profile_family_by_id = { + profile["id"]: profile["family"] for profile in agent_host_profiles + } + agent_host_profile_governance_state_by_id = { + profile["id"]: profile["governanceState"] for profile in agent_host_profiles + } + agent_host_profile_readiness_stage_by_id = { + profile["id"]: profile["readinessStage"] for profile in agent_host_profiles + } + agent_host_profile_host_identity_tokens_by_id = { + profile["id"]: profile["hostIdentityTokens"] for profile in agent_host_profiles + } + agent_host_profile_support_floor_by_id = { + profile["id"]: profile["supportFloor"] for profile in agent_host_profiles + } + agent_host_profile_storage_family_by_id = { + profile["id"]: profile["storageFamily"] for profile in agent_host_profiles + } primary_mode_by_id = { platform["id"]: platform["primaryMode"] for platform in platforms } @@ -297,6 +417,14 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: for platform in platforms if platform["governanceState"] == "presentation-only" ] + first_class_profile_ids = sorted( + set(agent_host_profile_ids).intersection([*supported_ids, *admitted_ids]) + ) + if first_class_profile_ids: + raise ValueError( + "agent host profile ids must not also be supported or admitted platform ids: " + + ", ".join(first_class_profile_ids) + ) platform_type_keys = [*supported_ids, *admitted_ids] known_source_platform_keys = [*platform_type_keys, *presentation_only_ids, "generic"] onboarding_path_keys = unique_preserve_order( @@ -310,6 +438,8 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: return { "schemaVersion": schema_version, "defaultInfrastructureSourceOrder": default_order, + "agentHostProfiles": agent_host_profiles, + "agentHostProfileIds": agent_host_profile_ids, "platforms": platforms, "supportedPlatformIds": supported_ids, "admittedPlatformIds": admitted_ids, @@ -320,6 +450,12 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: "auditTokens": audit_tokens, "displayTokens": display_tokens, "onboardingPathKeys": onboarding_path_keys, + "agentHostProfileFamilyById": agent_host_profile_family_by_id, + "agentHostProfileGovernanceStateById": agent_host_profile_governance_state_by_id, + "agentHostProfileReadinessStageById": agent_host_profile_readiness_stage_by_id, + "agentHostProfileHostIdentityTokensById": agent_host_profile_host_identity_tokens_by_id, + "agentHostProfileSupportFloorById": agent_host_profile_support_floor_by_id, + "agentHostProfileStorageFamilyById": agent_host_profile_storage_family_by_id, "familyById": family_by_id, "readinessStageById": readiness_stage_by_id, "primaryModeById": primary_mode_by_id, @@ -354,10 +490,14 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str: { "schemaVersion": normalized["schemaVersion"], "defaultInfrastructureSourceOrder": normalized["defaultInfrastructureSourceOrder"], + "agentHostProfiles": normalized["agentHostProfiles"], "platforms": normalized["platforms"], }, ), "export const SOURCE_PLATFORM_MANIFEST_ENTRIES = PLATFORM_SUPPORT_MANIFEST.platforms;\n", + "export const SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES =\n" + " PLATFORM_SUPPORT_MANIFEST.agentHostProfiles;\n", + render_const("AGENT_HOST_PROFILE_IDS", normalized["agentHostProfileIds"]), render_const("SUPPORTED_PLATFORM_IDS", normalized["supportedPlatformIds"]), render_const("ADMITTED_PLATFORM_IDS", normalized["admittedPlatformIds"]), render_const("PRESENTATION_ONLY_PLATFORM_IDS", normalized["presentationOnlyPlatformIds"]), @@ -371,6 +511,30 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str: render_const("SOURCE_PLATFORM_AUDIT_TOKENS", normalized["auditTokens"]), render_const("SOURCE_PLATFORM_DISPLAY_TOKENS", normalized["displayTokens"]), render_const("SOURCE_PLATFORM_ONBOARDING_PATH_KEYS", normalized["onboardingPathKeys"]), + render_const( + "SOURCE_AGENT_HOST_PROFILE_FAMILY", + normalized["agentHostProfileFamilyById"], + ), + render_const( + "SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE", + normalized["agentHostProfileGovernanceStateById"], + ), + render_const( + "SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE", + normalized["agentHostProfileReadinessStageById"], + ), + render_const( + "SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS", + normalized["agentHostProfileHostIdentityTokensById"], + ), + render_const( + "SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR", + normalized["agentHostProfileSupportFloorById"], + ), + render_const( + "SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY", + normalized["agentHostProfileStorageFamilyById"], + ), render_const("SOURCE_PLATFORM_FAMILY", normalized["familyById"]), render_const("SOURCE_PLATFORM_READINESS_STAGE", normalized["readinessStageById"]), render_const("SOURCE_PLATFORM_PRIMARY_MODE", normalized["primaryModeById"]), @@ -390,6 +554,17 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str: "export type PlatformSupportFloorValue = PlatformSupportFloor[keyof PlatformSupportFloor];\n", "export type GeneratedSourcePlatformOnboardingPath =\n" " (typeof SOURCE_PLATFORM_ONBOARDING_PATH_KEYS)[number];\n", + "export type GeneratedAgentHostProfileId = (typeof AGENT_HOST_PROFILE_IDS)[number];\n", + "export type GeneratedAgentHostProfileManifestEntry =\n" + " (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number];\n", + "export type AgentHostProfileGovernanceState =\n" + " (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['governanceState'];\n", + "export type AgentHostProfileReadinessStage =\n" + " (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['readinessStage'];\n", + "export type AgentHostProfileSupportFloor =\n" + " (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['supportFloor'];\n", + "export type AgentHostProfileSupportFloorValue =\n" + " AgentHostProfileSupportFloor[keyof AgentHostProfileSupportFloor];\n", "export type SourcePlatformStorageFamily =\n" " (typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['storageFamily'];\n", "export type GeneratedPlatformType = (typeof PLATFORM_TYPE_KEYS)[number];\n",