Schedule the existing VM inventory CSV export (#3039)

VM inventory was available only as an on-demand download. It can now be
scheduled weekly or monthly as CSV, emailed or saved on the Pulse
server. Each attempt exports all current VMs in the organization,
including newly discovered VMs, through the existing exporter.
Historical performance-report limits and windows remain separate.

The schedule uses existing delivery configuration checks, persisted
success/failure/uncertain states, bounded retries and deliberate
recovery controls. Pricing, feature gates, retention entitlements and
existing customer grants are unchanged.

Live checks also reproduced two unattended inventory defects: reporting
retained a stopped monitor after configuration reload, and canonical
snapshot reads relied on dashboard reads to publish polled VM inventory.
Reporting now receives reload replacements through an atomic manager
reference. Canonical snapshot reads reuse the existing publication
boundary, including its freshness fast path, provider/mode fences and
nonblocking warm reads.

Validation:

- 105 focused frontend tests; full type check, lint, production build
and bundle budgets.
- Race checks for reporting, inventory, reload, publication and
organization isolation; clean private runtime build.
- Current runtime browser checks on `/settings/support/reporting` and
`/alerts/notifications` at 1440×1000, 820×844 and 390×844, with actual
pixel review. The first inventory read exports 61 normally polled VMs;
real MIME CSV matches the download; a newly discovered 62nd VM enters
the next export.
- Local SMTP acceptance, 451 retry across a real restart, permanent
rejection, uncertain acknowledgement and deliberate recovery; disk
delivery, edit/reload, disable, details and delete.
- Staged status, registry, contract, completion and exact-content
browser guards pass. Regression proof is registered against the specific
runtime paths it covers.
This commit is contained in:
rcourtman
2026-10-17 14:59:04 +01:00
committed by GitHub
28 changed files with 752 additions and 83 deletions
@@ -5755,6 +5755,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
@@ -12139,6 +12139,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
@@ -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
@@ -7347,6 +7347,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,
@@ -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",
@@ -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
@@ -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."
}
@@ -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 (
<section class="space-y-4 border-y border-base-300 py-4">
<Show when={showReportTypeSelect()}>
@@ -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.'
}
>
<option value="resources">{reportScheduleKindLabel('resources')}</option>
<option value="patrol_digest">{reportScheduleKindLabel('patrol_digest')}</option>
<Show when={inventoryAvailable()}>
<option value="vm_inventory">{reportScheduleKindLabel('vm_inventory')}</option>
</Show>
<Show when={patrolDigestAvailable() || isDigestForm()}>
<option value="patrol_digest">{reportScheduleKindLabel('patrol_digest')}</option>
</Show>
</FormSelect>
</div>
</Show>
@@ -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'
}
/>
</FormField>
<FormField label="Timezone">
@@ -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'
}
>
<Show when={!isDigestForm()}>
@@ -152,7 +170,7 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
onInput={(e) => updateScheduleForm({ time: e.currentTarget.value })}
/>
</FormField>
<Show when={!isDigestForm()}>
<Show when={scheduleForm().kind === 'resources'}>
<FormSelect
label="Format"
value={scheduleForm().format}
@@ -166,7 +184,7 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
</Show>
</div>
<Show when={!isDigestForm()}>
<Show when={scheduleForm().kind === 'resources'}>
<FormField
label="Resources"
helpText="Use explicit resources, tags, or both. Scheduled reports use the previous reporting boundary."
@@ -180,17 +198,21 @@ export default function ReportScheduleEditor(props: { state: EditorState }) {
</Show>
<Show when={!isDigestForm()}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<FormField label="Tag filter" helpText="Comma-separated tags">
<input
aria-label="Tag filter"
type="text"
class={formControl}
value={scheduleForm().tagFilter}
onInput={(e) => updateScheduleForm({ tagFilter: e.currentTarget.value })}
placeholder="production, customer-facing"
/>
</FormField>
<div
class={`grid grid-cols-1 gap-4 ${isInventoryForm() ? 'md:grid-cols-2' : 'md:grid-cols-3'}`}
>
<Show when={!isInventoryForm()}>
<FormField label="Tag filter" helpText="Comma-separated tags">
<input
aria-label="Tag filter"
type="text"
class={formControl}
value={scheduleForm().tagFilter}
onInput={(e) => updateScheduleForm({ tagFilter: e.currentTarget.value })}
placeholder="production, customer-facing"
/>
</FormField>
</Show>
<FormSelect
label="Delivery"
value={scheduleForm().deliveryMethod}
@@ -282,8 +282,8 @@ export function ReportingPanel() {
<div class="space-y-1">
<h4 class="text-base font-semibold text-base-content">Scheduled reports</h4>
<p class="text-sm text-muted">
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.
<Show when={patrolDigestAvailable()}>
{' '}
A Patrol weekly summary can also be emailed: what Patrol checked, found,
@@ -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(() => <ReportingPanel />);
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(() => <ReportingPanel />);
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(
@@ -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,
});
});
});
@@ -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);
@@ -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<ReportSchedule, 'kind'>): 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 = [];
@@ -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
) {
+24 -8
View File
@@ -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)
}
}
@@ -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))
@@ -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)
}
+52 -30
View File
@@ -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
}
+3 -2
View File
@@ -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
}
+18 -8
View File
@@ -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 {
@@ -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()
}
@@ -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
}
+3
View File
@@ -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)
@@ -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)
}
+1
View File
@@ -24,6 +24,7 @@ const (
// weekly rollup for the whole workspace.
ReportScheduleKindResources = "resources"
ReportScheduleKindPatrolDigest = "patrol_digest"
ReportScheduleKindVMInventory = "vm_inventory"
ReportScheduleDeliveryEmail = "email"
ReportScheduleDeliveryDisk = "disk"
@@ -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).
@@ -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")
}
}
@@ -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"})