Define VM inventory export schema contract

This commit is contained in:
rcourtman 2026-03-25 22:09:37 +00:00
parent 968667330f
commit bb6571fd20
20 changed files with 455 additions and 26 deletions

View file

@ -176,6 +176,10 @@ inventory export route for reporting. Fleet and install surfaces may coexist
with that export, but `internal/api/reporting_inventory_handlers.go` and
`internal/api/router_routes_licensing.go` remain API-owned reporting transport,
not lifecycle-owned inventory or install behavior.
That adjacent reporting transport now also includes a VM inventory definition
route that owns export title, column schema, and filename prefix. Lifecycle-
adjacent install and fleet surfaces may read those facts, but they must not
redefine inventory schema locally.
That adjacent export contract now also carries canonical Proxmox pool
membership for each VM row. Lifecycle-adjacent install and fleet surfaces may
reuse those current-state facts, but they must still treat the pool column as

View file

@ -217,8 +217,11 @@ The reporting API contract now also treats current-state fleet inventory as a
first-class surface separate from historical metrics reports.
`internal/api/reporting_inventory_handlers.go`,
`internal/api/router_routes_licensing.go`, and the settings reporting shell now
own `/api/admin/reports/inventory/vms/export` as the canonical VM inventory CSV
contract. That export is intentionally spreadsheet-shaped rather than comment-
own `/api/admin/reports/inventory/vms/definition` plus
`/api/admin/reports/inventory/vms/export` as the canonical VM inventory
contract. The definition endpoint owns the operator-facing title, description,
filename prefix, and stable column schema, while the export endpoint remains
the spreadsheet-shaped CSV transport. That export is intentionally not comment-
prefixed like the legacy metrics CSV, and it now carries Proxmox pool
membership from the canonical unified VM runtime model instead of inferring or
reconstructing that field locally inside the frontend or handler.

View file

@ -174,6 +174,11 @@ historical performance reports and current-state VM inventory export.
keep those as separate operator jobs with separate request builders and success
copy, rather than collapsing inventory export back into the metrics-report
controls.
That same settings shell must now also render VM inventory export schema from
the backend-owned definition contract rather than hardcoding column copy in the
panel. The frontend model may validate and present the definition, but the
canonical title, description, filename prefix, and column list belong to the
API reporting contract.
The shared updates settings owner also defines the user-facing framing for
rc-tagged builds. `frontend-modern/src/components/Settings/updatesSettingsModel.ts`
and `frontend-modern/src/utils/updatesPresentation.ts` must present that

View file

@ -222,6 +222,10 @@ reporting surface. Storage and recovery workflows may consume similar current-
state VM facts, but `internal/api/reporting_inventory_handlers.go` and
`internal/api/router_routes_licensing.go` remain API/reporting transport
ownership rather than storage/recovery contract ownership.
That adjacent reporting transport now also includes a VM inventory definition
route that owns export title, stable column schema, and filename prefix.
Storage and recovery flows may read those facts when they need fleet context,
but they must not fork their own inventory column contract.
That adjacent export contract now also includes canonical Proxmox pool
membership for each VM row. Storage and recovery flows may use those current-
state facts when they need fleet context, but they must consume the API-owned

View file

@ -1,4 +1,4 @@
import { Show, JSX } from 'solid-js';
import { For, Show, JSX } from 'solid-js';
import FileText from 'lucide-solid/icons/file-text';
import Download from 'lucide-solid/icons/download';
import BarChart from 'lucide-solid/icons/bar-chart';
@ -55,6 +55,9 @@ export function ReportingPanel() {
generating,
handleGenerate,
handleStartTrial,
inventoryDefinition,
inventoryDefinitionError,
inventoryDefinitionLoading,
isLocked,
isReportingEnabled,
metricType,
@ -210,12 +213,34 @@ export function ReportingPanel() {
<div class="space-y-2">
<h4 class="text-base font-semibold text-base-content">VM Inventory Export</h4>
<p class="text-sm text-muted">
Export the current fleet-wide VM inventory as CSV using the canonical runtime
model. Includes VM identity, placement, CPU, memory allocation, disk allocation,
and disk usage columns.
{inventoryDefinition()?.description ??
'Export the current fleet-wide VM inventory as CSV using the canonical runtime model.'}
</p>
</div>
<Show when={inventoryDefinitionLoading()}>
<p class="text-xs text-muted">Loading export column definition...</p>
</Show>
<Show when={inventoryDefinitionError()}>
<p class="text-xs text-warning">{inventoryDefinitionError()}</p>
</Show>
<Show when={inventoryDefinition()?.columns.length}>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<For each={inventoryDefinition()?.columns ?? []}>
{(column) => (
<div class="rounded-lg border border-base-300/70 bg-base-100/70 p-3 space-y-1">
<div class="text-xs font-semibold uppercase tracking-wide text-base-content/80">
{column.label}
</div>
<p class="text-xs text-muted leading-relaxed">{column.description}</p>
</div>
)}
</For>
</div>
</Show>
<div class="flex justify-end">
<button
class={`w-full sm:w-auto flex items-center justify-center gap-2 px-6 py-3 rounded-md font-semibold transition-all ${

View file

@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
buildVMInventoryExportDefinitionRequest,
buildVMInventoryExportFilename,
buildVMInventoryExportRequest,
parseVMInventoryExportDefinition,
} from '../reportingInventoryExportModel';
describe('reporting inventory export model', () => {
@ -12,9 +14,39 @@ describe('reporting inventory export model', () => {
it('builds the canonical VM inventory export request', () => {
const now = new Date('2026-03-20T12:34:56.000Z');
const request = buildVMInventoryExportRequest(now);
const request = buildVMInventoryExportRequest(now, { filenamePrefix: 'vm-inventory' });
expect(request.filename).toBe('vm-inventory-2026-03-20.csv');
expect(request.request.url).toBe('/api/admin/reports/inventory/vms/export?format=csv');
});
it('builds the canonical VM inventory definition request', () => {
expect(buildVMInventoryExportDefinitionRequest()).toEqual({
url: '/api/admin/reports/inventory/vms/definition',
});
});
it('parses the canonical VM inventory export definition payload', () => {
const definition = parseVMInventoryExportDefinition({
id: 'vm_inventory',
title: 'VM Inventory Export',
description: 'Current-state VM inventory',
format: 'csv',
filenamePrefix: 'vm-inventory',
columns: [
{
key: 'pool',
label: 'Pool',
description: 'Canonical Proxmox pool membership.',
},
],
});
expect(definition.id).toBe('vm_inventory');
expect(definition.columns[0]).toEqual({
key: 'pool',
label: 'Pool',
description: 'Canonical Proxmox pool membership.',
});
});
});

View file

@ -1093,6 +1093,7 @@ describe('Settings architecture guardrails', () => {
expect(reportingPanelStateSource).toContain('runStartProTrialAction({');
expect(reportingPanelStateSource).not.toContain('startProTrial()');
expect(reportingPanelStateSource).toContain('buildReportingRequest');
expect(reportingPanelStateSource).toContain('buildVMInventoryExportDefinitionRequest');
expect(reportingPanelStateSource).toContain('buildVMInventoryExportRequest');
expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage');
expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()');
@ -1102,6 +1103,12 @@ describe('Settings architecture guardrails', () => {
expect(reportingInventoryExportModelSource).toContain(
'export function buildVMInventoryExportFilename',
);
expect(reportingInventoryExportModelSource).toContain(
'export function buildVMInventoryExportDefinitionRequest',
);
expect(reportingInventoryExportModelSource).toContain(
'export function parseVMInventoryExportDefinition',
);
});
it('keeps the shared operations wrapper rooted at SettingsPanel', () => {

View file

@ -5,19 +5,88 @@ export interface ReportingInventoryExportRequestDefinition {
};
}
export function buildVMInventoryExportFilename(now: Date): string {
export interface ReportingInventoryExportColumnDefinition {
key: string;
label: string;
description: string;
}
export interface ReportingInventoryExportDefinition {
id: string;
title: string;
description: string;
format: 'csv';
filenamePrefix: string;
columns: ReportingInventoryExportColumnDefinition[];
}
export function buildVMInventoryExportFilename(now: Date, filenamePrefix = 'vm-inventory'): string {
const date = now.toISOString().split('T')[0];
return `vm-inventory-${date}.csv`;
return `${filenamePrefix}-${date}.csv`;
}
export function buildVMInventoryExportDefinitionRequest(): { url: string } {
return {
url: '/api/admin/reports/inventory/vms/definition',
};
}
export function buildVMInventoryExportRequest(
now: Date,
definition?: Pick<ReportingInventoryExportDefinition, 'filenamePrefix'> | null,
): ReportingInventoryExportRequestDefinition {
const params = new URLSearchParams({ format: 'csv' });
return {
filename: buildVMInventoryExportFilename(now),
filename: buildVMInventoryExportFilename(now, definition?.filenamePrefix ?? 'vm-inventory'),
request: {
url: `/api/admin/reports/inventory/vms/export?${params.toString()}`,
},
};
}
export function parseVMInventoryExportDefinition(
input: unknown,
): ReportingInventoryExportDefinition {
if (!input || typeof input !== 'object') {
throw new Error('Invalid VM inventory export definition payload');
}
const candidate = input as Partial<ReportingInventoryExportDefinition>;
if (
typeof candidate.id !== 'string' ||
typeof candidate.title !== 'string' ||
typeof candidate.description !== 'string' ||
candidate.format !== 'csv' ||
typeof candidate.filenamePrefix !== 'string' ||
!Array.isArray(candidate.columns)
) {
throw new Error('Invalid VM inventory export definition payload');
}
const columns = candidate.columns.map((column) => {
if (
!column ||
typeof column !== 'object' ||
typeof column.key !== 'string' ||
typeof column.label !== 'string' ||
typeof column.description !== 'string'
) {
throw new Error('Invalid VM inventory export definition payload');
}
return {
key: column.key,
label: column.label,
description: column.description,
};
});
return {
id: candidate.id,
title: candidate.title,
description: candidate.description,
format: 'csv',
filenamePrefix: candidate.filenamePrefix,
columns,
};
}

View file

@ -24,7 +24,12 @@ import {
type ReportingFormat,
type ReportingRangeValue,
} from '@/components/Settings/reportingPanelModel';
import { buildVMInventoryExportRequest } from '@/components/Settings/reportingInventoryExportModel';
import {
buildVMInventoryExportDefinitionRequest,
buildVMInventoryExportRequest,
parseVMInventoryExportDefinition,
type ReportingInventoryExportDefinition,
} from '@/components/Settings/reportingInventoryExportModel';
export const useReportingPanelState = () => {
const [selectedResources, setSelectedResources] = createSignal<SelectedResource[]>([]);
@ -33,6 +38,11 @@ export const useReportingPanelState = () => {
const [range, setRange] = createSignal<ReportingRangeValue>('24h');
const [generating, setGenerating] = createSignal(false);
const [exportingInventory, setExportingInventory] = createSignal(false);
const [inventoryDefinition, setInventoryDefinition] =
createSignal<ReportingInventoryExportDefinition | null>(null);
const [inventoryDefinitionLoading, setInventoryDefinitionLoading] = createSignal(false);
const [inventoryDefinitionError, setInventoryDefinitionError] = createSignal('');
const [inventoryDefinitionRequested, setInventoryDefinitionRequested] = createSignal(false);
const [title, setTitle] = createSignal('');
const [startingTrial, setStartingTrial] = createSignal(false);
@ -53,6 +63,40 @@ export const useReportingPanelState = () => {
return visible;
}, false);
createEffect(() => {
if (
!isReportingEnabled() ||
inventoryDefinition() ||
inventoryDefinitionLoading() ||
inventoryDefinitionRequested()
) {
return;
}
void (async () => {
setInventoryDefinitionRequested(true);
setInventoryDefinitionLoading(true);
setInventoryDefinitionError('');
try {
const request = buildVMInventoryExportDefinitionRequest();
const response = await apiFetch(request.url);
if (!response.ok) {
const text = await response.text();
throw new Error(text || getReportingInventoryExportErrorMessage());
}
setInventoryDefinition(parseVMInventoryExportDefinition(await response.json()));
} catch (error) {
console.error('VM inventory export definition error:', error);
setInventoryDefinitionError(
error instanceof Error ? error.message : getReportingInventoryExportErrorMessage(),
);
} finally {
setInventoryDefinitionLoading(false);
}
})();
});
const handleStartTrial = async () => {
if (startingTrial()) return;
setStartingTrial(true);
@ -121,7 +165,7 @@ export const useReportingPanelState = () => {
setExportingInventory(true);
try {
const request = buildVMInventoryExportRequest(new Date());
const request = buildVMInventoryExportRequest(new Date(), inventoryDefinition());
const response = await apiFetch(request.request.url);
if (!response.ok) {
const text = await response.text();
@ -148,6 +192,9 @@ export const useReportingPanelState = () => {
generating,
handleGenerate,
handleStartTrial,
inventoryDefinition,
inventoryDefinitionError,
inventoryDefinitionLoading,
isLocked,
isReportingEnabled,
metricType,

View file

@ -49,6 +49,7 @@ func TestReportingEndpointsRequireSettingsReadScope(t *testing.T) {
paths := []string{
"/api/admin/reports/generate",
"/api/admin/reports/generate-multi",
"/api/admin/reports/inventory/vms/definition",
"/api/admin/reports/inventory/vms/export",
}

View file

@ -329,6 +329,93 @@ func TestContract_VMInventoryExportCSVHeaders(t *testing.T) {
}
}
func TestContract_VMInventoryExportDefinitionJSONSnapshot(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/definition", nil)
rec := httptest.NewRecorder()
handler.HandleGetVMInventoryDefinition(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Fatalf("expected json content type, got %q", got)
}
const want = `{
"id":"vm_inventory",
"title":"VM Inventory Export",
"description":"Export the current fleet-wide VM inventory as CSV using the canonical runtime model. Includes VM identity, placement, CPU, memory allocation, disk allocation, and disk usage columns.",
"format":"csv",
"filenamePrefix":"vm-inventory",
"columns":[
{
"key":"resource_id",
"label":"Resource ID",
"description":"Canonical Pulse resource ID for the VM."
},
{
"key":"instance",
"label":"Instance",
"description":"Configured Proxmox instance or cluster name."
},
{
"key":"node",
"label":"Node",
"description":"Proxmox node currently hosting the VM."
},
{
"key":"pool",
"label":"Pool",
"description":"Canonical Proxmox pool membership when the platform reports one."
},
{
"key":"vmid",
"label":"VMID",
"description":"Numeric Proxmox VM identifier."
},
{
"key":"vm_name",
"label":"VM Name",
"description":"Current VM display name from the runtime model."
},
{
"key":"status",
"label":"Status",
"description":"Canonical runtime status for the VM."
},
{
"key":"cpu_cores",
"label":"CPU Cores",
"description":"Allocated virtual CPU core count."
},
{
"key":"memory_allocated_bytes",
"label":"Memory Allocated Bytes",
"description":"Configured memory allocation in bytes."
},
{
"key":"disk_allocated_bytes",
"label":"Disk Allocated Bytes",
"description":"Total allocated disk capacity in bytes across the VM."
},
{
"key":"disk_used_bytes",
"label":"Disk Used Bytes",
"description":"Current used disk bytes from the canonical runtime disk view."
},
{
"key":"disk_status_reason",
"label":"Disk Status Reason",
"description":"Reason disk usage is partial or unavailable when the runtime cannot provide a full guest view."
}
]
}`
assertJSONSnapshot(t, rec.Body.Bytes(), want)
}
func TestContract_HostedTenantAISettingsFallbackJSONSnapshot(t *testing.T) {
t.Setenv("PULSE_HOSTED_MODE", "true")

View file

@ -10,6 +10,7 @@ import (
type testReportingAdminEndpoints struct {
generateCalls int
definitionCalls int
exportInventoryCalls int
}
@ -19,6 +20,10 @@ func (t *testReportingAdminEndpoints) HandleGenerateReport(http.ResponseWriter,
func (t *testReportingAdminEndpoints) HandleGenerateMultiReport(http.ResponseWriter, *http.Request) {}
func (t *testReportingAdminEndpoints) HandleGetVMInventoryDefinition(http.ResponseWriter, *http.Request) {
t.definitionCalls++
}
func (t *testReportingAdminEndpoints) HandleExportVMInventory(http.ResponseWriter, *http.Request) {
t.exportInventoryCalls++
}
@ -79,3 +84,19 @@ func TestResolveReportingAdminEndpoints_UsesDefaultInventoryHandler(t *testing.T
t.Fatalf("expected default VM inventory handler call, got %d", defaults.exportInventoryCalls)
}
}
func TestResolveReportingAdminEndpoints_UsesDefaultInventoryDefinitionHandler(t *testing.T) {
SetReportingAdminEndpointsBinder(nil)
t.Cleanup(func() {
SetReportingAdminEndpointsBinder(nil)
})
defaults := &testReportingAdminEndpoints{}
resolved := resolveReportingAdminEndpoints(defaults, extensions.ReportingAdminRuntime{})
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/definition", nil)
rec := httptest.NewRecorder()
resolved.HandleGetVMInventoryDefinition(rec, req)
if defaults.definitionCalls != 1 {
t.Fatalf("expected default VM inventory definition handler call, got %d", defaults.definitionCalls)
}
}

View file

@ -39,6 +39,7 @@ func TestReportingEndpointsRequireAuthInAPIMode(t *testing.T) {
}{
{method: http.MethodGet, path: "/api/admin/reports/generate", body: ""},
{method: http.MethodPost, path: "/api/admin/reports/generate-multi", body: `{}`},
{method: http.MethodGet, path: "/api/admin/reports/inventory/vms/definition", body: ""},
{method: http.MethodGet, path: "/api/admin/reports/inventory/vms/export", body: ""},
}

View file

@ -237,6 +237,44 @@ func TestReportingHandlers_ExportVMInventory_MethodNotAllowed(t *testing.T) {
}
}
func TestReportingHandlers_GetVMInventoryDefinition_MethodNotAllowed(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/inventory/vms/definition", nil)
rr := httptest.NewRecorder()
handler.HandleGetVMInventoryDefinition(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
}
}
func TestReportingHandlers_GetVMInventoryDefinition_ReturnsCanonicalDefinition(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/definition", nil)
rr := httptest.NewRecorder()
handler.HandleGetVMInventoryDefinition(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
}
if contentType := rr.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
t.Fatalf("expected JSON content type, got %q", contentType)
}
var payload reporting.VMInventoryExportDefinition
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
t.Fatalf("decode definition payload: %v", err)
}
if payload.ID != "vm_inventory" || payload.Format != reporting.FormatCSV {
t.Fatalf("unexpected VM inventory definition payload: %+v", payload)
}
if len(payload.Columns) == 0 || payload.Columns[3].Label != "Pool" {
t.Fatalf("expected canonical Pool column in definition payload, got %+v", payload.Columns)
}
}
func TestReportingHandlers_ExportVMInventory_InvalidFormat(t *testing.T) {
handler := NewReportingHandlers(nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export?format=pdf", nil)

View file

@ -1,6 +1,7 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
@ -9,6 +10,18 @@ import (
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
)
// HandleGetVMInventoryDefinition returns the canonical operator-facing
// definition for the VM inventory export surface.
func (h *ReportingHandlers) HandleGetVMInventoryDefinition(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(reporting.DescribeVMInventoryExport())
}
// HandleExportVMInventory exports the current VM inventory as spreadsheet-friendly CSV.
func (h *ReportingHandlers) HandleExportVMInventory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {

View file

@ -449,6 +449,7 @@ var allRouteAllowlist = []string{
"POST /api/admin/rbac/reset-admin",
"/api/admin/reports/generate",
"/api/admin/reports/generate-multi",
"/api/admin/reports/inventory/vms/definition",
"/api/admin/reports/inventory/vms/export",
"/api/admin/webhooks/audit",
"/api/security/change-password",

View file

@ -189,6 +189,12 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan
}
RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsRead, reportingAdminEndpoints.HandleGenerateMultiReport))(w, req)
}))
r.mux.HandleFunc("/api/admin/reports/inventory/vms/definition", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) {
if !ensureAdminSession(r.config, w, req) {
return
}
RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsRead, reportingAdminEndpoints.HandleGetVMInventoryDefinition))(w, req)
}))
r.mux.HandleFunc("/api/admin/reports/inventory/vms/export", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) {
if !ensureAdminSession(r.config, w, req) {
return
@ -309,6 +315,14 @@ func (a reportingAdminEndpointAdapter) HandleGenerateMultiReport(w http.Response
a.handlers.HandleGenerateMultiReport(w, req)
}
func (a reportingAdminEndpointAdapter) HandleGetVMInventoryDefinition(w http.ResponseWriter, req *http.Request) {
if a.handlers == nil {
writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil)
return
}
a.handlers.HandleGetVMInventoryDefinition(w, req)
}
func (a reportingAdminEndpointAdapter) HandleExportVMInventory(w http.ResponseWriter, req *http.Request) {
if a.handlers == nil {
writeErrorResponse(w, http.StatusNotImplemented, "reporting_unavailable", "Reporting is not available", nil)

View file

@ -12,6 +12,7 @@ import (
type ReportingAdminEndpoints interface {
HandleGenerateReport(http.ResponseWriter, *http.Request)
HandleGenerateMultiReport(http.ResponseWriter, *http.Request)
HandleGetVMInventoryDefinition(http.ResponseWriter, *http.Request)
HandleExportVMInventory(http.ResponseWriter, *http.Request)
}

View file

@ -9,6 +9,25 @@ import (
"time"
)
// InventoryExportColumnDefinition describes one stable column in a current-state
// inventory export contract.
type InventoryExportColumnDefinition struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
}
// VMInventoryExportDefinition describes the operator-facing contract for the VM
// inventory export surface.
type VMInventoryExportDefinition struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Format ReportFormat `json:"format"`
FilenamePrefix string `json:"filenamePrefix"`
Columns []InventoryExportColumnDefinition `json:"columns"`
}
// VMInventoryData captures the current-state VM inventory export payload.
type VMInventoryData struct {
GeneratedAt time.Time
@ -31,19 +50,39 @@ type VMInventoryRow struct {
DiskStatusReason string
}
var vmInventoryCSVHeaders = []string{
"Resource ID",
"Instance",
"Node",
"Pool",
"VMID",
"VM Name",
"Status",
"CPU Cores",
"Memory Allocated Bytes",
"Disk Allocated Bytes",
"Disk Used Bytes",
"Disk Status Reason",
// DescribeVMInventoryExport returns the canonical operator-facing definition for
// the current-state VM inventory CSV surface.
func DescribeVMInventoryExport() VMInventoryExportDefinition {
return VMInventoryExportDefinition{
ID: "vm_inventory",
Title: "VM Inventory Export",
Description: "Export the current fleet-wide VM inventory as CSV using the canonical runtime model. Includes VM identity, placement, CPU, memory allocation, disk allocation, and disk usage columns.",
Format: FormatCSV,
FilenamePrefix: "vm-inventory",
Columns: []InventoryExportColumnDefinition{
{Key: "resource_id", Label: "Resource ID", Description: "Canonical Pulse resource ID for the VM."},
{Key: "instance", Label: "Instance", Description: "Configured Proxmox instance or cluster name."},
{Key: "node", Label: "Node", Description: "Proxmox node currently hosting the VM."},
{Key: "pool", Label: "Pool", Description: "Canonical Proxmox pool membership when the platform reports one."},
{Key: "vmid", Label: "VMID", Description: "Numeric Proxmox VM identifier."},
{Key: "vm_name", Label: "VM Name", Description: "Current VM display name from the runtime model."},
{Key: "status", Label: "Status", Description: "Canonical runtime status for the VM."},
{Key: "cpu_cores", Label: "CPU Cores", Description: "Allocated virtual CPU core count."},
{Key: "memory_allocated_bytes", Label: "Memory Allocated Bytes", Description: "Configured memory allocation in bytes."},
{Key: "disk_allocated_bytes", Label: "Disk Allocated Bytes", Description: "Total allocated disk capacity in bytes across the VM."},
{Key: "disk_used_bytes", Label: "Disk Used Bytes", Description: "Current used disk bytes from the canonical runtime disk view."},
{Key: "disk_status_reason", Label: "Disk Status Reason", Description: "Reason disk usage is partial or unavailable when the runtime cannot provide a full guest view."},
},
}
}
func vmInventoryCSVHeaders() []string {
definition := DescribeVMInventoryExport()
headers := make([]string, 0, len(definition.Columns))
for _, column := range definition.Columns {
headers = append(headers, column.Label)
}
return headers
}
// GenerateVMInventoryCSV renders a spreadsheet-friendly CSV export for the
@ -52,7 +91,7 @@ func GenerateVMInventoryCSV(data VMInventoryData) ([]byte, error) {
var buf bytes.Buffer
w := csv.NewWriter(&buf)
if err := w.Write(vmInventoryCSVHeaders); err != nil {
if err := w.Write(vmInventoryCSVHeaders()); err != nil {
return nil, fmt.Errorf("write VM inventory CSV header: %w", err)
}

View file

@ -5,6 +5,23 @@ import (
"testing"
)
func TestDescribeVMInventoryExport_DefinesCanonicalColumns(t *testing.T) {
definition := DescribeVMInventoryExport()
if definition.ID != "vm_inventory" {
t.Fatalf("definition ID = %q, want vm_inventory", definition.ID)
}
if definition.Format != FormatCSV {
t.Fatalf("definition format = %q, want %q", definition.Format, FormatCSV)
}
if got := len(definition.Columns); got != 12 {
t.Fatalf("definition columns = %d, want 12", got)
}
if definition.Columns[3].Key != "pool" || definition.Columns[3].Label != "Pool" {
t.Fatalf("expected canonical Pool column at index 3, got %+v", definition.Columns[3])
}
}
func TestGenerateVMInventoryCSV_SortsRowsAndWritesHeader(t *testing.T) {
data := VMInventoryData{
Rows: []VMInventoryRow{