Generate typed platform support projection

This commit is contained in:
rcourtman 2026-04-10 16:52:18 +01:00
parent ab3e028359
commit 8e81add763
11 changed files with 776 additions and 248 deletions

1
.gitignore vendored
View file

@ -228,6 +228,7 @@ scripts/release_control/*
!scripts/release_control/contract_audit.py
!scripts/release_control/contract_audit_test.py
!scripts/release_control/control_plane.py
!scripts/release_control/generate_platform_support_frontend_module.py
!scripts/release_control/control_plane_audit.py
!scripts/release_control/control_plane_audit_test.py
!scripts/release_control/documentation_currentness_test.py

View file

@ -225,9 +225,11 @@ Rules:
`PLATFORM_SUPPORT_MANIFEST.json` is the machine-readable projection of the
supported, admitted, and presentation-only platform vocabulary declared here.
Tests and shared frontend vocabulary may consume that manifest, but it must not
introduce platform ids or governance states that are not declared in this
document.
Tests and shared frontend vocabulary may consume that manifest, and the tracked
frontend projection in
`frontend-modern/src/utils/platformSupportManifest.generated.ts` must be
generated from it, but neither projection may introduce platform ids or
governance states that are not declared in this document.
### Runtime variants

View file

@ -175,7 +175,7 @@ work extends shared components instead of creating new local variants.
4. Add guardrail tests when a new shared pattern is introduced
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.
6. Keep shared storage feature presenters on canonical platform truth. When reusable storage presenters under `frontend-modern/src/features/storageBackups/` classify canonical resources for the shared storage route, API-backed virtualization datastores such as VMware must stay inventory-only datastores instead of inheriting PBS-specific backup-repository or protected-target copy from older fallback branches.
7. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, `frontend-modern/src/utils/sourcePlatformOptions.ts`, and `frontend-modern/scripts/canonical-platform-audit.mjs` must derive supported, admitted, and presentation-only platform ids from `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json` instead of embedding divergent future-label lists in frontend helpers, audit rules, or storage presenters.
7. 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, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest.
8. 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. 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.
9. 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.
10. 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.

View file

@ -358,6 +358,14 @@ floor. `internal/unifiedresources/types.go`, `internal/unifiedresources/registry
single source onto the canonical platform vocabulary, but they must not invent
separate `vcenter` or `esxi` filter keys or a VMware-only top-level resource
family to make the phase-1 slice render.
That same frontend/runtime adapter floor now also owns the typed platform
projection. `frontend-modern/src/utils/platformSupportManifest.generated.ts`
must stay generated from
`docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, and
`frontend-modern/src/types/resource.ts` must derive `PlatformType` from that
generated supported-plus-admitted projection rather than hand-maintaining a
second platform union that can drift from the governed manifest or re-admit
presentation-only labels by mistake.
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

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
@ -14,6 +15,12 @@ const PLATFORM_SUPPORT_MANIFEST_PATH = path.join(
'internal',
'PLATFORM_SUPPORT_MANIFEST.json',
);
const GENERATED_PLATFORM_SUPPORT_MODULE_PATH = path.join(
ROOT,
'src',
'utils',
'platformSupportManifest.generated.ts',
);
const ALLOWLIST = new Set([
'src/components/shared/sourcePlatformBadges.ts',
'src/components/shared/workloadTypeBadges.ts',
@ -31,6 +38,7 @@ const ALLOWLIST = new Set([
'src/utils/resourceTypePresentation.ts',
'src/utils/resourceBadgePresentation.ts',
'src/utils/workloadTypePresentation.ts',
'src/utils/platformSupportManifest.generated.ts',
'src/features/storageBackups/diskPresentation.ts',
'src/features/storageBackups/diskDetailPresentation.ts',
'src/features/storageBackups/cephRecordPresentation.ts',
@ -253,6 +261,27 @@ const STORAGE_HEALTH_TOKENS = ['healthy', 'warning', 'critical', 'offline', 'unk
const findings = [];
function requireGeneratedPlatformSupportProjectionSync() {
const manifestBytes = fs.readFileSync(PLATFORM_SUPPORT_MANIFEST_PATH);
const expectedHash = crypto.createHash('sha256').update(manifestBytes).digest('hex');
const generatedSource = fs.readFileSync(GENERATED_PLATFORM_SUPPORT_MODULE_PATH, 'utf8');
const hashMatch = generatedSource.match(/^\/\/ Source SHA256: ([a-f0-9]{64})$/m);
if (!hashMatch) {
console.error(
'Canonical platform audit failed: generated platform support module is missing its source hash header.',
);
process.exit(1);
}
if (hashMatch[1] !== expectedHash) {
console.error(
'Canonical platform audit failed: frontend platform support projection is stale. Run `python3 scripts/release_control/generate_platform_support_frontend_module.py`.',
);
process.exit(1);
}
}
function toRelative(absPath) {
return path.relative(ROOT, absPath).replaceAll(path.sep, '/');
}
@ -1681,6 +1710,8 @@ const MAP_RULES = [
},
];
requireGeneratedPlatformSupportProjectionSync();
for (const dir of TARGET_DIRS) {
for (const filePath of collectFiles(dir)) {
const relativePath = toRelative(filePath);

View file

@ -3,6 +3,7 @@
*/
import { describe, expect, it } from 'vitest';
import {
PLATFORM_TYPES,
isInfrastructure,
isWorkload,
isStorage,
@ -15,6 +16,11 @@ import {
type Resource,
type ResourceType,
} from '@/types/resource';
import {
ADMITTED_PLATFORM_IDS,
PRESENTATION_ONLY_PLATFORM_IDS,
SUPPORTED_PLATFORM_IDS,
} from '@/utils/platformSupportManifest.generated';
import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity';
// Helper to create a minimal resource for testing
@ -34,6 +40,13 @@ function createResource(overrides: Partial<Resource> = {}): Resource {
}
describe('Resource Type Guards', () => {
it('keeps platform types aligned with the governed platform manifest projection', () => {
expect(PLATFORM_TYPES).toEqual([...SUPPORTED_PLATFORM_IDS, ...ADMITTED_PLATFORM_IDS]);
for (const platform of PRESENTATION_ONLY_PLATFORM_IDS) {
expect(PLATFORM_TYPES).not.toContain(platform as any);
}
});
describe('isInfrastructure', () => {
const infrastructureTypes: ResourceType[] = ['agent', 'docker-host', 'k8s-node', 'k8s-cluster'];
const nonInfrastructureTypes: ResourceType[] = [

View file

@ -14,6 +14,10 @@ import type {
HostRAIDArray,
Memory,
} from '@/types/api';
import {
PLATFORM_TYPE_KEYS as GENERATED_PLATFORM_TYPE_KEYS,
type GeneratedPlatformType,
} from '@/utils/platformSupportManifest.generated';
// Resource types - what kind of entity is being monitored
export type ResourceType =
@ -40,15 +44,8 @@ export type ResourceType =
| 'ceph'; // Ceph cluster
// Platform types - which system the resource comes from
export type PlatformType =
| 'proxmox-pve'
| 'proxmox-pbs'
| 'proxmox-pmg'
| 'docker'
| 'kubernetes'
| 'truenas'
| 'vmware-vsphere'
| 'agent';
export const PLATFORM_TYPES = GENERATED_PLATFORM_TYPE_KEYS;
export type PlatformType = GeneratedPlatformType;
// Source types - how data is collected
export type SourceType =

View file

@ -0,0 +1,345 @@
// 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: 8d255f43fe92759b9ea6918656d91a88c48d32e0f49729525197b6301cd7fbad
export const PLATFORM_SUPPORT_MANIFEST_SOURCE = {
path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json',
sha256: '8d255f43fe92759b9ea6918656d91a88c48d32e0f49729525197b6301cd7fbad',
} as const;
export const PLATFORM_SUPPORT_MANIFEST = {
schemaVersion: 1,
defaultInfrastructureSourceOrder: [
'proxmox-pve',
'agent',
'docker',
'proxmox-pbs',
'proxmox-pmg',
'kubernetes',
'truenas',
],
platforms: [
{
id: 'agent',
governanceState: 'supported',
uiLabel: 'Agent',
uiTone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-400',
aliases: [],
displayTokens: ['Agent'],
storageFamily: 'onprem',
},
{
id: 'docker',
governanceState: 'supported',
uiLabel: 'Containers',
uiTone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-400',
aliases: [],
displayTokens: ['Containers', 'Docker'],
storageFamily: 'container',
},
{
id: 'kubernetes',
governanceState: 'supported',
uiLabel: 'K8s',
uiTone: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400',
aliases: ['k8s'],
displayTokens: ['K8s', 'Kubernetes'],
storageFamily: 'container',
},
{
id: 'proxmox-pve',
governanceState: 'supported',
uiLabel: 'PVE',
uiTone: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400',
aliases: ['pve', 'proxmox'],
displayTokens: ['PVE', 'Proxmox VE'],
storageFamily: 'virtualization',
},
{
id: 'proxmox-pbs',
governanceState: 'supported',
uiLabel: 'PBS',
uiTone: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-400',
aliases: ['pbs'],
displayTokens: ['PBS', 'Proxmox Backup Server'],
storageFamily: 'virtualization',
},
{
id: 'proxmox-pmg',
governanceState: 'supported',
uiLabel: 'PMG',
uiTone: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-400',
aliases: ['pmg'],
displayTokens: ['PMG', 'Proxmox Mail Gateway'],
storageFamily: 'virtualization',
},
{
id: 'truenas',
governanceState: 'supported',
uiLabel: 'TrueNAS',
uiTone: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-400',
aliases: [],
displayTokens: ['TrueNAS'],
storageFamily: 'onprem',
},
{
id: 'vmware-vsphere',
governanceState: 'admitted',
uiLabel: 'vSphere',
uiTone: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
aliases: ['vmware'],
displayTokens: ['vSphere', 'VMware vSphere'],
storageFamily: 'virtualization',
},
{
id: 'unraid',
governanceState: 'presentation-only',
uiLabel: 'Unraid',
uiTone: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
aliases: [],
displayTokens: ['Unraid'],
storageFamily: 'onprem',
},
{
id: 'synology-dsm',
governanceState: 'presentation-only',
uiLabel: 'Synology',
uiTone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
aliases: [],
displayTokens: ['Synology', 'DSM'],
storageFamily: 'onprem',
},
{
id: 'microsoft-hyperv',
governanceState: 'presentation-only',
uiLabel: 'Hyper-V',
uiTone: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
aliases: ['hyper-v'],
displayTokens: ['Hyper-V'],
storageFamily: 'virtualization',
},
{
id: 'aws',
governanceState: 'presentation-only',
uiLabel: 'AWS',
uiTone: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
aliases: [],
displayTokens: ['AWS'],
storageFamily: 'cloud',
},
{
id: 'azure',
governanceState: 'presentation-only',
uiLabel: 'Azure',
uiTone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
aliases: [],
displayTokens: ['Azure'],
storageFamily: 'cloud',
},
{
id: 'gcp',
governanceState: 'presentation-only',
uiLabel: 'GCP',
uiTone: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
aliases: [],
displayTokens: ['GCP'],
storageFamily: 'cloud',
},
],
} as const;
export const SOURCE_PLATFORM_MANIFEST_ENTRIES = PLATFORM_SUPPORT_MANIFEST.platforms;
export const SUPPORTED_PLATFORM_IDS = [
'agent',
'docker',
'kubernetes',
'proxmox-pve',
'proxmox-pbs',
'proxmox-pmg',
'truenas',
] as const;
export const ADMITTED_PLATFORM_IDS = ['vmware-vsphere'] as const;
export const PRESENTATION_ONLY_PLATFORM_IDS = [
'unraid',
'synology-dsm',
'microsoft-hyperv',
'aws',
'azure',
'gcp',
] as const;
export const PLATFORM_TYPE_KEYS = [
'agent',
'docker',
'kubernetes',
'proxmox-pve',
'proxmox-pbs',
'proxmox-pmg',
'truenas',
'vmware-vsphere',
] as const;
export const KNOWN_SOURCE_PLATFORM_KEYS = [
'agent',
'docker',
'kubernetes',
'proxmox-pve',
'proxmox-pbs',
'proxmox-pmg',
'truenas',
'vmware-vsphere',
'unraid',
'synology-dsm',
'microsoft-hyperv',
'aws',
'azure',
'gcp',
'generic',
] as const;
export const DEFAULT_INFRASTRUCTURE_SOURCE_ORDER = [
'proxmox-pve',
'agent',
'docker',
'proxmox-pbs',
'proxmox-pmg',
'kubernetes',
'truenas',
] as const;
export const SOURCE_PLATFORM_ALIAS_MAP = {
k8s: 'kubernetes',
pve: 'proxmox-pve',
proxmox: 'proxmox-pve',
pbs: 'proxmox-pbs',
pmg: 'proxmox-pmg',
vmware: 'vmware-vsphere',
'hyper-v': 'microsoft-hyperv',
} as const;
export const SOURCE_PLATFORM_AUDIT_TOKENS = [
'agent',
'docker',
'kubernetes',
'k8s',
'proxmox-pve',
'pve',
'proxmox',
'proxmox-pbs',
'pbs',
'proxmox-pmg',
'pmg',
'truenas',
'vmware-vsphere',
'vmware',
'unraid',
'synology-dsm',
'microsoft-hyperv',
'hyper-v',
'aws',
'azure',
'gcp',
] as const;
export const SOURCE_PLATFORM_DISPLAY_TOKENS = [
'Agent',
'Containers',
'Docker',
'K8s',
'Kubernetes',
'PVE',
'Proxmox VE',
'PBS',
'Proxmox Backup Server',
'PMG',
'Proxmox Mail Gateway',
'TrueNAS',
'vSphere',
'VMware vSphere',
'Unraid',
'Synology',
'DSM',
'Hyper-V',
'AWS',
'Azure',
'GCP',
] as const;
export const SOURCE_PLATFORM_PRESENTATION = {
agent: {
label: 'Agent',
tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-400',
},
docker: {
label: 'Containers',
tone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-400',
},
kubernetes: {
label: 'K8s',
tone: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-400',
},
'proxmox-pve': {
label: 'PVE',
tone: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-400',
},
'proxmox-pbs': {
label: 'PBS',
tone: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-400',
},
'proxmox-pmg': {
label: 'PMG',
tone: 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-400',
},
truenas: {
label: 'TrueNAS',
tone: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-400',
},
'vmware-vsphere': {
label: 'vSphere',
tone: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
},
unraid: {
label: 'Unraid',
tone: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
},
'synology-dsm': {
label: 'Synology',
tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300',
},
'microsoft-hyperv': {
label: 'Hyper-V',
tone: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
},
aws: {
label: 'AWS',
tone: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
},
azure: {
label: 'Azure',
tone: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300',
},
gcp: {
label: 'GCP',
tone: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
},
generic: {
label: 'Generic',
tone: 'bg-surface-alt text-base-content',
},
} as const;
export const SOURCE_PLATFORM_STORAGE_FAMILY = {
agent: 'onprem',
docker: 'container',
kubernetes: 'container',
'proxmox-pve': 'virtualization',
'proxmox-pbs': 'virtualization',
'proxmox-pmg': 'virtualization',
truenas: 'onprem',
'vmware-vsphere': 'virtualization',
unraid: 'onprem',
'synology-dsm': 'onprem',
'microsoft-hyperv': 'virtualization',
aws: 'cloud',
azure: 'cloud',
gcp: 'cloud',
} as const;
export type PlatformGovernanceState =
(typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['governanceState'];
export type SourcePlatformStorageFamily =
(typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['storageFamily'];
export type GeneratedPlatformType = (typeof PLATFORM_TYPE_KEYS)[number];
export type GeneratedKnownSourcePlatform = (typeof KNOWN_SOURCE_PLATFORM_KEYS)[number];
export type GeneratedSourcePlatformManifestEntry =
(typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number];

View file

@ -1,205 +1,46 @@
import manifestJson from '../../../docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json';
import {
ADMITTED_PLATFORM_IDS,
DEFAULT_INFRASTRUCTURE_SOURCE_ORDER,
KNOWN_SOURCE_PLATFORM_KEYS,
PLATFORM_TYPE_KEYS,
PLATFORM_SUPPORT_MANIFEST_SOURCE,
PRESENTATION_ONLY_PLATFORM_IDS,
PLATFORM_SUPPORT_MANIFEST,
SOURCE_PLATFORM_PRESENTATION,
SOURCE_PLATFORM_ALIAS_MAP,
SOURCE_PLATFORM_AUDIT_TOKENS,
SOURCE_PLATFORM_DISPLAY_TOKENS,
SOURCE_PLATFORM_MANIFEST_ENTRIES,
SOURCE_PLATFORM_STORAGE_FAMILY,
SUPPORTED_PLATFORM_IDS,
type GeneratedKnownSourcePlatform,
type GeneratedSourcePlatformManifestEntry,
type PlatformGovernanceState,
type SourcePlatformStorageFamily,
} from '@/utils/platformSupportManifest.generated';
export type PlatformGovernanceState = 'supported' | 'admitted' | 'presentation-only';
export type SourcePlatformStorageFamily = 'onprem' | 'container' | 'virtualization' | 'cloud';
export type SourcePlatformManifestEntry = GeneratedSourcePlatformManifestEntry;
export type { GeneratedKnownSourcePlatform, PlatformGovernanceState, SourcePlatformStorageFamily };
export interface SourcePlatformManifestEntry {
id: string;
governanceState: PlatformGovernanceState;
uiLabel: string;
uiTone: string;
aliases: string[];
displayTokens: string[];
storageFamily: SourcePlatformStorageFamily;
}
export interface PlatformSupportManifest {
schemaVersion: number;
defaultInfrastructureSourceOrder: string[];
platforms: SourcePlatformManifestEntry[];
}
const VALID_GOVERNANCE_STATES = new Set<PlatformGovernanceState>([
'supported',
'admitted',
'presentation-only',
]);
const VALID_STORAGE_FAMILIES = new Set<SourcePlatformStorageFamily>([
'onprem',
'container',
'virtualization',
'cloud',
]);
const requireRecord = (value: unknown, label: string): Record<string, unknown> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`platform support manifest: expected ${label} to be an object`);
}
return value as Record<string, unknown>;
};
const requireString = (value: unknown, label: string): string => {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`platform support manifest: expected ${label} to be a non-empty string`);
}
return value.trim();
};
const requireLowercaseIdentifier = (value: unknown, label: string): string => {
const normalized = requireString(value, label);
if (normalized !== normalized.toLowerCase()) {
throw new Error(`platform support manifest: expected ${label} to be lowercase`);
}
return normalized;
};
const requireStringArray = (value: unknown, label: string): string[] => {
if (!Array.isArray(value)) {
throw new Error(`platform support manifest: expected ${label} to be an array`);
}
return value.map((item, index) => requireString(item, `${label}[${index}]`));
};
const uniqueStrings = (values: Iterable<string>): string[] => Array.from(new Set(values));
const parsePlatformEntry = (value: unknown, index: number): SourcePlatformManifestEntry => {
const record = requireRecord(value, `platforms[${index}]`);
const governanceState = requireString(
record.governance_state,
`platforms[${index}].governance_state`,
) as PlatformGovernanceState;
if (!VALID_GOVERNANCE_STATES.has(governanceState)) {
throw new Error(
`platform support manifest: invalid governance_state ${governanceState} at platforms[${index}]`,
);
}
const storageFamily = requireString(
record.storage_family,
`platforms[${index}].storage_family`,
) as SourcePlatformStorageFamily;
if (!VALID_STORAGE_FAMILIES.has(storageFamily)) {
throw new Error(
`platform support manifest: invalid storage_family ${storageFamily} at platforms[${index}]`,
);
}
return {
id: requireLowercaseIdentifier(record.id, `platforms[${index}].id`),
governanceState,
uiLabel: requireString(record.ui_label, `platforms[${index}].ui_label`),
uiTone: requireString(record.ui_tone, `platforms[${index}].ui_tone`),
aliases: uniqueStrings(
requireStringArray(record.aliases, `platforms[${index}].aliases`).map((alias, aliasIndex) =>
requireLowercaseIdentifier(alias, `platforms[${index}].aliases[${aliasIndex}]`),
),
),
displayTokens: uniqueStrings(
requireStringArray(record.display_tokens, `platforms[${index}].display_tokens`),
),
storageFamily,
};
};
const parsePlatformSupportManifest = (): PlatformSupportManifest => {
const raw = requireRecord(manifestJson, 'root');
const schemaVersion = Number(raw.schema_version);
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
throw new Error('platform support manifest: expected schema_version to be a positive integer');
}
if (!Array.isArray(raw.platforms)) {
throw new Error('platform support manifest: expected platforms to be an array');
}
const platforms = raw.platforms.map((platform, index) => parsePlatformEntry(platform, index));
const knownIds = new Set(platforms.map((platform) => platform.id));
const platformsById = new Map<string, SourcePlatformManifestEntry>();
const aliases = new Map<string, string>();
for (const platform of platforms) {
if (platformsById.has(platform.id)) {
throw new Error(`platform support manifest: duplicate platform id ${platform.id}`);
}
platformsById.set(platform.id, platform);
for (const alias of platform.aliases) {
if (alias === platform.id) {
throw new Error(`platform support manifest: alias ${alias} duplicates its platform id`);
}
if (knownIds.has(alias) || aliases.has(alias)) {
throw new Error(`platform support manifest: duplicate alias ${alias}`);
}
aliases.set(alias, platform.id);
}
}
const defaultInfrastructureSourceOrder = uniqueStrings(
requireStringArray(
raw.default_infrastructure_source_order,
'default_infrastructure_source_order',
).map((id, index) =>
requireLowercaseIdentifier(id, `default_infrastructure_source_order[${index}]`),
),
);
const supportedIds = new Set(
platforms
.filter((platform) => platform.governanceState === 'supported')
.map((platform) => platform.id),
);
for (const platformId of defaultInfrastructureSourceOrder) {
if (!supportedIds.has(platformId)) {
throw new Error(
`platform support manifest: default infrastructure source order contains non-supported platform ${platformId}`,
);
}
}
return {
schemaVersion,
defaultInfrastructureSourceOrder,
platforms,
};
};
export const PLATFORM_SUPPORT_MANIFEST = parsePlatformSupportManifest();
const entriesById = new Map(
PLATFORM_SUPPORT_MANIFEST.platforms.map((platform) => [platform.id, platform] as const),
const entriesById = new Map<string, SourcePlatformManifestEntry>(
SOURCE_PLATFORM_MANIFEST_ENTRIES.map((platform) => [platform.id, platform] as const),
);
export const SOURCE_PLATFORM_MANIFEST_ENTRIES = Object.freeze([
...PLATFORM_SUPPORT_MANIFEST.platforms,
]);
export const SOURCE_PLATFORM_ALIAS_MAP = Object.freeze(
Object.fromEntries(
SOURCE_PLATFORM_MANIFEST_ENTRIES.flatMap((platform) =>
platform.aliases.map((alias) => [alias, platform.id]),
),
) as Record<string, string>,
);
export const SOURCE_PLATFORM_AUDIT_TOKENS = Object.freeze(
uniqueStrings(
SOURCE_PLATFORM_MANIFEST_ENTRIES.flatMap((platform) => [platform.id, ...platform.aliases]),
),
);
export const SOURCE_PLATFORM_DISPLAY_TOKENS = Object.freeze(
uniqueStrings(
SOURCE_PLATFORM_MANIFEST_ENTRIES.flatMap((platform) => [
platform.uiLabel,
...platform.displayTokens,
]),
),
);
export const DEFAULT_INFRASTRUCTURE_SOURCE_ORDER = Object.freeze([
...PLATFORM_SUPPORT_MANIFEST.defaultInfrastructureSourceOrder,
]);
export {
ADMITTED_PLATFORM_IDS,
DEFAULT_INFRASTRUCTURE_SOURCE_ORDER,
KNOWN_SOURCE_PLATFORM_KEYS,
PLATFORM_SUPPORT_MANIFEST_SOURCE,
PLATFORM_TYPE_KEYS,
PRESENTATION_ONLY_PLATFORM_IDS,
PLATFORM_SUPPORT_MANIFEST,
SOURCE_PLATFORM_PRESENTATION,
SOURCE_PLATFORM_ALIAS_MAP,
SOURCE_PLATFORM_AUDIT_TOKENS,
SOURCE_PLATFORM_DISPLAY_TOKENS,
SOURCE_PLATFORM_MANIFEST_ENTRIES,
SUPPORTED_PLATFORM_IDS,
};
export const getSourcePlatformManifestEntry = (
value: string | null | undefined,
@ -207,11 +48,15 @@ export const getSourcePlatformManifestEntry = (
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return null;
const platformId = SOURCE_PLATFORM_ALIAS_MAP[normalized] || normalized;
const aliasMap = SOURCE_PLATFORM_ALIAS_MAP as Record<string, string>;
const platformId = aliasMap[normalized] || normalized;
return entriesById.get(platformId) || null;
};
export const getSourcePlatformStorageFamily = (
value: string | null | undefined,
): SourcePlatformStorageFamily | null =>
getSourcePlatformManifestEntry(value)?.storageFamily || null;
): SourcePlatformStorageFamily | null => {
const manifestPlatform = getSourcePlatformManifestEntry(value);
if (!manifestPlatform) return null;
return SOURCE_PLATFORM_STORAGE_FAMILY[manifestPlatform.id];
};

View file

@ -1,20 +1,16 @@
import type { PlatformType, SourceType } from '@/types/resource';
import {
KNOWN_SOURCE_PLATFORM_KEYS as GENERATED_KNOWN_SOURCE_PLATFORM_KEYS,
PRESENTATION_ONLY_PLATFORM_IDS,
SOURCE_PLATFORM_PRESENTATION as GENERATED_SOURCE_PLATFORM_PRESENTATION,
type GeneratedKnownSourcePlatform,
getSourcePlatformManifestEntry,
SOURCE_PLATFORM_ALIAS_MAP,
SOURCE_PLATFORM_MANIFEST_ENTRIES,
} from '@/utils/platformSupportManifest';
import { titleCaseDelimitedLabel } from '@/utils/textPresentation';
export type PresentationOnlySourcePlatform =
| 'unraid'
| 'synology-dsm'
| 'microsoft-hyperv'
| 'aws'
| 'azure'
| 'gcp';
export type KnownSourcePlatform = PlatformType | PresentationOnlySourcePlatform | 'generic';
export type PresentationOnlySourcePlatform = (typeof PRESENTATION_ONLY_PLATFORM_IDS)[number];
export type KnownSourcePlatform = GeneratedKnownSourcePlatform;
export interface SourcePlatformPresentation {
label: string;
@ -32,32 +28,11 @@ export interface SourcePlatformFlags {
hasVMware: boolean;
}
const MANIFEST_SOURCE_PLATFORM_PRESENTATION = Object.fromEntries(
SOURCE_PLATFORM_MANIFEST_ENTRIES.map((platform) => [
platform.id,
{
label: platform.uiLabel,
tone: platform.uiTone,
},
]),
) as Record<Exclude<KnownSourcePlatform, 'generic'>, SourcePlatformPresentation>;
export const SOURCE_PLATFORM_PRESENTATION: Record<KnownSourcePlatform, SourcePlatformPresentation> =
{
...MANIFEST_SOURCE_PLATFORM_PRESENTATION,
generic: {
label: 'Generic',
tone: 'bg-surface-alt text-base-content',
},
};
GENERATED_SOURCE_PLATFORM_PRESENTATION as Record<KnownSourcePlatform, SourcePlatformPresentation>;
export const KNOWN_SOURCE_PLATFORM_KEYS = Object.freeze([
...(SOURCE_PLATFORM_MANIFEST_ENTRIES.map((platform) => platform.id) as Exclude<
KnownSourcePlatform,
'generic'
>[]),
'generic',
]) as readonly KnownSourcePlatform[];
export const KNOWN_SOURCE_PLATFORM_KEYS =
GENERATED_KNOWN_SOURCE_PLATFORM_KEYS as readonly KnownSourcePlatform[];
const PLATFORM_ALIASES = SOURCE_PLATFORM_ALIAS_MAP as Record<
string,

View file

@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""Generate the typed frontend 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 / "frontend-modern/src/utils/platformSupportManifest.generated.ts"
SOURCE_RELATIVE_PATH = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json"
DEFAULT_GENERIC_PRESENTATION = {
"label": "Generic",
"tone": "bg-surface-alt text-base-content",
}
VALID_GOVERNANCE_STATES = {"supported", "admitted", "presentation-only"}
VALID_STORAGE_FAMILIES = {"onprem", "container", "virtualization", "cloud"}
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_platforms = raw_manifest.get("platforms")
if not isinstance(raw_platforms, list) or not raw_platforms:
raise ValueError("expected platforms to be a non-empty array")
platforms: list[dict[str, Any]] = []
known_ids: set[str] = set()
alias_map: dict[str, str] = {}
platform_records: list[tuple[int, dict[str, Any], str]] = []
for index, raw_platform in enumerate(raw_platforms):
record = require_dict(raw_platform, f"platforms[{index}]")
platform_id = require_lowercase_identifier(record.get("id"), f"platforms[{index}].id")
if platform_id in known_ids:
raise ValueError(f"duplicate platform id {platform_id}")
known_ids.add(platform_id)
platform_records.append((index, record, platform_id))
all_platform_ids = set(known_ids)
for index, record, platform_id in platform_records:
governance_state = require_string(
record.get("governance_state"), f"platforms[{index}].governance_state"
)
if governance_state not in VALID_GOVERNANCE_STATES:
raise ValueError(
f"expected platforms[{index}].governance_state to be one of "
f"{sorted(VALID_GOVERNANCE_STATES)}"
)
storage_family = require_string(
record.get("storage_family"), f"platforms[{index}].storage_family"
)
if storage_family not in VALID_STORAGE_FAMILIES:
raise ValueError(
f"expected platforms[{index}].storage_family to be one of "
f"{sorted(VALID_STORAGE_FAMILIES)}"
)
aliases = unique_preserve_order(
[
require_lowercase_identifier(alias, f"platforms[{index}].aliases")
for alias in require_string_list(record.get("aliases"), f"platforms[{index}].aliases")
]
)
for alias in aliases:
if alias == platform_id:
raise ValueError(f"alias {alias} duplicates its platform id")
if alias in all_platform_ids or alias in alias_map:
raise ValueError(f"duplicate alias {alias}")
alias_map[alias] = platform_id
display_tokens = unique_preserve_order(
require_string_list(record.get("display_tokens"), f"platforms[{index}].display_tokens")
)
platforms.append(
{
"id": platform_id,
"governanceState": governance_state,
"uiLabel": require_string(record.get("ui_label"), f"platforms[{index}].ui_label"),
"uiTone": require_string(record.get("ui_tone"), f"platforms[{index}].ui_tone"),
"aliases": aliases,
"displayTokens": display_tokens,
"storageFamily": storage_family,
}
)
default_order = unique_preserve_order(
[
require_lowercase_identifier(
platform_id,
f"default_infrastructure_source_order[{index}]",
)
for index, platform_id in enumerate(
require_string_list(
raw_manifest.get("default_infrastructure_source_order"),
"default_infrastructure_source_order",
)
)
]
)
supported_ids = [platform["id"] for platform in platforms if platform["governanceState"] == "supported"]
supported_id_set = set(supported_ids)
for platform_id in default_order:
if platform_id not in supported_id_set:
raise ValueError(
"default_infrastructure_source_order may only contain supported platforms; "
f"got {platform_id}"
)
audit_tokens = unique_preserve_order(
[
token
for platform in platforms
for token in [platform["id"], *platform["aliases"]]
]
)
display_tokens = unique_preserve_order(
[
token
for platform in platforms
for token in [platform["uiLabel"], *platform["displayTokens"]]
]
)
presentation = {
platform["id"]: {"label": platform["uiLabel"], "tone": platform["uiTone"]}
for platform in platforms
}
storage_family_by_id = {
platform["id"]: platform["storageFamily"] for platform in platforms
}
admitted_ids = [
platform["id"] for platform in platforms if platform["governanceState"] == "admitted"
]
presentation_only_ids = [
platform["id"]
for platform in platforms
if platform["governanceState"] == "presentation-only"
]
platform_type_keys = [*supported_ids, *admitted_ids]
known_source_platform_keys = [*platform_type_keys, *presentation_only_ids, "generic"]
return {
"schemaVersion": schema_version,
"defaultInfrastructureSourceOrder": default_order,
"platforms": platforms,
"supportedPlatformIds": supported_ids,
"admittedPlatformIds": admitted_ids,
"presentationOnlyPlatformIds": presentation_only_ids,
"platformTypeKeys": platform_type_keys,
"knownSourcePlatformKeys": known_source_platform_keys,
"aliasMap": alias_map,
"auditTokens": audit_tokens,
"displayTokens": display_tokens,
"presentation": {**presentation, "generic": DEFAULT_GENERIC_PRESENTATION},
"storageFamilyById": storage_family_by_id,
}
def render_const(name: str, value: Any) -> str:
rendered = json.dumps(value, indent=2, ensure_ascii=True)
return f"export const {name} = {rendered} as const;\n"
def render_module(normalized: dict[str, Any], manifest_hash: str) -> str:
sections = [
"// This file is generated by scripts/release_control/generate_platform_support_frontend_module.py.\n",
"// Do not edit by hand.\n",
f"// Source: {SOURCE_RELATIVE_PATH}\n",
f"// Source SHA256: {manifest_hash}\n\n",
render_const(
"PLATFORM_SUPPORT_MANIFEST_SOURCE",
{
"path": SOURCE_RELATIVE_PATH,
"sha256": manifest_hash,
},
),
render_const(
"PLATFORM_SUPPORT_MANIFEST",
{
"schemaVersion": normalized["schemaVersion"],
"defaultInfrastructureSourceOrder": normalized["defaultInfrastructureSourceOrder"],
"platforms": normalized["platforms"],
},
),
"export const SOURCE_PLATFORM_MANIFEST_ENTRIES = PLATFORM_SUPPORT_MANIFEST.platforms;\n",
render_const("SUPPORTED_PLATFORM_IDS", normalized["supportedPlatformIds"]),
render_const("ADMITTED_PLATFORM_IDS", normalized["admittedPlatformIds"]),
render_const("PRESENTATION_ONLY_PLATFORM_IDS", normalized["presentationOnlyPlatformIds"]),
render_const("PLATFORM_TYPE_KEYS", normalized["platformTypeKeys"]),
render_const("KNOWN_SOURCE_PLATFORM_KEYS", normalized["knownSourcePlatformKeys"]),
render_const(
"DEFAULT_INFRASTRUCTURE_SOURCE_ORDER",
normalized["defaultInfrastructureSourceOrder"],
),
render_const("SOURCE_PLATFORM_ALIAS_MAP", normalized["aliasMap"]),
render_const("SOURCE_PLATFORM_AUDIT_TOKENS", normalized["auditTokens"]),
render_const("SOURCE_PLATFORM_DISPLAY_TOKENS", normalized["displayTokens"]),
render_const("SOURCE_PLATFORM_PRESENTATION", normalized["presentation"]),
render_const("SOURCE_PLATFORM_STORAGE_FAMILY", normalized["storageFamilyById"]),
"export type PlatformGovernanceState =\n"
" (typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['governanceState'];\n",
"export type SourcePlatformStorageFamily =\n"
" (typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['storageFamily'];\n",
"export type GeneratedPlatformType = (typeof PLATFORM_TYPE_KEYS)[number];\n",
"export type GeneratedKnownSourcePlatform =\n"
" (typeof KNOWN_SOURCE_PLATFORM_KEYS)[number];\n",
"export type GeneratedSourcePlatformManifestEntry =\n"
" (typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number];\n",
]
return "".join(sections)
def format_typescript(source: str) -> str:
result = subprocess.run(
[
"npx",
"prettier",
"--stdin-filepath",
"src/utils/platformSupportManifest.generated.ts",
],
cwd=REPO_ROOT / "frontend-modern",
input=source,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
"failed to format generated TypeScript:\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_typescript(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 frontend projection is stale; run "
"scripts/release_control/generate_platform_support_frontend_module.py"
)
return 1
print("platform support frontend projection is up to date")
return 0
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))