From 37454ef47808adbe1cab8df07c7e8066811cbe4b Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:24:56 +0100 Subject: [PATCH] Schedule the existing VM inventory CSV export Reuse the canonical inventory exporter and report delivery workflow so operators can email or save a current organization-wide inventory weekly or monthly. Keep delivery checks, bounded retries and recovery states shared, with existing pricing and customer entitlements unchanged. Refresh the shared inventory for unattended reads and rebind reporting after monitor reload so schedules use the current infrastructure state. --- .../v6/internal/subsystems/agent-lifecycle.md | 4 + .../v6/internal/subsystems/api-contracts.md | 22 +++ .../subsystems/frontend-primitives.md | 6 + .../v6/internal/subsystems/monitoring.md | 9 ++ .../v6/internal/subsystems/registry.json | 50 ++++++ .../internal/subsystems/storage-recovery.md | 4 + .../b94d2555bb2c0bb4.json | 56 +++++++ .../Settings/ReportScheduleEditor.tsx | 58 ++++--- .../components/Settings/ReportingPanel.tsx | 4 +- .../__tests__/ReportingPanel.test.tsx | 29 +++- .../reportingSchedulesModel.inventory.test.ts | 62 ++++++++ .../__tests__/useReportingPanelState.test.ts | 30 ++++ .../Settings/reportingSchedulesModel.ts | 21 ++- .../Settings/useReportingPanelState.ts | 4 +- internal/api/metrics_reporting_handlers.go | 32 +++- internal/api/report_schedule_delivery_test.go | 2 +- .../api/report_schedule_inventory_test.go | 145 ++++++++++++++++++ internal/api/report_schedules.go | 82 ++++++---- internal/api/reporting_availability.go | 5 +- internal/api/reporting_inventory_handlers.go | 26 +++- internal/api/reporting_monitor_reload_test.go | 50 ++++++ .../api/reporting_runtime_snapshot_test.go | 16 +- internal/api/router_helpers.go | 3 + .../config/report_schedule_delivery_test.go | 10 ++ internal/config/report_schedules.go | 1 + .../monitoring/resource_stale_thresholds.go | 24 +++ .../unified_inventory_read_refresh_test.go | 47 ++++++ .../canonical_completion_guard_test.py | 33 ++++ 28 files changed, 752 insertions(+), 83 deletions(-) create mode 100644 frontend-modern/browser-verification/b94d2555bb2c0bb4.json create mode 100644 frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.inventory.test.ts create mode 100644 internal/api/report_schedule_inventory_test.go create mode 100644 internal/api/reporting_monitor_reload_test.go create mode 100644 internal/monitoring/unified_inventory_read_refresh_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 3e0912a430..2043fecd69 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -5751,6 +5751,10 @@ whose nested VM inventory definition owns panel copy, performance report options, export title, column schema, and filename prefixes. Lifecycle- adjacent install and fleet surfaces may read those facts, but they must not redefine reporting or inventory schema locally. +The adjacent `vm_inventory` schedule kind uses this same current-state VM +exporter through the reporting scheduler. Agent lifecycle does not own a +parallel inventory report or delivery queue, and agent/installation rights +are unchanged. That catalog route is intentionally metadata-readable without the `advanced_reporting` feature gate so locked admin reporting shells can stay on the same API-owned definition before upsell; lifecycle-adjacent surfaces must diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index cff38113b2..12393b8673 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -12126,6 +12126,28 @@ Proofs: `internal/api/report_schedule_delivery_test.go`, `internal/config/report_schedule_delivery_test.go`, `internal/notifications/email_delivery_test.go` and the Reports browser receipt. +The additive `vm_inventory` schedule kind uses the same current-state unified +VM projection and CSV generator as the on-demand inventory export. It includes +all VMs in the current organization on each attempt, including newly discovered +VMs, and has no historical window or performance-report resource cap. Only CSV +and an empty resource/tag scope are accepted. Weekly/monthly cadence, file +retention, notification preflight, delivery outcomes, bounded retry, deliberate +uncertain-delivery recovery and current `advanced_reporting` entitlement checks +are shared with other file reports. An unavailable organization snapshot fails +instead of exporting the default organization or pretending an empty inventory +is a successful read. This changes no prices or existing grants. +`internal/api/report_schedule_inventory_test.go` covers export equivalence, +inventories above fifty VMs, later discovery, validation, retry after reload and +unavailable-organization/current-entitlement failure. +The monitor-owned canonical snapshot read refreshes its shared resource +projection for unattended consumers. Neither export nor scheduling calls the +frontend state endpoint to make inventory appear. +`Router.SetMultiTenantMonitor` also replaces reporting's manager after runtime +reload. Reporting captures that dependency atomically for each read, so exports, +schedule persistence and retention checks do not keep using a stopped monitor. +Clearing the dependency fails closed. `reporting_monitor_reload_test.go` covers +replacement inventory/persistence and reads overlapping replacement. + ### Explicit historical incident archives `internal/api/router.go` pins a read-only legacy archive to each organization's diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 09f0800370..acb0bfa22f 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5161,6 +5161,12 @@ 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. +The schedule editor also offers `vm_inventory` as a current-state CSV job. It +explains the whole-organization scope and new-VM discovery, hides historical +format/resource/tag controls and keeps weekly/monthly cadence and email/disk +delivery available. Kind switching clears stale performance selections, while +saved inventory schedules retain their kind on reload and edit. Existing +delivery checks, outcomes and recovery controls apply without a separate UI. That same settings shell must now also render both historical performance options and VM inventory schema from the backend-owned reporting catalog rather than hardcoding panel copy, routes, or range presets in the frontend. The diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 10c4eace7f..67de592286 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -7341,6 +7341,15 @@ collections. Provider callbacks run outside wiring, publication and adapter lock Read refreshes share ingest publication ordering, retain fresh-generation skips and never queue behind a warm ingest; a cold read still builds its first registry. +Canonical unified snapshot reads also enter this same read-refresh boundary, +with a cheap fresh-store check before taking a state copy. Unattended inventory +and reporting consumers must not depend on a dashboard `/api/state` request to +publish polled VM data. Mock fixture projections and stores without read-refresh +support keep their existing read semantics. The refresh retains the existing +provider, tenant, wiring and mode fences and warm non-blocking publication gate. +`unified_inventory_read_refresh_test.go` covers headless VM publication and a +warm read contending with ingest publication. + `resource_publication_ownership_test.go` supplies deterministic channel barriers, consume-once replacement-store controls, refused and skipped drains, read-provider re-entry, and duplicate/loss assertions, alongside the original initialization, diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 374de536ee..459a4a8d1e 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -3250,10 +3250,46 @@ "test_prefixes": [], "exact_files": [ "internal/api/report_schedule_delivery_test.go", + "internal/api/report_schedule_inventory_test.go", "internal/api/report_schedules_test.go", "internal/config/report_schedule_delivery_test.go" ] }, + { + "id": "reporting-runtime-reads", + "label": "reporting inventory, enrichment and reload proof", + "match_prefixes": [], + "match_files": [ + "internal/api/metrics_reporting_handlers.go", + "internal/api/reporting_availability.go", + "internal/api/reporting_inventory_handlers.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/api/report_schedule_inventory_test.go", + "internal/api/reporting_availability_test.go", + "internal/api/reporting_handlers_test.go", + "internal/api/reporting_monitor_reload_test.go", + "internal/api/reporting_org_scope_test.go", + "internal/api/reporting_runtime_snapshot_test.go" + ] + }, + { + "id": "router-runtime-wiring", + "label": "router dependency replacement proof", + "match_prefixes": [], + "match_files": [ + "internal/api/router_helpers.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/api/multi_tenant_setters_additional_test.go", + "internal/api/reporting_monitor_reload_test.go", + "internal/api/router_helpers_more_test.go" + ] + }, { "id": "backend-payload-contracts", "label": "backend API payload proof", @@ -6631,6 +6667,20 @@ "internal/monitoring/monitoring_fleet_doctor_helpers_branchcov0716_test.go" ] }, + { + "id": "unified-read-freshness", + "label": "canonical resource freshness and unattended read proof", + "match_prefixes": [], + "match_files": [ + "internal/monitoring/resource_stale_thresholds.go" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "internal/monitoring/resource_stale_thresholds_test.go", + "internal/monitoring/unified_inventory_read_refresh_test.go" + ] + }, { "id": "monitoring-runtime", "label": "monitoring runtime proof", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6d8b65b6bc..990b2c7958 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -4966,6 +4966,10 @@ whose nested VM inventory definition owns panel copy, 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 reporting or inventory column contract. +The API-owned `vm_inventory` schedule kind reuses that same exporter and the +reporting-owned email/disk delivery and retry path. Current-state inventory +scheduling does not add storage history, change retention entitlements or +move generated artifacts out of their organization-local reporting storage. That catalog route is intentionally metadata-readable without the `advanced_reporting` feature gate so locked admin reporting shells can stay on the same API-owned definition before upsell; storage- and recovery-adjacent diff --git a/frontend-modern/browser-verification/b94d2555bb2c0bb4.json b/frontend-modern/browser-verification/b94d2555bb2c0bb4.json new file mode 100644 index 0000000000..5d0687a79b --- /dev/null +++ b/frontend-modern/browser-verification/b94d2555bb2c0bb4.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "base_sha": "3f511a5270a813ad48bbe7a74f2411fb276180be", + "verified_at": "2026-10-17T13:14:54.818Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Settings/ReportScheduleEditor.tsx", + "frontend-modern/src/components/Settings/ReportingPanel.tsx", + "frontend-modern/src/components/Settings/reportingSchedulesModel.ts", + "frontend-modern/src/components/Settings/useReportingPanelState.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Settings/ReportScheduleEditor.tsx": "e3b6eaa12d3545afda642eca4fdd527c299d0f84e2dec68da6dffca184c471ac", + "frontend-modern/src/components/Settings/ReportingPanel.tsx": "5af4982de629b41375934efe399fa46ed103c32276ee306ae4743e24c460855c", + "frontend-modern/src/components/Settings/reportingSchedulesModel.ts": "1e034dcac867b7a3e1210ba25e1e3a60048ae544ada0bd7b55c96e011a2118d4", + "frontend-modern/src/components/Settings/useReportingPanelState.ts": "29ec982d667d5329609c21b5cd946df5fc89fd8b90d629c8017d3bd7baa87751" + }, + "routes": [ + "/settings/support/reporting", + "/alerts/notifications" + ], + "viewports": [ + { "width": 1440, "height": 1000 }, + { "width": 820, "height": 844 }, + { "width": 390, "height": 844 } + ], + "states": [ + "CSV is the first inventory read after normal infrastructure configuration and runtime reload, without dashboard or resources API priming; all 61 polled VMs appear", + "Empty list, focused inventory editor, fixed CSV and organization-wide scope, weekly/monthly cadence, email/disk delivery and disabled recipients", + "Missing email configuration refuses enabled schedules; a disabled draft saves and links to Notifications", + "Actual accepted MIME attachment matches the existing on-demand exporter byte for byte, including every VM beyond the performance-report cap", + "Temporary 451 failure persists across a real process restart and succeeds once on attempt 2; permanent rejection stays failed without automatic retry", + "Lost final acknowledgement remains unconfirmed; deliberate retry warns about duplication and recovers", + "A newly discovered 62nd VM enters the next disk export without changing scope; disk delivery sends no mail" + ], + "interactions": [ + "Create, edit, save, cancel, and reload; inspect name focus and switch performance/inventory kinds, weekly/monthly cadence and email/disk delivery at all three widths", + "Inspect top and delivery-controls views of the editor, including retention, disabled recipients, attachment state, enabled toggle and reachable Save/Cancel controls", + "Follow Email settings to Notifications, configure local SMTP and enable the persisted draft", + "Run actual CSV delivery, parse its MIME attachment, restart during a pending retry, and inspect accepted/retrying/rejected/unconfirmed results", + "Cancel uncertain-delivery retry without sending, then accept the duplicate warning and recover", + "Edit to disk delivery, disable, expand report details, retain expansion through polling, cancel deletion and confirm deletion" + ], + "environment": "Current private runtime built over base_sha, ordinary disposable signed Pro grant, dev/mock modes disabled. Synthetic Proxmox infrastructure and local SMTP only. Pricing and existing grants are unchanged.", + "backend_content_sha256": { + "internal/api/metrics_reporting_handlers.go": "e261d0bbb847e7512c1f8e26015da49b3f3f3f10bd61ba67bea6e5b5815ac300", + "internal/api/report_schedules.go": "6aa8315b9f5f23e885ebc8ade8ffa8caef33c83c963be57848d0dbf615ca8698", + "internal/api/reporting_availability.go": "2e23049bf046eab01bfdfccd4f6ce1babf8e7252c35525eac8a08d8e1a4102ee", + "internal/api/reporting_inventory_handlers.go": "81e3a460929bf36a02e0af770e2490acc5132131a6b9cc8b52ece771869ebdba", + "internal/api/router_helpers.go": "68b2e32beec9753b63c159f76f0ccd2fe4e5958cab80782b2238cf3d9e3ec75d", + "internal/config/report_schedules.go": "c7230c33c825a621c892958fbaecf1a530559473afeef96ddf32caa01ea2e225", + "internal/monitoring/resource_stale_thresholds.go": "2a9a5f3248a43af7f27eb4dbd75a822241ad3a7decf12bb8193864e5f749c36a" + }, + "evidence": "/opt/pulse-release-worker/task-proofs/vm-inventory-schedules-20261017/browser-evidence", + "pixel_review": "Reviewed current desktop, intermediate and mobile screenshots of editor/cadence controls, delivery controls and Save/Cancel, accepted and pending retry states, unconfirmed delivery and expanded disk details. Controls remain readable, scrollable and usable without horizontal overflow." +} diff --git a/frontend-modern/src/components/Settings/ReportScheduleEditor.tsx b/frontend-modern/src/components/Settings/ReportScheduleEditor.tsx index caaf8b722f..3e2bcdc73a 100644 --- a/frontend-modern/src/components/Settings/ReportScheduleEditor.tsx +++ b/frontend-modern/src/components/Settings/ReportScheduleEditor.tsx @@ -51,8 +51,11 @@ export default function ReportScheduleEditor(props: { state: EditorState }) { } = props.state; const performanceReport = () => reportingCatalog()?.performanceReport ?? null; const isDigestForm = () => scheduleForm().kind === 'patrol_digest'; + const isInventoryForm = () => scheduleForm().kind === 'vm_inventory'; // Existing digest schedules stay editable after Patrol is switched off. - const showReportTypeSelect = () => patrolDigestAvailable() || isDigestForm(); + const inventoryAvailable = () => !!reportingCatalog()?.vmInventoryExport || isInventoryForm(); + const showReportTypeSelect = () => + inventoryAvailable() || patrolDigestAvailable() || isDigestForm(); return (
@@ -64,11 +67,18 @@ export default function ReportScheduleEditor(props: { state: EditorState }) { help={ isDigestForm() ? 'Emails what Patrol checked, found, fixed, and spent over the last seven days. Nothing is saved to disk.' - : 'A performance report for the resources you pick, as a PDF or CSV.' + : isInventoryForm() + ? 'Exports the current inventory of all VMs in this organization as CSV each time it runs, including newly discovered VMs.' + : 'A performance report for the resources you pick, as a PDF or CSV.' } > - + + + + + + @@ -81,7 +91,13 @@ export default function ReportScheduleEditor(props: { state: EditorState }) { class={formControl} value={scheduleForm().name} onInput={(e) => updateScheduleForm({ name: e.currentTarget.value })} - placeholder={isDigestForm() ? 'Patrol weekly summary' : 'Monthly client report'} + placeholder={ + isDigestForm() + ? 'Patrol weekly summary' + : isInventoryForm() + ? 'Monthly VM inventory' + : 'Monthly client report' + } /> @@ -100,7 +116,9 @@ export default function ReportScheduleEditor(props: { state: EditorState }) { class={ isDigestForm() ? 'grid grid-cols-1 gap-4 md:grid-cols-2' - : 'grid grid-cols-1 gap-4 md:grid-cols-4' + : isInventoryForm() + ? 'grid grid-cols-1 gap-4 md:grid-cols-3' + : 'grid grid-cols-1 gap-4 md:grid-cols-4' } > @@ -152,7 +170,7 @@ export default function ReportScheduleEditor(props: { state: EditorState }) { onInput={(e) => updateScheduleForm({ time: e.currentTarget.value })} /> - + - + -
- - updateScheduleForm({ tagFilter: e.currentTarget.value })} - placeholder="production, customer-facing" - /> - +
+ + + updateScheduleForm({ tagFilter: e.currentTarget.value })} + placeholder="production, customer-facing" + /> + +

Scheduled reports

- Send recurring client performance reports using the same resource scope and - branding as generated reports. + Schedule performance reports or a CSV of the current VM inventory, delivered by + email or saved on this server. {' '} A Patrol weekly summary can also be emailed: what Patrol checked, found, diff --git a/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx index 7aabfc6a3d..ad52e7d889 100644 --- a/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ReportingPanel.test.tsx @@ -183,7 +183,7 @@ describe('ReportingPanel', () => { ).toBeVisible(); }); - it('keeps the schedule form free of the report type choice while Patrol cannot run', async () => { + it('offers performance and inventory schedules while Patrol cannot run', async () => { useReportingPanelStateMock.mockReturnValue( buildState({ scheduleFormOpen: () => true, patrolDigestAvailable: () => false }), ); @@ -191,7 +191,11 @@ describe('ReportingPanel', () => { render(() => ); await screen.findByLabelText('Schedule name', { selector: 'input' }); - expect(screen.queryByLabelText('Report type')).not.toBeInTheDocument(); + const select = screen.getByLabelText('Report type') as HTMLSelectElement; + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + 'Performance report', + 'VM inventory CSV', + ]); expect(screen.getByLabelText('Schedule name', { selector: 'input' })).toHaveFocus(); expect(screen.queryByText(/Patrol weekly summary can also be emailed/)).not.toBeInTheDocument(); expect(screen.getByLabelText('Cadence')).toBeInTheDocument(); @@ -217,6 +221,7 @@ describe('ReportingPanel', () => { expect(select.value).toBe('resources'); expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ 'Performance report', + 'VM inventory CSV', 'Patrol weekly summary', ]); expect( @@ -228,6 +233,26 @@ describe('ReportingPanel', () => { expect(setScheduleKind).toHaveBeenCalledWith('patrol_digest'); }); + it('keeps inventory CSV scope clear and both cadences available without performance controls', async () => { + const state = buildState(); + useReportingPanelStateMock.mockReturnValue( + buildState({ + scheduleFormOpen: () => true, + scheduleForm: () => ({ ...state.scheduleForm(), kind: 'vm_inventory', format: 'csv' }), + }), + ); + render(() => ); + await screen.findByLabelText('Schedule name', { selector: 'input' }); + expect(screen.getByLabelText('Report type')).toHaveValue('vm_inventory'); + expect(screen.getByLabelText('Cadence')).toHaveValue('monthly'); + expect(screen.getByText(/including newly discovered VMs/)).toBeVisible(); + expect(screen.queryByLabelText('Format')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Tag filter')).not.toBeInTheDocument(); + expect(screen.getAllByText('Mock Resource Picker')).toHaveLength(1); + expect(screen.getByLabelText('Delivery')).toBeVisible(); + expect(screen.getByRole('spinbutton', { name: 'Retention' })).toBeVisible(); + }); + it('reduces the form to weekday, time and recipients for a Patrol weekly summary', async () => { const base = buildState(); useReportingPanelStateMock.mockReturnValue( diff --git a/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.inventory.test.ts b/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.inventory.test.ts new file mode 100644 index 0000000000..f1fa34a0af --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/reportingSchedulesModel.inventory.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { + applyReportScheduleKind, + buildReportSchedulePayload, + DEFAULT_REPORT_SCHEDULE_FORM, + normalizeReportSchedule, + reportScheduleKindLabel, + reportScheduleScopeLabel, + scheduleToForm, +} from '../reportingSchedulesModel'; + +describe('scheduled VM inventory CSV', () => { + it('removes stale performance scope and PDF settings, preserving cadence and delivery', () => { + const form = applyReportScheduleKind( + { + ...DEFAULT_REPORT_SCHEDULE_FORM(), + name: 'Inventory', + format: 'pdf', + tagFilter: 'client', + cadenceType: 'weekly', + deliveryMethod: 'disk', + }, + 'vm_inventory', + ); + const payload = buildReportSchedulePayload(form, [ + { id: 'old-vm', type: 'vm', name: 'Old VM' }, + ]); + expect(payload).toMatchObject({ + kind: 'vm_inventory', + format: 'csv', + cadence: { type: 'weekly' }, + scope: { resources: [], tags: [] }, + delivery: { method: 'disk', save_to_disk: true }, + }); + expect(reportScheduleScopeLabel(payload)).toBe('All VMs in this organization'); + expect(reportScheduleKindLabel(payload.kind)).toBe('VM inventory CSV'); + expect(scheduleToForm(normalizeReportSchedule(payload))).toMatchObject({ + kind: 'vm_inventory', + format: 'csv', + }); + }); + + it('restores a performance report after switching from inventory or Patrol', () => { + const inventory = applyReportScheduleKind(DEFAULT_REPORT_SCHEDULE_FORM(), 'vm_inventory'); + expect(applyReportScheduleKind(inventory, 'resources')).toMatchObject({ + kind: 'resources', + format: 'pdf', + }); + const digest = applyReportScheduleKind(inventory, 'patrol_digest'); + expect(buildReportSchedulePayload(digest, [])).toMatchObject({ + kind: 'patrol_digest', + format: 'email', + delivery: { attach: false }, + }); + expect(applyReportScheduleKind(digest, 'vm_inventory')).toMatchObject({ + kind: 'vm_inventory', + format: 'csv', + attach: true, + saveToDisk: true, + }); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts index 776272beeb..ced1603b62 100644 --- a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts @@ -572,6 +572,36 @@ describe('useReportingPanelState', () => { dispose(); }); + it('saves an inventory CSV schedule without leaking old resources or tags', async () => { + apiFetchMock.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/api/admin/reports/schedules' && init?.method === 'POST') { + return Promise.resolve(jsonResponse({ ...JSON.parse(String(init.body)), id: 'inventory' })); + } + if (url === '/api/admin/reports/schedules') + return Promise.resolve(jsonResponse(schedulesPayload)); + return Promise.resolve(jsonResponse(catalogPayload)); + }); + const { hookState, dispose } = mountHook(); + await settleMountLoads(hookState); + hookState.startCreateSchedule(); + hookState.updateScheduleForm({ name: 'Inventory', tagFilter: 'old-client' }); + hookState.setScheduleResources([{ id: 'vm-old', name: 'Old VM', type: 'vm' }]); + hookState.setScheduleKind('vm_inventory'); + expect(hookState.scheduleResources()).toEqual([]); + await hookState.saveReportSchedule(); + expect(showWarningMock).not.toHaveBeenCalled(); + const post = apiFetchMock.mock.calls.find( + ([url, init]) => url === '/api/admin/reports/schedules' && init?.method === 'POST', + ); + expect(post).toBeDefined(); + expect(JSON.parse(String(post![1].body))).toMatchObject({ + kind: 'vm_inventory', + format: 'csv', + scope: { resources: [], tags: [] }, + }); + dispose(); + }); + it('still requires a scope for a performance report schedule', async () => { const { hookState, dispose } = mountHook(); await settleMountLoads(hookState); diff --git a/frontend-modern/src/components/Settings/reportingSchedulesModel.ts b/frontend-modern/src/components/Settings/reportingSchedulesModel.ts index 9b2513942e..6f63a5eaf1 100644 --- a/frontend-modern/src/components/Settings/reportingSchedulesModel.ts +++ b/frontend-modern/src/components/Settings/reportingSchedulesModel.ts @@ -6,7 +6,7 @@ export type ReportScheduleRunStatus = '' | 'ok' | 'failed' | 'running' | 'retryi export type ReportScheduleDeliveryMethod = 'email' | 'disk'; // 'resources' is the multi-resource performance report; 'patrol_digest' emails // the last seven days of Patrol work and has no scope, format, or disk output. -export type ReportScheduleKind = 'resources' | 'patrol_digest'; +export type ReportScheduleKind = 'resources' | 'patrol_digest' | 'vm_inventory'; // Digest schedules carry the server-assigned 'email' format: the email body is // the report, so there is no PDF or CSV artifact. export type ReportScheduleFormat = ReportingFormat | 'email'; @@ -122,7 +122,7 @@ export function parseReportSchedulesResponse(value: unknown): ReportSchedule[] { } export function normalizeReportScheduleKind(kind: unknown): ReportScheduleKind { - return kind === 'patrol_digest' ? 'patrol_digest' : 'resources'; + return kind === 'patrol_digest' || kind === 'vm_inventory' ? kind : 'resources'; } export function isPatrolDigestSchedule(schedule: Pick): boolean { @@ -211,12 +211,20 @@ export function applyReportScheduleKind( return { ...form, kind, + format: + kind === 'vm_inventory' + ? 'csv' + : form.kind === 'vm_inventory' + ? defaults.format + : form.format, + tagFilter: kind === 'vm_inventory' ? '' : form.tagFilter, attach: defaults.attach, saveToDisk: defaults.saveToDisk, }; } export function reportScheduleKindLabel(kind: unknown): string { + if (kind === 'vm_inventory') return 'VM inventory CSV'; return normalizeReportScheduleKind(kind) === 'patrol_digest' ? 'Patrol weekly summary' : 'Performance report'; @@ -252,7 +260,7 @@ export function buildReportSchedulePayload( return { id: form.id, name: form.name.trim(), - kind: 'resources', + kind: form.kind, enabled: form.enabled, cadence: { type: form.cadenceType, @@ -262,14 +270,14 @@ export function buildReportSchedulePayload( timezone: form.timezone.trim() || 'UTC', }, scope: { - resources: resources.map((resource) => ({ + resources: (form.kind === 'vm_inventory' ? [] : resources).map((resource) => ({ resourceType: resource.type, resourceId: resource.id, name: resource.name, })), - tags: parseCommaList(form.tagFilter), + tags: form.kind === 'vm_inventory' ? [] : parseCommaList(form.tagFilter), }, - format: form.format, + format: form.kind === 'vm_inventory' ? 'csv' : form.format, delivery: { method: form.deliveryMethod, to: parseCommaList(form.recipients), @@ -304,6 +312,7 @@ export function reportScheduleCadenceLabel(schedule: ReportSchedule): string { export function reportScheduleScopeLabel(schedule: ReportSchedule): string { if (isPatrolDigestSchedule(schedule)) return 'Patrol activity, last 7 days'; + if (schedule.kind === 'vm_inventory') return 'All VMs in this organization'; const resources = schedule.scope.resources?.length ?? 0; const tags = schedule.scope.tags?.length ?? 0; const parts = []; diff --git a/frontend-modern/src/components/Settings/useReportingPanelState.ts b/frontend-modern/src/components/Settings/useReportingPanelState.ts index 7a2bb58eb6..a33ca88d88 100644 --- a/frontend-modern/src/components/Settings/useReportingPanelState.ts +++ b/frontend-modern/src/components/Settings/useReportingPanelState.ts @@ -298,7 +298,7 @@ export const useReportingPanelState = () => { const setScheduleKind = (kind: ReportScheduleKind) => { setScheduleForm((current) => applyReportScheduleKind(current, kind)); - if (kind === 'patrol_digest') { + if (kind !== 'resources') { setScheduleResources([]); } }; @@ -331,7 +331,7 @@ export const useReportingPanelState = () => { return; } if ( - payload.kind !== 'patrol_digest' && + payload.kind === 'resources' && (payload.scope.resources?.length ?? 0) === 0 && (payload.scope.tags?.length ?? 0) === 0 ) { diff --git a/internal/api/metrics_reporting_handlers.go b/internal/api/metrics_reporting_handlers.go index 5ad7074d9a..1da16a4e5a 100644 --- a/internal/api/metrics_reporting_handlers.go +++ b/internal/api/metrics_reporting_handlers.go @@ -12,6 +12,7 @@ import ( "regexp" "strings" "sync" + "sync/atomic" "time" "github.com/rcourtman/pulse-go-rewrite/internal/ai" @@ -98,7 +99,7 @@ func normalizeReportResourceType(raw string) (string, error) { // ReportingHandlers handles reporting-related requests type ReportingHandlers struct { - mtMonitor *monitoring.MultiTenantMonitor + mtMonitor atomic.Pointer[monitoring.MultiTenantMonitor] recoveryManager *recoverymanager.Manager narratorResolver func(ctx context.Context) (reporting.Narrator, reporting.FleetNarrator, reporting.FindingsProvider) existingFindingsResolver func(ctx context.Context) reporting.FindingsProvider @@ -411,19 +412,34 @@ func (s *reportingEnrichmentSnapshot) normalizeCollections() { // NewReportingHandlers creates a new ReportingHandlers func NewReportingHandlers(mtMonitor *monitoring.MultiTenantMonitor, recoveryManager *recoverymanager.Manager) *ReportingHandlers { - return &ReportingHandlers{ - mtMonitor: mtMonitor, - recoveryManager: recoveryManager, + h := &ReportingHandlers{recoveryManager: recoveryManager} + h.SetMultiTenantMonitor(mtMonitor) + return h +} + +// SetMultiTenantMonitor replaces the runtime dependency after a configuration +// reload. Scheduled and on-demand reads may overlap the replacement. +func (h *ReportingHandlers) SetMultiTenantMonitor(manager *monitoring.MultiTenantMonitor) { + if h != nil { + h.mtMonitor.Store(manager) } } +func (h *ReportingHandlers) reportMonitorManager() *monitoring.MultiTenantMonitor { + if h == nil { + return nil + } + return h.mtMonitor.Load() +} + func (h *ReportingHandlers) getReportingEnrichmentSnapshot(ctx context.Context, orgID string) (reportingEnrichmentSnapshot, bool) { _ = ctx - if h == nil || h.mtMonitor == nil { + manager := h.reportMonitorManager() + if manager == nil { return emptyReportingEnrichmentSnapshot(), false } - monitor, err := h.mtMonitor.GetMonitor(orgID) + monitor, err := manager.GetMonitor(orgID) if err != nil || monitor == nil { return emptyReportingEnrichmentSnapshot(), false } @@ -864,8 +880,8 @@ func (h *ReportingHandlers) resolveReportSubject(orgID string, req *reporting.Me // it explicitly, so ask the tenant monitor first and fall back to // the struct field. var target *unifiedresources.MetricsTarget - if h != nil && h.mtMonitor != nil { - if monitor, err := h.mtMonitor.GetMonitor(orgID); err == nil && monitor != nil { + if manager := h.reportMonitorManager(); manager != nil { + if monitor, err := manager.GetMonitor(orgID); err == nil && monitor != nil { target = monitor.MetricsTargetForResource(req.ResourceID) } } diff --git a/internal/api/report_schedule_delivery_test.go b/internal/api/report_schedule_delivery_test.go index ea5303bd7e..5333ec1689 100644 --- a/internal/api/report_schedule_delivery_test.go +++ b/internal/api/report_schedule_delivery_test.go @@ -189,7 +189,7 @@ func TestReportScheduleDeliveryRetriesSurviveRestartAndKeepWindow(t *testing.T) window := engine.lastMulti.End require.NotContains(t, first.LastError, "provider-verdict") // A fresh handler reads only the persisted state, with no in-memory retry job. - restarted := NewReportingHandlers(h.mtMonitor, nil) + restarted := NewReportingHandlers(h.reportMonitorManager(), nil) restarted.runDueReportSchedulesForOrg(ctx, "default", now.Add(30*time.Second)) require.EqualValues(t, 1, attempts.Load()) restarted.runDueReportSchedulesForOrg(ctx, "default", first.RetryAt.Add(time.Second)) diff --git a/internal/api/report_schedule_inventory_test.go b/internal/api/report_schedule_inventory_test.go new file mode 100644 index 0000000000..fe4c1a46f1 --- /dev/null +++ b/internal/api/report_schedule_inventory_test.go @@ -0,0 +1,145 @@ +package api + +import ( + "context" + "encoding/csv" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/stretchr/testify/require" +) + +func inventoryScheduleFixture(t *testing.T) (*ReportingHandlers, *config.ConfigPersistence, context.Context, config.ReportSchedule, func(int)) { + t.Helper() + // Build the real inventory and persistence without starting poll goroutines: + // fixture setup must not replace state underneath a running monitor. + state := models.NewState() + monitor := newReportingMonitorForTest(t, state, nil) + p := config.NewConfigPersistence(t.TempDir()) + setUnexportedField(t, monitor, "configPersist", p) + h := NewReportingHandlers(newReportingMTMForTest(t, monitor), nil) + adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)) + setVMCount := func(count int) { + vms := make([]models.VM, count) + for i := range vms { + vms[i] = models.VM{ + ID: fmt.Sprintf("fixture-node-%d", 100+i), VMID: 100 + i, + Name: fmt.Sprintf("inventory-vm-%03d", i), Instance: "fixture", Node: "node", Pool: "customer", + Type: "qemu", Status: "running", CPUs: 4, + Memory: models.Memory{Total: 4 << 30}, Disk: models.Disk{Total: 100 << 30, Used: 20 << 30}, + } + } + state.UpdateVMs(vms) + monitor.SetResourceStore(adapter) + } + setVMCount(61) + s := validReportSchedulePayload() + s.ID, s.Kind, s.Format = "delivery", config.ReportScheduleKindVMInventory, "csv" + s.Scope = config.ReportScheduleScope{} + s.Cadence = config.ReportScheduleCadence{Type: "weekly", Weekday: "monday", Time: "09:00", Timezone: "UTC"} + return h, p, context.WithValue(context.Background(), OrgIDContextKey, "default"), s, setVMCount +} + +func TestVMInventoryScheduleUsesExistingExportWithoutHistoricalLimit(t *testing.T) { + h, p, ctx, s, setVMCount := inventoryScheduleFixture(t) + now := time.Date(2026, 10, 19, 9, 0, 1, 0, time.UTC) + prepared, err := h.prepareReportSchedule(ctx, s, now, false) + require.NoError(t, err) + result, finished := h.runReportSchedule(ctx, p, prepared, now, true, "") + require.Equal(t, "ok", finished.LastRunStatus, finished.LastError) + require.Equal(t, "saved", finished.LastDeliveryStatus) + data, err := os.ReadFile(result.path) + require.NoError(t, err) + download := httptest.NewRecorder() + h.HandleExportVMInventory(download, httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export", nil).WithContext(ctx)) + require.Equal(t, http.StatusOK, download.Code) + require.Equal(t, download.Body.Bytes(), data) + records, err := csv.NewReader(strings.NewReader(string(data))).ReadAll() + require.NoError(t, err) + require.Len(t, records, 62) // Header plus every VM, including beyond the performance-report cap. + require.Contains(t, string(data), "customer") + require.Contains(t, string(data), "inventory-vm-060") + require.Contains(t, string(data), "4294967296") + setVMCount(62) + result, finished = h.runReportSchedule(ctx, p, prepared, now.AddDate(0, 0, 7), false, "") + require.Equal(t, "ok", finished.LastRunStatus, finished.LastError) + data, err = os.ReadFile(result.path) + require.NoError(t, err) + require.Contains(t, string(data), "inventory-vm-061") +} + +func TestVMInventoryScheduleRejectsMisleadingFormatAndScope(t *testing.T) { + h, _, ctx, s, _ := inventoryScheduleFixture(t) + for _, tc := range []struct { + name string + change func(*config.ReportSchedule) + }{ + {"PDF", func(s *config.ReportSchedule) { s.Format = "pdf" }}, + {"resources", func(s *config.ReportSchedule) { + s.Scope.Resources = []config.ReportScheduleResource{{ResourceType: "vm", ResourceID: "vm-1"}} + }}, + {"tags", func(s *config.ReportSchedule) { s.Scope.Tags = []string{"client"} }}, + } { + t.Run(tc.name, func(t *testing.T) { + incoming := s + tc.change(&incoming) + _, err := h.prepareReportSchedule(ctx, incoming, time.Now(), false) + require.Error(t, err) + }) + } + s.Format = "" + prepared, err := h.prepareReportSchedule(ctx, s, time.Now(), false) + require.NoError(t, err) + require.Equal(t, "csv", prepared.Format) + s.Delivery.Method = "email" + _, err = h.prepareReportSchedule(ctx, s, time.Now(), false) + require.ErrorContains(t, err, "Enable email notifications") + s.Enabled = false + _, err = h.prepareReportSchedule(ctx, s, time.Now(), false) + require.NoError(t, err) +} + +func TestVMInventoryScheduleRetriesThroughPersistedDeliveryState(t *testing.T) { + h, p, ctx, s, _ := inventoryScheduleFixture(t) + cfg, attempts := reportSMTP(t, []int{451, 250}, nil) + require.NoError(t, p.SaveEmailConfig(cfg)) + s.Delivery = config.ReportScheduleDelivery{Method: "email", Attach: true, SaveToDisk: true} + require.NoError(t, p.SaveReportScheduleStore(config.ReportScheduleStore{Schedules: []config.ReportSchedule{s}})) + now := time.Date(2026, 10, 19, 9, 0, 1, 0, time.UTC) + h.runDueReportSchedulesForOrg(ctx, "default", now) + first := storedReport(t, p) + require.Equal(t, "retrying", first.LastRunStatus, first.LastError) + require.NotNil(t, first.RetryAt) + restarted := NewReportingHandlers(h.reportMonitorManager(), nil) + restarted.runDueReportSchedulesForOrg(ctx, "default", first.RetryAt.Add(time.Second)) + finished := storedReport(t, p) + require.Equal(t, "ok", finished.LastRunStatus, finished.LastError) + require.Equal(t, "vm_inventory", finished.Kind) + require.Equal(t, "sent_with_attachment", finished.LastDeliveryStatus) + require.Equal(t, 2, finished.RunAttempts) + require.Nil(t, finished.RetryAt) + require.EqualValues(t, 2, attempts.Load()) + restarted.runDueReportSchedulesForOrg(ctx, "default", now.Add(time.Hour)) + require.EqualValues(t, 2, attempts.Load()) +} + +func TestVMInventoryScheduleUnavailableOrganizationCannotExportDefault(t *testing.T) { + h, p, ctx, s, _ := inventoryScheduleFixture(t) + _, failed := h.runReportSchedule(context.WithValue(ctx, OrgIDContextKey, "missing-organization"), p, s, time.Now(), true, "") + require.Equal(t, "failed", failed.LastRunStatus) + require.Contains(t, failed.LastError, "VM inventory is unavailable") + require.Empty(t, failed.LastReportPath) + h.SetCommercialLicenseResolver(func(context.Context) *licenseService { return newLicenseService() }) + _, failed = h.runReportSchedule(ctx, p, s, time.Now(), true, "") + require.Equal(t, "failed", failed.LastRunStatus) + require.Contains(t, failed.LastError, "entitlement required") + require.Empty(t, failed.LastReportPath) +} diff --git a/internal/api/report_schedules.go b/internal/api/report_schedules.go index ca8fd53e44..93eda8ae7e 100644 --- a/internal/api/report_schedules.go +++ b/internal/api/report_schedules.go @@ -294,14 +294,15 @@ func (h *ReportingHandlers) reportSchedulePersistenceAndStore(ctx context.Contex } func (h *ReportingHandlers) reportSchedulePersistence(ctx context.Context) (*config.ConfigPersistence, error) { - if h == nil || h.mtMonitor == nil { + manager := h.reportMonitorManager() + if manager == nil { return nil, fmt.Errorf("multi-tenant monitor is not configured") } orgID := GetOrgID(ctx) if strings.TrimSpace(orgID) == "" { orgID = "default" } - monitor, err := h.mtMonitor.GetMonitor(orgID) + monitor, err := manager.GetMonitor(orgID) if err != nil { return nil, err } @@ -433,6 +434,16 @@ func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule } switch schedule.Kind { case config.ReportScheduleKindResources: + case config.ReportScheduleKindVMInventory: + if schedule.Format == "" { + schedule.Format = string(reporting.DescribeVMInventoryExport().Format) + } + if !reporting.DescribeVMInventoryExport().SupportsFormat(reporting.ReportFormat(schedule.Format)) { + return schedule, reportScheduleValidationError{code: "invalid_format", message: "VM inventory schedules require CSV format"} + } + if len(schedule.Scope.Resources) > 0 || len(schedule.Scope.Tags) > 0 { + return schedule, reportScheduleValidationError{code: "invalid_scope", message: "VM inventory includes all VMs in this organization. Resource and tag filters are not supported."} + } case config.ReportScheduleKindPatrolDigest: prepared, err := preparePatrolDigestSchedule(schedule, now) if err != nil { @@ -440,7 +451,7 @@ func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule } return prepared, h.validateEnabledReportDelivery(ctx, prepared) default: - return schedule, reportScheduleValidationError{code: "invalid_kind", message: "Schedule kind must be resources or patrol_digest"} + return schedule, reportScheduleValidationError{code: "invalid_kind", message: "Schedule kind must be resources, vm_inventory or patrol_digest"} } if schedule.Format == "" { schedule.Format = config.ReportScheduleFormatPDF @@ -463,8 +474,10 @@ func (h *ReportingHandlers) prepareReportSchedule(ctx context.Context, schedule if err := validateReportScheduleCadence(schedule.Cadence); err != nil { return schedule, err } - if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil { - return schedule, err + if schedule.Kind == config.ReportScheduleKindResources { + if err := h.validateReportScheduleScope(ctx, schedule.Scope); err != nil { + return schedule, err + } } next, err := nextReportScheduleRunAt(schedule, now) if err != nil { @@ -633,27 +646,7 @@ func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence * } } - orgID := GetOrgID(ctx) - resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope) - if err != nil { - return result, markReportScheduleFailed(schedule, now, err) - } - windowEnd := now - if schedule.RunWindowEnd != nil { - windowEnd = *schedule.RunWindowEnd - } - start, end, err := reportScheduleWindow(schedule, windowEnd, manual) - if err != nil { - return result, markReportScheduleFailed(schedule, now, err) - } - - report, err := h.generateMultiReportFromBody(ctx, multiReportRequestBody{ - Resources: resources, - Format: schedule.Format, - Start: start.UTC().Format(time.RFC3339), - End: end.UTC().Format(time.RFC3339), - Title: schedule.Name, - }, now) + report, err := h.generateScheduledFileReport(ctx, schedule, now, manual) if err != nil { return result, markReportScheduleFailed(schedule, now, err) } @@ -682,6 +675,33 @@ func (h *ReportingHandlers) runReportSchedule(ctx context.Context, persistence * return result, schedule } +func (h *ReportingHandlers) generateScheduledFileReport(ctx context.Context, schedule config.ReportSchedule, now time.Time, manual bool) (generatedMultiReport, error) { + orgID := GetOrgID(ctx) + if schedule.Kind == config.ReportScheduleKindVMInventory { + snapshot, ok := h.getReportingEnrichmentSnapshot(ctx, orgID) + if !ok { + return generatedMultiReport{}, fmt.Errorf("VM inventory is unavailable. Check this organization's infrastructure connections before running again.") + } + return generateVMInventoryReport(snapshot, now) + } + resources, err := h.resolveReportScheduleResources(ctx, orgID, schedule.Scope) + if err != nil { + return generatedMultiReport{}, err + } + windowEnd := now + if schedule.RunWindowEnd != nil { + windowEnd = *schedule.RunWindowEnd + } + start, end, err := reportScheduleWindow(schedule, windowEnd, manual) + if err != nil { + return generatedMultiReport{}, err + } + return h.generateMultiReportFromBody(ctx, multiReportRequestBody{ + Resources: resources, Format: schedule.Format, + Start: start.UTC().Format(time.RFC3339), End: end.UTC().Format(time.RFC3339), Title: schedule.Name, + }, now) +} + func markReportScheduleFailed(schedule config.ReportSchedule, now time.Time, err error) config.ReportSchedule { schedule.LastRunStatus = config.ReportScheduleLastRunFailed schedule.LastError = strings.TrimSpace(err.Error()) @@ -1043,10 +1063,11 @@ func (h *ReportingHandlers) RunReportScheduleScheduler(ctx context.Context) { } func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time.Time) { - if h == nil || h.mtMonitor == nil { + manager := h.reportMonitorManager() + if manager == nil { return } - orgIDs, err := h.mtMonitor.ListOrganizationIDs() + orgIDs, err := manager.ListOrganizationIDs() if err != nil { log.Warn().Err(err).Msg("report schedules: list organizations") return @@ -1059,10 +1080,11 @@ func (h *ReportingHandlers) runDueReportSchedules(ctx context.Context, now time. } func (h *ReportingHandlers) syncCommercialHistoryRetention(ctx context.Context, orgID string, now time.Time) { - if h == nil || h.mtMonitor == nil || h.commercialLicenseResolver == nil { + manager := h.reportMonitorManager() + if manager == nil || h.commercialLicenseResolver == nil { return } - monitor, err := h.mtMonitor.GetMonitor(orgID) + monitor, err := manager.GetMonitor(orgID) if err != nil || monitor == nil || monitor.GetMetricsStore() == nil { return } diff --git a/internal/api/reporting_availability.go b/internal/api/reporting_availability.go index 26f999745a..e9697b9550 100644 --- a/internal/api/reporting_availability.go +++ b/internal/api/reporting_availability.go @@ -139,7 +139,8 @@ func computeReportAvailability(changes []unifiedresources.ResourceChange, curren // subject. The change timeline is keyed by the canonical unified resource ID // (not the metrics-target ID), so this runs against req.ResourceID. func (h *ReportingHandlers) resolveReportAvailability(orgID string, req *reporting.MetricReportRequest, snapshot reportingEnrichmentSnapshot, start, end time.Time) { - if h == nil || req == nil || h.mtMonitor == nil { + manager := h.reportMonitorManager() + if manager == nil || req == nil { return } @@ -155,7 +156,7 @@ func (h *ReportingHandlers) resolveReportAvailability(orgID string, req *reporti return } - monitor, err := h.mtMonitor.GetMonitor(orgID) + monitor, err := manager.GetMonitor(orgID) if err != nil || monitor == nil { return } diff --git a/internal/api/reporting_inventory_handlers.go b/internal/api/reporting_inventory_handlers.go index 583bdab572..d039ef1f2f 100644 --- a/internal/api/reporting_inventory_handlers.go +++ b/internal/api/reporting_inventory_handlers.go @@ -34,19 +34,29 @@ func (h *ReportingHandlers) HandleExportVMInventory(w http.ResponseWriter, r *ht } generatedAt := time.Now().UTC() - data, err := reporting.GenerateVMInventoryCSV(reporting.VMInventoryData{ - GeneratedAt: generatedAt, - Rows: buildVMInventoryRows(snapshot.Resources), - }) + report, err := generateVMInventoryReport(snapshot, generatedAt) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, "generation_failed", "Failed to generate VM inventory export", nil) return } - w.Header().Set("Content-Type", "text/csv; charset=utf-8") - filename := definition.AttachmentFilename(generatedAt) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - _, _ = w.Write(data) + w.Header().Set("Content-Type", report.ContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", report.Filename)) + _, _ = w.Write(report.Data) +} + +// Both downloads and schedules use the same current-state projection and CSV +// contract. Inventory does not use historical metric windows or resource caps. +func generateVMInventoryReport(snapshot reportingEnrichmentSnapshot, generatedAt time.Time) (generatedMultiReport, error) { + definition := reporting.DescribeVMInventoryExport() + data, err := reporting.GenerateVMInventoryCSV(reporting.VMInventoryData{ + GeneratedAt: generatedAt, + Rows: buildVMInventoryRows(snapshot.Resources), + }) + return generatedMultiReport{ + Data: data, Format: definition.Format, ContentType: "text/csv; charset=utf-8", + Filename: definition.AttachmentFilename(generatedAt), + }, err } func buildVMInventoryRows(resources []unifiedresources.Resource) []reporting.VMInventoryRow { diff --git a/internal/api/reporting_monitor_reload_test.go b/internal/api/reporting_monitor_reload_test.go new file mode 100644 index 0000000000..aecb4d2688 --- /dev/null +++ b/internal/api/reporting_monitor_reload_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReportingMonitorReloadUsesReplacementForInventoryAndSchedules(t *testing.T) { + original, originalPersistence, ctx, _, _ := inventoryScheduleFixture(t) + replacement, replacementPersistence, _, _, setVMCount := inventoryScheduleFixture(t) + setVMCount(62) + router := &Router{reportingHandlers: original} + router.SetMultiTenantMonitor(replacement.reportMonitorManager()) + + snapshot, ok := original.getReportingEnrichmentSnapshot(ctx, "default") + require.True(t, ok) + require.Len(t, buildVMInventoryRows(snapshot.Resources), 62) + persistence, err := original.reportSchedulePersistence(ctx) + require.NoError(t, err) + require.Same(t, replacementPersistence, persistence) + require.NotSame(t, originalPersistence, persistence) + + // Clearing the runtime never falls back to the stopped manager. + router.SetMultiTenantMonitor(nil) + _, ok = original.getReportingEnrichmentSnapshot(ctx, "default") + require.False(t, ok) + _, err = original.reportSchedulePersistence(ctx) + require.Error(t, err) +} + +func TestReportingMonitorReloadCanOverlapReportingReads(t *testing.T) { + h, _, _, _, _ := inventoryScheduleFixture(t) + manager := h.reportMonitorManager() + var readers sync.WaitGroup + readers.Add(1) + go func() { + defer readers.Done() + for range 100 { + h.getReportingEnrichmentSnapshot(context.Background(), "default") + } + }() + for range 100 { + h.SetMultiTenantMonitor(nil) + h.SetMultiTenantMonitor(manager) + } + readers.Wait() +} diff --git a/internal/api/reporting_runtime_snapshot_test.go b/internal/api/reporting_runtime_snapshot_test.go index 162f0b2b31..935ed0fd19 100644 --- a/internal/api/reporting_runtime_snapshot_test.go +++ b/internal/api/reporting_runtime_snapshot_test.go @@ -219,11 +219,16 @@ func TestReportingEnrichmentSnapshotNormalizesEmptyCollections(t *testing.T) { } } +type reportingFixtureRecords []unifiedresources.IngestRecord + +func (records reportingFixtureRecords) SupplementalRecords(*monitoring.Monitor, string) []unifiedresources.IngestRecord { + return records +} + func newReportingMonitorForTest(t *testing.T, state *models.State, resources []unifiedresources.Resource) *monitoring.Monitor { t.Helper() registry := unifiedresources.NewRegistry(nil) - registry.IngestSnapshot(state.GetSnapshot()) records := make([]unifiedresources.IngestRecord, 0, len(resources)) for i, resource := range resources { sourceID := resource.ID @@ -236,11 +241,14 @@ func newReportingMonitorForTest(t *testing.T, state *models.State, resources []u Identity: resource.Identity, }) } - registry.IngestRecords(unifiedresources.SourceTrueNAS, records) - monitor := &monitoring.Monitor{} - monitor.SetResourceStore(unifiedresources.NewMonitorAdapter(registry)) setUnexportedField(t, monitor, "state", state) + // Use the real provider boundary so read-driven publications retain these + // supplemental fixtures as well as live snapshot resources. + monitor.SetResourceStoreAndProviders(unifiedresources.NewMonitorAdapter(registry), + map[unifiedresources.DataSource]monitoring.MonitorSupplementalRecordsProvider{ + unifiedresources.SourceTrueNAS: reportingFixtureRecords(records), + }) return monitor } diff --git a/internal/api/router_helpers.go b/internal/api/router_helpers.go index 63eb2887b3..231e205d7f 100644 --- a/internal/api/router_helpers.go +++ b/internal/api/router_helpers.go @@ -124,6 +124,9 @@ func (p *MultiTenantStateProvider) ResourceHealthAlertsForTenant(orgID string) [ // Used during reload. func (r *Router) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) { r.mtMonitor = mtm + if r.reportingHandlers != nil { + r.reportingHandlers.SetMultiTenantMonitor(mtm) + } if r.configHandlers != nil { r.configHandlers.SetMultiTenantMonitor(mtm) r.configHandlers.SetConfig(r.config) diff --git a/internal/config/report_schedule_delivery_test.go b/internal/config/report_schedule_delivery_test.go index 32d5dd7bb7..e80e959279 100644 --- a/internal/config/report_schedule_delivery_test.go +++ b/internal/config/report_schedule_delivery_test.go @@ -40,3 +40,13 @@ func TestLegacyReportScheduleHasNoInventedDeliveryOutcome(t *testing.T) { require.Nil(t, store.Schedules[0].RetryAt) require.Zero(t, store.Schedules[0].RunAttempts) } + +func TestVMInventoryScheduleKindSurvivesEncryptedReload(t *testing.T) { + p := NewConfigPersistence(t.TempDir()) + s := ReportSchedule{ID: "inventory", Kind: ReportScheduleKindVMInventory, Format: "csv"} + require.NoError(t, p.SaveReportScheduleStore(ReportScheduleStore{Schedules: []ReportSchedule{s}})) + loaded, err := p.LoadReportScheduleStore() + require.NoError(t, err) + require.Equal(t, ReportScheduleKindVMInventory, loaded.Schedules[0].Kind) + require.Equal(t, "csv", loaded.Schedules[0].Format) +} diff --git a/internal/config/report_schedules.go b/internal/config/report_schedules.go index 3756a9aebf..db611609cb 100644 --- a/internal/config/report_schedules.go +++ b/internal/config/report_schedules.go @@ -24,6 +24,7 @@ const ( // weekly rollup for the whole workspace. ReportScheduleKindResources = "resources" ReportScheduleKindPatrolDigest = "patrol_digest" + ReportScheduleKindVMInventory = "vm_inventory" ReportScheduleDeliveryEmail = "email" ReportScheduleDeliveryDisk = "disk" diff --git a/internal/monitoring/resource_stale_thresholds.go b/internal/monitoring/resource_stale_thresholds.go index b0c2c8af5e..f5dc5cbb0a 100644 --- a/internal/monitoring/resource_stale_thresholds.go +++ b/internal/monitoring/resource_stale_thresholds.go @@ -109,10 +109,34 @@ func resourceStaleThresholdsForIntervals( // does, judges by the same thresholds, so its stale pass and presentation // agree with the monitor's views. func (m *Monitor) UnifiedResourceSnapshotWithStaleThresholds() ([]unifiedresources.Resource, time.Time, map[unifiedresources.DataSource]time.Duration) { + m.refreshUnifiedResourceRead() view := m.currentUnifiedStateView() return m.applyPersistedMetadataToUnifiedResources(view.resources), view.freshness, view.staleThresholds } +// Headless readers need the same publication freshness as frontend state reads. +// Check the store before copying state, preserving the cheap warm-read path. +// The existing refresh boundary owns publication, provider and mode fencing. +func (m *Monitor) refreshUnifiedResourceRead() { + if m == nil || mock.IsMockEnabled() { + return + } + m.mu.RLock() + store := m.resourceStore + m.mu.RUnlock() + if _, ok := store.(readRefreshResourceStore); !ok { + return + } + if timed, ok := store.(interface{ LastRebuiltAt() time.Time }); ok { + last := timed.LastRebuiltAt() + if !last.IsZero() && time.Since(last) < readPathRegistryFreshness { + return + } + } + state, scope := m.currentStateWithScope() + m.updateResourceStoreForRead(state, scope) +} + // unifiedResourceThresholdProjectionLister lists a read state with the stale // thresholds the listed registry generation was judged by (MonitorAdapter, // and any wrapper embedding one). diff --git a/internal/monitoring/unified_inventory_read_refresh_test.go b/internal/monitoring/unified_inventory_read_refresh_test.go new file mode 100644 index 0000000000..5209738383 --- /dev/null +++ b/internal/monitoring/unified_inventory_read_refresh_test.go @@ -0,0 +1,47 @@ +package monitoring + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +// A stale store lets the tests exercise read admission without a wall-clock +// freshness sleep. Registry publication itself remains the real adapter path. +type staleInventoryReadStore struct { + *unifiedresources.MonitorAdapter +} + +func (*staleInventoryReadStore) LastRebuiltAt() time.Time { return time.Now().Add(-time.Hour) } +func (s *staleInventoryReadStore) TryReplaceRegistryForRead(snapshot models.StateSnapshot, _ time.Duration, supply func() map[unifiedresources.DataSource][]unifiedresources.IngestRecord) bool { + return s.MonitorAdapter.TryReplaceRegistryForRead(snapshot, 0, supply) +} + +func TestUnifiedInventoryReadRefreshesWithoutFrontendStateRead(t *testing.T) { + state := models.NewState() + adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)) + adapter.PopulateFromSnapshot(models.StateSnapshot{}) + m := &Monitor{state: state, resourceStore: &staleInventoryReadStore{adapter}} + state.UpdateVMs([]models.VM{{ID: "site-node-100", Instance: "site", Node: "node", VMID: 100, Name: "headless-vm", Type: "qemu", Status: "running"}}) + resources, _, _ := m.UnifiedResourceSnapshotWithStaleThresholds() + if len(resources) != 1 || resources[0].Name != "headless-vm" || resources[0].Type != unifiedresources.ResourceTypeVM { + t.Fatalf("unattended snapshot did not publish VM inventory: %#v", resources) + } +} + +func TestUnifiedInventoryWarmReadDoesNotWaitForIngestPublication(t *testing.T) { + adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)) + adapter.PopulateFromSnapshot(models.StateSnapshot{}) + m := &Monitor{state: models.NewState(), resourceStore: &staleInventoryReadStore{adapter}} + m.resourcePublishMu.Lock() + defer m.resourcePublishMu.Unlock() + done := make(chan struct{}) + go func() { m.UnifiedResourceSnapshot(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("warm unattended read queued behind an ingest publication") + } +} diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index d03376b3cf..7e4ca68849 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -1342,6 +1342,39 @@ class CanonicalCompletionGuardTest(unittest.TestCase): other = infer_impacted_subsystems(["internal/api/access_control_handlers.go"])["api-contracts"] self.assertNotIn(proof, other["verification_requirements"][0]["exact_files"]) + def test_unattended_reporting_proofs_do_not_satisfy_unrelated_runtime_changes(self): + cases = [ + ( + "api-contracts", "internal/api/metrics_reporting_handlers.go", + "internal/api/report_schedule_inventory_test.go", + "internal/api/access_control_handlers.go", + ), + ( + "api-contracts", "internal/api/router_helpers.go", + "internal/api/reporting_monitor_reload_test.go", + "internal/api/access_control_handlers.go", + ), + ( + "monitoring", "internal/monitoring/resource_stale_thresholds.go", + "internal/monitoring/unified_inventory_read_refresh_test.go", + "internal/monitoring/monitor.go", + ), + ] + rules = {rule["id"]: rule for rule in load_subsystem_rules()} + for subsystem, runtime, proof, unrelated in cases: + with self.subTest(runtime=runtime): + rule = rules[subsystem] + requirement = build_verification_requirements(rule, [runtime])[0] + self.assertEqual( + staged_verification_files_for_requirement(rule, requirement, [proof]), + [proof], + ) + other = build_verification_requirements(rule, [unrelated])[0] + self.assertEqual( + staged_verification_files_for_requirement(rule, other, [proof]), + [], + ) + def test_organization_rbac_backend_change_requires_organization_settings_and_api_contracts(self): required = infer_impacted_subsystems(["internal/api/access_control_handlers.go"]) self.assertEqual(set(required), {"api-contracts", "organization-settings"})