diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c7c94c140..72fa322ef 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -483,6 +483,8 @@ On your TrueNAS system: | Data | Where it appears | |---|---| | System info (CPU, memory, uptime) | Infrastructure page | +| Virtual machines | TrueNAS Overview | +| Apps | TrueNAS Overview | | ZFS Pools & datasets | Storage page | | Physical disks | Storage page | | ZFS Snapshots | Recovery page | diff --git a/docs/FAQ.md b/docs/FAQ.md index f9e115186..fac1a53c4 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -45,7 +45,7 @@ See [VM Disk Monitoring](VM_DISK_MONITORING.md) for details. Yes! If Pulse detects Ceph storage, it automatically queries cluster health, OSD status, and pool usage. No extra config needed. ### Does Pulse monitor TrueNAS? -Yes! Pulse v6 includes first-class TrueNAS SCALE/CORE integration. Add your TrueNAS server in **Settings → TrueNAS** with the URL and API key. Pulse monitors pools, datasets, disks, ZFS snapshots, replication tasks, and alerts. TrueNAS resources appear in the unified Infrastructure, Storage, and Recovery views. +Yes. Pulse v6 includes first-class TrueNAS SCALE/CORE integration. Add your TrueNAS server in **Settings → TrueNAS** with the URL and API key. Pulse monitors the appliance, native VMs, apps, pools, datasets, disks, ZFS snapshots, replication tasks, and alerts. TrueNAS resources appear in the TrueNAS, Infrastructure, Storage, and Recovery views. ### Where did my pages go? (Unified Navigation) Pulse v6 organises the UI by **task** instead of **platform**: diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index aabd3dead..b64ec4e3e 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -18,13 +18,15 @@ On your TrueNAS system: 2. Click **Add** and create a new key. 3. Copy the key value and paste it into Pulse. -> **Tip**: A read-only key is sufficient for monitoring. Pulse does not write to TrueNAS. +> **Tip**: A read-only key is sufficient for monitoring. Native app control actions require a key with the corresponding TrueNAS app permissions. ## What Gets Monitored | Data | Unified Page | Details | |---|---|---| | System info (hostname, version, uptime) | Infrastructure | CPU, memory, health status | +| Virtual machines | TrueNAS Overview | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API | +| Apps | TrueNAS Overview | Native app state, image/version, ports, volumes, networks, and runtime container details | | ZFS Pools | Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) | | ZFS Datasets | Storage | Used/available space, mount status, read-only flag | | Physical Disks | Storage | Model, serial, size, transport type, rotational flag | @@ -37,6 +39,8 @@ On your TrueNAS system: TrueNAS resources are mapped into the unified resource model: - **TrueNAS host** → appears as a resource with `source: truenas` on the **Infrastructure** page. +- **TrueNAS VMs** → appear as canonical `vm` workloads on the **TrueNAS** page. +- **TrueNAS apps** → appear as canonical `app-container` workloads on the **TrueNAS** page. - **ZFS pools and datasets** → appear on the **Storage** page. - **ZFS snapshots and replication** → appear on the **Recovery** page as recovery points. - **TrueNAS alerts** → surfaced on the **Alerts** page alongside Proxmox and other platform alerts. diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json index 55cf3cba7..f4e9a571d 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json @@ -262,6 +262,7 @@ ], "canonical_projections": [ "agent", + "vm", "app-container", "storage", "physical-disk" diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md index 4a92a0a54..d279a8fee 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md @@ -308,7 +308,7 @@ Support floor fields are recorded in this order: `setup`, `visibility`, | `proxmox-pve` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=augmentation-only` | | `proxmox-pbs` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pbs`, `storage` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` | | `proxmox-pmg` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `pmg` | `setup=supported`; `visibility=supported`; `workloads=n/a`; `storage=n/a`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` | -| `truenas` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `app-container`, `storage`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` | +| `truenas` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `app-container`, `storage`, `physical-disk` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=supported`; `alerts=supported`; `assistant_read=supported`; `assistant_control=supported` | | `vmware-vsphere` | `platform` | `supported` | `supported` | `api-backed` | `platform-connections` | `agent`, `vm`, `storage` | `setup=supported`; `visibility=supported`; `workloads=supported`; `storage=supported`; `recovery=n/a`; `alerts=supported`; `assistant_read=supported`; `assistant_control=read-only` | Presentation-only platform vocabulary uses `governance=presentation-only`, @@ -368,7 +368,7 @@ or runtime platform fallbacks that are not declared in this document. | `proxmox-pve` | platform | Proxmox | api-backed | host agent may augment into hybrid | `agent`, `vm`, `system-container`, `storage`, `ceph`, `physical-disk` | | `proxmox-pbs` | platform | Proxmox | api-backed | host agent may augment into hybrid | `pbs`, `storage` | | `proxmox-pmg` | platform | Proxmox | api-backed | none today | `pmg` | -| `truenas` | platform | TrueNAS | api-backed | host agent may augment into hybrid | `agent`, `app-container`, `storage`, `physical-disk` | +| `truenas` | platform | TrueNAS | api-backed | host agent may augment into hybrid | `agent`, `vm`, `app-container`, `storage`, `physical-disk` | | `vmware-vsphere` | platform | VMware | api-backed | host or guest agent later, not phase 1 | `agent`, `vm`, `storage` | | Platform | Setup | Visibility | Workloads | Storage | Recovery | Alerts | Assistant read | Assistant control | diff --git a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md index 935a32d86..eaff7d160 100644 --- a/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md +++ b/docs/release-control/v6/internal/SOURCE_OF_TRUTH.md @@ -493,11 +493,12 @@ declared floor. 1. Architecture boundary: TrueNAS is API-first. The unified agent may augment a TrueNAS system later, but it is not required for bootstrap or baseline support. TrueNAS must - project into the canonical `agent`, `app-container`, `storage`, + project into the canonical `agent`, `vm`, `app-container`, `storage`, `physical-disk`, and recovery contracts. TrueNAS app rows may carry a - TrueNAS-native app facet sourced from `app.query` / `active_workloads` so - the platform page can render API-native app facts without turning Docker - labels into the TrueNAS product model. + TrueNAS-native app facet sourced from `app.query` / `active_workloads`, and + native VM rows may carry a TrueNAS VM facet sourced from `vm.query`, so the + platform page can render API-native app and VM facts without turning Docker + labels or generic guest assumptions into the TrueNAS product model. 2. Onboarding path: Supported now through the shared platform-connections flow and `/api/truenas/connections`. Operators can add, test, edit, retest, and @@ -514,11 +515,13 @@ declared floor. separate `truenas-system` surface or provider-local infrastructure model. 4. Workloads: Supported now for TrueNAS apps projected as canonical `app-container` - workloads with shared workload navigation, metrics, and related links. The - TrueNAS platform overview renders those apps through the TrueNAS API-native - app facet first, using Docker metadata only as runtime compatibility - fallback. Out of scope: a parallel provider-local workload control plane - that bypasses the canonical resource/action contracts. + workloads and native TrueNAS virtual machines projected as canonical `vm` + workloads. The TrueNAS platform overview renders those workloads through + the TrueNAS API-native facets first, using Docker metadata only as runtime + compatibility fallback for apps. Out of scope: a parallel provider-local + workload control plane that bypasses the canonical resource/action + contracts, or VM lifecycle control before it lands on the governed action + path. 5. Storage and disk health: Supported now for pools, datasets, and physical disks projected into the shared storage and disk contracts, including SMART/disk-state risk, live diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index ddcf66b3c..fed43c651 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -222,6 +222,11 @@ work extends shared components instead of creating new local variants. 9. `frontend-modern/src/utils/platformSupportManifest.generated.ts` shared with `unified-resources`: the generated platform support projection is both a canonical unified-resource platform union boundary and a shared frontend source/platform vocabulary boundary. It must expose the manifest `surface_kind` field so runtime lenses such as `docker` are not collapsed back into owning platform semantics. + It must also preserve canonical projection lists from the governed manifest + without page-local narrowing; for example, TrueNAS exposes both native + `vm` workloads and `app-container` workloads through the same generated + platform projection used by route helpers, badges, source filters, and + type unions. 10. `frontend-modern/src/utils/sourcePlatforms.ts` shared with `unified-resources`: the source platform normalizer is both a canonical unified-resource source adapter boundary and a shared frontend source/platform vocabulary boundary. That shared boundary must preserve `availability` as the agentless infrastructure source for `network-endpoint` resources and settings diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 94a4b576b..4a6ec686f 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -916,6 +916,14 @@ telemetry. `internal/truenas/client.go`, `internal/truenas/provider.go`, and canonical `app-container` metrics contract, and sync them into the existing guest metrics-history/store path. Pulse must not add a TrueNAS-only charts lane for that telemetry. +That same monitoring boundary now also owns native TrueNAS VM inventory. +`internal/truenas/client.go` must ingest `vm.query` through the official +`/api/current` JSON-RPC websocket transport, `internal/truenas/provider.go` +must project those rows as canonical `vm` resources under the top-level +TrueNAS appliance, and frontend TrueNAS surfaces must read the typed +`TrueNASData.VM` facet instead of inventing a provider-local VM table contract. +Pulse must not treat TrueNAS VMs as Proxmox guests, Docker containers, or a +separate `truenas-vm` resource type. That same monitoring boundary now also owns connected-infrastructure projection for API-backed platforms. `internal/monitoring/connected_infrastructure.go` must project TrueNAS into the canonical connected-infrastructure surface list, @@ -984,10 +992,11 @@ contract. Pulse must not treat TrueNAS CPU temperature as an agent-only capability or invent a TrueNAS-local sensor payload. Taken together, this is the current monitoring-owned TrueNAS floor: one stored API connection can surface one canonical top-level system, shared host -telemetry/history, app-container workloads, disk health/history, and -per-connection poll health without requiring the unified agent. The same -poller/provider path also owns assistant-driven app start/stop, logs, and -config refresh for those canonical workloads. Pulse does not promise a +telemetry/history, app-container workloads, native VM workloads, disk +health/history, and per-connection poll health without requiring the unified +agent. The same poller/provider path also owns assistant-driven app +start/stop, logs, and config refresh for canonical app workloads. Pulse does +not promise a separate TrueNAS runtime model, broader NAS administration, or agent-required bootstrap at this floor. That same monitoring boundary now also owns VMware signal enrichment on the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ff76b9b86..c6fa9e152 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -144,12 +144,13 @@ services, including mock-mode config/data/log path examples. That context is read-only evidence: it must not become a storage/recovery-owned command path, secret source, restore entitlement, or frontend-only fixture separate from the canonical `/api/discovery` payload. -Storage and recovery may also consume unified-resource TrueNAS app metadata as -read-only workload context when explaining appliance-owned storage or protected -items. `TrueNASData.App` remains unified-resource/platform truth sourced from -the TrueNAS API; storage/recovery must not reinterpret app containers, volumes, -or update posture as storage ownership, restore entitlement, or a separate -Docker-only inventory path. +Storage and recovery may also consume unified-resource TrueNAS app and VM +metadata as read-only workload context when explaining appliance-owned storage +or protected items. `TrueNASData.App` and `TrueNASData.VM` remain +unified-resource/platform truth sourced from the TrueNAS API; storage/recovery +must not reinterpret app containers, VM device inventory, volumes, or update +posture as storage ownership, restore entitlement, or a separate Docker-only +or TrueNAS-local inventory path. 1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/` 2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6ecdd05e1..a3ddf6c49 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1170,6 +1170,12 @@ lane-local workload UI. API-backed `app.stats` telemetry must now project onto that same canonical `app-container` contract as live metrics and metrics targets, using the shared `docker:` in-memory key and `dockerContainer` history store path instead of adding a TrueNAS-local workload history lane. +TrueNAS-managed virtual machines follow the same canonical workload rule. One +TrueNAS VM from `vm.query` must project as one canonical `vm` resource under +`SourceTrueNAS`, carrying native `TrueNASData.VM` metadata instead of inventing +a `truenas-vm` resource type, borrowing Proxmox guest fields, or rendering a +provider-local VM contract. Native VM lifecycle/control is read-only at this +floor until a governed action path owns it. TrueNAS top-level systems now follow the same canonical host rule. One TrueNAS appliance must project as one canonical `agent` resource under `SourceTrueNAS`, carrying host-facing `AgentData` plus provider-specific @@ -1193,10 +1199,10 @@ inventing a provider-local temperature payload or leaving TrueNAS host temperatures unavailable without the unified agent. AI discovery and query surfaces now follow the same rule. Assistant runtime paths such as `pulse_query` and unified AI context must expose TrueNAS-backed -canonical `agent`, `app-container`, `storage`, and `physical-disk` resources -through those shared contracts, filtering dataset-topology storage children -out of any `storage-pool` presentation instead of inventing TrueNAS-local -assistant types or mislabeling datasets as pools. +canonical `agent`, `vm`, `app-container`, `storage`, and `physical-disk` +resources through those shared contracts, filtering dataset-topology storage +children out of any `storage-pool` presentation instead of inventing +TrueNAS-local assistant types or mislabeling datasets as pools. That same canonical app-container rule now also governs diagnostics. Assistant runtime paths such as `pulse_read` must resolve API-backed TrueNAS apps through the shared canonical `app-container` identity and `resource_id` @@ -1209,7 +1215,7 @@ and `resource_id` contract, then project native runtime/config shape through the shared app-container payload rather than forcing those resources through guest-config routing or inventing a TrueNAS-local config type. That projection contract is the product-definition floor for TrueNAS support: -Pulse supports TrueNAS only when it lands on the shared `agent`, +Pulse supports TrueNAS only when it lands on the shared `agent`, `vm`, `app-container`, `storage`, `physical-disk`, and recovery-linked resource shapes. The unified agent may augment a TrueNAS system later, but baseline support does not depend on it, and product surfaces must not reopen a parallel diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index d2de66c54..1354f7f7b 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -17,6 +17,7 @@ import proxmoxReplicationTableSource from '@/features/proxmox/ProxmoxReplication import sharedPlatformPageSource from '@/features/platformPage/sharedPlatformPage.tsx?raw'; import truenasPageSurfaceSource from '@/features/truenas/TrueNASPageSurface.tsx?raw'; import truenasSystemsTableSource from '@/features/truenas/TrueNASSystemsTable.tsx?raw'; +import truenasVirtualMachinesTableSource from '@/features/truenas/TrueNASVirtualMachinesTable.tsx?raw'; import vmwarePageSurfaceSource from '@/features/vmware/VmwarePageSurface.tsx?raw'; import vsphereHostsTableSource from '@/features/vmware/VsphereHostsTable.tsx?raw'; @@ -28,6 +29,7 @@ const platformTableSources = [ kubernetesNodesTableSource, kubernetesDeploymentsTableSource, truenasSystemsTableSource, + truenasVirtualMachinesTableSource, vsphereHostsTableSource, ]; @@ -155,6 +157,7 @@ describe('platform overview layout guardrails', () => { expect(kubernetesPageSurfaceSource).toContain('(TRUENAS_TAB_SPECS.map((tab) => tab.id)); @@ -116,6 +117,15 @@ function TrueNASOverview(props: TrueNASOverviewProps) { emptyDescription="TrueNAS systems appear here once a TrueNAS connection reports its top-level appliance." showToolbar={false} /> + 0}> + + 0}> [] = [ + { value: 'all', label: 'All' }, + { value: 'running', label: 'Running', tone: 'success' }, + { value: 'attention', label: 'Attention', tone: 'warning' }, + { value: 'stopped', label: 'Stopped', tone: 'danger' }, +]; + +const vmMeta = (resource: Resource): ResourceTrueNASVMMeta | undefined => resource.truenas?.vm; + +const titleCase = (value: string | undefined): string => { + const normalized = asTrimmedString(value); + if (!normalized) return 'Unknown'; + return normalized.charAt(0).toUpperCase() + normalized.slice(1).toLowerCase(); +}; + +const formatBytes = (bytes: number | undefined): string => { + if (!bytes || bytes <= 0) return '-'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIdx = 0; + while (value >= 1024 && unitIdx < units.length - 1) { + value /= 1024; + unitIdx += 1; + } + return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIdx]}`; +}; + +const formatCPU = (vm: ResourceTrueNASVMMeta | undefined): string => { + const vcpus = vm?.vcpus; + if (typeof vcpus === 'number' && Number.isFinite(vcpus) && vcpus > 0) return `${vcpus} vCPU`; + const cores = vm?.cores; + const threads = vm?.threads; + if ( + typeof cores === 'number' && + Number.isFinite(cores) && + cores > 0 && + typeof threads === 'number' && + Number.isFinite(threads) && + threads > 0 + ) { + return `${cores}c / ${threads}t`; + } + return '-'; +}; + +const formatDevices = (vm: ResourceTrueNASVMMeta | undefined): { label: string; title: string } => { + const counts = [ + ['disk', vm?.diskCount], + ['nic', vm?.nicCount], + ['display', vm?.displayCount], + ['cdrom', vm?.cdromCount], + ['usb', vm?.usbCount], + ['pci', vm?.pciCount], + ] as const; + const parts = counts + .filter(([, count]) => typeof count === 'number' && Number.isFinite(count) && count > 0) + .map(([kind, count]) => `${count} ${kind}`); + const total = vm?.deviceCount; + if (parts.length === 0) { + return typeof total === 'number' && total > 0 + ? { label: `${total} devices`, title: `${total} devices` } + : { label: '-', title: '' }; + } + const visible = parts.slice(0, 2); + const suffix = parts.length > visible.length ? ` +${parts.length - visible.length}` : ''; + return { label: `${visible.join(', ')}${suffix}`, title: parts.join(', ') }; +}; + +const flagLabels = (vm: ResourceTrueNASVMMeta | undefined): string[] => { + const labels: string[] = []; + if (vm?.autostart) labels.push('Autostart'); + if (vm?.secureBoot) labels.push('Secure boot'); + if (vm?.trustedPlatformModule) labels.push('TPM'); + if (vm?.suspendOnSnapshot) labels.push('Suspend'); + return labels; +}; + +export const TrueNASVirtualMachinesTable: Component<{ + vms: Resource[]; + scope: Resource[]; + emptyIcon: JSX.Element; + emptyTitle: string; + emptyDescription: string; + showToolbar?: boolean; +}> = (props) => { + const tableState = createPlatformTableFilterState({ + resources: () => props.vms, + initialStatus: 'all' as TrueNASVMStatusFilter, + filter: filterTrueNASVMs, + }); + const drawer = createPlatformResourceDetailState({ idPrefix: 'truenas-vm-drawer' }); + const resolveResourceLabel = createPlatformResourceLabelResolver(() => props.scope); + + return ( + 0} + fallback={ + + } + > +
+ + + + + 0} + fallback={ + + } + > + + + + + + + VM + + + State + + + + + + + Flags + + + + + + {(resource) => { + const vm = () => vmMeta(resource); + const name = () => + asTrimmedString(vm()?.name) || + asTrimmedString(resource.displayName) || + asTrimmedString(resource.name) || + resource.id; + const indicator = () => getSimpleStatusIndicator(resource.status); + const stateLabel = () => titleCase(vm()?.state || vm()?.domainState); + const devices = createMemo(() => formatDevices(vm())); + const flags = createMemo(() => flagLabels(vm())); + const detailRowId = () => drawer.detailRowId(resource); + const isExpanded = () => drawer.isExpanded(resource); + return ( + <> + drawer.toggle(resource)} + onKeyDown={drawer.handleActivationKey(resource)} + tabIndex={0} + > + +
+ +
+
+ {name()} +
+
+ {vm()?.description || vm()?.uuid || resource.parentName || 'TrueNAS'} +
+
+
+
+ + + {stateLabel()} + + + + + + + + 0} + fallback={-} + > + {flags().slice(0, 2).join(', ')} + + +
+ drawer.close(resource)} + /> + + ); + }} +
+
+
+
+
+
+
+ ); +}; + +export default TrueNASVirtualMachinesTable; diff --git a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts index 8042b49fa..bc580f004 100644 --- a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts +++ b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts @@ -4,7 +4,9 @@ import { TRUENAS_TAB_SPECS, buildTrueNASPageModel, filterTrueNASApps, + filterTrueNASVMs, mapTrueNASAppStatus, + mapTrueNASVMStatus, } from '../truenasPageModel'; const makeResource = (resource: Partial & Pick): Resource => ({ @@ -26,6 +28,7 @@ describe('truenasPageModel', () => { it('buckets systems and apps while keeping storage inventory in scope for shared surfaces', () => { const model = buildTrueNASPageModel([ makeResource({ id: 'truenas-system', type: 'agent' }), + makeResource({ id: 'truenas-vm', type: 'vm' }), makeResource({ id: 'truenas-app', type: 'app-container' }), makeResource({ id: 'truenas-pool', type: 'pool' }), makeResource({ id: 'truenas-disk', type: 'physical_disk' }), @@ -34,9 +37,10 @@ describe('truenasPageModel', () => { ]); expect(model.systems.map((r) => r.id)).toEqual(['truenas-system']); + expect(model.vms.map((r) => r.id)).toEqual(['truenas-vm']); expect(model.apps.map((r) => r.id)).toEqual(['truenas-app']); expect(model.resources.map((r) => r.id).sort()).toEqual( - ['truenas-app', 'truenas-disk', 'truenas-pool', 'truenas-system'].sort(), + ['truenas-app', 'truenas-disk', 'truenas-pool', 'truenas-system', 'truenas-vm'].sort(), ); }); @@ -87,4 +91,45 @@ describe('truenasPageModel', () => { filterTrueNASApps([nextcloud, adguard], 'adguardhome', 'stopped').map((r) => r.id), ).toEqual(['app-adguard']); }); + + it('filters VMs using native TrueNAS vm.query metadata', () => { + const windows = makeResource({ + id: 'vm-windows', + type: 'vm', + truenas: { + hostname: 'truenas-main', + vm: { + id: '42', + name: 'windows-lab', + description: 'Build validation workstation', + state: 'RUNNING', + bootloader: 'UEFI', + cpuMode: 'HOST-PASSTHROUGH', + uuid: 'vm-uuid-1', + }, + }, + }); + const ubuntu = makeResource({ + id: 'vm-ubuntu', + type: 'vm', + status: 'offline', + truenas: { + vm: { + id: '43', + name: 'ubuntu-build', + state: 'STOPPED', + machineType: 'q35', + }, + }, + }); + + expect(mapTrueNASVMStatus(windows)).toBe('running'); + expect(mapTrueNASVMStatus(ubuntu)).toBe('stopped'); + expect(filterTrueNASVMs([windows, ubuntu], 'passthrough', 'running').map((r) => r.id)).toEqual([ + 'vm-windows', + ]); + expect(filterTrueNASVMs([windows, ubuntu], 'q35', 'stopped').map((r) => r.id)).toEqual([ + 'vm-ubuntu', + ]); + }); }); diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index 3ba6ec781..4b8f2e0b5 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -3,6 +3,7 @@ import type { Resource, ResourceType } from '@/types/resource'; export type TrueNASPageTabId = 'overview' | 'storage'; export type TrueNASAppStatusFilter = 'all' | 'running' | 'attention' | 'stopped'; +export type TrueNASVMStatusFilter = 'all' | 'running' | 'attention' | 'stopped'; export type TrueNASTabSpec = { id: TrueNASPageTabId; @@ -10,10 +11,11 @@ export type TrueNASTabSpec = { path: string; }; -// The Overview tab is intentionally narrow: appliance systems first, then apps -// when present. Storage inventory, pool topology, and physical disks all live -// on the Storage tab so operators have one canonical storage surface instead -// of a duplicated overview snapshot plus a richer storage page. +// The Overview tab is intentionally narrow: appliance systems first, then +// native workload inventory when present. Storage inventory, pool topology, and +// physical disks all live on the Storage tab so operators have one canonical +// storage surface instead of a duplicated overview snapshot plus a richer +// storage page. export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [ { id: 'overview', label: 'Overview', path: '/truenas/overview' }, { id: 'storage', label: 'Storage', path: '/truenas/storage' }, @@ -21,6 +23,7 @@ export const TRUENAS_TAB_SPECS: readonly TrueNASTabSpec[] = [ const TRUENAS_RESOURCE_TYPES = new Set([ 'agent', + 'vm', 'app-container', 'storage', 'pool', @@ -34,6 +37,7 @@ const isTrueNASPlatform = (resource: Resource): boolean => export type TrueNASPageModel = { resources: Resource[]; systems: Resource[]; + vms: Resource[]; apps: Resource[]; }; @@ -43,10 +47,12 @@ export function buildTrueNASPageModel(resources: Resource[]): TrueNASPageModel { ); const systems = trueNasResources.filter((resource) => resource.type === 'agent'); + const vms = trueNasResources.filter((resource) => resource.type === 'vm'); const apps = trueNasResources.filter((resource) => resource.type === 'app-container'); return { resources: trueNasResources, systems, + vms, apps, }; } @@ -66,6 +72,21 @@ export function mapTrueNASAppStatus(resource: Resource): Exclude { + const state = normalize(resource.truenas?.vm?.state || resource.truenas?.vm?.domainState); + if (state === 'running' || state === 'active') return 'running'; + if (state === 'stopped' || state === 'shutoff' || state === 'shutdown' || state === 'poweroff') { + return 'stopped'; + } + if (state === 'paused' || state === 'suspended' || state === 'error' || state === 'crashed') { + return 'attention'; + } + + if (resource.status === 'online' || resource.status === 'running') return 'running'; + if (resource.status === 'offline' || resource.status === 'stopped') return 'stopped'; + return 'attention'; +} + const portSearchTokens = (resource: Resource): string[] => { const app = resource.truenas?.app; const tokens: string[] = []; @@ -139,3 +160,45 @@ export function filterTrueNASApps( return appSearchHaystack(app).includes(needle); }); } + +const vmSearchHaystack = (resource: Resource): string => { + const vm = resource.truenas?.vm; + return [ + resource.name, + resource.displayName, + resource.id, + resource.parentName, + resource.platformId, + resource.platformType, + resource.truenas?.hostname, + vm?.id, + vm?.name, + vm?.description, + vm?.state, + vm?.domainState, + vm?.cpuMode, + vm?.cpuModel, + vm?.bootloader, + vm?.time, + vm?.archType, + vm?.machineType, + vm?.uuid, + ...(resource.tags ?? []), + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join(' ') + .toLowerCase(); +}; + +export function filterTrueNASVMs( + vms: Resource[], + search: string, + status: TrueNASVMStatusFilter, +): Resource[] { + const needle = normalize(search); + return vms.filter((vm) => { + if (status !== 'all' && mapTrueNASVMStatus(vm) !== status) return false; + if (!needle) return true; + return vmSearchHaystack(vm).includes(needle); + }); +} diff --git a/frontend-modern/src/types/__tests__/resource.test.ts b/frontend-modern/src/types/__tests__/resource.test.ts index fc010059c..dfbbfc56c 100644 --- a/frontend-modern/src/types/__tests__/resource.test.ts +++ b/frontend-modern/src/types/__tests__/resource.test.ts @@ -22,6 +22,7 @@ import { import { ADMITTED_PLATFORM_IDS, PRESENTATION_ONLY_PLATFORM_IDS, + SOURCE_PLATFORM_CANONICAL_PROJECTIONS, SUPPORTED_PLATFORM_IDS, } from '@/utils/platformSupportManifest.generated'; import { getPreferredResourceDisplayName } from '@/utils/resourceIdentity'; @@ -50,6 +51,16 @@ describe('Resource Type Guards', () => { } }); + it('keeps TrueNAS native VM support in the governed platform projection', () => { + expect(SOURCE_PLATFORM_CANONICAL_PROJECTIONS.truenas).toEqual([ + 'agent', + 'vm', + 'app-container', + 'storage', + 'physical-disk', + ]); + }); + describe('isInfrastructure', () => { const infrastructureTypes: ResourceType[] = [ 'agent', diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index fd94a6662..d968be2e6 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -651,6 +651,39 @@ export interface ResourceTrueNASAppMeta { stats?: ResourceTrueNASAppStats; } +export interface ResourceTrueNASVMMeta { + id?: string; + name?: string; + description?: string; + state?: string; + domainState?: string; + pid?: number; + vcpus?: number; + cores?: number; + threads?: number; + memoryBytes?: number; + minMemoryBytes?: number; + cpuMode?: string; + cpuModel?: string; + bootloader?: string; + autostart?: boolean; + suspendOnSnapshot?: boolean; + trustedPlatformModule?: boolean; + secureBoot?: boolean; + time?: string; + archType?: string; + machineType?: string; + uuid?: string; + displayAvailable?: boolean; + deviceCount?: number; + diskCount?: number; + nicCount?: number; + displayCount?: number; + cdromCount?: number; + usbCount?: number; + pciCount?: number; +} + export interface ResourceTrueNASMeta { hostname?: string; version?: string; @@ -663,6 +696,7 @@ export interface ResourceTrueNASMeta { rebuildInProgress?: boolean; rebuildSummary?: string; app?: ResourceTrueNASAppMeta; + vm?: ResourceTrueNASVMMeta; } export interface ResourceKubernetesMetricCapabilities { diff --git a/frontend-modern/src/utils/platformSupportManifest.generated.ts b/frontend-modern/src/utils/platformSupportManifest.generated.ts index 7f5720089..8c72c9980 100644 --- a/frontend-modern/src/utils/platformSupportManifest.generated.ts +++ b/frontend-modern/src/utils/platformSupportManifest.generated.ts @@ -1,11 +1,11 @@ // This file is generated by scripts/release_control/generate_platform_support_frontend_module.py. // Do not edit by hand. // Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json -// Source SHA256: 01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34 +// Source SHA256: 921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda export const PLATFORM_SUPPORT_MANIFEST_SOURCE = { path: 'docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json', - sha256: '01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34', + sha256: '921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda', } as const; export const PLATFORM_SUPPORT_MANIFEST = { schemaVersion: 2, @@ -199,7 +199,7 @@ export const PLATFORM_SUPPORT_MANIFEST = { readinessStage: 'supported', primaryMode: 'api-backed', onboardingPaths: ['platform-connections'], - canonicalProjections: ['agent', 'app-container', 'storage', 'physical-disk'], + canonicalProjections: ['agent', 'vm', 'app-container', 'storage', 'physical-disk'], supportFloor: { setup: 'supported', visibility: 'supported', @@ -625,7 +625,7 @@ export const SOURCE_PLATFORM_CANONICAL_PROJECTIONS = { 'proxmox-pve': ['agent', 'vm', 'system-container', 'storage', 'ceph', 'physical-disk'], 'proxmox-pbs': ['pbs', 'storage'], 'proxmox-pmg': ['pmg'], - truenas: ['agent', 'app-container', 'storage', 'physical-disk'], + truenas: ['agent', 'vm', 'app-container', 'storage', 'physical-disk'], 'vmware-vsphere': ['agent', 'vm', 'storage'], unraid: [], 'synology-dsm': [], diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 2d45614f4..352a9000c 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -3419,6 +3419,66 @@ func TestResourceListIncludesTrueNASAppsAsAppContainers(t *testing.T) { } } +func TestResourceListIncludesTrueNASVMsAsCanonicalWorkloads(t *testing.T) { + previous := truenas.IsFeatureEnabled() + truenas.SetFeatureEnabled(true) + t.Cleanup(func() { + truenas.SetFeatureEnabled(previous) + }) + + fixtures := truenas.DefaultFixtures() + cfg := &config.Config{DataPath: t.TempDir()} + h := NewResourceHandlers(cfg) + h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}}) + h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{ + records: truenas.NewProvider(fixtures).Records(), + ownedSources: []unified.DataSource{unified.SourceTrueNAS}, + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/resources?type=vm&source=truenas", nil) + h.HandleListResources(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp ResourcesResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Data) != len(fixtures.VMs) { + t.Fatalf("expected %d TrueNAS VM resources, got %d", len(fixtures.VMs), len(resp.Data)) + } + + var windows *unified.Resource + for i := range resp.Data { + resource := &resp.Data[i] + if resource.Name == "windows-lab" { + windows = resource + break + } + } + if windows == nil { + t.Fatalf("expected windows-lab in TrueNAS VM response, got %+v", resp.Data) + } + if !containsSource(windows.Sources, unified.SourceTrueNAS) { + t.Fatalf("expected TrueNAS source, got %+v", windows.Sources) + } + if windows.ParentName != "truenas-main" { + t.Fatalf("expected windows-lab parentName truenas-main, got %q", windows.ParentName) + } + if windows.TrueNAS == nil || windows.TrueNAS.VM == nil { + t.Fatalf("expected native VM payload on TrueNAS VM resource, got %+v", windows) + } + if windows.TrueNAS.VM.ID != "42" || windows.TrueNAS.VM.VCPUs != 4 || windows.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 { + t.Fatalf("unexpected native TrueNAS VM payload: %+v", windows.TrueNAS.VM) + } + if windows.MetricsTarget == nil || windows.MetricsTarget.ResourceType != "vm" || windows.MetricsTarget.ResourceID != "42" { + t.Fatalf("expected canonical TrueNAS VM metrics target 42, got %+v", windows.MetricsTarget) + } +} + func TestResourceListIncludesTrueNASSystemAsCanonicalHost(t *testing.T) { previous := truenas.IsFeatureEnabled() truenas.SetFeatureEnabled(true) diff --git a/internal/platformsupport/manifest_generated.go b/internal/platformsupport/manifest_generated.go index 7eb2e389a..792f3287d 100644 --- a/internal/platformsupport/manifest_generated.go +++ b/internal/platformsupport/manifest_generated.go @@ -1,7 +1,7 @@ // Code generated by scripts/release_control/generate_platform_support_backend_module.py. // DO NOT EDIT. // Source: docs/release-control/v6/internal/PLATFORM_SUPPORT_MANIFEST.json -// Source SHA256: 01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34 +// Source SHA256: 921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda package platformsupport @@ -13,7 +13,7 @@ const ManifestSourcePath = "docs/release-control/v6/internal/PLATFORM_SUPPORT_MA // ManifestSourceSHA256 is the sha256 of the manifest bytes used to produce // this projection. -const ManifestSourceSHA256 = "01e0f1c5cbe2f5b46745a0a5fd51d0d085f48346cc2dbadf391dac558666fb34" +const ManifestSourceSHA256 = "921181082d41829ac4884d00175791157ac500a27888ca2b81f09a9e97989bda" // ManifestSchemaVersion is the schema_version field of the manifest. const ManifestSchemaVersion = 2 diff --git a/internal/truenas/client.go b/internal/truenas/client.go index db0185ce5..8f065c7f8 100644 --- a/internal/truenas/client.go +++ b/internal/truenas/client.go @@ -513,6 +513,51 @@ func (c *Client) GetAlerts(ctx context.Context) ([]Alert, error) { return alerts, nil } +// GetVMs returns the best-effort native TrueNAS VM inventory. TrueNAS 25.04+ +// documents vm.query on the JSON-RPC API, with the legacy REST endpoint kept +// as a compatibility fallback for existing client tests and older deployments. +func (c *Client) GetVMs(ctx context.Context) ([]VirtualMachine, error) { + vms, err := c.getVMsRPC(ctx) + if err == nil { + return vms, nil + } + restVMs, restErr := c.getVMsREST(ctx) + if restErr != nil { + return nil, fmt.Errorf("fetch truenas vms via rpc and rest: rpc=%w rest=%v", err, restErr) + } + return restVMs, nil +} + +func (c *Client) getVMsRPC(ctx context.Context) ([]VirtualMachine, error) { + conn, err := c.dialRPC(ctx) + if err != nil { + return nil, err + } + defer func() { _ = conn.Close() }() + + rpc := trueNASRPCClient{ + conn: conn, + nextID: 1, + } + if err := rpc.authenticate(ctx, c.config); err != nil { + return nil, err + } + + var response []map[string]any + if err := rpc.call(ctx, "vm.query", []any{[]any{}, map[string]any{}}, &response); err != nil { + return nil, err + } + return parseVirtualMachines(response), nil +} + +func (c *Client) getVMsREST(ctx context.Context) ([]VirtualMachine, error) { + var response []map[string]any + if err := c.getJSON(ctx, http.MethodGet, "/vm", &response); err != nil { + return nil, err + } + return parseVirtualMachines(response), nil +} + // GetApps returns the best-effort TrueNAS app inventory as canonical workload // candidates. The API surface varies across TrueNAS releases, so we parse the // documented app.query response shape loosely. @@ -841,6 +886,7 @@ func (c *Client) FetchSnapshot(ctx context.Context) (*FixtureSnapshot, error) { // Recovery artifacts are best-effort: do not fail monitoring if additional endpoints are unavailable. apps, _ := c.GetApps(ctx) + vms, _ := c.GetVMs(ctx) zfsSnapshots, _ := c.GetZFSSnapshots(ctx) replicationTasks, _ := c.GetReplicationTasks(ctx) @@ -852,6 +898,7 @@ func (c *Client) FetchSnapshot(ctx context.Context) (*FixtureSnapshot, error) { Disks: disks, Alerts: alerts, Apps: apps, + VMs: vms, ZFSSnapshots: zfsSnapshots, ReplicationTasks: replicationTasks, }, nil @@ -2411,6 +2458,132 @@ func readFloatAny(record map[string]any, keys ...string) float64 { return 0 } +func parseVirtualMachines(entries []map[string]any) []VirtualMachine { + if len(entries) == 0 { + return nil + } + vms := make([]VirtualMachine, 0, len(entries)) + for _, item := range entries { + if item == nil { + continue + } + + status := readMapAny(item, "status") + deviceCounts := parseVMDeviceCounts(readSliceAny(item, "devices")) + memory := trueNASVMMemoryBytes(readInt64Any(item, "memory")) + minMemory := trueNASVMMemoryBytes(readInt64Any(item, "min_memory", "minMemory")) + id := strings.TrimSpace(readStringAny(item, "id")) + name := strings.TrimSpace(readStringAny(item, "name")) + if id == "" { + id = name + } + if name == "" { + name = id + } + + vm := VirtualMachine{ + ID: id, + Name: name, + Description: strings.TrimSpace(readStringAny(item, "description")), + State: strings.TrimSpace(readStringAny(status, "state")), + DomainState: strings.TrimSpace(readStringAny(status, "domain_state", "domainState")), + PID: readIntAny(status, "pid"), + VCPUs: readIntAny(item, "vcpus"), + Cores: readIntAny(item, "cores"), + Threads: readIntAny(item, "threads"), + MemoryBytes: memory, + MinMemoryBytes: minMemory, + CPUMode: strings.TrimSpace(readStringAny(item, "cpu_mode", "cpuMode")), + CPUModel: strings.TrimSpace(readStringAny(item, "cpu_model", "cpuModel")), + Bootloader: strings.TrimSpace(readStringAny(item, "bootloader")), + Autostart: readBoolAny(item, "autostart"), + SuspendOnSnapshot: readBoolAny(item, "suspend_on_snapshot", "suspendOnSnapshot"), + TrustedPlatformModule: readBoolAny(item, "trusted_platform_module", "trustedPlatformModule"), + SecureBoot: readBoolAny(item, "enable_secure_boot", "enableSecureBoot"), + Time: strings.TrimSpace(readStringAny(item, "time")), + ArchType: strings.TrimSpace(readStringAny(item, "arch_type", "archType")), + MachineType: strings.TrimSpace(readStringAny(item, "machine_type", "machineType")), + UUID: strings.TrimSpace(readStringAny(item, "uuid")), + DisplayAvailable: readBoolAny(item, "display_available", "displayAvailable"), + DeviceCount: deviceCounts.total, + DiskCount: deviceCounts.disks, + NICCount: deviceCounts.nics, + DisplayCount: deviceCounts.displays, + CDROMCount: deviceCounts.cdroms, + USBCount: deviceCounts.usbs, + PCICount: deviceCounts.pcis, + } + if vm.State == "" { + vm.State = strings.TrimSpace(readStringAny(item, "state")) + } + if vm.DomainState == "" { + vm.DomainState = strings.TrimSpace(readStringAny(item, "domain_state", "domainState")) + } + if vm.DeviceCount == 0 { + vm.DeviceCount = readIntAny(item, "device_count", "deviceCount") + } + + if vm.ID == "" && vm.Name == "" { + continue + } + vms = append(vms, vm) + } + if len(vms) == 0 { + return nil + } + return vms +} + +type vmDeviceCounts struct { + total int + disks int + nics int + displays int + cdroms int + usbs int + pcis int +} + +func parseVMDeviceCounts(entries []any) vmDeviceCounts { + counts := vmDeviceCounts{total: len(entries)} + for _, entry := range entries { + record, ok := entry.(map[string]any) + if !ok { + continue + } + attributes := readMapAny(record, "attributes") + dtype := strings.ToUpper(strings.TrimSpace(readStringAny(attributes, "dtype"))) + if dtype == "" { + dtype = strings.ToUpper(strings.TrimSpace(readStringAny(record, "dtype", "type"))) + } + switch dtype { + case "DISK", "RAW": + counts.disks++ + case "NIC": + counts.nics++ + case "DISPLAY": + counts.displays++ + case "CDROM": + counts.cdroms++ + case "USB": + counts.usbs++ + case "PCI": + counts.pcis++ + } + } + return counts +} + +func trueNASVMMemoryBytes(memory int64) int64 { + if memory <= 0 { + return 0 + } + if memory >= 1<<30 { + return memory + } + return memory * 1024 * 1024 +} + func parseAppPorts(entries []any) []AppPort { if len(entries) == 0 { return nil diff --git a/internal/truenas/client_test.go b/internal/truenas/client_test.go index 530d11c81..b219b63bd 100644 --- a/internal/truenas/client_test.go +++ b/internal/truenas/client_test.go @@ -248,9 +248,9 @@ func TestFetchSnapshot(t *testing.T) { if snapshot.System.Hostname != "truenas-main" { t.Fatalf("unexpected snapshot system: %+v", snapshot.System) } - if len(snapshot.Pools) != 1 || len(snapshot.Datasets) != 1 || len(snapshot.Disks) != 2 || len(snapshot.Alerts) != 1 || len(snapshot.Apps) != 1 { - t.Fatalf("unexpected snapshot counts: pools=%d datasets=%d disks=%d alerts=%d apps=%d", - len(snapshot.Pools), len(snapshot.Datasets), len(snapshot.Disks), len(snapshot.Alerts), len(snapshot.Apps)) + if len(snapshot.Pools) != 1 || len(snapshot.Datasets) != 1 || len(snapshot.Disks) != 2 || len(snapshot.Alerts) != 1 || len(snapshot.Apps) != 1 || len(snapshot.VMs) != 1 { + t.Fatalf("unexpected snapshot counts: pools=%d datasets=%d disks=%d alerts=%d apps=%d vms=%d", + len(snapshot.Pools), len(snapshot.Datasets), len(snapshot.Disks), len(snapshot.Alerts), len(snapshot.Apps), len(snapshot.VMs)) } if snapshot.Disks[0].Temperature != 34 || snapshot.Disks[1].Temperature != 49 { t.Fatalf("unexpected snapshot disk temperatures: %+v", snapshot.Disks) @@ -258,6 +258,85 @@ func TestFetchSnapshot(t *testing.T) { if snapshot.Apps[0].ID != "nextcloud" || snapshot.Apps[0].ContainerCount != 2 { t.Fatalf("unexpected snapshot apps: %+v", snapshot.Apps) } + if snapshot.VMs[0].Name != "windows-lab" || snapshot.VMs[0].MemoryBytes != 8*1024*1024*1024 { + t.Fatalf("unexpected snapshot vms: %+v", snapshot.VMs) + } +} + +func TestGetVMsParsesNativeVMQueryShape(t *testing.T) { + server := newMockServerWithRPC(t, map[string]apiResponse{}, nil, func(t *testing.T, conn *websocket.Conn) { + authReq := readRPCRequest(t, conn) + if authReq.Method != "auth.login_with_api_key" { + t.Fatalf("expected api-key auth method, got %q", authReq.Method) + } + writeRPCResult(t, conn, authReq.ID, true) + + queryReq := readRPCRequest(t, conn) + if queryReq.Method != "vm.query" { + t.Fatalf("expected vm.query, got %q", queryReq.Method) + } + writeRPCResult(t, conn, queryReq.ID, []map[string]any{ + { + "id": 42, + "name": "windows-lab", + "description": "Build test box", + "vcpus": 4, + "cores": 2, + "threads": 2, + "memory": 8192, + "min_memory": 4096, + "cpu_mode": "HOST-PASSTHROUGH", + "cpu_model": nil, + "bootloader": "UEFI", + "autostart": true, + "suspend_on_snapshot": true, + "trusted_platform_module": true, + "enable_secure_boot": true, + "time": "UTC", + "arch_type": "x86_64", + "machine_type": "q35", + "uuid": "vm-uuid-1", + "display_available": true, + "status": map[string]any{ + "state": "RUNNING", + "pid": 1234, + "domain_state": "RUNNING", + }, + "devices": []map[string]any{ + {"id": 1, "attributes": map[string]any{"dtype": "DISK"}}, + {"id": 2, "attributes": map[string]any{"dtype": "NIC"}}, + {"id": 3, "attributes": map[string]any{"dtype": "DISPLAY"}}, + {"id": 4, "attributes": map[string]any{"dtype": "CDROM"}}, + }, + }, + }) + }) + t.Cleanup(server.Close) + + client := mustClientForServer(t, server.URL, ClientConfig{APIKey: "api-key"}) + vms, err := client.GetVMs(context.Background()) + if err != nil { + t.Fatalf("GetVMs() error = %v", err) + } + if len(vms) != 1 { + t.Fatalf("expected 1 VM, got %d", len(vms)) + } + vm := vms[0] + if vm.ID != "42" || vm.Name != "windows-lab" || vm.State != "RUNNING" || vm.DomainState != "RUNNING" { + t.Fatalf("unexpected VM identity/status: %+v", vm) + } + if vm.VCPUs != 4 || vm.Cores != 2 || vm.Threads != 2 { + t.Fatalf("unexpected VM CPU topology: %+v", vm) + } + if vm.MemoryBytes != 8*1024*1024*1024 || vm.MinMemoryBytes != 4*1024*1024*1024 { + t.Fatalf("unexpected VM memory: %+v", vm) + } + if !vm.Autostart || !vm.SuspendOnSnapshot || !vm.TrustedPlatformModule || !vm.SecureBoot { + t.Fatalf("expected VM boolean flags, got %+v", vm) + } + if vm.DeviceCount != 4 || vm.DiskCount != 1 || vm.NICCount != 1 || vm.DisplayCount != 1 || vm.CDROMCount != 1 { + t.Fatalf("unexpected VM device counts: %+v", vm) + } } func TestGetAppsEnrichesStatsFromRPC(t *testing.T) { @@ -1171,6 +1250,9 @@ func defaultAPIResponses() map[string]apiResponse { "/api/v2.0/app": { body: `[{"id":"nextcloud","name":"Nextcloud","state":"RUNNING","version":"1.0.3","human_version":"29.0.7","upgrade_available":true,"image_updates_available":true,"notes":"Team cloud","active_workloads":{"containers":2,"used_host_ips":["0.0.0.0"],"used_ports":[{"container_port":443,"protocol":"tcp","host_ports":[{"host_port":30443,"host_ip":"0.0.0.0"}]}],"container_details":[{"id":"nextcloud-web-1","service_name":"nextcloud","image":"docker.io/library/nextcloud:29.0.7","state":"running","port_config":[{"container_port":443,"protocol":"tcp","host_ports":[{"host_port":30443,"host_ip":"0.0.0.0"}]}],"volume_mounts":[{"source":"/mnt/tank/apps/nextcloud","destination":"/var/www/html","mode":"rw","type":"bind"}]},{"id":"nextcloud-redis-1","service_name":"redis","image":"docker.io/library/redis:7.2","state":"running","port_config":[],"volume_mounts":[{"source":"ix-nextcloud-redis","destination":"/data","mode":"rw","type":"volume"}]}],"volumes":[{"source":"/mnt/tank/apps/nextcloud","destination":"/var/www/html","mode":"rw","type":"bind"},{"source":"ix-nextcloud-redis","destination":"/data","mode":"rw","type":"volume"}],"images":["docker.io/library/nextcloud:29.0.7","docker.io/library/redis:7.2"],"networks":[{"name":"ix-nextcloud_default","id":"net-1","labels":{"com.docker.compose.project":"nextcloud"}}]}}]`, }, + "/api/v2.0/vm": { + body: `[{"id":42,"name":"windows-lab","vcpus":4,"cores":2,"threads":2,"memory":8192,"bootloader":"UEFI","autostart":true,"status":{"state":"RUNNING","pid":1234,"domain_state":"RUNNING"},"devices":[{"id":1,"attributes":{"dtype":"DISK"}},{"id":2,"attributes":{"dtype":"NIC"}}]}]`, + }, } } diff --git a/internal/truenas/contract_test.go b/internal/truenas/contract_test.go index 13adebf1b..7dfa03342 100644 --- a/internal/truenas/contract_test.go +++ b/internal/truenas/contract_test.go @@ -52,15 +52,15 @@ func TestRegistryIngestRecordsTreatsTrueNASAsGenericDataSource(t *testing.T) { registry.IngestRecords(unifiedresources.SourceTrueNAS, records) resources := registry.List() - wantCount := 1 + len(fixtures.Pools) + len(fixtures.Datasets) + len(fixtures.Disks) + len(fixtures.Apps) + wantCount := 1 + len(fixtures.Pools) + len(fixtures.Datasets) + len(fixtures.Disks) + len(fixtures.Apps) + len(fixtures.VMs) if len(resources) != wantCount { t.Fatalf("expected %d resources, got %d", wantCount, len(resources)) } system := requireResource(t, resources, unifiedresources.ResourceTypeAgent, fixtures.System.Hostname) assertSourceTracking(t, *system, unifiedresources.SourceTrueNAS) - if system.ChildCount != len(fixtures.Pools)+len(fixtures.Apps) { - t.Fatalf("expected system child count %d, got %d", len(fixtures.Pools)+len(fixtures.Apps), system.ChildCount) + if system.ChildCount != len(fixtures.Pools)+len(fixtures.Apps)+len(fixtures.VMs) { + t.Fatalf("expected system child count %d, got %d", len(fixtures.Pools)+len(fixtures.Apps)+len(fixtures.VMs), system.ChildCount) } if system.TrueNAS == nil { t.Fatal("expected TrueNAS metadata on system record") @@ -170,6 +170,30 @@ func TestRegistryIngestRecordsTreatsTrueNASAsGenericDataSource(t *testing.T) { t.Fatalf("expected Nextcloud network rates, got in=%v out=%v", app.Docker.NetInRate, app.Docker.NetOutRate) } + vm := requireResource(t, resources, unifiedresources.ResourceTypeVM, "windows-lab") + assertSourceTracking(t, *vm, unifiedresources.SourceTrueNAS) + if vm.ParentID == nil || *vm.ParentID != system.ID { + t.Fatalf("expected VM parent %q, got %+v", system.ID, vm.ParentID) + } + if vm.TrueNAS == nil || vm.TrueNAS.VM == nil { + t.Fatal("expected native TrueNAS VM metadata on VM resource") + } + if vm.TrueNAS.VM.ID != "42" || vm.TrueNAS.VM.State != "RUNNING" { + t.Fatalf("unexpected native TrueNAS VM identity/state: %+v", vm.TrueNAS.VM) + } + if vm.TrueNAS.VM.VCPUs != 4 || vm.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 { + t.Fatalf("unexpected native TrueNAS VM compute metadata: %+v", vm.TrueNAS.VM) + } + if !vm.TrueNAS.VM.Autostart || !vm.TrueNAS.VM.SecureBoot || !vm.TrueNAS.VM.TrustedPlatformModule { + t.Fatalf("unexpected native TrueNAS VM flags: %+v", vm.TrueNAS.VM) + } + if vm.TrueNAS.VM.DiskCount != 1 || vm.TrueNAS.VM.NICCount != 1 || vm.TrueNAS.VM.DisplayCount != 1 { + t.Fatalf("unexpected native TrueNAS VM device counts: %+v", vm.TrueNAS.VM) + } + if vm.Status != unifiedresources.StatusOnline { + t.Fatalf("expected windows-lab status online, got %q", vm.Status) + } + disk := requireResource(t, resources, unifiedresources.ResourceTypePhysicalDisk, "sda") assertSourceTracking(t, *disk, unifiedresources.SourceTrueNAS) if disk.ParentID == nil || *disk.ParentID != pool.ID { @@ -240,7 +264,7 @@ func TestTrueNASResourcesFlowThroughUnifiedTypesWithoutSpecialCasing(t *testing. t.Fatalf("expected canonical render type, got truenas-specific type for %s", resource.ID) } switch resource.Type { - case unifiedresources.ResourceTypeAgent, unifiedresources.ResourceTypeStorage, unifiedresources.ResourceTypePhysicalDisk, unifiedresources.ResourceTypeAppContainer: + case unifiedresources.ResourceTypeAgent, unifiedresources.ResourceTypeStorage, unifiedresources.ResourceTypePhysicalDisk, unifiedresources.ResourceTypeAppContainer, unifiedresources.ResourceTypeVM: default: t.Fatalf("unexpected unified type for truenas fixture resource: %s (%s)", resource.Type, resource.ID) } diff --git a/internal/truenas/fixtures.go b/internal/truenas/fixtures.go index a3fbfca05..7dc75fc8a 100644 --- a/internal/truenas/fixtures.go +++ b/internal/truenas/fixtures.go @@ -715,6 +715,59 @@ func DefaultFixtures() FixtureSnapshot { Images: []string{"docker.io/adguard/adguardhome:v0.107.64"}, }, }, + VMs: []VirtualMachine{ + { + ID: "42", + Name: "windows-lab", + Description: "Build validation workstation", + State: "RUNNING", + DomainState: "RUNNING", + PID: 1234, + VCPUs: 4, + Cores: 2, + Threads: 2, + MemoryBytes: truenasGiB(8), + MinMemoryBytes: truenasGiB(4), + CPUMode: "HOST-PASSTHROUGH", + Bootloader: "UEFI", + Autostart: true, + SuspendOnSnapshot: true, + TrustedPlatformModule: true, + SecureBoot: true, + Time: "UTC", + ArchType: "x86_64", + MachineType: "q35", + UUID: "truenas-vm-windows-lab-uuid", + DisplayAvailable: true, + DeviceCount: 4, + DiskCount: 1, + NICCount: 1, + DisplayCount: 1, + CDROMCount: 1, + }, + { + ID: "43", + Name: "ubuntu-build", + Description: "Linux build worker", + State: "STOPPED", + DomainState: "SHUTOFF", + VCPUs: 2, + Cores: 2, + Threads: 1, + MemoryBytes: truenasGiB(4), + CPUMode: "HOST-MODEL", + Bootloader: "UEFI", + Time: "UTC", + ArchType: "x86_64", + MachineType: "q35", + UUID: "truenas-vm-ubuntu-build-uuid", + DisplayAvailable: true, + DeviceCount: 3, + DiskCount: 1, + NICCount: 1, + DisplayCount: 1, + }, + }, ZFSSnapshots: []ZFSSnapshot{ { ID: "zfs-snapshot-tank-apps-20260331-0600", diff --git a/internal/truenas/provider.go b/internal/truenas/provider.go index ed60a19a1..3cb60bf72 100644 --- a/internal/truenas/provider.go +++ b/internal/truenas/provider.go @@ -457,7 +457,7 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time) systemRisk := unifiedresources.StorageRiskFromAssessment(systemAssessment) _, protectionReduced, rebuildInProgress, protectionSummary, rebuildSummary := unifiedresources.StorageRiskSemantics(systemRisk) systemIncidents, poolIncidents, diskIncidents := buildIncidentAssignments(snapshot) - records := make([]unifiedresources.IngestRecord, 0, 1+len(snapshot.Pools)+len(snapshot.Datasets)+len(snapshot.Apps)+len(snapshot.Disks)) + records := make([]unifiedresources.IngestRecord, 0, 1+len(snapshot.Pools)+len(snapshot.Datasets)+len(snapshot.Apps)+len(snapshot.VMs)+len(snapshot.Disks)) totalCapacity, totalUsed := aggregatePoolUsage(snapshot.Pools) systemAgent := agentDataFromTrueNASSystem(snapshot.System, systemRisk, protectionReduced, protectionSummary, rebuildInProgress, rebuildSummary) @@ -638,6 +638,32 @@ func truenasRecordsFromSnapshot(snapshot *FixtureSnapshot, now func() time.Time) }) } + for _, vm := range snapshot.VMs { + records = append(records, unifiedresources.IngestRecord{ + SourceID: virtualMachineSourceID(vm), + ParentSourceID: systemSourceID, + Resource: unifiedresources.Resource{ + Type: unifiedresources.ResourceTypeVM, + Name: virtualMachineDisplayName(vm), + Status: statusFromVirtualMachine(vm), + LastSeen: collectedAt, + UpdatedAt: collectedAt, + TrueNAS: &unifiedresources.TrueNASData{ + Hostname: strings.TrimSpace(snapshot.System.Hostname), + VM: trueNASVMDataFromVirtualMachine(vm), + }, + Tags: virtualMachineTags(vm), + }, + Identity: unifiedresources.ResourceIdentity{ + MachineID: strings.TrimSpace(vm.UUID), + Hostnames: dedupeStrings([]string{ + strings.TrimSpace(snapshot.System.Hostname), + virtualMachineDisplayName(vm), + }), + }, + }) + } + for _, disk := range snapshot.Disks { assessment := assessDisk(disk) incidents := diskIncidents[strings.TrimSpace(disk.Name)] @@ -1209,6 +1235,13 @@ func appSourceID(id string) string { return "app:" + strings.TrimSpace(id) } +func virtualMachineSourceID(vm VirtualMachine) string { + if id := strings.TrimSpace(vm.ID); id != "" { + return "vm:" + id + } + return "vm:" + virtualMachineDisplayName(vm) +} + func diskSourceID(name string) string { return "disk:" + strings.TrimSpace(name) } @@ -1227,6 +1260,13 @@ func appCanonicalID(app App) string { return appDisplayName(app) } +func virtualMachineDisplayName(vm VirtualMachine) string { + if name := strings.TrimSpace(vm.Name); name != "" { + return name + } + return strings.TrimSpace(vm.ID) +} + func findAppInSnapshot(snapshot *FixtureSnapshot, appID string) (*App, error) { if snapshot == nil { return nil, fmt.Errorf("truenas snapshot is unavailable") @@ -1470,6 +1510,41 @@ func trueNASAppNetworksFromAppNetworks(networks []AppNetwork) []unifiedresources return out } +func trueNASVMDataFromVirtualMachine(vm VirtualMachine) *unifiedresources.TrueNASVM { + return &unifiedresources.TrueNASVM{ + ID: strings.TrimSpace(vm.ID), + Name: strings.TrimSpace(vm.Name), + Description: strings.TrimSpace(vm.Description), + State: strings.TrimSpace(vm.State), + DomainState: strings.TrimSpace(vm.DomainState), + PID: vm.PID, + VCPUs: vm.VCPUs, + Cores: vm.Cores, + Threads: vm.Threads, + MemoryBytes: vm.MemoryBytes, + MinMemoryBytes: vm.MinMemoryBytes, + CPUMode: strings.TrimSpace(vm.CPUMode), + CPUModel: strings.TrimSpace(vm.CPUModel), + Bootloader: strings.TrimSpace(vm.Bootloader), + Autostart: vm.Autostart, + SuspendOnSnapshot: vm.SuspendOnSnapshot, + TrustedPlatformModule: vm.TrustedPlatformModule, + SecureBoot: vm.SecureBoot, + Time: strings.TrimSpace(vm.Time), + ArchType: strings.TrimSpace(vm.ArchType), + MachineType: strings.TrimSpace(vm.MachineType), + UUID: strings.TrimSpace(vm.UUID), + DisplayAvailable: vm.DisplayAvailable, + DeviceCount: vm.DeviceCount, + DiskCount: vm.DiskCount, + NICCount: vm.NICCount, + DisplayCount: vm.DisplayCount, + CDROMCount: vm.CDROMCount, + USBCount: vm.USBCount, + PCICount: vm.PCICount, + } +} + func copyStringLabels(labels map[string]string) map[string]string { if len(labels) == 0 { return nil @@ -1511,6 +1586,43 @@ func statusFromApp(app App) unifiedresources.ResourceStatus { } } +func statusFromVirtualMachine(vm VirtualMachine) unifiedresources.ResourceStatus { + state := strings.ToUpper(strings.TrimSpace(vm.State)) + if state == "" { + state = strings.ToUpper(strings.TrimSpace(vm.DomainState)) + } + switch state { + case "RUNNING", "ACTIVE": + return unifiedresources.StatusOnline + case "STOPPED", "SHUTOFF", "SHUTDOWN", "POWEROFF": + return unifiedresources.StatusOffline + case "PAUSED", "SUSPENDED", "ERROR", "CRASHED", "PANICKED": + return unifiedresources.StatusWarning + default: + return unifiedresources.StatusUnknown + } +} + +func virtualMachineTags(vm VirtualMachine) []string { + tags := []string{"truenas", "vm"} + if state := strings.ToLower(strings.TrimSpace(vm.State)); state != "" { + tags = append(tags, "state:"+state) + } + if bootloader := strings.ToLower(strings.TrimSpace(vm.Bootloader)); bootloader != "" { + tags = append(tags, "boot:"+bootloader) + } + if vm.Autostart { + tags = append(tags, "autostart") + } + if vm.TrustedPlatformModule { + tags = append(tags, "tpm") + } + if vm.SecureBoot { + tags = append(tags, "secure-boot") + } + return dedupeStrings(tags) +} + func truenasAppCapabilities() []unifiedresources.ResourceCapability { return []unifiedresources.ResourceCapability{ { @@ -1797,6 +1909,7 @@ func copyFixtureSnapshot(snapshot *FixtureSnapshot) *FixtureSnapshot { copied.Disks = append([]Disk(nil), snapshot.Disks...) copied.Alerts = append([]Alert(nil), snapshot.Alerts...) copied.Apps = cloneApps(snapshot.Apps) + copied.VMs = append([]VirtualMachine(nil), snapshot.VMs...) copied.ZFSSnapshots = append([]ZFSSnapshot(nil), snapshot.ZFSSnapshots...) copied.ReplicationTasks = append([]ReplicationTask(nil), snapshot.ReplicationTasks...) return &copied diff --git a/internal/truenas/provider_test.go b/internal/truenas/provider_test.go index 81a267175..10611bee6 100644 --- a/internal/truenas/provider_test.go +++ b/internal/truenas/provider_test.go @@ -740,6 +740,64 @@ func TestRecordsIncludeTrueNASAppsAsCanonicalWorkloads(t *testing.T) { } } +func TestRecordsIncludeTrueNASVMsAsCanonicalWorkloads(t *testing.T) { + previous := IsFeatureEnabled() + SetFeatureEnabled(true) + t.Cleanup(func() { + SetFeatureEnabled(previous) + }) + + provider := NewProvider(DefaultFixtures()) + records := provider.Records() + if len(records) == 0 { + t.Fatal("expected fixture records from provider") + } + + var windows *unifiedresources.IngestRecord + var ubuntu *unifiedresources.IngestRecord + for i := range records { + record := &records[i] + if record.Resource.Type != unifiedresources.ResourceTypeVM { + continue + } + switch record.Resource.Name { + case "windows-lab": + windows = record + case "ubuntu-build": + ubuntu = record + } + } + + if windows == nil || ubuntu == nil { + t.Fatalf("expected TrueNAS VM records for windows-lab and ubuntu-build") + } + if windows.ParentSourceID != "system:truenas-main" { + t.Fatalf("expected windows-lab parent system:truenas-main, got %q", windows.ParentSourceID) + } + if windows.Resource.TrueNAS == nil || windows.Resource.TrueNAS.VM == nil { + t.Fatal("expected windows-lab native TrueNAS VM metadata") + } + if windows.Resource.TrueNAS.VM.ID != "42" || windows.Resource.TrueNAS.VM.Name != "windows-lab" { + t.Fatalf("unexpected windows-lab native VM identity: %+v", windows.Resource.TrueNAS.VM) + } + if windows.Resource.TrueNAS.VM.VCPUs != 4 || windows.Resource.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 { + t.Fatalf("unexpected windows-lab native VM compute metadata: %+v", windows.Resource.TrueNAS.VM) + } + if !windows.Resource.TrueNAS.VM.Autostart || !windows.Resource.TrueNAS.VM.SecureBoot || !windows.Resource.TrueNAS.VM.TrustedPlatformModule { + t.Fatalf("expected windows-lab native VM flags, got %+v", windows.Resource.TrueNAS.VM) + } + if windows.Resource.Status != unifiedresources.StatusOnline { + t.Fatalf("expected windows-lab status online, got %q", windows.Resource.Status) + } + + if ubuntu.Resource.Status != unifiedresources.StatusOffline { + t.Fatalf("expected ubuntu-build status offline, got %q", ubuntu.Resource.Status) + } + if ubuntu.Resource.TrueNAS == nil || ubuntu.Resource.TrueNAS.VM == nil || ubuntu.Resource.TrueNAS.VM.DomainState != "SHUTOFF" { + t.Fatalf("expected ubuntu-build native VM domain state, got %+v", ubuntu.Resource.TrueNAS) + } +} + func TestRecordsElevateOnlineDiskWhenTemperatureCritical(t *testing.T) { previous := IsFeatureEnabled() SetFeatureEnabled(true) diff --git a/internal/truenas/types.go b/internal/truenas/types.go index 16dda380b..b4a087cf2 100644 --- a/internal/truenas/types.go +++ b/internal/truenas/types.go @@ -11,6 +11,7 @@ type FixtureSnapshot struct { Disks []Disk Alerts []Alert Apps []App + VMs []VirtualMachine ZFSSnapshots []ZFSSnapshot ReplicationTasks []ReplicationTask } @@ -216,6 +217,41 @@ type AppNetwork struct { Labels map[string]string } +// VirtualMachine mirrors the subset of TrueNAS VM fields needed for unified +// workload mapping from vm.query. +type VirtualMachine struct { + ID string + Name string + Description string + State string + DomainState string + PID int + VCPUs int + Cores int + Threads int + MemoryBytes int64 + MinMemoryBytes int64 + CPUMode string + CPUModel string + Bootloader string + Autostart bool + SuspendOnSnapshot bool + TrustedPlatformModule bool + SecureBoot bool + Time string + ArchType string + MachineType string + UUID string + DisplayAvailable bool + DeviceCount int + DiskCount int + NICCount int + DisplayCount int + CDROMCount int + USBCount int + PCICount int +} + // ZFSSnapshot mirrors the subset of snapshot fields needed for recovery-point mapping. type ZFSSnapshot struct { ID string diff --git a/internal/unifiedresources/canonical_identity_test.go b/internal/unifiedresources/canonical_identity_test.go index 6e4217d3b..b91af844e 100644 --- a/internal/unifiedresources/canonical_identity_test.go +++ b/internal/unifiedresources/canonical_identity_test.go @@ -569,3 +569,45 @@ func TestRefreshCanonicalIdentityPreservesTrueNASAppContainerIdentity(t *testing } } } + +func TestRefreshCanonicalIdentityPreservesTrueNASVMIdentity(t *testing.T) { + resource := Resource{ + ID: "truenas-vm-windows-lab", + Type: ResourceTypeVM, + Name: "windows-lab", + TrueNAS: &TrueNASData{ + Hostname: "truenas-a.local", + VM: &TrueNASVM{ + ID: "42", + Name: "windows-lab", + UUID: "vm-uuid-1", + }, + }, + MetricsTarget: &MetricsTarget{ + ResourceType: string(ResourceTypeVM), + ResourceID: "42", + }, + } + + if got := ContractResourceType(resource); got != ResourceTypeVM { + t.Fatalf("ContractResourceType = %q, want %q", got, ResourceTypeVM) + } + + RefreshCanonicalIdentity(&resource) + + if resource.Canonical == nil { + t.Fatalf("expected canonical identity") + } + if got := resource.Canonical.DisplayName; got != "windows-lab" { + t.Fatalf("displayName = %q, want windows-lab", got) + } + if got := resource.Canonical.Hostname; got != "truenas-a.local" { + t.Fatalf("hostname = %q, want truenas-a.local", got) + } + if got := resource.Canonical.PlatformID; got != "truenas-a.local" { + t.Fatalf("platformId = %q, want truenas-a.local", got) + } + if got := resource.Canonical.PrimaryID; got != "vm:42" { + t.Fatalf("primaryId = %q, want vm:42", got) + } +} diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index f440d3590..37f746d0d 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -358,6 +358,7 @@ func cloneTrueNASData(in *TrueNASData) *TrueNASData { out := *in out.StorageRisk = cloneStorageRisk(in.StorageRisk) out.App = cloneTrueNASApp(in.App) + out.VM = cloneTrueNASVM(in.VM) return &out } @@ -416,6 +417,14 @@ func cloneTrueNASAppNetworks(in []TrueNASAppNetwork) []TrueNASAppNetwork { return out } +func cloneTrueNASVM(in *TrueNASVM) *TrueNASVM { + if in == nil { + return nil + } + out := *in + return &out +} + func cloneCPUInfo(in *CPUInfo) *CPUInfo { if in == nil { return nil diff --git a/internal/unifiedresources/metrics_targets.go b/internal/unifiedresources/metrics_targets.go index 1895d8fd0..2e9dd3cf3 100644 --- a/internal/unifiedresources/metrics_targets.go +++ b/internal/unifiedresources/metrics_targets.go @@ -44,6 +44,11 @@ func BuildMetricsTarget(resource Resource, sourceTargets []SourceTarget) *Metric if st, ok := bySource[SourceVMware]; ok { return &MetricsTarget{ResourceType: "vm", ResourceID: st.SourceID} } + if st, ok := bySource[SourceTrueNAS]; ok { + if resourceID := canonicalTrueNASVMMetricID(st.SourceID); resourceID != "" { + return &MetricsTarget{ResourceType: "vm", ResourceID: resourceID} + } + } case ResourceTypeSystemContainer: if st, ok := bySource[SourceProxmox]; ok { return &MetricsTarget{ResourceType: "system-container", ResourceID: st.SourceID} @@ -149,6 +154,12 @@ func canonicalAppContainerMetricID(sourceID string) string { return strings.TrimSpace(trimmed) } +func canonicalTrueNASVMMetricID(sourceID string) string { + trimmed := strings.TrimSpace(sourceID) + trimmed = strings.TrimPrefix(trimmed, "vm:") + return strings.TrimSpace(trimmed) +} + func canonicalAgentMetricID(sourceID string) string { trimmed := strings.TrimSpace(sourceID) trimmed = strings.TrimPrefix(trimmed, "system:") diff --git a/internal/unifiedresources/metrics_targets_test.go b/internal/unifiedresources/metrics_targets_test.go index 5be832817..d9180196d 100644 --- a/internal/unifiedresources/metrics_targets_test.go +++ b/internal/unifiedresources/metrics_targets_test.go @@ -389,6 +389,27 @@ func TestBuildMetricsTarget_UsesCanonicalAgentMetricIDForTrueNAS(t *testing.T) { } } +func TestBuildMetricsTarget_UsesCanonicalVMMetricIDForTrueNAS(t *testing.T) { + target := BuildMetricsTarget( + Resource{ + Type: ResourceTypeVM, + }, + []SourceTarget{{ + Source: SourceTrueNAS, + SourceID: " vm:42 ", + }}, + ) + if target == nil { + t.Fatal("BuildMetricsTarget() returned nil") + } + if target.ResourceType != "vm" { + t.Fatalf("ResourceType = %q, want vm", target.ResourceType) + } + if target.ResourceID != "42" { + t.Fatalf("ResourceID = %q, want 42", target.ResourceID) + } +} + func TestMetricsFromKubernetesDeployment_NilOutsideMockMode(t *testing.T) { mockruntime.SetEnabled(false) got := metricsFromKubernetesDeployment( diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index 05c6c9d7e..acf4d8ce5 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -1559,6 +1559,9 @@ func mergeTrueNASData(existing *TrueNASData, incoming *TrueNASData) *TrueNASData if incoming.App != nil { merged.App = cloneTrueNASApp(incoming.App) } + if incoming.VM != nil { + merged.VM = cloneTrueNASVM(incoming.VM) + } return &merged } diff --git a/internal/unifiedresources/registry_merge_policy_test.go b/internal/unifiedresources/registry_merge_policy_test.go index 8490ffc18..efc1a5139 100644 --- a/internal/unifiedresources/registry_merge_policy_test.go +++ b/internal/unifiedresources/registry_merge_policy_test.go @@ -165,6 +165,13 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) { }, }, }, + VM: &TrueNASVM{ + ID: "42", + Name: "windows-lab", + State: "RUNNING", + VCPUs: 4, + MemoryBytes: 8 * 1024 * 1024 * 1024, + }, } merged := mergeTrueNASData(existing, incoming) @@ -180,6 +187,12 @@ func TestMergeTrueNASDataPreservesNativeAppFacetAsClone(t *testing.T) { if got := merged.App.ID; got != "nextcloud" { t.Fatalf("app id = %q, want nextcloud", got) } + if merged.VM == nil || merged.VM.ID != "42" || merged.VM.Name != "windows-lab" { + t.Fatalf("unexpected merged TrueNAS VM facet: %+v", merged.VM) + } + if merged.VM == incoming.VM { + t.Fatal("expected merged TrueNAS VM facet to be cloned") + } incoming.App.Images[0] = "mutated:latest" incoming.App.Volumes[0].Source = "mutated" diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 1a6f74581..574542ec1 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -1040,6 +1040,7 @@ type TrueNASData struct { RebuildInProgress bool `json:"rebuildInProgress,omitempty"` RebuildSummary string `json:"rebuildSummary,omitempty"` App *TrueNASApp `json:"app,omitempty"` + VM *TrueNASVM `json:"vm,omitempty"` } // TrueNASApp contains app.query / active_workloads data for one TrueNAS app. @@ -1108,6 +1109,40 @@ type TrueNASAppNetwork struct { Labels map[string]string `json:"labels,omitempty"` } +// TrueNASVM contains vm.query data for one TrueNAS virtual machine. +type TrueNASVM struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + State string `json:"state,omitempty"` + DomainState string `json:"domainState,omitempty"` + PID int `json:"pid,omitempty"` + VCPUs int `json:"vcpus,omitempty"` + Cores int `json:"cores,omitempty"` + Threads int `json:"threads,omitempty"` + MemoryBytes int64 `json:"memoryBytes,omitempty"` + MinMemoryBytes int64 `json:"minMemoryBytes,omitempty"` + CPUMode string `json:"cpuMode,omitempty"` + CPUModel string `json:"cpuModel,omitempty"` + Bootloader string `json:"bootloader,omitempty"` + Autostart bool `json:"autostart,omitempty"` + SuspendOnSnapshot bool `json:"suspendOnSnapshot,omitempty"` + TrustedPlatformModule bool `json:"trustedPlatformModule,omitempty"` + SecureBoot bool `json:"secureBoot,omitempty"` + Time string `json:"time,omitempty"` + ArchType string `json:"archType,omitempty"` + MachineType string `json:"machineType,omitempty"` + UUID string `json:"uuid,omitempty"` + DisplayAvailable bool `json:"displayAvailable,omitempty"` + DeviceCount int `json:"deviceCount,omitempty"` + DiskCount int `json:"diskCount,omitempty"` + NICCount int `json:"nicCount,omitempty"` + DisplayCount int `json:"displayCount,omitempty"` + CDROMCount int `json:"cdromCount,omitempty"` + USBCount int `json:"usbCount,omitempty"` + PCICount int `json:"pciCount,omitempty"` +} + // AvailabilityData contains agentless endpoint probe metadata for a resource. type AvailabilityData struct { TargetID string `json:"targetId,omitempty"`