diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json index bb538591f..cc7c9339e 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json @@ -18,6 +18,7 @@ "host_identity_tokens": [ "unraid" ], + "runtime_platform": "linux", "support_floor": { "setup": "supported", "visibility": "supported", diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md index 1fb459d28..b91f87904 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md @@ -105,8 +105,11 @@ Agent host profiles must define: 3. governance state 4. readiness stage 5. host identity tokens -6. support-floor row -7. optional storage family +6. runtime platform fallback (the canonical agent runtime platform string used + when a profile-tagged host reports a non-canonical or empty runtime + platform — e.g. Unraid agents report `linux`) +7. support-floor row +8. optional storage family ## Canonical Ingestion Modes @@ -255,9 +258,9 @@ 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` | +| Profile | Family | Governance | Readiness | Host identity tokens | Runtime platform | Storage family | Support floor | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `unraid` | `Unraid` | `supported` | `supported` | `unraid` | `linux` | `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 @@ -287,13 +290,16 @@ 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, 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. +classification (including the runtime-platform fallback) 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` and the +tracked backend projection in +`internal/platformsupport/manifest_generated.go` 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, host identity tokens, or runtime +platform fallbacks 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 59c4ceba5..e1e9896b0 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -180,6 +180,10 @@ 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. +Agent runtime normalization must use that same governed host-profile manifest: +profile identity tokens and runtime platform fallback values such as +`unraid` -> `linux` are generated into the runtime resolver instead of being +redeclared in host-agent or settings-table branches. 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 fdce72f79..a2c03d8cd 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -804,6 +804,10 @@ the canonical monitored-system blocked payload. `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. + The API aggregator must resolve host-profile identity tokens and runtime + platform fallback values through the generated platform-support backend + projection in `internal/platformsupport/manifest_generated.go`, so payload + normalization cannot drift into API-local Unraid or appliance branches. `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 13e4481d7..5d0b97a59 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -608,6 +608,11 @@ frontend primitive boundary. 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, 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. + The generated host-profile projection also carries runtime platform fallback + metadata for shared explanation and parity with backend normalization, but + frontend primitives must still render host-profile labels through + `agentIdentity.hostProfile` rather than prettifying presentation-only ids as + platform values. 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 cf18fc051..ce4b63498 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -552,6 +552,11 @@ bypass the API fail-closed execution gate. `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json` through `frontend-modern/src/utils/platformSupportManifest.ts`, not from storage-local hard-coded provider arrays. + Agent host-profile appliance identity is adjacent context for storage + rows, not a storage-local platform classifier: Unraid storage may render + through the generated host-profile label and runtime platform fallback, but + storage/recovery must not reintroduce `unraid` as a first-class provider or + recovery platform. 32. Keep agentless availability endpoints neutral on the shared unified-resource and API contracts. When `internal/api/availability_handlers.go`, `internal/api/connections_handlers.go`, `internal/api/platform_mock_connections.go`, or `frontend-modern/src/hooks/useUnifiedResources.ts` surface `network-endpoint` availability resources, storage and recovery may consume their liveness as infrastructure context only; they must not reinterpret ping/TCP/HTTP endpoints as storage providers, backup targets, recovery repositories, or protected-workload evidence. 33. Keep infrastructure summary chart bucketing presentation-only on the adjacent shared API boundary. When `internal/api/router.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets for operator-facing summary cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples as recovery freshness windows, backup cadence, or restore evidence. 34. Keep workload chart downsampling presentation-only on that same adjacent shared API boundary. When `internal/api/router.go` caps mixed-cadence workload history into equal-time buckets for operator-facing workload cards, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples as recovery freshness windows, backup cadence, or restore evidence. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 832834808..4c6cf70d4 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -616,6 +616,10 @@ 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. +Host-profile runtime fallback values are generated beside those labels so +agent-backed appliance reports can normalize to canonical runtime platforms +such as `linux` without making the appliance profile a unified-resource +platform. 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/components/Settings/ConnectionEditor/AddressProbeStep.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx index 9cb46f9af..1d2dcb43f 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx @@ -69,12 +69,7 @@ export const AddressProbeStep: Component = (props) => {
No supported API-backed platform detected at that address.
- Pick a supported product from the catalog below, or if this is - } - > + - , or if this is + . - this is one of the supported {getInfrastructureAgentHostProfileSupportText()},{' '} + If this is one of the supported {getInfrastructureAgentHostProfileSupportText()},{' '} install Pulse Agent instead} diff --git a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx index 00a145355..4ec011f2f 100644 --- a/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx +++ b/frontend-modern/src/components/Settings/ConnectionEditor/__tests__/ConnectionEditor.test.tsx @@ -86,10 +86,13 @@ describe('ConnectionEditor', () => { fireEvent.click(screen.getByRole('button', { name: /probe address/i })); await waitFor(() => - expect( - screen.getByText(/No supported API-backed platform detected/i), - ).toBeInTheDocument(), + expect(screen.getByText(/No supported API-backed platform detected/i)).toBeInTheDocument(), ); + const normalizedBodyText = document.body.textContent?.replace(/\s+/g, ' ') ?? ''; + expect(normalizedBodyText).toContain( + 'Choose a source type instead. If this is one of the supported Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles', + ); + expect(normalizedBodyText).not.toContain('if this is this is'); fireEvent.click(screen.getByRole('button', { name: /Choose a source type instead/i })); expect(onBackToCatalog).toHaveBeenCalledTimes(1); @@ -110,6 +113,12 @@ describe('ConnectionEditor', () => { const agentButton = await screen.findByRole('button', { name: /install pulse agent instead/i, }); + const normalizedBodyText = document.body.textContent?.replace(/\s+/g, ' ') ?? ''; + expect(normalizedBodyText).toContain( + 'If this is one of the supported Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles', + ); + expect(normalizedBodyText).not.toContain('catalog below, or if this is'); + fireEvent.click(agentButton); expect(screen.getByTestId('slot').textContent).toBe('slot:agent'); @@ -121,7 +130,12 @@ describe('ConnectionEditor', () => { it('reopens the detect form from a selected credential slot', async () => { mockedProbe.mockResolvedValueOnce({ candidates: [ - { type: 'truenas', host: 'https://truenas.lab', port: 443, hints: { product: 'TrueNAS SCALE' } }, + { + type: 'truenas', + host: 'https://truenas.lab', + port: 443, + hints: { product: 'TrueNAS SCALE' }, + }, ], probedMs: 142, }); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx index 0b762f301..ea6137008 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureWorkspace.test.tsx @@ -775,6 +775,8 @@ describe('InfrastructureWorkspace', () => { expect(screen.getByText('Tower')).toBeInTheDocument(); expect(screen.getByText('Unraid 7.1.0')).toBeInTheDocument(); expect(screen.getByText('192.168.0.10')).toBeInTheDocument(); + expect(towerAgent.agentIdentity?.hostProfile).toBe('unraid'); + expect(towerAgent.agentIdentity?.platform).toBe('linux'); fireEvent.click(screen.getByRole('button', { name: /^Manage$/i })); diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index b3b0c67ec..aafd2670e 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -48,8 +48,6 @@ const prettifyPlatform = (platform?: string | null): string | null => { return 'macOS'; case 'freebsd': return 'FreeBSD'; - case 'unraid': - return 'Unraid'; default: return platform?.trim() ?? null; } diff --git a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts index 25d105d1f..65cfda368 100644 --- a/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts +++ b/frontend-modern/src/utils/__tests__/sourcePlatforms.test.ts @@ -13,6 +13,7 @@ import { PLATFORM_TYPE_KEYS, PRESENTATION_ONLY_PLATFORM_IDS, getAgentHostProfileFamily, + getAgentHostProfileRuntimePlatform, getSourcePlatformCanonicalProjections, getSourcePlatformReadinessStage, getSourcePlatformSupportFloor, @@ -147,6 +148,7 @@ describe('sourcePlatforms', () => { 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(getAgentHostProfileRuntimePlatform('unraid')).toBe('linux'); expect(PLATFORM_TYPE_KEYS).not.toContain('unraid'); expect(PRESENTATION_ONLY_PLATFORM_IDS).toContain('unraid'); }); diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts index e56a97069..d60765d94 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: 48a9ef03ba53e39c676b8a3aaf3b608ffd4c2c0269c2e1e485170a006eadfe21 +// Source SHA256: 769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3 export const PLATFORM_SUPPORT_MANIFEST_SOURCE = { path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json', - sha256: '48a9ef03ba53e39c676b8a3aaf3b608ffd4c2c0269c2e1e485170a006eadfe21', + sha256: '769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3', } as const; export const PLATFORM_SUPPORT_MANIFEST = { schemaVersion: 1, @@ -25,6 +25,7 @@ export const PLATFORM_SUPPORT_MANIFEST = { governanceState: 'supported', readinessStage: 'supported', hostIdentityTokens: ['unraid'], + runtimePlatform: 'linux', supportFloor: { setup: 'supported', visibility: 'supported', @@ -523,6 +524,9 @@ export const SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR = { export const SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY = { unraid: 'onprem', } as const; +export const SOURCE_AGENT_HOST_PROFILE_RUNTIME_PLATFORM = { + unraid: 'linux', +} as const; export const SOURCE_PLATFORM_FAMILY = { agent: 'Pulse-managed host', docker: 'Container runtime', @@ -844,6 +848,8 @@ export type AgentHostProfileSupportFloor = (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['supportFloor']; export type AgentHostProfileSupportFloorValue = AgentHostProfileSupportFloor[keyof AgentHostProfileSupportFloor]; +export type AgentHostProfileRuntimePlatform = + (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['runtimePlatform']; 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 666da2abe..9778a9a3d 100644 --- a/frontend-modern/src/utils/platformSupportManifest.ts +++ b/frontend-modern/src/utils/platformSupportManifest.ts @@ -12,6 +12,7 @@ import { SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS, SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES, SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE, + SOURCE_AGENT_HOST_PROFILE_RUNTIME_PLATFORM, SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY, SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR, SOURCE_PLATFORM_CANONICAL_PROJECTIONS, @@ -30,6 +31,7 @@ import { SUPPORTED_PLATFORM_IDS, type AgentHostProfileGovernanceState, type AgentHostProfileReadinessStage, + type AgentHostProfileRuntimePlatform, type AgentHostProfileSupportFloor, type AgentHostProfileSupportFloorValue, type GeneratedAgentHostProfileId, @@ -51,6 +53,7 @@ export type SourceAgentHostProfileManifestEntry = GeneratedAgentHostProfileManif export type { AgentHostProfileGovernanceState, AgentHostProfileReadinessStage, + AgentHostProfileRuntimePlatform, AgentHostProfileSupportFloor, AgentHostProfileSupportFloorValue, GeneratedAgentHostProfileId as AgentHostProfileId, @@ -92,6 +95,7 @@ export { SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS, SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES, SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE, + SOURCE_AGENT_HOST_PROFILE_RUNTIME_PLATFORM, SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY, SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR, SOURCE_PLATFORM_CANONICAL_PROJECTIONS, @@ -138,6 +142,14 @@ export const getAgentHostProfileFamily = (value: string | null | undefined): str return manifestProfile?.family ?? null; }; +export const getAgentHostProfileRuntimePlatform = ( + value: string | null | undefined, +): AgentHostProfileRuntimePlatform | null => { + const manifestProfile = getAgentHostProfileManifestEntry(value); + if (!manifestProfile) return null; + return SOURCE_AGENT_HOST_PROFILE_RUNTIME_PLATFORM[manifestProfile.id]; +}; + export const getSourcePlatformStorageFamily = ( value: string | null | undefined, ): SourcePlatformStorageFamily | null => { diff --git a/internal/api/connections_aggregator.go b/internal/api/connections_aggregator.go index 77f563b89..725d1649f 100644 --- a/internal/api/connections_aggregator.go +++ b/internal/api/connections_aggregator.go @@ -10,6 +10,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/platformsupport" pulseutils "github.com/rcourtman/pulse-go-rewrite/internal/utils" ) @@ -489,24 +490,22 @@ func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity { } 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 + return platformsupport.NormalizeRuntimePlatformForAgentHostProfile(hostProfile, host.Platform) } func connectionAgentHostProfileForHost(host models.Host) string { if host.Unraid != nil { - return "unraid" + return connectionAgentHostProfileIDFromIdentity("unraid") } - if strings.EqualFold(strings.TrimSpace(host.OSName), "unraid") { - return "unraid" + return connectionAgentHostProfileIDFromIdentity(host.OSName, host.Platform) +} + +func connectionAgentHostProfileIDFromIdentity(values ...string) string { + profile, ok := platformsupport.AgentHostProfileForIdentity(values...) + if !ok { + return "" } - if strings.EqualFold(strings.TrimSpace(host.Platform), "unraid") { - return "unraid" - } - return "" + return profile.ID } func connectionHostAliasesForAgent(host models.Host, name, address string) []string { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index c5c8408da..19ce51b98 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -12859,6 +12859,9 @@ func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) { if err != nil { t.Fatalf("marshal agent Connection: %v", err) } + if conn.AgentIdentity.Platform == conn.AgentIdentity.HostProfile { + t.Fatalf("agent platform must remain runtime platform, got profile id %q", conn.AgentIdentity.Platform) + } 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 c5fb33744..bd9dae21b 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -21,6 +21,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agenttls" "github.com/rcourtman/pulse-go-rewrite/internal/agentupdate" + "github.com/rcourtman/pulse-go-rewrite/internal/platformsupport" "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rcourtman/pulse-go-rewrite/internal/sensors" "github.com/rcourtman/pulse-go-rewrite/internal/utils" @@ -997,9 +998,10 @@ func normalisePlatform(platform string) string { switch platform { case "darwin": return "macos" - case "unraid": - return "linux" default: + if runtimePlatform := platformsupport.RuntimePlatformForHostIdentityToken(platform); runtimePlatform != "" { + return runtimePlatform + } return platform } } diff --git a/internal/hostagent/agent_new_test.go b/internal/hostagent/agent_new_test.go index 2e46ddb9c..7407bd937 100644 --- a/internal/hostagent/agent_new_test.go +++ b/internal/hostagent/agent_new_test.go @@ -512,6 +512,9 @@ Platform = QNAP if agent.osVersion != "7.1.0" { t.Fatalf("osVersion = %q, want %q", agent.osVersion, "7.1.0") } + if got := normalisePlatform("unraid"); got != "linux" { + t.Fatalf("normalisePlatform(unraid) = %q, want linux", got) + } }) } diff --git a/internal/mock/platform_support_contract_test.go b/internal/mock/platform_support_contract_test.go index 7cdecf2d5..c55efa1e3 100644 --- a/internal/mock/platform_support_contract_test.go +++ b/internal/mock/platform_support_contract_test.go @@ -11,13 +11,15 @@ import ( "strings" "testing" "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/platformsupport" ) var ( 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+\\|") + agentHostProfileTableRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|") ) type platformSupportManifest struct { @@ -33,6 +35,7 @@ type agentHostProfileManifestEntry struct { GovernanceState string `json:"governance_state"` ReadinessStage string `json:"readiness_stage"` HostIdentityTokens []string `json:"host_identity_tokens"` + RuntimePlatform string `json:"runtime_platform"` SupportFloor map[string]string `json:"support_floor"` StorageFamily string `json:"storage_family"` } @@ -122,6 +125,14 @@ func TestPlatformSupportManifestMatchesSupportModel(t *testing.T) { ); 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.RuntimePlatform} + }), + manifestAgentHostProfileRuntimePlatformsByState(t, manifest, "supported"), + ); diff != "" { + t.Fatalf("agent host profile runtime-platform fallback drifted from the canonical support model:\n%s", diff) + } if diff := diffPlatformFieldMap( agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string { return []string{profile.StorageFamily} @@ -257,6 +268,9 @@ func TestUnraidRemainsInPresentationOnlyVocabularyAndAgentHostProfiles(t *testin 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.RuntimePlatform != "linux" { + t.Fatalf("unraid runtime platform = %q, want linux", profile.RuntimePlatform) + } if profile.StorageFamily != "onprem" { t.Fatalf("unraid storage family = %q, want onprem", profile.StorageFamily) } @@ -271,12 +285,56 @@ func TestUnraidRemainsInPresentationOnlyVocabularyAndAgentHostProfiles(t *testin } if !strings.Contains( model, - "| `unraid` | `Unraid` | `supported` | `supported` | `unraid` | `onprem` |", + "| `unraid` | `Unraid` | `supported` | `supported` | `unraid` | `linux` | `onprem` |", ) { t.Fatal("expected platform support model to keep the unraid host-profile row") } } +func TestPlatformSupportBackendProjectionMatchesSupportManifest(t *testing.T) { + manifest := loadPlatformSupportManifest(t) + profiles := platformsupport.AgentHostProfiles() + + gotIDs := make([]string, 0, len(profiles)) + gotTokens := make(map[string][]string, len(profiles)) + gotRuntimePlatforms := make(map[string][]string, len(profiles)) + for _, profile := range profiles { + if profile.GovernanceState != "supported" { + continue + } + gotIDs = append(gotIDs, profile.ID) + gotTokens[profile.ID] = append([]string(nil), profile.HostIdentityTokens...) + gotRuntimePlatforms[profile.ID] = []string{profile.RuntimePlatform} + } + + if diff := diffPlatformSets(manifestAgentHostProfileIDsByState(t, manifest, "supported"), gotIDs); diff != "" { + t.Fatalf("backend agent host profile ids drifted from manifest:\n%s", diff) + } + if diff := diffPlatformFieldMap( + manifestAgentHostProfileHostIdentityTokensByState(t, manifest, "supported"), + gotTokens, + ); diff != "" { + t.Fatalf("backend agent host profile identity tokens drifted from manifest:\n%s", diff) + } + if diff := diffPlatformFieldMap( + manifestAgentHostProfileRuntimePlatformsByState(t, manifest, "supported"), + gotRuntimePlatforms, + ); diff != "" { + t.Fatalf("backend agent host profile runtime platforms drifted from manifest:\n%s", diff) + } + + profile, ok := platformsupport.AgentHostProfileForIdentity("Unraid") + if !ok { + t.Fatal("expected backend projection to resolve Unraid identity token") + } + if profile.ID != "unraid" || profile.RuntimePlatform != "linux" { + t.Fatalf("backend Unraid profile = %+v, want id=unraid runtime=linux", profile) + } + if got := platformsupport.NormalizeRuntimePlatformForAgentHostProfile("unraid", "unraid"); got != "linux" { + t.Fatalf("backend runtime normalization = %q, want linux", got) + } +} + func assertAgentMockCoverage(t *testing.T, graph FixtureGraph) { t.Helper() @@ -610,6 +668,7 @@ type agentHostProfileSectionEntry struct { GovernanceState string ReadinessStage string HostIdentityTokens []string + RuntimePlatform string StorageFamily string } @@ -638,7 +697,7 @@ func parseAgentHostProfileSection( } matches := agentHostProfileTableRowRE.FindStringSubmatch(line) - if len(matches) != 7 { + if len(matches) != 8 { continue } @@ -647,7 +706,8 @@ func parseAgentHostProfileSection( GovernanceState: matches[3], ReadinessStage: matches[4], HostIdentityTokens: parseInlineTokenList(matches[5]), - StorageFamily: matches[6], + RuntimePlatform: matches[6], + StorageFamily: matches[7], } } @@ -805,6 +865,20 @@ func manifestAgentHostProfileHostIdentityTokensByState( ) } +func manifestAgentHostProfileRuntimePlatformsByState( + t *testing.T, + manifest platformSupportManifest, + governanceState string, +) map[string][]string { + return manifestAgentHostProfileFieldMap( + t, + manifest, + governanceState, + func(profile agentHostProfileManifestEntry) []string { return []string{profile.RuntimePlatform} }, + fmt.Sprintf("manifest agent host profile runtime platforms with state %s", governanceState), + ) +} + func manifestAgentHostProfileStorageFamiliesByState( t *testing.T, manifest platformSupportManifest, diff --git a/internal/platformsupport/manifest_generated.go b/internal/platformsupport/manifest_generated.go new file mode 100644 index 000000000..a25d2a478 --- /dev/null +++ b/internal/platformsupport/manifest_generated.go @@ -0,0 +1,122 @@ +// Code generated by scripts/release_control/generate_platform_support_backend_module.py. +// DO NOT EDIT. +// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +// Source SHA256: 769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3 + +package platformsupport + +import "strings" + +// ManifestSourcePath is the repo-relative path to the canonical manifest used +// to produce this projection. +const ManifestSourcePath = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json" + +// ManifestSourceSHA256 is the sha256 of the manifest bytes used to produce +// this projection. +const ManifestSourceSHA256 = "769261b515a0bd5baa4d17847a5d04b1f1a93ea0e5a8e0a048e85cd717def2e3" + +// ManifestSchemaVersion is the schema_version field of the manifest. +const ManifestSchemaVersion = 1 + +// AgentHostProfileEntry is the generated projection of an agent host profile. +type AgentHostProfileEntry struct { + ID string + Family string + GovernanceState string + ReadinessStage string + HostIdentityTokens []string + RuntimePlatform string +} + +// agentHostProfileEntries is the generated agent host profile manifest projection. +var agentHostProfileEntries = []AgentHostProfileEntry{ + { + ID: "unraid", + Family: "Unraid", + GovernanceState: "supported", + ReadinessStage: "supported", + HostIdentityTokens: []string{"unraid"}, + RuntimePlatform: "linux", + }, +} + +func AgentHostProfiles() []AgentHostProfileEntry { + out := make([]AgentHostProfileEntry, len(agentHostProfileEntries)) + for index, profile := range agentHostProfileEntries { + out[index] = cloneAgentHostProfile(profile) + } + return out +} + +func AgentHostProfileByID(value string) (AgentHostProfileEntry, bool) { + normalized := normalizeIdentityToken(value) + if normalized == "" { + return AgentHostProfileEntry{}, false + } + for _, profile := range agentHostProfileEntries { + if profile.ID == normalized { + return cloneAgentHostProfile(profile), true + } + } + return AgentHostProfileEntry{}, false +} + +func AgentHostProfileForIdentity(values ...string) (AgentHostProfileEntry, bool) { + for _, value := range values { + for _, profile := range agentHostProfileEntries { + if agentHostProfileMatchesIdentity(profile, value) { + return cloneAgentHostProfile(profile), true + } + } + } + return AgentHostProfileEntry{}, false +} + +func RuntimePlatformForHostIdentityToken(value string) string { + profile, ok := AgentHostProfileForIdentity(value) + if !ok { + return "" + } + return profile.RuntimePlatform +} + +func NormalizeRuntimePlatformForAgentHostProfile(profileID, platform string) string { + reportedPlatform := strings.TrimSpace(platform) + profile, ok := AgentHostProfileByID(profileID) + if !ok || strings.TrimSpace(profile.RuntimePlatform) == "" { + return reportedPlatform + } + if reportedPlatform == "" || agentHostProfileMatchesIdentity(profile, reportedPlatform) { + return profile.RuntimePlatform + } + return reportedPlatform +} + +func (profile AgentHostProfileEntry) MatchesIdentity(value string) bool { + return agentHostProfileMatchesIdentity(profile, value) +} + +func agentHostProfileMatchesIdentity(profile AgentHostProfileEntry, value string) bool { + normalized := normalizeIdentityToken(value) + if normalized == "" { + return false + } + if profile.ID == normalized { + return true + } + for _, token := range profile.HostIdentityTokens { + if normalizeIdentityToken(token) == normalized { + return true + } + } + return false +} + +func cloneAgentHostProfile(profile AgentHostProfileEntry) AgentHostProfileEntry { + profile.HostIdentityTokens = append([]string(nil), profile.HostIdentityTokens...) + return profile +} + +func normalizeIdentityToken(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} diff --git a/internal/platformsupport/manifest_generated_test.go b/internal/platformsupport/manifest_generated_test.go new file mode 100644 index 000000000..40db167c5 --- /dev/null +++ b/internal/platformsupport/manifest_generated_test.go @@ -0,0 +1,40 @@ +package platformsupport + +import "testing" + +func TestAgentHostProfileResolverUsesManifestTokens(t *testing.T) { + profile, ok := AgentHostProfileForIdentity(" Unraid ") + if !ok { + t.Fatal("expected Unraid identity token to resolve") + } + if profile.ID != "unraid" { + t.Fatalf("profile id = %q, want unraid", profile.ID) + } + if profile.RuntimePlatform != "linux" { + t.Fatalf("runtime platform = %q, want linux", profile.RuntimePlatform) + } + + profile.HostIdentityTokens[0] = "mutated" + profile, ok = AgentHostProfileForIdentity("unraid") + if !ok { + t.Fatal("expected resolver data to be immutable across callers") + } + if profile.HostIdentityTokens[0] != "unraid" { + t.Fatalf("identity tokens mutated globally: %+v", profile.HostIdentityTokens) + } +} + +func TestNormalizeRuntimePlatformForAgentHostProfile(t *testing.T) { + if got := NormalizeRuntimePlatformForAgentHostProfile("unraid", ""); got != "linux" { + t.Fatalf("empty reported platform = %q, want linux", got) + } + if got := NormalizeRuntimePlatformForAgentHostProfile("unraid", "unraid"); got != "linux" { + t.Fatalf("legacy reported platform = %q, want linux", got) + } + if got := NormalizeRuntimePlatformForAgentHostProfile("unraid", "linux"); got != "linux" { + t.Fatalf("canonical reported platform = %q, want linux", got) + } + if got := NormalizeRuntimePlatformForAgentHostProfile("unknown", "unraid"); got != "unraid" { + t.Fatalf("unknown host profile platform = %q, want original value", got) + } +} diff --git a/scripts/release_control/generate_platform_support_backend_module.py b/scripts/release_control/generate_platform_support_backend_module.py new file mode 100755 index 000000000..9bf0c8628 --- /dev/null +++ b/scripts/release_control/generate_platform_support_backend_module.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +"""Generate the typed backend platform-support projection from governance JSON.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import subprocess +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +MANIFEST_PATH = REPO_ROOT / "docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json" +OUTPUT_PATH = REPO_ROOT / "internal/platformsupport/manifest_generated.go" +SOURCE_RELATIVE_PATH = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json" +VALID_AGENT_HOST_PROFILE_GOVERNANCE_STATES = {"supported", "presentation-only"} +VALID_AGENT_HOST_PROFILE_RUNTIME_PLATFORMS = {"linux", "macos", "windows", "freebsd"} + + +def require_dict(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ValueError(f"expected {label} to be an object") + return value + + +def require_string(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"expected {label} to be a non-empty string") + return value.strip() + + +def require_lowercase_identifier(value: Any, label: str) -> str: + normalized = require_string(value, label) + if normalized != normalized.lower(): + raise ValueError(f"expected {label} to be lowercase") + return normalized + + +def require_string_list(value: Any, label: str) -> list[str]: + if not isinstance(value, list): + raise ValueError(f"expected {label} to be an array") + return [require_string(item, f"{label}[{index}]") for index, item in enumerate(value)] + + +def unique_preserve_order(values: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for value in values: + if value in seen: + continue + seen.add(value) + ordered.append(value) + return ordered + + +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") + + agent_host_profiles: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + 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 seen_ids: + raise ValueError(f"duplicate agent host profile id {profile_id}") + seen_ids.add(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)}" + ) + + 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" + ) + + runtime_platform = require_lowercase_identifier( + record.get("runtime_platform"), + f"agent_host_profiles[{index}].runtime_platform", + ) + if runtime_platform not in VALID_AGENT_HOST_PROFILE_RUNTIME_PLATFORMS: + raise ValueError( + "expected agent_host_profiles[" + f"{index}].runtime_platform to be one of " + f"{sorted(VALID_AGENT_HOST_PROFILE_RUNTIME_PLATFORMS)}" + ) + + agent_host_profiles.append( + { + "id": profile_id, + "family": require_string(record.get("family"), f"agent_host_profiles[{index}].family"), + "governanceState": governance_state, + "readinessStage": require_string( + record.get("readiness_stage"), + f"agent_host_profiles[{index}].readiness_stage", + ), + "hostIdentityTokens": host_identity_tokens, + "runtimePlatform": runtime_platform, + } + ) + + return { + "schemaVersion": schema_version, + "agentHostProfiles": agent_host_profiles, + } + + +def go_string(value: str) -> str: + return json.dumps(value, ensure_ascii=True) + + +def go_string_slice(values: list[str]) -> str: + if not values: + return "nil" + return "[]string{" + ", ".join(go_string(value) for value in values) + "}" + + +def render_module(normalized: dict[str, Any], manifest_hash: str) -> str: + profile_entries = [] + for profile in normalized["agentHostProfiles"]: + profile_entries.append( + "\t{\n" + f"\t\tID: {go_string(profile['id'])},\n" + f"\t\tFamily: {go_string(profile['family'])},\n" + f"\t\tGovernanceState: {go_string(profile['governanceState'])},\n" + f"\t\tReadinessStage: {go_string(profile['readinessStage'])},\n" + f"\t\tHostIdentityTokens: {go_string_slice(profile['hostIdentityTokens'])},\n" + f"\t\tRuntimePlatform: {go_string(profile['runtimePlatform'])},\n" + "\t},\n" + ) + + return ( + "// Code generated by scripts/release_control/generate_platform_support_backend_module.py.\n" + "// DO NOT EDIT.\n" + f"// Source: {SOURCE_RELATIVE_PATH}\n" + f"// Source SHA256: {manifest_hash}\n\n" + "package platformsupport\n\n" + 'import "strings"\n\n' + "// ManifestSourcePath is the repo-relative path to the canonical manifest used\n" + "// to produce this projection.\n" + f"const ManifestSourcePath = {go_string(SOURCE_RELATIVE_PATH)}\n\n" + "// ManifestSourceSHA256 is the sha256 of the manifest bytes used to produce\n" + "// this projection.\n" + f"const ManifestSourceSHA256 = {go_string(manifest_hash)}\n\n" + "// ManifestSchemaVersion is the schema_version field of the manifest.\n" + f"const ManifestSchemaVersion = {normalized['schemaVersion']}\n\n" + "// AgentHostProfileEntry is the generated projection of an agent host profile.\n" + "type AgentHostProfileEntry struct {\n" + "\tID string\n" + "\tFamily string\n" + "\tGovernanceState string\n" + "\tReadinessStage string\n" + "\tHostIdentityTokens []string\n" + "\tRuntimePlatform string\n" + "}\n\n" + "// agentHostProfileEntries is the generated agent host profile manifest projection.\n" + "var agentHostProfileEntries = []AgentHostProfileEntry{\n" + + "".join(profile_entries) + + "}\n\n" + "func AgentHostProfiles() []AgentHostProfileEntry {\n" + "\tout := make([]AgentHostProfileEntry, len(agentHostProfileEntries))\n" + "\tfor index, profile := range agentHostProfileEntries {\n" + "\t\tout[index] = cloneAgentHostProfile(profile)\n" + "\t}\n" + "\treturn out\n" + "}\n\n" + "func AgentHostProfileByID(value string) (AgentHostProfileEntry, bool) {\n" + "\tnormalized := normalizeIdentityToken(value)\n" + "\tif normalized == \"\" {\n" + "\t\treturn AgentHostProfileEntry{}, false\n" + "\t}\n" + "\tfor _, profile := range agentHostProfileEntries {\n" + "\t\tif profile.ID == normalized {\n" + "\t\t\treturn cloneAgentHostProfile(profile), true\n" + "\t\t}\n" + "\t}\n" + "\treturn AgentHostProfileEntry{}, false\n" + "}\n\n" + "func AgentHostProfileForIdentity(values ...string) (AgentHostProfileEntry, bool) {\n" + "\tfor _, value := range values {\n" + "\t\tfor _, profile := range agentHostProfileEntries {\n" + "\t\t\tif agentHostProfileMatchesIdentity(profile, value) {\n" + "\t\t\t\treturn cloneAgentHostProfile(profile), true\n" + "\t\t\t}\n" + "\t\t}\n" + "\t}\n" + "\treturn AgentHostProfileEntry{}, false\n" + "}\n\n" + "func RuntimePlatformForHostIdentityToken(value string) string {\n" + "\tprofile, ok := AgentHostProfileForIdentity(value)\n" + "\tif !ok {\n" + "\t\treturn \"\"\n" + "\t}\n" + "\treturn profile.RuntimePlatform\n" + "}\n\n" + "func NormalizeRuntimePlatformForAgentHostProfile(profileID, platform string) string {\n" + "\treportedPlatform := strings.TrimSpace(platform)\n" + "\tprofile, ok := AgentHostProfileByID(profileID)\n" + "\tif !ok || strings.TrimSpace(profile.RuntimePlatform) == \"\" {\n" + "\t\treturn reportedPlatform\n" + "\t}\n" + "\tif reportedPlatform == \"\" || agentHostProfileMatchesIdentity(profile, reportedPlatform) {\n" + "\t\treturn profile.RuntimePlatform\n" + "\t}\n" + "\treturn reportedPlatform\n" + "}\n\n" + "func (profile AgentHostProfileEntry) MatchesIdentity(value string) bool {\n" + "\treturn agentHostProfileMatchesIdentity(profile, value)\n" + "}\n\n" + "func agentHostProfileMatchesIdentity(profile AgentHostProfileEntry, value string) bool {\n" + "\tnormalized := normalizeIdentityToken(value)\n" + "\tif normalized == \"\" {\n" + "\t\treturn false\n" + "\t}\n" + "\tif profile.ID == normalized {\n" + "\t\treturn true\n" + "\t}\n" + "\tfor _, token := range profile.HostIdentityTokens {\n" + "\t\tif normalizeIdentityToken(token) == normalized {\n" + "\t\t\treturn true\n" + "\t\t}\n" + "\t}\n" + "\treturn false\n" + "}\n\n" + "func cloneAgentHostProfile(profile AgentHostProfileEntry) AgentHostProfileEntry {\n" + "\tprofile.HostIdentityTokens = append([]string(nil), profile.HostIdentityTokens...)\n" + "\treturn profile\n" + "}\n\n" + "func normalizeIdentityToken(value string) string {\n" + "\treturn strings.ToLower(strings.TrimSpace(value))\n" + "}\n" + ) + + +def format_go(source: str) -> str: + result = subprocess.run( + ["gofmt"], + input=source, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + "failed to format generated Go:\n" + f"{result.stderr.strip() or result.stdout.strip()}" + ) + return result.stdout + + +def generate(check: bool) -> int: + manifest_bytes = MANIFEST_PATH.read_bytes() + manifest_hash = hashlib.sha256(manifest_bytes).hexdigest() + raw_manifest = require_dict(json.loads(manifest_bytes), "manifest root") + normalized = normalize_manifest(raw_manifest) + expected = format_go(render_module(normalized, manifest_hash)) + + if check: + current = OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else "" + if current != expected: + print( + "platform support backend projection is stale; run " + "scripts/release_control/generate_platform_support_backend_module.py" + ) + return 1 + print("platform support backend projection is up to date") + return 0 + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_PATH.write_text(expected, encoding="utf-8") + print(f"wrote {OUTPUT_PATH.relative_to(REPO_ROOT)}") + return 0 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true", help="fail if output would change") + return parser.parse_args() + + +if __name__ == "__main__": + raise SystemExit(generate(parse_args().check)) diff --git a/scripts/release_control/generate_platform_support_frontend_module.py b/scripts/release_control/generate_platform_support_frontend_module.py index a214b4ef8..d59599854 100755 --- a/scripts/release_control/generate_platform_support_frontend_module.py +++ b/scripts/release_control/generate_platform_support_frontend_module.py @@ -37,6 +37,7 @@ SUPPORT_FLOOR_FIELDS = ( 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"} +VALID_AGENT_HOST_PROFILE_RUNTIME_PLATFORMS = {"linux", "macos", "windows", "freebsd"} def require_dict(value: Any, label: str) -> dict[str, Any]: @@ -160,6 +161,17 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: f"presentation-only agent host profile {profile_id} must not declare host identity tokens" ) + runtime_platform = require_lowercase_identifier( + record.get("runtime_platform"), + f"agent_host_profiles[{index}].runtime_platform", + ) + if runtime_platform not in VALID_AGENT_HOST_PROFILE_RUNTIME_PLATFORMS: + raise ValueError( + "expected agent_host_profiles[" + f"{index}].runtime_platform to be one of " + f"{sorted(VALID_AGENT_HOST_PROFILE_RUNTIME_PLATFORMS)}" + ) + support_floor = normalize_support_floor( record.get("support_floor"), f"agent_host_profiles[{index}].support_floor", @@ -188,6 +200,7 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: "governanceState": governance_state, "readinessStage": readiness_stage, "hostIdentityTokens": host_identity_tokens, + "runtimePlatform": runtime_platform, "supportFloor": support_floor, "storageFamily": storage_family, } @@ -394,6 +407,9 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: agent_host_profile_storage_family_by_id = { profile["id"]: profile["storageFamily"] for profile in agent_host_profiles } + agent_host_profile_runtime_platform_by_id = { + profile["id"]: profile["runtimePlatform"] for profile in agent_host_profiles + } primary_mode_by_id = { platform["id"]: platform["primaryMode"] for platform in platforms } @@ -456,6 +472,7 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]: "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, + "agentHostProfileRuntimePlatformById": agent_host_profile_runtime_platform_by_id, "familyById": family_by_id, "readinessStageById": readiness_stage_by_id, "primaryModeById": primary_mode_by_id, @@ -535,6 +552,10 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str: "SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY", normalized["agentHostProfileStorageFamilyById"], ), + render_const( + "SOURCE_AGENT_HOST_PROFILE_RUNTIME_PLATFORM", + normalized["agentHostProfileRuntimePlatformById"], + ), render_const("SOURCE_PLATFORM_FAMILY", normalized["familyById"]), render_const("SOURCE_PLATFORM_READINESS_STAGE", normalized["readinessStageById"]), render_const("SOURCE_PLATFORM_PRIMARY_MODE", normalized["primaryModeById"]), @@ -565,6 +586,8 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str: " (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['supportFloor'];\n", "export type AgentHostProfileSupportFloorValue =\n" " AgentHostProfileSupportFloor[keyof AgentHostProfileSupportFloor];\n", + "export type AgentHostProfileRuntimePlatform =\n" + " (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['runtimePlatform'];\n", "export type SourcePlatformStorageFamily =\n" " (typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['storageFamily'];\n", "export type GeneratedPlatformType = (typeof PLATFORM_TYPE_KEYS)[number];\n",