mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Clarify Pulse Agent host profile support
Separate first-class platform support from Pulse Agent host profiles and classify Unraid as an agent-backed host profile while preserving it as presentation-only platform vocabulary.
This commit is contained in:
@@ -9,6 +9,28 @@
|
||||
"docker",
|
||||
"kubernetes"
|
||||
],
|
||||
"agent_host_profiles": [
|
||||
{
|
||||
"id": "unraid",
|
||||
"family": "Unraid",
|
||||
"governance_state": "supported",
|
||||
"readiness_stage": "supported",
|
||||
"host_identity_tokens": [
|
||||
"unraid"
|
||||
],
|
||||
"support_floor": {
|
||||
"setup": "supported",
|
||||
"visibility": "supported",
|
||||
"workloads": "n/a",
|
||||
"storage": "supported",
|
||||
"recovery": "n/a",
|
||||
"alerts": "supported",
|
||||
"assistant_read": "supported",
|
||||
"assistant_control": "read-only"
|
||||
},
|
||||
"storage_family": "onprem"
|
||||
}
|
||||
],
|
||||
"platforms": [
|
||||
{
|
||||
"id": "agent",
|
||||
|
||||
@@ -33,6 +33,11 @@ through platform-by-platform improvisation.
|
||||
intentionally merges API-backed and agent-backed truth.
|
||||
7. Platform work must project into canonical shared resources first.
|
||||
Do not add provider-local top-level resource types by default.
|
||||
8. Agent host and appliance profiles are governed separately from first-class
|
||||
platforms.
|
||||
A profile may describe supported Pulse Agent deployment on a specific host
|
||||
or appliance family, but it does not promote that family into
|
||||
`PLATFORM_TYPE_KEYS` or first-class platform status.
|
||||
|
||||
## Platform Categories
|
||||
|
||||
@@ -84,6 +89,25 @@ Examples:
|
||||
2. a unified agent on a TrueNAS appliance enriching a `truenas` system that is
|
||||
already supported through the API-backed poller
|
||||
|
||||
### Agent Host Profile
|
||||
|
||||
An agent host profile is a governed compatibility profile for running Pulse
|
||||
Agent on a specific host or appliance family.
|
||||
|
||||
It may reuse source/platform vocabulary for identity matching, but it is not a
|
||||
platform id, does not carry `PLATFORM_TYPE_KEYS` membership, and does not
|
||||
replace first-class platform support.
|
||||
|
||||
Agent host profiles must define:
|
||||
|
||||
1. canonical profile id
|
||||
2. profile family or label
|
||||
3. governance state
|
||||
4. readiness stage
|
||||
5. host identity tokens
|
||||
6. support-floor row
|
||||
7. optional storage family
|
||||
|
||||
## Canonical Ingestion Modes
|
||||
|
||||
### API-backed
|
||||
@@ -221,6 +245,20 @@ Rules:
|
||||
5. `azure`
|
||||
6. `gcp`
|
||||
|
||||
Unraid remains presentation-only platform vocabulary so source/platform labels
|
||||
stay available for compatibility, but its governed Pulse Agent support is
|
||||
tracked below as an agent host profile instead of as a first-class platform.
|
||||
|
||||
### Agent Host Profiles
|
||||
|
||||
Support floor fields are recorded in this order: `setup`, `visibility`,
|
||||
`workloads`, `storage`, `recovery`, `alerts`, `assistant_read`,
|
||||
`assistant_control`.
|
||||
|
||||
| Profile | Family | Governance | Readiness | Host identity tokens | Storage family | Support floor |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `unraid` | `Unraid` | `supported` | `supported` | `unraid` | `onprem` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` |
|
||||
|
||||
### Current support rows
|
||||
|
||||
Support floor fields are recorded in this order: `setup`, `visibility`,
|
||||
@@ -248,13 +286,14 @@ paths, no canonical projections, and `n/a` for every support-floor field.
|
||||
supported, admitted, and presentation-only platform vocabulary declared here,
|
||||
plus the canonical platform-family, readiness-stage, primary-mode,
|
||||
onboarding-path, projection, and support-floor classification for supported
|
||||
and admitted platforms. Tests and shared frontend vocabulary may consume that
|
||||
manifest, and the tracked frontend projection in
|
||||
`frontend-modern/src/utils/platformSupportManifest.generated.ts` must be
|
||||
generated from it, but neither projection may introduce platform ids or
|
||||
governance states, platform families, readiness stages, primary modes,
|
||||
onboarding paths, projections, or support-floor claims that are not declared in
|
||||
this document.
|
||||
and admitted platforms, and the separate machine-readable agent host profile
|
||||
classification for supported host/appliance profiles. Tests and shared
|
||||
frontend vocabulary may consume that manifest, and the tracked frontend
|
||||
projection in `frontend-modern/src/utils/platformSupportManifest.generated.ts`
|
||||
must be generated from it, but neither projection may introduce platform ids,
|
||||
host profile ids, governance states, families, readiness stages, primary
|
||||
modes, onboarding paths, projections, support-floor claims, or host identity
|
||||
tokens that are not declared in this document.
|
||||
|
||||
### Runtime variants
|
||||
|
||||
|
||||
@@ -175,6 +175,11 @@ those facts into source-strategy copy, but it must not turn an admitted
|
||||
`first-lab-ready` platform such as VMware into a product-level supported
|
||||
claim, invent platform-local projections, or classify assistant control beyond
|
||||
the manifest's support-floor row.
|
||||
That same lifecycle-owned helper must keep first-class platform APIs separate
|
||||
from governed Pulse Agent host profiles. Host/appliance compatibility such as
|
||||
Unraid is presented as an agent install/profile path sourced from the manifest
|
||||
`agent_host_profiles` section, not as `PLATFORM_TYPE_KEYS` membership or a
|
||||
peer API-backed platform.
|
||||
The lifecycle-owned infrastructure source manager also owns platform/system
|
||||
grouping as source-management content, but not its table band presentation:
|
||||
`frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx` must
|
||||
|
||||
@@ -792,6 +792,12 @@ the canonical monitored-system blocked payload.
|
||||
kernel, architecture, and command capability, so settings surfaces can
|
||||
render recognizable standalone-host identity without a second inventory
|
||||
fetch or frontend-local host reconciliation rules.
|
||||
Appliance-specific Pulse Agent compatibility is an additive host-profile
|
||||
fact on that same identity payload. For Unraid and similar host profiles,
|
||||
`agentIdentity.platform` remains the canonical runtime platform such as
|
||||
`linux`, while `agentIdentity.hostProfile` carries the governed profile id
|
||||
such as `unraid`; frontend clients must not re-promote those profile ids
|
||||
into first-class platform types.
|
||||
`pulse fleet connections` may read that same `GET /api/connections`
|
||||
payload as a deterministic CLI adapter for agent-ready operations, but it
|
||||
must remain a read-only view over the canonical connections ledger rather
|
||||
|
||||
@@ -607,7 +607,7 @@ frontend primitive boundary.
|
||||
must derive primary issue labels and summaries from explicit incidents,
|
||||
storage risk summaries, or storage-risk reasons so healthy rows do not
|
||||
render impact text as a warning.
|
||||
8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest. The generic `docker` source-platform label is "Docker / Podman" in shared selectors, badges, and filter options so v5 Docker users can find the runtime surface while Podman-backed rows are not mislabeled as Docker-only; "Container runtime" remains the governed platform family, not the primary customer-facing label.
|
||||
8. Keep shared source/platform vocabulary on the governed manifest boundary. `frontend-modern/src/utils/platformSupportManifest.generated.ts` must be the tracked frontend projection of `docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json`, `frontend-modern/src/utils/platformSupportManifest.ts`, `frontend-modern/src/utils/sourcePlatforms.ts`, and `frontend-modern/src/utils/sourcePlatformOptions.ts` must consume that generated projection instead of embedding divergent future-label lists, setup/onboarding path allowlists, host-profile labels, or presentation-only guesses, and `frontend-modern/scripts/canonical-platform-audit.mjs` must fail when the generated projection drifts from the governed manifest. The generic `docker` source-platform label is "Docker / Podman" in shared selectors, badges, and filter options so v5 Docker users can find the runtime surface while Podman-backed rows are not mislabeled as Docker-only; "Container runtime" remains the governed platform family, not the primary customer-facing label. Agent host-profile entries, including Unraid, stay in the generated `agentHostProfiles` projection and shared wrapper helpers; frontend primitives may render those labels for Pulse Agent install/identity copy but must not add them to the first-class platform union.
|
||||
9. Keep top-of-page summary interaction on shared primitives. Infrastructure, workloads, and storage summary cards must route sticky-shell behavior through `frontend-modern/src/components/shared/StickySummarySection.tsx` and route row-hover or focused-series rendering through shared chart primitives such as `frontend-modern/src/components/shared/InteractiveSparkline.tsx` and `frontend-modern/src/components/shared/DensityMap.tsx`, rather than page-local sticky wrappers or metric-card-specific hover logic. When a page keeps summary charts visible below the desktop breakpoint, it must use the shared `stickyDesktopOnly` mode instead of adding page-local media queries, so wrapped two-column summaries scroll as normal content and only become sticky once the large-screen layout is active. The shared summary-card contract must also own stable summary-card geometry for chart-backed cards so row hover, focus, synchronized readouts, or idle header metadata cannot ratchet the sticky summary taller across rerenders.
|
||||
10. Keep summary chart interaction identity on one shared helper. Summary surfaces that expose row-hover, group-hover, chart-hover, or route-focus-driven chart emphasis must derive page/group/entity scope through `frontend-modern/src/components/shared/summaryCardInteraction.ts` and pass that same resolved scope into card-state, sparkline, and density-map primitives, rather than letting cards read `hovered || focused` while charts listen to a different page-local ID source. Hovering one summary chart must promote that series into the shared active entity so sibling cards highlight the same object instead of keeping chart-local hover islands, and hovering or pinning a workload group header, infrastructure cluster header, or storage pool-group header must scope the matching summary cards through that same shared contract instead of forking a page-local summary filter path. Sibling cards should surface that synchronized hover as one compact header readout through the shared summary-card contract, while the chart under the pointer keeps the only floating tooltip. `frontend-modern/src/components/Recovery/RecoverySummary.tsx` is explicitly outside this interaction dialect: recovery posture cards may share summary framing, but they must not silently grow row/group/chart hover behavior without a separate governed product decision.
|
||||
11. Keep page summaries page-scoped when table rows enter contextual focus. Route-backed row selection may add a focused label and shared series emphasis, but infrastructure, workloads, and storage summary cards must continue to render the page-level series set instead of collapsing the summary down to the selected row or replacing the global trend view with row-local empty states.
|
||||
|
||||
@@ -508,9 +508,11 @@ bypass the API fail-closed execution gate.
|
||||
systems, duplicate recovery inventory rows, or a storage-local ownership
|
||||
model. The same shared `/api/connections` contract also owns compact
|
||||
`agentIdentity` facts for agent-backed rows; storage and recovery may read
|
||||
that metadata when they need to label a represented host, but they must not
|
||||
rebuild OS/endpoint identity from recovery inventory or alias heuristics.
|
||||
taxonomy. When that grouped platform row is a Proxmox cluster, storage and
|
||||
that metadata, including host-profile ids such as `unraid`, when they need
|
||||
to label a represented host, but they must not rebuild OS/endpoint identity
|
||||
from recovery inventory or alias heuristics, or reinterpret an agent
|
||||
host-profile id as a storage provider platform.
|
||||
When that grouped platform row is a Proxmox cluster, storage and
|
||||
recovery must also treat the backend-authored cluster moniker as the
|
||||
canonical row identity instead of re-expanding cluster-member agents into
|
||||
sibling host rows or per-node storage owners. If the grouped row carries
|
||||
|
||||
@@ -611,7 +611,11 @@ must stay generated from
|
||||
`frontend-modern/src/types/resource.ts` must derive `PlatformType` from that
|
||||
generated supported-plus-admitted projection rather than hand-maintaining a
|
||||
second platform union that can drift from the governed manifest or re-admit
|
||||
presentation-only labels by mistake.
|
||||
presentation-only labels by mistake. Agent host profiles are generated beside
|
||||
that platform projection for shared identity/presentation use only; a profile
|
||||
such as Unraid may label a Pulse Agent host, but it must not enter
|
||||
`PlatformType`, `PLATFORM_TYPE_KEYS`, unified-resource source filters, or
|
||||
canonical top-level platform identity.
|
||||
That same shared source boundary also applies when unified seeds and
|
||||
supplemental providers coexist. If a canonical unified-resource seed omits an
|
||||
owned supplemental source such as TrueNAS or VMware, the shared resource API
|
||||
|
||||
@@ -165,7 +165,8 @@ describe('ConnectionsAPI', () => {
|
||||
source: 'agent',
|
||||
agentIdentity: {
|
||||
hostname: 'tower',
|
||||
platform: 'unraid',
|
||||
platform: 'linux',
|
||||
hostProfile: 'unraid',
|
||||
osName: 'Unraid',
|
||||
osVersion: '7.1.0',
|
||||
kernelVersion: '6.12.0',
|
||||
@@ -183,7 +184,8 @@ describe('ConnectionsAPI', () => {
|
||||
expect(result.connections[0]).toMatchObject({
|
||||
agentIdentity: {
|
||||
hostname: 'tower',
|
||||
platform: 'unraid',
|
||||
platform: 'linux',
|
||||
hostProfile: 'unraid',
|
||||
osName: 'Unraid',
|
||||
osVersion: '7.1.0',
|
||||
kernelVersion: '6.12.0',
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ConnectionFleetGovernance {
|
||||
export interface ConnectionAgentIdentity {
|
||||
hostname?: string;
|
||||
platform?: string;
|
||||
hostProfile?: string;
|
||||
osName?: string;
|
||||
osVersion?: string;
|
||||
kernelVersion?: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, For, Show } from 'solid-js';
|
||||
import type { ProbeCandidate } from '@/api/connections';
|
||||
import { formControl, formField, formHelpText, formLabel } from '@/components/shared/Form';
|
||||
import { getInfrastructureAgentHostProfileSupportText } from '@/utils/infrastructureOnboardingPresentation';
|
||||
import type { CompletedProbePhase, ConnectionEditorState } from './useConnectionEditor';
|
||||
import { CONNECTION_TYPE_LABELS } from './useConnectionEditor';
|
||||
|
||||
@@ -70,7 +71,9 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
|
||||
<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>}
|
||||
fallback={
|
||||
<span>Pick a supported product from the catalog below, or if this is </span>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -81,7 +84,7 @@ export const AddressProbeStep: Component<AddressProbeStepProps> = (props) => {
|
||||
</button>
|
||||
<span>, or if this is </span>
|
||||
</Show>
|
||||
a Linux, macOS, Windows, FreeBSD, or Unraid host,{' '}
|
||||
this is one of the supported {getInfrastructureAgentHostProfileSupportText()},{' '}
|
||||
<Show
|
||||
when={props.onInstallAgent}
|
||||
fallback={<span class="font-medium">install Pulse Agent instead</span>}
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
createConnectionEditorState,
|
||||
type ConnectionEditorState,
|
||||
} from './useConnectionEditor';
|
||||
import { getInfrastructureAutoDetectLabels } from '@/utils/infrastructureOnboardingPresentation';
|
||||
import {
|
||||
getInfrastructureAgentHostProfileSupportText,
|
||||
getInfrastructureAutoDetectLabels,
|
||||
} from '@/utils/infrastructureOnboardingPresentation';
|
||||
|
||||
export type ConnectionEditorMode = 'add' | 'edit';
|
||||
|
||||
@@ -128,7 +131,7 @@ export const ConnectionEditor: Component<ConnectionEditorProps> = (props) => {
|
||||
>
|
||||
Install Pulse Agent
|
||||
</button>{' '}
|
||||
for Linux, macOS, Windows, FreeBSD, or Unraid hosts.
|
||||
for {getInfrastructureAgentHostProfileSupportText()}.
|
||||
</p>
|
||||
|
||||
<AddressProbeStep
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, For, Show } from 'solid-js';
|
||||
import { Activity, Archive, Cpu, Database, Mail, Search, Server, ServerCog } from 'lucide-solid';
|
||||
import type { InfrastructureOnboardingConnectionType } from '@/utils/infrastructureOnboardingPresentation';
|
||||
import {
|
||||
getInfrastructureGovernanceBadgeLabel,
|
||||
getInfrastructureSourcePickerGroups,
|
||||
getInfrastructureOnboardingProductPresentation,
|
||||
getInfrastructureSourceStrategyPresentation,
|
||||
@@ -66,6 +67,10 @@ export const InfrastructureSourcePicker: Component<InfrastructureSourcePickerPro
|
||||
const strategy = getInfrastructureSourceStrategyPresentation(
|
||||
details.sourceStrategy,
|
||||
);
|
||||
const governanceBadge = getInfrastructureGovernanceBadgeLabel(
|
||||
details.governanceState,
|
||||
details.readinessStage,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -87,9 +92,9 @@ export const InfrastructureSourcePicker: Component<InfrastructureSourcePickerPro
|
||||
<span class="inline-flex items-center rounded-full border border-border bg-surface-alt px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-base-content">
|
||||
{strategy.label}
|
||||
</span>
|
||||
<Show when={details.governanceState === 'admitted'}>
|
||||
<Show when={governanceBadge}>
|
||||
<span class="inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200">
|
||||
Available now
|
||||
{governanceBadge}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { InfrastructureSourcePicker } from '../InfrastructureSourcePicker';
|
||||
|
||||
describe('InfrastructureSourcePicker', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('labels admitted VMware as first-lab-ready and keeps Pulse Agent on the host profile path', () => {
|
||||
render(() => <InfrastructureSourcePicker onSelectType={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('First lab ready')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Available now')).toBeNull();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles where you want low-overhead node-local telemetry.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -729,7 +729,8 @@ describe('InfrastructureWorkspace', () => {
|
||||
agentVersion: '6.0.2',
|
||||
agentIdentity: {
|
||||
hostname: 'tower',
|
||||
platform: 'unraid',
|
||||
platform: 'linux',
|
||||
hostProfile: 'unraid',
|
||||
osName: 'Unraid',
|
||||
osVersion: '7.1.0',
|
||||
kernelVersion: '6.12.0',
|
||||
|
||||
@@ -536,7 +536,7 @@ describe('settings architecture guardrails', () => {
|
||||
expect(addressProbeStepSource).toContain('Probe address');
|
||||
expect(addressProbeStepSource).toContain('install Pulse Agent instead');
|
||||
expect(addressProbeStepSource).toContain('Choose a source type instead');
|
||||
expect(addressProbeStepSource).toContain('Linux, macOS, Windows, FreeBSD, or Unraid host');
|
||||
expect(addressProbeStepSource).toContain('getInfrastructureAgentHostProfileSupportText');
|
||||
expect(addressProbeStepSource).toContain('supported API-backed platform');
|
||||
|
||||
expect(connectionEditorStateSource).toContain('ConnectionsAPI.probe(value)');
|
||||
|
||||
@@ -35,7 +35,8 @@ describe('useConnectionsLedger', () => {
|
||||
},
|
||||
agentIdentity: {
|
||||
hostname: 'tower',
|
||||
platform: 'unraid',
|
||||
platform: 'linux',
|
||||
hostProfile: 'unraid',
|
||||
osName: 'Unraid',
|
||||
osVersion: '7.1.0',
|
||||
kernelVersion: '6.12.0',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Connection } from '@/api/connections';
|
||||
import type { Connection, ConnectionAgentIdentity } from '@/api/connections';
|
||||
import type { ConnectionType } from '@/api/connections';
|
||||
import type {
|
||||
ConnectionFleetAdapterHealth,
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ConnectionFleetUpdateStatus,
|
||||
ConnectionFleetVersionDrift,
|
||||
} from '@/api/connections';
|
||||
import { getAgentHostProfileFamily } from '@/utils/platformSupportManifest';
|
||||
|
||||
export const lastActivityTextFromLastSeen = (lastSeen?: string | null): string => {
|
||||
if (!lastSeen) return 'No activity yet';
|
||||
@@ -30,6 +31,10 @@ export const lastActivityTextFromLastSeen = (lastSeen?: string | null): string =
|
||||
export const connectionLastActivityText = (connection: Connection): string =>
|
||||
lastActivityTextFromLastSeen(connection.lastSeen);
|
||||
|
||||
type ConnectionAgentIdentityPresentation = ConnectionAgentIdentity & {
|
||||
hostProfile?: string | null;
|
||||
};
|
||||
|
||||
const prettifyPlatform = (platform?: string | null): string | null => {
|
||||
const normalized = platform?.trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
@@ -50,6 +55,16 @@ const prettifyPlatform = (platform?: string | null): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
const connectionAgentHostProfileLabel = (
|
||||
identity?: ConnectionAgentIdentityPresentation | null,
|
||||
): string | null => {
|
||||
const hostProfile = identity?.hostProfile?.trim();
|
||||
if (hostProfile) {
|
||||
return getAgentHostProfileFamily(hostProfile) ?? prettifyPlatform(hostProfile);
|
||||
}
|
||||
return prettifyPlatform(identity?.platform ?? identity?.osName);
|
||||
};
|
||||
|
||||
const isIPv4Literal = (value: string): boolean => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value);
|
||||
|
||||
const firstDistinctAgentIPv4Alias = (connection: Connection): string | null => {
|
||||
@@ -74,11 +89,13 @@ const firstDistinctAgentIPv4Alias = (connection: Connection): string | null => {
|
||||
};
|
||||
|
||||
export const connectionAgentIdentitySummary = (connection: Connection): string | null => {
|
||||
const hostProfile = connectionAgentHostProfileLabel(connection.agentIdentity);
|
||||
const osName = connection.agentIdentity?.osName?.trim();
|
||||
const osVersion = connection.agentIdentity?.osVersion?.trim();
|
||||
if (hostProfile && osVersion) return `${hostProfile} ${osVersion}`;
|
||||
if (osName && osVersion) return `${osName} ${osVersion}`;
|
||||
if (osName) return osName;
|
||||
return prettifyPlatform(connection.agentIdentity?.platform);
|
||||
return hostProfile;
|
||||
};
|
||||
|
||||
export const connectionAgentEndpointDisplay = (connection: Connection): string | null => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const CONNECTION_TYPE_LABELS: Record<ConnectionType, string> = {
|
||||
vmware: 'VMware vCenter',
|
||||
truenas: 'TrueNAS SCALE',
|
||||
availability: 'Network Endpoint',
|
||||
agent: 'Pulse Unified Agent',
|
||||
agent: 'Pulse Agent',
|
||||
docker: 'Docker',
|
||||
kubernetes: 'Kubernetes',
|
||||
};
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getInfrastructureAgentHostProfileSupportText,
|
||||
getInfrastructureCoverageCompleteActionPresentation,
|
||||
getInfrastructureApiProductsByGovernanceState,
|
||||
getInfrastructureAutoDetectLabels,
|
||||
getInfrastructureEmptyStateDetail,
|
||||
getInfrastructureGovernanceBadgeLabel,
|
||||
getInfrastructureEmptyStateSummary,
|
||||
INFRASTRUCTURE_ONBOARDING_PATHS,
|
||||
getInfrastructureOnboardingProductPresentation,
|
||||
getInfrastructureSourceManagerProducts,
|
||||
getInfrastructureSourcePickerGroups,
|
||||
@@ -37,7 +40,11 @@ describe('infrastructureOnboardingPresentation', () => {
|
||||
const agent = getInfrastructureOnboardingProductPresentation('agent');
|
||||
const pve = getInfrastructureOnboardingProductPresentation('pve');
|
||||
|
||||
expect(agent.bestFor).toContain('full node-local telemetry');
|
||||
expect(getInfrastructureAgentHostProfileSupportText()).toBe(
|
||||
'Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles',
|
||||
);
|
||||
expect(agent.bestFor).toContain('host/appliance profiles');
|
||||
expect(agent.bestFor).toContain('low-overhead node-local telemetry');
|
||||
expect(agent.coverage).toContain('Low-overhead host telemetry');
|
||||
expect(agent.catalogDescription).toContain('Low-overhead host telemetry');
|
||||
expect(agent.sourceStrategy).toBe('agent');
|
||||
@@ -55,6 +62,8 @@ describe('infrastructureOnboardingPresentation', () => {
|
||||
expect(getInfrastructureSourceStrategyPresentation('agent')).toMatchObject({
|
||||
label: 'Agent telemetry',
|
||||
summary: 'Pulse Agent',
|
||||
detail:
|
||||
'Installs Pulse Agent for Linux, macOS, Windows, FreeBSD, and Unraid host/appliance profiles, local services, Docker, and Kubernetes.',
|
||||
});
|
||||
expect(getInfrastructureSourceStrategyPresentation('api-agent')).toMatchObject({
|
||||
label: 'API first',
|
||||
@@ -73,6 +82,8 @@ describe('infrastructureOnboardingPresentation', () => {
|
||||
primaryMode: 'api-backed',
|
||||
canonicalProjections: ['network-endpoint'],
|
||||
});
|
||||
expect(INFRASTRUCTURE_ONBOARDING_PATHS.api.title).toBe('Connect platform API');
|
||||
expect(INFRASTRUCTURE_ONBOARDING_PATHS.agent.title).toBe('Install Pulse Agent');
|
||||
});
|
||||
|
||||
it('keeps supported API products separate from the admitted VMware path', () => {
|
||||
@@ -89,6 +100,13 @@ describe('infrastructureOnboardingPresentation', () => {
|
||||
expect(
|
||||
getInfrastructureApiProductsByGovernanceState('admitted').map((product) => product.label),
|
||||
).toEqual(['VMware vCenter']);
|
||||
expect(
|
||||
getInfrastructureGovernanceBadgeLabel(
|
||||
getInfrastructureOnboardingProductPresentation('vmware').governanceState,
|
||||
getInfrastructureOnboardingProductPresentation('vmware').readinessStage,
|
||||
),
|
||||
).toBe('First lab ready');
|
||||
expect(getInfrastructureGovernanceBadgeLabel('supported', 'supported')).toBeNull();
|
||||
});
|
||||
|
||||
it('derives picker groups, auto-detect copy, and landing summaries from the shared helper', () => {
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
resolveSourceTypeFromSources,
|
||||
} from '@/utils/sourcePlatforms';
|
||||
import {
|
||||
AGENT_HOST_PROFILE_IDS,
|
||||
PLATFORM_TYPE_KEYS,
|
||||
PRESENTATION_ONLY_PLATFORM_IDS,
|
||||
getAgentHostProfileFamily,
|
||||
getSourcePlatformCanonicalProjections,
|
||||
getSourcePlatformReadinessStage,
|
||||
getSourcePlatformSupportFloor,
|
||||
@@ -139,6 +143,13 @@ describe('sourcePlatforms', () => {
|
||||
assistantControl: 'read-only',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Unraid as an agent host profile instead of a platform type', () => {
|
||||
expect(AGENT_HOST_PROFILE_IDS).toEqual(['unraid']);
|
||||
expect(getAgentHostProfileFamily('unraid')).toBe('Unraid');
|
||||
expect(PLATFORM_TYPE_KEYS).not.toContain('unraid');
|
||||
expect(PRESENTATION_ONLY_PLATFORM_IDS).toContain('unraid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePlatformTypeFromSources', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConnectionType } from '@/api/connections';
|
||||
import {
|
||||
SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES,
|
||||
getSourcePlatformManifestEntry,
|
||||
type PlatformGovernanceState,
|
||||
type PlatformPrimaryMode,
|
||||
@@ -72,6 +73,45 @@ export interface InfrastructureCoverageCompleteActionPresentation {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const INFRASTRUCTURE_AGENT_RUNTIME_PLATFORM_LABELS = [
|
||||
'Linux',
|
||||
'macOS',
|
||||
'Windows',
|
||||
'FreeBSD',
|
||||
] as const;
|
||||
|
||||
const formatJoinedLabelList = (labels: readonly string[]): string => {
|
||||
if (labels.length === 0) return '';
|
||||
if (labels.length === 1) return labels[0];
|
||||
if (labels.length === 2) return `${labels[0]} and ${labels[1]}`;
|
||||
return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]}`;
|
||||
};
|
||||
|
||||
const getInfrastructureAgentHostProfileLabels = (): readonly string[] => {
|
||||
const labels = [
|
||||
...INFRASTRUCTURE_AGENT_RUNTIME_PLATFORM_LABELS,
|
||||
...SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES.filter(
|
||||
(profile) => profile.governanceState === 'supported',
|
||||
).map((profile) => profile.family),
|
||||
];
|
||||
return Array.from(new Set(labels));
|
||||
};
|
||||
|
||||
export const getInfrastructureAgentHostProfileSupportText = (): string =>
|
||||
`${formatJoinedLabelList(getInfrastructureAgentHostProfileLabels())} host/appliance profiles`;
|
||||
|
||||
export const getInfrastructureGovernanceBadgeLabel = (
|
||||
governanceState: PlatformGovernanceState,
|
||||
readinessStage: PlatformReadinessStage,
|
||||
): string | null => {
|
||||
if (governanceState === 'supported') return null;
|
||||
if (governanceState === 'admitted') {
|
||||
return readinessStage === 'first-lab-ready' ? 'First lab ready' : 'Admitted';
|
||||
}
|
||||
if (governanceState === 'presentation-only') return 'Presentation only';
|
||||
return null;
|
||||
};
|
||||
|
||||
const SOURCE_STRATEGY_PRESENTATION: Record<
|
||||
InfrastructureSourceStrategy,
|
||||
InfrastructureSourceStrategyPresentation
|
||||
@@ -84,7 +124,7 @@ const SOURCE_STRATEGY_PRESENTATION: Record<
|
||||
agent: {
|
||||
label: 'Agent telemetry',
|
||||
summary: 'Pulse Agent',
|
||||
detail: 'Installs Pulse Agent for host telemetry, local services, Docker, and Kubernetes.',
|
||||
detail: `Installs Pulse Agent for ${getInfrastructureAgentHostProfileSupportText()}, local services, Docker, and Kubernetes.`,
|
||||
},
|
||||
'api-agent': {
|
||||
label: 'API first',
|
||||
@@ -105,8 +145,7 @@ const PRODUCT_PRESENTATION: Record<
|
||||
> = {
|
||||
agent: {
|
||||
label: 'Pulse Agent',
|
||||
bestFor:
|
||||
'Linux, macOS, Windows, FreeBSD, and compatible hosts such as Unraid. Recommended on each machine where you want full node-local telemetry.',
|
||||
bestFor: `${getInfrastructureAgentHostProfileSupportText()} where you want low-overhead node-local telemetry.`,
|
||||
coverage: 'Low-overhead host telemetry, SMART, services, Docker, and Kubernetes',
|
||||
catalogDescription: 'Low-overhead host telemetry, services, Docker, Kubernetes',
|
||||
sourceStrategy: 'agent',
|
||||
@@ -232,7 +271,7 @@ export const INFRASTRUCTURE_ONBOARDING_PATHS: Record<
|
||||
InfrastructureOnboardingPathPresentation
|
||||
> = {
|
||||
api: {
|
||||
title: 'Connect a supported platform',
|
||||
title: 'Connect platform API',
|
||||
description:
|
||||
'Use a management API when the platform exposes one. Pulse validates the endpoint, requests credentials, and then starts collecting platform inventory and health.',
|
||||
bestFor: 'TrueNAS, Proxmox, and the current VMware vCenter integration path',
|
||||
@@ -240,10 +279,8 @@ export const INFRASTRUCTURE_ONBOARDING_PATHS: Record<
|
||||
},
|
||||
agent: {
|
||||
title: 'Install Pulse Agent',
|
||||
description:
|
||||
'Use the agent when you want low-overhead machine telemetry, or when the system does not expose a management API Pulse can connect to directly.',
|
||||
bestFor:
|
||||
'Linux, macOS, Windows, FreeBSD, and compatible hosts such as Unraid. Recommended on each machine where you want full node-local telemetry.',
|
||||
description: `Use the agent when you want low-overhead machine telemetry for ${getInfrastructureAgentHostProfileSupportText()}, or when the system does not expose a management API Pulse can connect to directly.`,
|
||||
bestFor: `${getInfrastructureAgentHostProfileSupportText()} where you want full node-local telemetry.`,
|
||||
coverage:
|
||||
'Low-overhead CPU temperature, disk SMART, services, network metrics, Docker, and Kubernetes telemetry',
|
||||
},
|
||||
@@ -255,13 +292,7 @@ export const INFRASTRUCTURE_AGENT_DISCOVERY_LABELS = [
|
||||
'Kubernetes',
|
||||
] as const;
|
||||
|
||||
export const INFRASTRUCTURE_AGENT_HOST_LABELS = [
|
||||
'Linux',
|
||||
'macOS',
|
||||
'Windows',
|
||||
'FreeBSD',
|
||||
'Unraid',
|
||||
] as const;
|
||||
export const INFRASTRUCTURE_AGENT_HOST_LABELS = getInfrastructureAgentHostProfileLabels();
|
||||
|
||||
const SOURCE_PICKER_GROUPS: InfrastructureSourcePickerGroupPresentation[] = [
|
||||
{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// This file is generated by scripts/release_control/generate_platform_support_frontend_module.py.
|
||||
// Do not edit by hand.
|
||||
// Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json
|
||||
// Source SHA256: 60952e07f032d1a607075c2163c44ffbd5144617fd2e04a5ff1a9ff78490c353
|
||||
// Source SHA256: 48a9ef03ba53e39c676b8a3aaf3b608ffd4c2c0269c2e1e485170a006eadfe21
|
||||
|
||||
export const PLATFORM_SUPPORT_MANIFEST_SOURCE = {
|
||||
path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json',
|
||||
sha256: '60952e07f032d1a607075c2163c44ffbd5144617fd2e04a5ff1a9ff78490c353',
|
||||
sha256: '48a9ef03ba53e39c676b8a3aaf3b608ffd4c2c0269c2e1e485170a006eadfe21',
|
||||
} as const;
|
||||
export const PLATFORM_SUPPORT_MANIFEST = {
|
||||
schemaVersion: 1,
|
||||
@@ -18,6 +18,26 @@ export const PLATFORM_SUPPORT_MANIFEST = {
|
||||
'docker',
|
||||
'kubernetes',
|
||||
],
|
||||
agentHostProfiles: [
|
||||
{
|
||||
id: 'unraid',
|
||||
family: 'Unraid',
|
||||
governanceState: 'supported',
|
||||
readinessStage: 'supported',
|
||||
hostIdentityTokens: ['unraid'],
|
||||
supportFloor: {
|
||||
setup: 'supported',
|
||||
visibility: 'supported',
|
||||
workloads: 'n/a',
|
||||
storage: 'supported',
|
||||
recovery: 'n/a',
|
||||
alerts: 'supported',
|
||||
assistantRead: 'supported',
|
||||
assistantControl: 'read-only',
|
||||
},
|
||||
storageFamily: 'onprem',
|
||||
},
|
||||
],
|
||||
platforms: [
|
||||
{
|
||||
id: 'agent',
|
||||
@@ -358,6 +378,9 @@ export const PLATFORM_SUPPORT_MANIFEST = {
|
||||
],
|
||||
} as const;
|
||||
export const SOURCE_PLATFORM_MANIFEST_ENTRIES = PLATFORM_SUPPORT_MANIFEST.platforms;
|
||||
export const SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES =
|
||||
PLATFORM_SUPPORT_MANIFEST.agentHostProfiles;
|
||||
export const AGENT_HOST_PROFILE_IDS = ['unraid'] as const;
|
||||
export const SUPPORTED_PLATFORM_IDS = [
|
||||
'agent',
|
||||
'docker',
|
||||
@@ -473,6 +496,33 @@ export const SOURCE_PLATFORM_ONBOARDING_PATH_KEYS = [
|
||||
'install-workspace',
|
||||
'platform-connections',
|
||||
] as const;
|
||||
export const SOURCE_AGENT_HOST_PROFILE_FAMILY = {
|
||||
unraid: 'Unraid',
|
||||
} as const;
|
||||
export const SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE = {
|
||||
unraid: 'supported',
|
||||
} as const;
|
||||
export const SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE = {
|
||||
unraid: 'supported',
|
||||
} as const;
|
||||
export const SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS = {
|
||||
unraid: ['unraid'],
|
||||
} as const;
|
||||
export const SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR = {
|
||||
unraid: {
|
||||
setup: 'supported',
|
||||
visibility: 'supported',
|
||||
workloads: 'n/a',
|
||||
storage: 'supported',
|
||||
recovery: 'n/a',
|
||||
alerts: 'supported',
|
||||
assistantRead: 'supported',
|
||||
assistantControl: 'read-only',
|
||||
},
|
||||
} as const;
|
||||
export const SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY = {
|
||||
unraid: 'onprem',
|
||||
} as const;
|
||||
export const SOURCE_PLATFORM_FAMILY = {
|
||||
agent: 'Pulse-managed host',
|
||||
docker: 'Container runtime',
|
||||
@@ -783,6 +833,17 @@ export type PlatformSupportFloor =
|
||||
export type PlatformSupportFloorValue = PlatformSupportFloor[keyof PlatformSupportFloor];
|
||||
export type GeneratedSourcePlatformOnboardingPath =
|
||||
(typeof SOURCE_PLATFORM_ONBOARDING_PATH_KEYS)[number];
|
||||
export type GeneratedAgentHostProfileId = (typeof AGENT_HOST_PROFILE_IDS)[number];
|
||||
export type GeneratedAgentHostProfileManifestEntry =
|
||||
(typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number];
|
||||
export type AgentHostProfileGovernanceState =
|
||||
(typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['governanceState'];
|
||||
export type AgentHostProfileReadinessStage =
|
||||
(typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['readinessStage'];
|
||||
export type AgentHostProfileSupportFloor =
|
||||
(typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['supportFloor'];
|
||||
export type AgentHostProfileSupportFloorValue =
|
||||
AgentHostProfileSupportFloor[keyof AgentHostProfileSupportFloor];
|
||||
export type SourcePlatformStorageFamily =
|
||||
(typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['storageFamily'];
|
||||
export type GeneratedPlatformType = (typeof PLATFORM_TYPE_KEYS)[number];
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import {
|
||||
ADMITTED_PLATFORM_IDS,
|
||||
AGENT_HOST_PROFILE_IDS,
|
||||
DEFAULT_INFRASTRUCTURE_SOURCE_ORDER,
|
||||
KNOWN_SOURCE_PLATFORM_KEYS,
|
||||
PLATFORM_TYPE_KEYS,
|
||||
PLATFORM_SUPPORT_MANIFEST_SOURCE,
|
||||
PRESENTATION_ONLY_PLATFORM_IDS,
|
||||
PLATFORM_SUPPORT_MANIFEST,
|
||||
SOURCE_AGENT_HOST_PROFILE_FAMILY,
|
||||
SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE,
|
||||
SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS,
|
||||
SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES,
|
||||
SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE,
|
||||
SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY,
|
||||
SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR,
|
||||
SOURCE_PLATFORM_CANONICAL_PROJECTIONS,
|
||||
SOURCE_PLATFORM_ONBOARDING_PATH_KEYS,
|
||||
SOURCE_PLATFORM_ONBOARDING_PATHS,
|
||||
@@ -20,6 +28,12 @@ import {
|
||||
SOURCE_PLATFORM_STORAGE_FAMILY,
|
||||
SOURCE_PLATFORM_SUPPORT_FLOOR,
|
||||
SUPPORTED_PLATFORM_IDS,
|
||||
type AgentHostProfileGovernanceState,
|
||||
type AgentHostProfileReadinessStage,
|
||||
type AgentHostProfileSupportFloor,
|
||||
type AgentHostProfileSupportFloorValue,
|
||||
type GeneratedAgentHostProfileId,
|
||||
type GeneratedAgentHostProfileManifestEntry,
|
||||
type GeneratedKnownSourcePlatform,
|
||||
type GeneratedSourcePlatformOnboardingPath,
|
||||
type GeneratedSourcePlatformManifestEntry,
|
||||
@@ -33,7 +47,13 @@ import {
|
||||
} from '@/utils/platformSupportManifest.generated';
|
||||
|
||||
export type SourcePlatformManifestEntry = GeneratedSourcePlatformManifestEntry;
|
||||
export type SourceAgentHostProfileManifestEntry = GeneratedAgentHostProfileManifestEntry;
|
||||
export type {
|
||||
AgentHostProfileGovernanceState,
|
||||
AgentHostProfileReadinessStage,
|
||||
AgentHostProfileSupportFloor,
|
||||
AgentHostProfileSupportFloorValue,
|
||||
GeneratedAgentHostProfileId as AgentHostProfileId,
|
||||
GeneratedKnownSourcePlatform,
|
||||
GeneratedSourcePlatformOnboardingPath as SourcePlatformOnboardingPath,
|
||||
PlatformPrimaryMode,
|
||||
@@ -48,16 +68,32 @@ export type {
|
||||
const entriesById = new Map<string, SourcePlatformManifestEntry>(
|
||||
SOURCE_PLATFORM_MANIFEST_ENTRIES.map((platform) => [platform.id, platform] as const),
|
||||
);
|
||||
const agentHostProfileEntriesById = new Map<string, SourceAgentHostProfileManifestEntry>(
|
||||
SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES.map((profile) => [profile.id, profile] as const),
|
||||
);
|
||||
const agentHostProfileEntriesByToken = new Map<string, SourceAgentHostProfileManifestEntry>(
|
||||
SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES.flatMap((profile) =>
|
||||
profile.hostIdentityTokens.map((token) => [token, profile] as const),
|
||||
),
|
||||
);
|
||||
const EMPTY_ONBOARDING_PATHS: readonly GeneratedSourcePlatformOnboardingPath[] = [];
|
||||
|
||||
export {
|
||||
ADMITTED_PLATFORM_IDS,
|
||||
AGENT_HOST_PROFILE_IDS,
|
||||
DEFAULT_INFRASTRUCTURE_SOURCE_ORDER,
|
||||
KNOWN_SOURCE_PLATFORM_KEYS,
|
||||
PLATFORM_SUPPORT_MANIFEST_SOURCE,
|
||||
PLATFORM_TYPE_KEYS,
|
||||
PRESENTATION_ONLY_PLATFORM_IDS,
|
||||
PLATFORM_SUPPORT_MANIFEST,
|
||||
SOURCE_AGENT_HOST_PROFILE_FAMILY,
|
||||
SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE,
|
||||
SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS,
|
||||
SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES,
|
||||
SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE,
|
||||
SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY,
|
||||
SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR,
|
||||
SOURCE_PLATFORM_CANONICAL_PROJECTIONS,
|
||||
SOURCE_PLATFORM_ONBOARDING_PATH_KEYS,
|
||||
SOURCE_PLATFORM_ONBOARDING_PATHS,
|
||||
@@ -84,6 +120,24 @@ export const getSourcePlatformManifestEntry = (
|
||||
return entriesById.get(platformId) || null;
|
||||
};
|
||||
|
||||
export const getAgentHostProfileManifestEntry = (
|
||||
value: string | null | undefined,
|
||||
): SourceAgentHostProfileManifestEntry | null => {
|
||||
const normalized = (value || '').trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
return (
|
||||
agentHostProfileEntriesById.get(normalized) ||
|
||||
agentHostProfileEntriesByToken.get(normalized) ||
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
export const getAgentHostProfileFamily = (value: string | null | undefined): string | null => {
|
||||
const manifestProfile = getAgentHostProfileManifestEntry(value);
|
||||
return manifestProfile?.family ?? null;
|
||||
};
|
||||
|
||||
export const getSourcePlatformStorageFamily = (
|
||||
value: string | null | undefined,
|
||||
): SourcePlatformStorageFamily | null => {
|
||||
|
||||
@@ -462,9 +462,11 @@ func connectionFleetRemoteControl(conn Connection) string {
|
||||
}
|
||||
|
||||
func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity {
|
||||
hostProfile := connectionAgentHostProfileForHost(host)
|
||||
identity := &ConnectionAgentIdentity{
|
||||
Hostname: strings.TrimSpace(host.Hostname),
|
||||
Platform: strings.TrimSpace(host.Platform),
|
||||
Platform: connectionAgentPlatformForHost(host, hostProfile),
|
||||
HostProfile: hostProfile,
|
||||
OSName: strings.TrimSpace(host.OSName),
|
||||
OSVersion: strings.TrimSpace(host.OSVersion),
|
||||
KernelVersion: strings.TrimSpace(host.KernelVersion),
|
||||
@@ -474,6 +476,7 @@ func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity {
|
||||
}
|
||||
if identity.Hostname == "" &&
|
||||
identity.Platform == "" &&
|
||||
identity.HostProfile == "" &&
|
||||
identity.OSName == "" &&
|
||||
identity.OSVersion == "" &&
|
||||
identity.KernelVersion == "" &&
|
||||
@@ -485,6 +488,27 @@ func connectionAgentIdentityForHost(host models.Host) *ConnectionAgentIdentity {
|
||||
return identity
|
||||
}
|
||||
|
||||
func connectionAgentPlatformForHost(host models.Host, hostProfile string) string {
|
||||
platform := strings.TrimSpace(host.Platform)
|
||||
if hostProfile == "unraid" && (platform == "" || strings.EqualFold(platform, "unraid")) {
|
||||
return "linux"
|
||||
}
|
||||
return platform
|
||||
}
|
||||
|
||||
func connectionAgentHostProfileForHost(host models.Host) string {
|
||||
if host.Unraid != nil {
|
||||
return "unraid"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(host.OSName), "unraid") {
|
||||
return "unraid"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(host.Platform), "unraid") {
|
||||
return "unraid"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func connectionHostAliasesForAgent(host models.Host, name, address string) []string {
|
||||
values := []string{name, address, host.Hostname, host.ReportIP}
|
||||
for _, iface := range host.NetworkInterfaces {
|
||||
|
||||
@@ -257,7 +257,8 @@ func TestBuildConnections_AgentHostAliasesIncludeReportedIdentityHints(t *testin
|
||||
got[0].AgentIdentity,
|
||||
&ConnectionAgentIdentity{
|
||||
Hostname: "pi",
|
||||
Platform: "unraid",
|
||||
Platform: "linux",
|
||||
HostProfile: "unraid",
|
||||
OSName: "Unraid",
|
||||
OSVersion: "7.1.0",
|
||||
KernelVersion: "6.12.0",
|
||||
@@ -270,6 +271,42 @@ func TestBuildConnections_AgentHostAliasesIncludeReportedIdentityHints(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_AgentHostProfileFromUnraidStorageFacts(t *testing.T) {
|
||||
now := time.Now()
|
||||
in := aggregatorInputs{
|
||||
hosts: []models.Host{
|
||||
{
|
||||
ID: "tower",
|
||||
Hostname: "tower",
|
||||
Platform: "linux",
|
||||
Unraid: &models.HostUnraidStorage{ArrayStarted: true},
|
||||
LastSeen: now,
|
||||
AgentVersion: "6.12.10",
|
||||
CommandsEnabled: false,
|
||||
},
|
||||
},
|
||||
now: now,
|
||||
}
|
||||
|
||||
got := buildConnections(in)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 connection, got %d", len(got))
|
||||
}
|
||||
identity := got[0].AgentIdentity
|
||||
if identity == nil {
|
||||
t.Fatal("expected agent identity metadata")
|
||||
}
|
||||
if identity.HostProfile != "unraid" {
|
||||
t.Fatalf("host profile = %q, want %q", identity.HostProfile, "unraid")
|
||||
}
|
||||
if identity.Platform != "linux" {
|
||||
t.Fatalf("platform = %q, want %q", identity.Platform, "linux")
|
||||
}
|
||||
if identity.OSName != "" {
|
||||
t.Fatalf("os name = %q, want empty when source identity is absent", identity.OSName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnections_AgentVersionUpdateAvailability(t *testing.T) {
|
||||
now := time.Now()
|
||||
in := aggregatorInputs{
|
||||
|
||||
@@ -81,6 +81,7 @@ type ConnectionError struct {
|
||||
type ConnectionAgentIdentity struct {
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
HostProfile string `json:"hostProfile,omitempty"`
|
||||
OSName string `json:"osName,omitempty"`
|
||||
OSVersion string `json:"osVersion,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
|
||||
@@ -12826,7 +12826,8 @@ func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) {
|
||||
Source: ConnectionSourceAgent,
|
||||
AgentIdentity: &ConnectionAgentIdentity{
|
||||
Hostname: "host-1",
|
||||
Platform: "unraid",
|
||||
Platform: "linux",
|
||||
HostProfile: "unraid",
|
||||
OSName: "Unraid",
|
||||
OSVersion: "7.1.0",
|
||||
KernelVersion: "6.12.0",
|
||||
@@ -12859,7 +12860,7 @@ func TestContract_AgentConnectionPayloadIncludesVersionFields(t *testing.T) {
|
||||
t.Fatalf("marshal agent Connection: %v", err)
|
||||
}
|
||||
|
||||
want := `{"id":"agent:host-1","type":"agent","name":"host-1","address":"host-1","hostAliases":["host-1","192.168.0.2"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-04-22T12:00:00Z","source":"agent","agentIdentity":{"hostname":"host-1","platform":"unraid","osName":"Unraid","osVersion":"7.1.0","kernelVersion":"6.12.0","architecture":"x86_64","reportIp":"192.168.0.2","commandsEnabled":true},"agentVersion":"6.0.0","expectedAgentVersion":"6.0.2","agentUpdateAvailable":true,"fleet":{"enrollmentState":"enrolled","livenessState":"active","versionDrift":"behind","adapterHealth":"healthy","configRollout":"reported","credentialStatus":"verified","updateStatus":"update-available","remoteControl":"enabled"},"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}`
|
||||
want := `{"id":"agent:host-1","type":"agent","name":"host-1","address":"host-1","hostAliases":["host-1","192.168.0.2"],"state":"active","enabled":true,"surfaces":["host"],"scope":{"host":true},"lastSeen":"2026-04-22T12:00:00Z","source":"agent","agentIdentity":{"hostname":"host-1","platform":"linux","hostProfile":"unraid","osName":"Unraid","osVersion":"7.1.0","kernelVersion":"6.12.0","architecture":"x86_64","reportIp":"192.168.0.2","commandsEnabled":true},"agentVersion":"6.0.0","expectedAgentVersion":"6.0.2","agentUpdateAvailable":true,"fleet":{"enrollmentState":"enrolled","livenessState":"active","versionDrift":"behind","adapterHealth":"healthy","configRollout":"reported","credentialStatus":"verified","updateStatus":"update-available","remoteControl":"enabled"},"capabilities":{"supportsPause":false,"supportsScope":false,"supportsTest":false}}`
|
||||
assertJSONSnapshot(t, body, want)
|
||||
}
|
||||
|
||||
|
||||
@@ -997,6 +997,8 @@ func normalisePlatform(platform string) string {
|
||||
switch platform {
|
||||
case "darwin":
|
||||
return "macos"
|
||||
case "unraid":
|
||||
return "linux"
|
||||
default:
|
||||
return platform
|
||||
}
|
||||
|
||||
@@ -478,6 +478,41 @@ Platform = QNAP
|
||||
t.Fatalf("osVersion = %q, want %q", agent.osVersion, "5.2.0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unraid from version file", func(t *testing.T) {
|
||||
mc := &mockCollector{
|
||||
goos: "linux",
|
||||
hostInfoFn: func(context.Context) (*gohost.InfoStat, error) {
|
||||
return &gohost.InfoStat{
|
||||
Hostname: "unraid",
|
||||
HostID: "hid",
|
||||
Platform: "linux",
|
||||
PlatformFamily: "linux",
|
||||
PlatformVersion: "",
|
||||
KernelArch: runtime.GOARCH,
|
||||
}, nil
|
||||
},
|
||||
readFileFn: func(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "/etc/unraid-version":
|
||||
return []byte("Unraid OS 7.1.0\n"), nil
|
||||
default:
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
agent, err := New(Config{APIToken: "token", LogLevel: zerolog.InfoLevel, Collector: mc})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if agent.osName != "Unraid" {
|
||||
t.Fatalf("osName = %q, want %q", agent.osName, "Unraid")
|
||||
}
|
||||
if agent.osVersion != "7.1.0" {
|
||||
t.Fatalf("osVersion = %q, want %q", agent.osVersion, "7.1.0")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNew_UsesCustomCABundleForHTTPTransport(t *testing.T) {
|
||||
|
||||
@@ -50,6 +50,11 @@ func TestNormalisePlatform(t *testing.T) {
|
||||
platform: "freebsd",
|
||||
expected: "freebsd",
|
||||
},
|
||||
{
|
||||
name: "unraid reports linux platform",
|
||||
platform: "unraid",
|
||||
expected: "linux",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
platform: "",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package hostagent
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var unraidVersionPattern = regexp.MustCompile(`\b\d+(?:\.\d+)+(?:[-+._][A-Za-z0-9]+)*\b|\b\d+\b`)
|
||||
|
||||
func resolveHostOSIdentity(collector SystemCollector, osName, osVersion string) (string, string) {
|
||||
currentName := strings.TrimSpace(osName)
|
||||
@@ -24,6 +29,13 @@ func resolveHostOSIdentity(collector SystemCollector, osName, osVersion string)
|
||||
return name, strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
if name, version, ok := detectUnraidOSIdentity(collector); ok {
|
||||
if version == "" {
|
||||
version = currentVersion
|
||||
}
|
||||
return name, strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
return currentName, currentVersion
|
||||
}
|
||||
|
||||
@@ -138,6 +150,38 @@ func detectQNAPOSIdentity(collector SystemCollector) (string, string, bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func detectUnraidOSIdentity(collector SystemCollector) (string, string, bool) {
|
||||
data, err := collector.ReadFile(hostAgentUnraidVersionPath)
|
||||
if err != nil {
|
||||
if _, statErr := collector.Stat(hostAgentUnraidVersionPath); statErr != nil {
|
||||
return "", "", false
|
||||
}
|
||||
return "Unraid", "", true
|
||||
}
|
||||
|
||||
version := cleanUnraidVersion(string(data))
|
||||
return "Unraid", version, true
|
||||
}
|
||||
|
||||
func cleanUnraidVersion(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if match := unraidVersionPattern.FindString(line); match != "" {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseAssignmentConfig(content string) map[string]string {
|
||||
parsed := make(map[string]string)
|
||||
|
||||
|
||||
@@ -14,15 +14,27 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
numberedPlatformListRE = regexp.MustCompile("^\\d+\\.\\s+`([^`]+)`")
|
||||
currentSupportMatrixRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|")
|
||||
currentSupportSetupRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|\\s+([^|]+?)\\s+\\|")
|
||||
numberedPlatformListRE = regexp.MustCompile("^\\d+\\.\\s+`([^`]+)`")
|
||||
currentSupportMatrixRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|")
|
||||
currentSupportSetupRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|\\s+([^|]+?)\\s+\\|")
|
||||
agentHostProfileTableRowRE = regexp.MustCompile("^\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|\\s+`([^`]+)`\\s+\\|")
|
||||
)
|
||||
|
||||
type platformSupportManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
DefaultInfrastructureSourceOrder []string `json:"default_infrastructure_source_order"`
|
||||
Platforms []platformSupportManifestEntry `json:"platforms"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
DefaultInfrastructureSourceOrder []string `json:"default_infrastructure_source_order"`
|
||||
AgentHostProfiles []agentHostProfileManifestEntry `json:"agent_host_profiles"`
|
||||
Platforms []platformSupportManifestEntry `json:"platforms"`
|
||||
}
|
||||
|
||||
type agentHostProfileManifestEntry struct {
|
||||
ID string `json:"id"`
|
||||
Family string `json:"family"`
|
||||
GovernanceState string `json:"governance_state"`
|
||||
ReadinessStage string `json:"readiness_stage"`
|
||||
HostIdentityTokens []string `json:"host_identity_tokens"`
|
||||
SupportFloor map[string]string `json:"support_floor"`
|
||||
StorageFamily string `json:"storage_family"`
|
||||
}
|
||||
|
||||
type platformSupportManifestEntry struct {
|
||||
@@ -47,6 +59,7 @@ func TestPlatformSupportManifestMatchesSupportModel(t *testing.T) {
|
||||
classified := parsePlatformListSection(t, model, "### First-class platforms")
|
||||
admitted := parsePlatformListSection(t, model, "### Admitted platforms (not yet supported)")
|
||||
presentationOnly := parsePlatformListSection(t, model, "### Presentation-only platform vocabulary")
|
||||
agentHostProfiles := parseAgentHostProfileSection(t, model, "### Agent Host Profiles")
|
||||
matrix := parseCurrentSupportMatrixPlatforms(t, model)
|
||||
|
||||
if diff := diffPlatformSets(classified, matrix); diff != "" {
|
||||
@@ -71,6 +84,52 @@ func TestPlatformSupportManifestMatchesSupportModel(t *testing.T) {
|
||||
); diff != "" {
|
||||
t.Fatalf("supported onboarding-path manifest drifted from the canonical support model:\n%s", diff)
|
||||
}
|
||||
if diff := diffPlatformSets(
|
||||
sortedAgentHostProfileIDs(agentHostProfiles),
|
||||
manifestAgentHostProfileIDsByState(t, manifest, "supported"),
|
||||
); diff != "" {
|
||||
t.Fatalf("agent host profile manifest drifted from the canonical support model:\n%s", diff)
|
||||
}
|
||||
if diff := diffPlatformFieldMap(
|
||||
agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string {
|
||||
return []string{profile.Family}
|
||||
}),
|
||||
manifestAgentHostProfileFamiliesByState(t, manifest, "supported"),
|
||||
); diff != "" {
|
||||
t.Fatalf("agent host profile family drifted from the canonical support model:\n%s", diff)
|
||||
}
|
||||
if diff := diffPlatformFieldMap(
|
||||
agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string {
|
||||
return []string{profile.GovernanceState}
|
||||
}),
|
||||
manifestAgentHostProfileGovernanceStatesByState(t, manifest, "supported"),
|
||||
); diff != "" {
|
||||
t.Fatalf("agent host profile governance drifted from the canonical support model:\n%s", diff)
|
||||
}
|
||||
if diff := diffPlatformFieldMap(
|
||||
agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string {
|
||||
return []string{profile.ReadinessStage}
|
||||
}),
|
||||
manifestAgentHostProfileReadinessStagesByState(t, manifest, "supported"),
|
||||
); diff != "" {
|
||||
t.Fatalf("agent host profile readiness drifted from the canonical support model:\n%s", diff)
|
||||
}
|
||||
if diff := diffPlatformFieldMap(
|
||||
agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string {
|
||||
return append([]string(nil), profile.HostIdentityTokens...)
|
||||
}),
|
||||
manifestAgentHostProfileHostIdentityTokensByState(t, manifest, "supported"),
|
||||
); diff != "" {
|
||||
t.Fatalf("agent host profile host-identity tokens drifted from the canonical support model:\n%s", diff)
|
||||
}
|
||||
if diff := diffPlatformFieldMap(
|
||||
agentHostProfileFieldMap(agentHostProfiles, func(profile agentHostProfileSectionEntry) []string {
|
||||
return []string{profile.StorageFamily}
|
||||
}),
|
||||
manifestAgentHostProfileStorageFamiliesByState(t, manifest, "supported"),
|
||||
); diff != "" {
|
||||
t.Fatalf("agent host profile storage-family drifted from the canonical support model:\n%s", diff)
|
||||
}
|
||||
if diff := diffPlatformSets(classified, manifest.DefaultInfrastructureSourceOrder); diff != "" {
|
||||
t.Fatalf("default infrastructure source ordering drifted from the canonical supported platform set:\n%s", diff)
|
||||
}
|
||||
@@ -167,6 +226,57 @@ func TestVMwareFixturesRemainAdmittedButNotSupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnraidRemainsInPresentationOnlyVocabularyAndAgentHostProfiles(t *testing.T) {
|
||||
model := loadPlatformSupportModel(t)
|
||||
manifest := loadPlatformSupportManifest(t)
|
||||
supported := manifestPlatformsByState(t, manifest, "supported")
|
||||
admitted := manifestPlatformsByState(t, manifest, "admitted")
|
||||
presentationOnly := manifestPlatformsByState(t, manifest, "presentation-only")
|
||||
profiles := parseAgentHostProfileSection(t, model, "### Agent Host Profiles")
|
||||
|
||||
if containsPlatform(supported, "unraid") {
|
||||
t.Fatal("unraid must not appear in the current supported platform set")
|
||||
}
|
||||
if containsPlatform(admitted, "unraid") {
|
||||
t.Fatal("unraid must not appear in the admitted platform set")
|
||||
}
|
||||
if !containsPlatform(presentationOnly, "unraid") {
|
||||
t.Fatal("expected unraid to remain presentation-only platform vocabulary")
|
||||
}
|
||||
if !containsPlatform(sortedAgentHostProfileIDs(profiles), "unraid") {
|
||||
t.Fatal("expected unraid to remain a supported agent host profile")
|
||||
}
|
||||
|
||||
profile := requireAgentHostProfileManifestEntry(t, manifest, "unraid")
|
||||
if profile.GovernanceState != "supported" {
|
||||
t.Fatalf("unraid governance state = %q, want supported", profile.GovernanceState)
|
||||
}
|
||||
if profile.ReadinessStage != "supported" {
|
||||
t.Fatalf("unraid readiness stage = %q, want supported", profile.ReadinessStage)
|
||||
}
|
||||
if diff := diffPlatformSets([]string{"unraid"}, profile.HostIdentityTokens); diff != "" {
|
||||
t.Fatalf("unraid host identity tokens drifted from the host-profile model:\n%s", diff)
|
||||
}
|
||||
if profile.StorageFamily != "onprem" {
|
||||
t.Fatalf("unraid storage family = %q, want onprem", profile.StorageFamily)
|
||||
}
|
||||
if got := profile.SupportFloor["assistant_control"]; got != "read-only" {
|
||||
t.Fatalf("unraid assistant control support floor = %q, want read-only", got)
|
||||
}
|
||||
if got := profile.SupportFloor["storage"]; got != "supported" {
|
||||
t.Fatalf("unraid storage support floor = %q, want supported", got)
|
||||
}
|
||||
if !strings.Contains(model, "### Agent Host Profiles") {
|
||||
t.Fatal("expected platform support model to keep an agent host profiles section")
|
||||
}
|
||||
if !strings.Contains(
|
||||
model,
|
||||
"| `unraid` | `Unraid` | `supported` | `supported` | `unraid` | `onprem` |",
|
||||
) {
|
||||
t.Fatal("expected platform support model to keep the unraid host-profile row")
|
||||
}
|
||||
}
|
||||
|
||||
func assertAgentMockCoverage(t *testing.T, graph FixtureGraph) {
|
||||
t.Helper()
|
||||
|
||||
@@ -495,6 +605,236 @@ func manifestPlatformOnboardingPaths(manifest platformSupportManifest) map[strin
|
||||
return rows
|
||||
}
|
||||
|
||||
type agentHostProfileSectionEntry struct {
|
||||
Family string
|
||||
GovernanceState string
|
||||
ReadinessStage string
|
||||
HostIdentityTokens []string
|
||||
StorageFamily string
|
||||
}
|
||||
|
||||
func parseAgentHostProfileSection(
|
||||
t *testing.T,
|
||||
model string,
|
||||
heading string,
|
||||
) map[string]agentHostProfileSectionEntry {
|
||||
t.Helper()
|
||||
|
||||
entries := make(map[string]agentHostProfileSectionEntry)
|
||||
inSection := false
|
||||
|
||||
for _, raw := range strings.Split(model, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case line == heading:
|
||||
inSection = true
|
||||
continue
|
||||
case !inSection:
|
||||
continue
|
||||
case strings.HasPrefix(line, "### ") || strings.HasPrefix(line, "## "):
|
||||
return requireNonEmptyAgentHostProfileMap(t, entries, heading)
|
||||
case line == "" || strings.HasPrefix(line, "| ---"):
|
||||
continue
|
||||
}
|
||||
|
||||
matches := agentHostProfileTableRowRE.FindStringSubmatch(line)
|
||||
if len(matches) != 7 {
|
||||
continue
|
||||
}
|
||||
|
||||
entries[matches[1]] = agentHostProfileSectionEntry{
|
||||
Family: matches[2],
|
||||
GovernanceState: matches[3],
|
||||
ReadinessStage: matches[4],
|
||||
HostIdentityTokens: parseInlineTokenList(matches[5]),
|
||||
StorageFamily: matches[6],
|
||||
}
|
||||
}
|
||||
|
||||
return requireNonEmptyAgentHostProfileMap(t, entries, heading)
|
||||
}
|
||||
|
||||
func parseInlineTokenList(value string) []string {
|
||||
tokens := make([]string, 0, 1)
|
||||
for _, token := range strings.Split(value, ",") {
|
||||
token = strings.TrimSpace(token)
|
||||
token = strings.Trim(token, "`")
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func sortedAgentHostProfileIDs(profiles map[string]agentHostProfileSectionEntry) []string {
|
||||
ids := make([]string, 0, len(profiles))
|
||||
for id := range profiles {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func agentHostProfileFieldMap(
|
||||
profiles map[string]agentHostProfileSectionEntry,
|
||||
selector func(agentHostProfileSectionEntry) []string,
|
||||
) map[string][]string {
|
||||
rows := make(map[string][]string, len(profiles))
|
||||
for id, profile := range profiles {
|
||||
rows[id] = append([]string(nil), selector(profile)...)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func requireNonEmptyAgentHostProfileMap(
|
||||
t *testing.T,
|
||||
entries map[string]agentHostProfileSectionEntry,
|
||||
heading string,
|
||||
) map[string]agentHostProfileSectionEntry {
|
||||
t.Helper()
|
||||
|
||||
if len(entries) == 0 {
|
||||
t.Fatalf("expected %s to declare at least one agent host profile", heading)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func manifestAgentHostProfileIDsByState(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
governanceState string,
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
ids := make([]string, 0)
|
||||
for _, profile := range manifest.AgentHostProfiles {
|
||||
if strings.TrimSpace(profile.ID) == "" {
|
||||
t.Fatal("expected agent host profile ids to be non-empty")
|
||||
}
|
||||
if strings.TrimSpace(profile.GovernanceState) == "" {
|
||||
t.Fatalf("expected agent host profile manifest to classify %s", profile.ID)
|
||||
}
|
||||
if profile.GovernanceState == governanceState {
|
||||
ids = append(ids, profile.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return requireNonEmptyPlatformList(
|
||||
t,
|
||||
ids,
|
||||
fmt.Sprintf("manifest agent host profiles with state %s", governanceState),
|
||||
)
|
||||
}
|
||||
|
||||
func manifestAgentHostProfileFieldMap(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
governanceState string,
|
||||
selector func(agentHostProfileManifestEntry) []string,
|
||||
label string,
|
||||
) map[string][]string {
|
||||
t.Helper()
|
||||
|
||||
rows := make(map[string][]string)
|
||||
for _, profile := range manifest.AgentHostProfiles {
|
||||
if profile.GovernanceState != governanceState {
|
||||
continue
|
||||
}
|
||||
rows[profile.ID] = append([]string(nil), selector(profile)...)
|
||||
}
|
||||
|
||||
return requireNonEmptyPlatformFieldMap(t, rows, label)
|
||||
}
|
||||
|
||||
func manifestAgentHostProfileFamiliesByState(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
governanceState string,
|
||||
) map[string][]string {
|
||||
return manifestAgentHostProfileFieldMap(
|
||||
t,
|
||||
manifest,
|
||||
governanceState,
|
||||
func(profile agentHostProfileManifestEntry) []string { return []string{profile.Family} },
|
||||
fmt.Sprintf("manifest agent host profile families with state %s", governanceState),
|
||||
)
|
||||
}
|
||||
|
||||
func manifestAgentHostProfileGovernanceStatesByState(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
governanceState string,
|
||||
) map[string][]string {
|
||||
return manifestAgentHostProfileFieldMap(
|
||||
t,
|
||||
manifest,
|
||||
governanceState,
|
||||
func(profile agentHostProfileManifestEntry) []string { return []string{profile.GovernanceState} },
|
||||
fmt.Sprintf("manifest agent host profile governance with state %s", governanceState),
|
||||
)
|
||||
}
|
||||
|
||||
func manifestAgentHostProfileReadinessStagesByState(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
governanceState string,
|
||||
) map[string][]string {
|
||||
return manifestAgentHostProfileFieldMap(
|
||||
t,
|
||||
manifest,
|
||||
governanceState,
|
||||
func(profile agentHostProfileManifestEntry) []string { return []string{profile.ReadinessStage} },
|
||||
fmt.Sprintf("manifest agent host profile readiness with state %s", governanceState),
|
||||
)
|
||||
}
|
||||
|
||||
func manifestAgentHostProfileHostIdentityTokensByState(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
governanceState string,
|
||||
) map[string][]string {
|
||||
return manifestAgentHostProfileFieldMap(
|
||||
t,
|
||||
manifest,
|
||||
governanceState,
|
||||
func(profile agentHostProfileManifestEntry) []string {
|
||||
return append([]string(nil), profile.HostIdentityTokens...)
|
||||
},
|
||||
fmt.Sprintf("manifest agent host profile identity tokens with state %s", governanceState),
|
||||
)
|
||||
}
|
||||
|
||||
func manifestAgentHostProfileStorageFamiliesByState(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
governanceState string,
|
||||
) map[string][]string {
|
||||
return manifestAgentHostProfileFieldMap(
|
||||
t,
|
||||
manifest,
|
||||
governanceState,
|
||||
func(profile agentHostProfileManifestEntry) []string { return []string{profile.StorageFamily} },
|
||||
fmt.Sprintf("manifest agent host profile storage families with state %s", governanceState),
|
||||
)
|
||||
}
|
||||
|
||||
func requireAgentHostProfileManifestEntry(
|
||||
t *testing.T,
|
||||
manifest platformSupportManifest,
|
||||
profileID string,
|
||||
) agentHostProfileManifestEntry {
|
||||
t.Helper()
|
||||
|
||||
for _, profile := range manifest.AgentHostProfiles {
|
||||
if profile.ID == profileID {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected agent host profile manifest entry for %s", profileID)
|
||||
return agentHostProfileManifestEntry{}
|
||||
}
|
||||
|
||||
func requireManifestPlatform(t *testing.T, manifest platformSupportManifest, platformID string) platformSupportManifestEntry {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ SUPPORT_FLOOR_FIELDS = (
|
||||
"assistant_control",
|
||||
)
|
||||
VALID_SUPPORT_FLOOR_VALUES = {"supported", "augmentation-only", "read-only", "n/a"}
|
||||
VALID_AGENT_HOST_PROFILE_GOVERNANCE_STATES = {"supported", "presentation-only"}
|
||||
VALID_AGENT_HOST_PROFILE_READINESS_STAGES = {"supported", "presentation-only"}
|
||||
|
||||
|
||||
def require_dict(value: Any, label: str) -> dict[str, Any]:
|
||||
@@ -73,15 +75,125 @@ def unique_preserve_order(values: list[str]) -> list[str]:
|
||||
return ordered
|
||||
|
||||
|
||||
def normalize_support_floor(support_floor_record: Any, label: str) -> dict[str, str]:
|
||||
record = require_dict(support_floor_record, label)
|
||||
support_floor: dict[str, str] = {}
|
||||
for field in SUPPORT_FLOOR_FIELDS:
|
||||
value = require_string(record.get(field), f"{label}.{field}")
|
||||
if value not in VALID_SUPPORT_FLOOR_VALUES:
|
||||
raise ValueError(
|
||||
f"expected {label}.{field} to be one of {sorted(VALID_SUPPORT_FLOOR_VALUES)}"
|
||||
)
|
||||
camel_field = field.split("_")[0] + "".join(part.title() for part in field.split("_")[1:])
|
||||
support_floor[camel_field] = value
|
||||
return support_floor
|
||||
|
||||
|
||||
def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
schema_version = raw_manifest.get("schema_version")
|
||||
if not isinstance(schema_version, int) or schema_version < 1:
|
||||
raise ValueError("expected schema_version to be a positive integer")
|
||||
|
||||
raw_agent_host_profiles = raw_manifest.get("agent_host_profiles")
|
||||
if not isinstance(raw_agent_host_profiles, list) or not raw_agent_host_profiles:
|
||||
raise ValueError("expected agent_host_profiles to be a non-empty array")
|
||||
|
||||
raw_platforms = raw_manifest.get("platforms")
|
||||
if not isinstance(raw_platforms, list) or not raw_platforms:
|
||||
raise ValueError("expected platforms to be a non-empty array")
|
||||
|
||||
agent_host_profiles: list[dict[str, Any]] = []
|
||||
agent_host_profile_ids: list[str] = []
|
||||
for index, raw_profile in enumerate(raw_agent_host_profiles):
|
||||
record = require_dict(raw_profile, f"agent_host_profiles[{index}]")
|
||||
profile_id = require_lowercase_identifier(record.get("id"), f"agent_host_profiles[{index}].id")
|
||||
if profile_id in agent_host_profile_ids:
|
||||
raise ValueError(f"duplicate agent host profile id {profile_id}")
|
||||
|
||||
governance_state = require_string(
|
||||
record.get("governance_state"), f"agent_host_profiles[{index}].governance_state"
|
||||
)
|
||||
if governance_state not in VALID_AGENT_HOST_PROFILE_GOVERNANCE_STATES:
|
||||
raise ValueError(
|
||||
"expected agent_host_profiles["
|
||||
f"{index}].governance_state to be one of "
|
||||
f"{sorted(VALID_AGENT_HOST_PROFILE_GOVERNANCE_STATES)}"
|
||||
)
|
||||
|
||||
readiness_stage = require_string(
|
||||
record.get("readiness_stage"), f"agent_host_profiles[{index}].readiness_stage"
|
||||
)
|
||||
if readiness_stage not in VALID_AGENT_HOST_PROFILE_READINESS_STAGES:
|
||||
raise ValueError(
|
||||
"expected agent_host_profiles["
|
||||
f"{index}].readiness_stage to be one of "
|
||||
f"{sorted(VALID_AGENT_HOST_PROFILE_READINESS_STAGES)}"
|
||||
)
|
||||
if governance_state == "supported" and readiness_stage != "supported":
|
||||
raise ValueError(
|
||||
f"supported agent host profile {profile_id} must use readiness_stage supported"
|
||||
)
|
||||
if governance_state == "presentation-only" and readiness_stage != "presentation-only":
|
||||
raise ValueError(
|
||||
"presentation-only agent host profile "
|
||||
f"{profile_id} must use readiness_stage presentation-only"
|
||||
)
|
||||
|
||||
host_identity_tokens = unique_preserve_order(
|
||||
[
|
||||
require_lowercase_identifier(
|
||||
token,
|
||||
f"agent_host_profiles[{index}].host_identity_tokens",
|
||||
)
|
||||
for token in require_string_list(
|
||||
record.get("host_identity_tokens"),
|
||||
f"agent_host_profiles[{index}].host_identity_tokens",
|
||||
)
|
||||
]
|
||||
)
|
||||
if governance_state == "supported" and not host_identity_tokens:
|
||||
raise ValueError(
|
||||
f"supported agent host profile {profile_id} must declare host identity tokens"
|
||||
)
|
||||
if governance_state == "presentation-only" and host_identity_tokens:
|
||||
raise ValueError(
|
||||
f"presentation-only agent host profile {profile_id} must not declare host identity tokens"
|
||||
)
|
||||
|
||||
support_floor = normalize_support_floor(
|
||||
record.get("support_floor"),
|
||||
f"agent_host_profiles[{index}].support_floor",
|
||||
)
|
||||
if governance_state == "presentation-only" and any(
|
||||
value != "n/a" for value in support_floor.values()
|
||||
):
|
||||
raise ValueError(
|
||||
f"presentation-only agent host profile {profile_id} support floor must be n/a"
|
||||
)
|
||||
|
||||
storage_family = require_string(
|
||||
record.get("storage_family"), f"agent_host_profiles[{index}].storage_family"
|
||||
)
|
||||
if storage_family not in VALID_STORAGE_FAMILIES:
|
||||
raise ValueError(
|
||||
"expected agent_host_profiles["
|
||||
f"{index}].storage_family to be one of "
|
||||
f"{sorted(VALID_STORAGE_FAMILIES)}"
|
||||
)
|
||||
|
||||
agent_host_profiles.append(
|
||||
{
|
||||
"id": profile_id,
|
||||
"family": require_string(record.get("family"), f"agent_host_profiles[{index}].family"),
|
||||
"governanceState": governance_state,
|
||||
"readinessStage": readiness_stage,
|
||||
"hostIdentityTokens": host_identity_tokens,
|
||||
"supportFloor": support_floor,
|
||||
"storageFamily": storage_family,
|
||||
}
|
||||
)
|
||||
agent_host_profile_ids.append(profile_id)
|
||||
|
||||
platforms: list[dict[str, Any]] = []
|
||||
known_ids: set[str] = set()
|
||||
alias_map: dict[str, str] = {}
|
||||
@@ -175,20 +287,10 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
if governance_state == "presentation-only" and canonical_projections:
|
||||
raise ValueError(f"presentation-only platform {platform_id} must not declare projections")
|
||||
|
||||
support_floor_record = require_dict(record.get("support_floor"), f"platforms[{index}].support_floor")
|
||||
support_floor: dict[str, str] = {}
|
||||
for field in SUPPORT_FLOOR_FIELDS:
|
||||
value = require_string(
|
||||
support_floor_record.get(field),
|
||||
f"platforms[{index}].support_floor.{field}",
|
||||
)
|
||||
if value not in VALID_SUPPORT_FLOOR_VALUES:
|
||||
raise ValueError(
|
||||
f"expected platforms[{index}].support_floor.{field} to be one of "
|
||||
f"{sorted(VALID_SUPPORT_FLOOR_VALUES)}"
|
||||
)
|
||||
camel_field = field.split("_")[0] + "".join(part.title() for part in field.split("_")[1:])
|
||||
support_floor[camel_field] = value
|
||||
support_floor = normalize_support_floor(
|
||||
record.get("support_floor"),
|
||||
f"platforms[{index}].support_floor",
|
||||
)
|
||||
if governance_state == "presentation-only" and any(value != "n/a" for value in support_floor.values()):
|
||||
raise ValueError(f"presentation-only platform {platform_id} support floor must be n/a")
|
||||
|
||||
@@ -274,6 +376,24 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
readiness_stage_by_id = {
|
||||
platform["id"]: platform["readinessStage"] for platform in platforms
|
||||
}
|
||||
agent_host_profile_family_by_id = {
|
||||
profile["id"]: profile["family"] for profile in agent_host_profiles
|
||||
}
|
||||
agent_host_profile_governance_state_by_id = {
|
||||
profile["id"]: profile["governanceState"] for profile in agent_host_profiles
|
||||
}
|
||||
agent_host_profile_readiness_stage_by_id = {
|
||||
profile["id"]: profile["readinessStage"] for profile in agent_host_profiles
|
||||
}
|
||||
agent_host_profile_host_identity_tokens_by_id = {
|
||||
profile["id"]: profile["hostIdentityTokens"] for profile in agent_host_profiles
|
||||
}
|
||||
agent_host_profile_support_floor_by_id = {
|
||||
profile["id"]: profile["supportFloor"] for profile in agent_host_profiles
|
||||
}
|
||||
agent_host_profile_storage_family_by_id = {
|
||||
profile["id"]: profile["storageFamily"] for profile in agent_host_profiles
|
||||
}
|
||||
primary_mode_by_id = {
|
||||
platform["id"]: platform["primaryMode"] for platform in platforms
|
||||
}
|
||||
@@ -297,6 +417,14 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
for platform in platforms
|
||||
if platform["governanceState"] == "presentation-only"
|
||||
]
|
||||
first_class_profile_ids = sorted(
|
||||
set(agent_host_profile_ids).intersection([*supported_ids, *admitted_ids])
|
||||
)
|
||||
if first_class_profile_ids:
|
||||
raise ValueError(
|
||||
"agent host profile ids must not also be supported or admitted platform ids: "
|
||||
+ ", ".join(first_class_profile_ids)
|
||||
)
|
||||
platform_type_keys = [*supported_ids, *admitted_ids]
|
||||
known_source_platform_keys = [*platform_type_keys, *presentation_only_ids, "generic"]
|
||||
onboarding_path_keys = unique_preserve_order(
|
||||
@@ -310,6 +438,8 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schemaVersion": schema_version,
|
||||
"defaultInfrastructureSourceOrder": default_order,
|
||||
"agentHostProfiles": agent_host_profiles,
|
||||
"agentHostProfileIds": agent_host_profile_ids,
|
||||
"platforms": platforms,
|
||||
"supportedPlatformIds": supported_ids,
|
||||
"admittedPlatformIds": admitted_ids,
|
||||
@@ -320,6 +450,12 @@ def normalize_manifest(raw_manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
"auditTokens": audit_tokens,
|
||||
"displayTokens": display_tokens,
|
||||
"onboardingPathKeys": onboarding_path_keys,
|
||||
"agentHostProfileFamilyById": agent_host_profile_family_by_id,
|
||||
"agentHostProfileGovernanceStateById": agent_host_profile_governance_state_by_id,
|
||||
"agentHostProfileReadinessStageById": agent_host_profile_readiness_stage_by_id,
|
||||
"agentHostProfileHostIdentityTokensById": agent_host_profile_host_identity_tokens_by_id,
|
||||
"agentHostProfileSupportFloorById": agent_host_profile_support_floor_by_id,
|
||||
"agentHostProfileStorageFamilyById": agent_host_profile_storage_family_by_id,
|
||||
"familyById": family_by_id,
|
||||
"readinessStageById": readiness_stage_by_id,
|
||||
"primaryModeById": primary_mode_by_id,
|
||||
@@ -354,10 +490,14 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str:
|
||||
{
|
||||
"schemaVersion": normalized["schemaVersion"],
|
||||
"defaultInfrastructureSourceOrder": normalized["defaultInfrastructureSourceOrder"],
|
||||
"agentHostProfiles": normalized["agentHostProfiles"],
|
||||
"platforms": normalized["platforms"],
|
||||
},
|
||||
),
|
||||
"export const SOURCE_PLATFORM_MANIFEST_ENTRIES = PLATFORM_SUPPORT_MANIFEST.platforms;\n",
|
||||
"export const SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES =\n"
|
||||
" PLATFORM_SUPPORT_MANIFEST.agentHostProfiles;\n",
|
||||
render_const("AGENT_HOST_PROFILE_IDS", normalized["agentHostProfileIds"]),
|
||||
render_const("SUPPORTED_PLATFORM_IDS", normalized["supportedPlatformIds"]),
|
||||
render_const("ADMITTED_PLATFORM_IDS", normalized["admittedPlatformIds"]),
|
||||
render_const("PRESENTATION_ONLY_PLATFORM_IDS", normalized["presentationOnlyPlatformIds"]),
|
||||
@@ -371,6 +511,30 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str:
|
||||
render_const("SOURCE_PLATFORM_AUDIT_TOKENS", normalized["auditTokens"]),
|
||||
render_const("SOURCE_PLATFORM_DISPLAY_TOKENS", normalized["displayTokens"]),
|
||||
render_const("SOURCE_PLATFORM_ONBOARDING_PATH_KEYS", normalized["onboardingPathKeys"]),
|
||||
render_const(
|
||||
"SOURCE_AGENT_HOST_PROFILE_FAMILY",
|
||||
normalized["agentHostProfileFamilyById"],
|
||||
),
|
||||
render_const(
|
||||
"SOURCE_AGENT_HOST_PROFILE_GOVERNANCE_STATE",
|
||||
normalized["agentHostProfileGovernanceStateById"],
|
||||
),
|
||||
render_const(
|
||||
"SOURCE_AGENT_HOST_PROFILE_READINESS_STAGE",
|
||||
normalized["agentHostProfileReadinessStageById"],
|
||||
),
|
||||
render_const(
|
||||
"SOURCE_AGENT_HOST_PROFILE_HOST_IDENTITY_TOKENS",
|
||||
normalized["agentHostProfileHostIdentityTokensById"],
|
||||
),
|
||||
render_const(
|
||||
"SOURCE_AGENT_HOST_PROFILE_SUPPORT_FLOOR",
|
||||
normalized["agentHostProfileSupportFloorById"],
|
||||
),
|
||||
render_const(
|
||||
"SOURCE_AGENT_HOST_PROFILE_STORAGE_FAMILY",
|
||||
normalized["agentHostProfileStorageFamilyById"],
|
||||
),
|
||||
render_const("SOURCE_PLATFORM_FAMILY", normalized["familyById"]),
|
||||
render_const("SOURCE_PLATFORM_READINESS_STAGE", normalized["readinessStageById"]),
|
||||
render_const("SOURCE_PLATFORM_PRIMARY_MODE", normalized["primaryModeById"]),
|
||||
@@ -390,6 +554,17 @@ def render_module(normalized: dict[str, Any], manifest_hash: str) -> str:
|
||||
"export type PlatformSupportFloorValue = PlatformSupportFloor[keyof PlatformSupportFloor];\n",
|
||||
"export type GeneratedSourcePlatformOnboardingPath =\n"
|
||||
" (typeof SOURCE_PLATFORM_ONBOARDING_PATH_KEYS)[number];\n",
|
||||
"export type GeneratedAgentHostProfileId = (typeof AGENT_HOST_PROFILE_IDS)[number];\n",
|
||||
"export type GeneratedAgentHostProfileManifestEntry =\n"
|
||||
" (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number];\n",
|
||||
"export type AgentHostProfileGovernanceState =\n"
|
||||
" (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['governanceState'];\n",
|
||||
"export type AgentHostProfileReadinessStage =\n"
|
||||
" (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['readinessStage'];\n",
|
||||
"export type AgentHostProfileSupportFloor =\n"
|
||||
" (typeof SOURCE_AGENT_HOST_PROFILE_MANIFEST_ENTRIES)[number]['supportFloor'];\n",
|
||||
"export type AgentHostProfileSupportFloorValue =\n"
|
||||
" AgentHostProfileSupportFloor[keyof AgentHostProfileSupportFloor];\n",
|
||||
"export type SourcePlatformStorageFamily =\n"
|
||||
" (typeof SOURCE_PLATFORM_MANIFEST_ENTRIES)[number]['storageFamily'];\n",
|
||||
"export type GeneratedPlatformType = (typeof PLATFORM_TYPE_KEYS)[number];\n",
|
||||
|
||||
Reference in New Issue
Block a user