mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Drive agent host profiles from platform manifest
(cherry picked from commit 4736358acc)
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"host_identity_tokens": [
|
||||
"unraid"
|
||||
],
|
||||
"runtime_platform": "linux",
|
||||
"support_floor": {
|
||||
"setup": "supported",
|
||||
"visibility": "supported",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -69,12 +69,7 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
|
||||
<div class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<div class="font-medium">No supported API-backed platform detected at that address.</div>
|
||||
<div class="mt-1 text-xs">
|
||||
<Show
|
||||
when={props.onChooseSourceTypeInstead}
|
||||
fallback={
|
||||
<span>Pick a supported product from the catalog below, or if this is </span>
|
||||
}
|
||||
>
|
||||
<Show when={props.onChooseSourceTypeInstead}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onChooseSourceTypeInstead}
|
||||
@@ -82,9 +77,9 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
|
||||
>
|
||||
Choose a source type instead
|
||||
</button>
|
||||
<span>, or if this is </span>
|
||||
<span>. </span>
|
||||
</Show>
|
||||
this is one of the supported {getInfrastructureAgentHostProfileSupportText()},{' '}
|
||||
If this is one of the supported {getInfrastructureAgentHostProfileSupportText()},{' '}
|
||||
<Show
|
||||
when={props.onInstallAgent}
|
||||
fallback={<span class="font-medium">install Pulse Agent instead</span>}
|
||||
|
||||
+18
-4
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user