mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Generate typed platform support projection
This commit is contained in:
parent
ab3e028359
commit
8e81add763
11 changed files with 776 additions and 248 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
345
frontend-modern/src/utils/platformSupportManifest.generated.ts
Normal file
345
frontend-modern/src/utils/platformSupportManifest.generated.ts
Normal 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];
|
||||
|
|
@ -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];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
311
scripts/release_control/generate_platform_support_frontend_module.py
Executable file
311
scripts/release_control/generate_platform_support_frontend_module.py
Executable 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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue