From 0d8d5beb5fd77389b50949465de5ba1b01483405 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:40:53 +0100 Subject: [PATCH 01/86] Keep open resource details on current snapshots Read replacement snapshots reactively through the existing drawer state and Overview. Key local state and reads by canonical resource ID, and keep availability selections on the current fleet rather than a clicked snapshot. Preserve same-ID History range and tab continuity while withdrawing lost targets. Parent regressions and Chromium/WebKit controls reproduce stale headings, history targets and false Up details; retain scoped tests, browser proof and the unified-resources contract. Change-source: pulse-maintainer --- .../internal/subsystems/unified-resources.md | 28 +++ .../browser-tests/drawer-snapshot.cjs | 211 ++++++++++++++++ .../browser-tests/drawer-snapshot.html | 12 + .../browser-tests/drawer-snapshot.tsx | 90 +++++++ frontend-modern/browser-verification.json | 56 +++-- .../Infrastructure/ResourceDetailDrawer.tsx | 30 ++- .../ResourceDetailDrawerOverviewTab.tsx | 37 +-- .../ResourceDetailDrawer.history.test.tsx | 2 +- .../ResourceDetailDrawer.snapshot.test.tsx | 233 ++++++++++++++++++ .../useResourceDetailDrawerDerivedState.ts | 112 ++++----- .../useResourceDetailDrawerHistoryState.ts | 12 +- .../useResourceDetailDrawerState.ts | 15 +- .../standalone/AvailabilityFleetView.tsx | 16 +- .../__tests__/AvailabilityFleetView.test.tsx | 29 ++- 14 files changed, 758 insertions(+), 125 deletions(-) create mode 100644 frontend-modern/browser-tests/drawer-snapshot.cjs create mode 100644 frontend-modern/browser-tests/drawer-snapshot.html create mode 100644 frontend-modern/browser-tests/drawer-snapshot.tsx create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ae311147f4..83a8a073de 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,34 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Detail snapshot and selection ownership + +ResourceDetailDrawer reads the current resource through a reactive getter in +its state, derived identity/telemetry and Overview consumers. Replacing a +same-ID object must update the header, availability evidence and metrics target +without remounting the drawer or discarding its selected History range. This +also preserves the reconciled-store clients used by platform table rows. +Transient loss of a same-resource metrics target withdraws its History panel +and shows the existing unavailable notice; returning the target restores the +selected tab. No target or alias is inferred to fill the gap. + +A different canonical ID disposes per-resource tabs, disclosures and outstanding +reads before rendering the new resource. Late completions cannot become the +new resource's facets or intelligence. Discovery source-key replacement does +not retain a former target's identification while the next read is pending. +Availability fleet selection stores an ID, not a clicked snapshot: the open +drawer resolves that ID in the current fleet. Removing the selected check +closes and clears its selection, including when the ID subsequently reappears. + +ResourceDetailDrawer.snapshot.test.tsx covers immutable and reconciled-store +updates, target withdrawal/restoration, current availability and late facet +completion. The existing History consumer guard and AvailabilityFleetView +regressions protect composition. The production-components/browser client +fixture browser-tests/drawer-snapshot.cjs exercises PBS History requests and +availability selection on desktop Chromium and phone WebKit with synthetic +snapshots. It proves no native appliance, release or installed recovery result. +Resource identity, policy, permissions, collectors and persistence are unchanged. + ### Canonical RAID configured-member evidence โ€” issue #2369 Host RAID metadata and read views retain optional `requiredDevices` (configured diff --git a/frontend-modern/browser-tests/drawer-snapshot.cjs b/frontend-modern/browser-tests/drawer-snapshot.cjs new file mode 100644 index 0000000000..a82737edf2 --- /dev/null +++ b/frontend-modern/browser-tests/drawer-snapshot.cjs @@ -0,0 +1,211 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +const parent = false; +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules/drawer-snapshot-proof'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), + server: { host: '127.0.0.1', port: 5242, strictPort: true }, + }); + const playwright = require('playwright/package.json').version; + const expected = JSON.parse( + fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'), + ).packages['node_modules/@playwright/test'].version; + assert.equal(playwright, expected); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + }); + const requests = [], + errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5242') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const query = Object.fromEntries(url.searchParams); + requests.push(query); + const value = + query.resourceId === 'history-a-current' + ? 77 + : query.resourceId === 'history-b' + ? 25 + : 12; + return route.fulfill({ + json: { + resourceType: 'agent', + resourceId: query.resourceId, + range: query.range, + start: Date.now() - 60000, + end: Date.now(), + source: 'store', + metrics: { + cpu: [0, 1].map((i) => ({ + timestamp: Date.now() - (1 - i) * 60000, + value, + min: value, + max: value, + })), + }, + }, + }); + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { + capabilities: [], + relationships: [], + recentChanges: [], + audits: [], + count: 0, + available: false, + data: [], + enabled: false, + }, + }); + }); + await page.goto('http://127.0.0.1:5242/browser-tests/drawer-snapshot.html'); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + const detail = page.getByRole('region', { name: 'PBS detail verification' }); + const fleet = page.getByRole('region', { name: 'Availability fleet' }); + const capture = async (phase, scope) => { + const dimensions = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + width: innerWidth, + })); + assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions)); + const screenshot = `${parent ? 'parent-' : ''}${name}-${phase}.png`; + await page.screenshot({ path: path.join(artifacts, screenshot), fullPage: true }); + observations.push({ + name, + phase, + browser: browser.version(), + text: await scope.innerText(), + dimensions, + screenshot, + }); + }; + await detail.getByRole('tab', { name: 'History', exact: true }).click(); + await detail.getByRole('combobox', { name: 'History range' }).selectOption('7d'); + await detail.getByText('12.0%', { exact: true }).waitFor(); + const history = await detail.getByTestId('resource-metrics-history-tab').elementHandle(); + await page.getByRole('button', { name: 'Update PBS A', exact: true }).click(); + await detail + .getByRole('heading', { name: parent ? 'PBS A' : 'PBS A current', exact: true }) + .waitFor(); + await detail.getByText(parent ? '12.0%' : '77.0%', { exact: true }).waitFor(); + assert.equal( + await detail.getByRole('combobox', { name: 'History range' }).inputValue(), + '7d', + ); + assert.ok(await history.evaluate((element) => element.isConnected)); + await capture('pbs-replacement', detail); + await page.getByRole('button', { name: 'Withdraw target', exact: true }).click(); + if (parent) assert.equal(await detail.getByTestId('resource-metrics-history-tab').count(), 1); + else await detail.getByText('Metrics history is unavailable.', { exact: true }).waitFor(); + await capture('pbs-withdrawn', detail); + await page.getByRole('button', { name: 'Restore target', exact: true }).click(); + await detail.getByTestId('resource-metrics-history-tab').waitFor(); + assert.equal( + await detail + .getByRole('tab', { name: 'History', exact: true }) + .getAttribute('aria-selected'), + 'true', + ); + await page.getByRole('button', { name: 'Select PBS B', exact: true }).click(); + await detail + .getByRole('heading', { name: parent ? 'PBS A' : 'PBS B', exact: true }) + .waitFor(); + assert.equal( + await detail + .getByRole('tab', { name: parent ? 'History' : 'Overview', exact: true }) + .getAttribute('aria-selected'), + 'true', + ); + if (!parent) await detail.getByRole('tab', { name: 'History', exact: true }).click(); + await detail.getByText(parent ? '12.0%' : '25.0%', { exact: true }).waitFor(); + await capture('pbs-selected-b', detail); + const targets = [...new Set(requests.map((r) => r.resourceId))]; + assert.deepEqual( + targets, + parent ? ['history-a'] : ['history-a', 'history-a-current', 'history-b'], + ); + assert.ok( + requests.every( + (r) => !('metric' in r) && r.resourceType === 'agent' && r.maxPoints === '240', + ), + ); + await fleet.getByRole('button', { name: 'Open details for Check A', exact: true }).click(); + const availability = fleet.getByTestId('availability-probe-status'); + await availability.getByText('Up', { exact: true }).waitFor(); + await page.getByRole('button', { name: 'Fail check A', exact: true }).click(); + await availability.getByText(parent ? 'Up' : 'Down', { exact: true }).waitFor(); + await capture('availability-replacement', fleet); + await fleet.getByRole('button', { name: 'Open details for Check B', exact: true }).click(); + await fleet + .getByRole('heading', { name: parent ? 'Check A' : 'Check B', exact: true }) + .waitFor(); + await capture('availability-selected-b', fleet); + await page.getByRole('button', { name: 'Remove check B', exact: true }).click(); + assert.equal( + await fleet + .getByRole('heading', { name: parent ? 'Check A' : 'Check B', exact: true }) + .count(), + parent ? 1 : 0, + ); + await page.getByRole('button', { name: 'Restore check B', exact: true }).click(); + assert.equal( + await fleet.locator('[id^="resource-detail-drawer-heading-"]').count(), + parent ? 1 : 0, + ); + await capture('availability-removed-restored', fleet); + assert.deepEqual(errors, []); + observations.push({ name, phase: 'requests', requests, errors }); + await browser.close(); + browser = null; + } + fs.writeFileSync( + path.join(artifacts, parent ? 'parent.json' : 'result.json'), + JSON.stringify({ playwright, expected, parent, observations }, null, 2), + ); + console.log(JSON.stringify({ result: 'passed', parent, observations: observations.length })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/drawer-snapshot.html b/frontend-modern/browser-tests/drawer-snapshot.html new file mode 100644 index 0000000000..2ccb902b6f --- /dev/null +++ b/frontend-modern/browser-tests/drawer-snapshot.html @@ -0,0 +1,12 @@ + + + + + + Detail snapshot verification + + +
+ + + diff --git a/frontend-modern/browser-tests/drawer-snapshot.tsx b/frontend-modern/browser-tests/drawer-snapshot.tsx new file mode 100644 index 0000000000..4af32cc3ad --- /dev/null +++ b/frontend-modern/browser-tests/drawer-snapshot.tsx @@ -0,0 +1,90 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer'; +import { AvailabilityFleetView } from '../src/features/standalone/AvailabilityFleetView'; +import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +// Synthetic replacement snapshots; the drawers, API client, fleet and CSS are production code. +syncAIRuntimeSettings({ discovery_enabled: false }); +const pbs = (current = false, other = false, missing = false): Resource => ({ + id: other ? 'pbs-b' : 'pbs-a', + name: other ? 'PBS B' : 'PBS A', + displayName: other ? 'PBS B' : current ? 'PBS A current' : 'PBS A', + type: 'pbs', + platformId: other ? 'pbs-b' : 'pbs-a', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: current ? 'offline' : 'online', + lastSeen: Date.now(), + cpu: { current: current ? 77 : other ? 25 : 12 }, + metricsTarget: missing + ? undefined + : { + resourceType: 'agent', + resourceId: other ? 'history-b' : current ? 'history-a-current' : 'history-a', + }, +}); +const check = (other = false, down = false): Resource => ({ + id: other ? 'check-b' : 'check-a', + name: other ? 'Check B' : 'Check A', + displayName: other ? 'Check B' : 'Check A', + type: 'network-endpoint', + platformId: other ? 'check-b' : 'check-a', + platformType: 'availability', + sourceType: 'api', + status: down ? 'offline' : 'online', + lastSeen: Date.now(), + availability: { + targetId: other ? 'check-b' : 'check-a', + address: other ? 'check-b.invalid' : 'check-a.invalid', + protocol: 'https', + path: '/health', + enabled: true, + available: !down, + latencyMillis: down ? undefined : 12, + lastChecked: new Date().toISOString(), + pollIntervalSeconds: 60, + }, +}); +const [resource, setResource] = createSignal(pbs()); +const [checks, setChecks] = createSignal([check(), check(true)]); +const controls = [ + ['Update PBS A', () => setResource(pbs(true))], + ['Withdraw target', () => setResource(pbs(true, false, true))], + ['Restore target', () => setResource(pbs(true))], + ['Select PBS B', () => setResource(pbs(false, true))], + ['Fail check A', () => setChecks([check(false, true), check(true)])], + ['Remove check B', () => setChecks([check(false, true)])], + ['Restore check B', () => setChecks([check(false, true), check(true)])], +] as const; +render( + () => ( + true } as any}> + document.documentElement.classList.contains('dark')}> +
+

Detail snapshot verification

+

Synthetic PBS and availability snapshots; no appliance or release acceptance.

+ +
+ +
+ +
+
+
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4833520b01..79959c2dd1 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,46 +1,52 @@ { "version": 1, - "base_sha": "78796b411c1f37f57b303cae9fab5ddaa13e1121", - "verified_at": "2026-10-02T22:13:00Z", "result": "passed", + "base_sha": "90c436c83d616eccbe544bf3737b13c3b6251e53", + "verified_at": "2026-10-02T23:38:43Z", "changed_paths": [ - "frontend-modern/src/components/Settings/ReportingPanel.tsx", - "frontend-modern/src/components/Settings/reportingSchedulesModel.ts", - "frontend-modern/src/components/Settings/useReportingPanelState.ts" + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx" ], "content_sha256": { - "frontend-modern/src/components/Settings/ReportingPanel.tsx": "fbf12a7025b520e1f4ba99144f88a35778e9360f982b72bba251797361504afb", - "frontend-modern/src/components/Settings/reportingSchedulesModel.ts": "69fe58abb763e528ac3e3dfe5c50b969038e905fcf46974209cac29d1bc77e83", - "frontend-modern/src/components/Settings/useReportingPanelState.ts": "cc102740cf6eec47b8f3e5b4c9d6c24e1017d7ffd14bd653d25bfd45fc88b1af" + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "63429552c461f36d4f5af06ac2ecd612b2a84630bf82e2e5d8311653519585d1", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "0e313b23f06c2c5fa139f2a8f1acd70299a047ab20da88016ec1aa01b5a50b4d", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "c7afd1510b7249021eb2af2a7dcd5fe71e5183bed019ade505489a2f46eb9635", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "fa04600d900f51395493d1d3a51d578d84d13adb64cc5a159322a61877854bd0", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "1ac0ffb9ee287a9da04fbf24e9953dbcbf4e55de906241e7702266808da2243d", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "161617fb877870ce0729dcb8312762b81d6eb4bd5cb3835023fed543b88ff02d" }, "routes": [ - "/settings/support/reporting" + "PBS resource detail History in existing platform tables", + "Availability fleet open resource detail" ], "viewports": [ { - "width": 1280, + "width": 1365, "height": 900 }, { - "width": 375, - "height": 812 + "width": 390, + "height": 844 } ], "states": [ - "Pro licence active with advanced_reporting; Scheduled reports header mentions the Patrol weekly summary because sessionCapabilities.assistantEnabled is true", - "Create schedule form opened: Report type select present with Performance report selected; cadence, format, resources, tag filter, delivery, retention, attach and save-copy controls visible", - "Report type switched to Patrol weekly summary: form reduced to name, timezone, weekday, time, email recipients and Enabled; help text explains the email and that nothing is saved to disk; name placeholder reads Patrol weekly summary", - "Saved schedule row: Weekly Patrol, Monday at 09:00, scope Patrol activity, last 7 days, delivery Email config recipients; API record carries kind patrol_digest and format email", - "Run now without an email destination: Last run shows Failed with the server message naming Settings > Notifications", - "Edit reopens the saved schedule with Report type Patrol weekly summary selected", - "375px: form fields stack full width, table shows name with cadence line, no horizontal clipping" + "Same-ID replacement updates PBS heading, current CPU and history target without remounting the History body or losing the 7-day selection", + "Lost metrics target withdraws the History body and returning target restores the selected tab", + "Selecting another canonical resource starts on its own Overview and uses only its explicit History target", + "An open availability check changes from Up to Down with the current fleet snapshot", + "Selecting Check B after Check A changes header, endpoint and availability together", + "Removal of the selected check closes details; reintroduction does not reopen it", + "Desktop light Chromium and phone dark WebKit have no page overflow or uncaught page errors" ], "interactions": [ - "Click Create schedule", - "Change Report type select to Patrol weekly summary", - "Type schedule name Weekly Patrol and click Save schedule", - "Click Run now on the saved schedule and reload", - "Click Edit on the saved schedule", - "Resize viewport to 375x812 and scroll through the form and table" + "Select History and 7-day range", + "Replace PBS snapshot, withdraw and restore metrics target", + "Select PBS B and open its History", + "Open Check A, publish failed replacement, then select Check B", + "Remove and restore selected check" ] } diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 0d3cba03bd..6ec5669a28 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -65,7 +65,9 @@ export const getSpecializedTabAvailabilityMessage = (tab: SpecializedDrawerTab): const DrawerContent: Component = (props) => { const presentation = () => props.presentation ?? DEFAULT_RESOURCE_DETAIL_DRAWER_PRESENTATION; const drawer = useResourceDetailDrawerState({ - resource: props.resource, + get resource() { + return props.resource; + }, presentation: presentation(), resolveResourceLabel: props.resolveResourceLabel, initialShowAccessContext: props.initialShowAccessContext, @@ -378,16 +380,22 @@ const DrawerContent: Component = (props) => { export const ResourceDetailDrawer: Component = (props) => { return ( - + // Local tabs, disclosures and asynchronous reads belong to one canonical + // resource. Same-ID snapshots stay mounted; a different ID starts fresh. + + {(_resourceId) => ( + + )} + ); }; diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index d69e02c2de..0d224b25f1 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -352,8 +352,9 @@ export const ResourceAccessDisclosure: Component<{ export const ResourceDetailDrawerOverviewTab: Component = ( props, ) => { - const { resource, drawer } = props; - const showPlatformId = shouldShowResourcePlatformId(resource); + const resource = () => props.resource; + const { drawer } = props; + const showPlatformId = () => shouldShowResourcePlatformId(resource()); const pbsJobHealthEvidence = () => buildPbsJobHealthEvidenceModel(drawer.pbsData()); const compactTableRow = () => props.presentation === 'table-row'; const shouldRenderChangeHistorySection = () => @@ -363,14 +364,14 @@ export const ResourceDetailDrawerOverviewTab: Component 0 || Boolean(drawer.facetBundleError()); const attentionItems = () => { - const items = (resource.alerts ?? []).map((alert) => ({ + const items = (resource().alerts ?? []).map((alert) => ({ id: alert.id, message: alert.message, severity: alert.level, })); const healthIssue = drawer.healthIssue(); if (healthIssue && !items.some((item) => item.message === healthIssue.primary)) { - const severity = /critical|failed|faulted|error/i.test(resource.status) + const severity = /critical|failed|faulted|error/i.test(resource().status) ? 'critical' : 'warning'; items.unshift( @@ -389,19 +390,19 @@ export const ResourceDetailDrawerOverviewTab: Component - 0}> + 0}>
@@ -690,7 +691,7 @@ export const ResourceDetailDrawerOverviewTab: Component - +
@@ -706,7 +707,7 @@ export const ResourceDetailDrawerOverviewTab: Component - +
@@ -860,7 +861,7 @@ export const ResourceDetailDrawerOverviewTab: Component {(pbs) => { const connection = getServiceHealthPresentation( - resource.status, + resource().status, pbs().connectionHealth, ); return ( @@ -887,10 +888,10 @@ export const ResourceDetailDrawerOverviewTab: Component - + 0}> @@ -1109,7 +1110,7 @@ export const ResourceDetailDrawerOverviewTab: Component {(pmg) => { const connection = getServiceHealthPresentation( - resource.status, + resource().status, pmg().connectionHealth, ); return ( @@ -1136,10 +1137,10 @@ export const ResourceDetailDrawerOverviewTab: Component - + @@ -1241,7 +1242,7 @@ export const ResourceDetailDrawerOverviewTab: Component - +
diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index a7c1fd727b..42b6336174 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -236,7 +236,7 @@ describe('ResourceDetailDrawer change history section', () => { ); expect(resourceDetailDrawerHistoryStateSource).toContain('resourceFacetRelationships'); expect(resourceDetailDrawerDerivedStateSource).toContain('options.resourceRelationships?.()'); - expect(resourceDetailDrawerDerivedStateSource).toContain('resource.relationships ?? []'); + expect(resourceDetailDrawerDerivedStateSource).toContain('resource().relationships ?? []'); expect(resourceActionHistorySource).toContain('getActionAuditRecordStatePresentation'); expect(resourceActionHistorySource).toContain('getActionAuditResultPresentation'); expect(resourceActionHistorySource).toContain('getActionAuditVerificationOutcomePresentation'); diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx new file mode 100644 index 0000000000..e10cb232a1 --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.snapshot.test.tsx @@ -0,0 +1,233 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { createStore, reconcile } from 'solid-js/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import { ResourceDetailDrawer } from '../ResourceDetailDrawer'; +import { useResourceDetailDrawerDerivedState } from '../useResourceDetailDrawerDerivedState'; + +const requests = vi.hoisted(() => ({ + facets: vi.fn(), + intelligence: vi.fn(), + audits: vi.fn(), + discovery: vi.fn(), +})); + +vi.mock('@/contexts/appRuntime', () => ({ + useWebSocket: () => ({ state: { pmg: [] }, connected: () => true }), + useDarkMode: () => () => false, +})); +vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({ + GuestDrawerHistory: (props: { + target: { resourceType: string; resourceId: string }; + currentMetrics?: Record; + range: string; + }) => ( +
+ ), + GuestDrawerHistoryRangeSelect: (props: { + range: string; + onRangeChange: (range: string) => void; + }) => ( + + ), +})); +vi.mock('@/components/Discovery/DiscoveryTab', () => ({ DiscoveryTab: () => null })); +vi.mock('@/api/discovery', () => ({ getDiscovery: requests.discovery })); +vi.mock('@/api/resources', () => ({ ResourceAPI: { getFacetBundle: requests.facets } })); +vi.mock('@/api/ai', () => ({ AIAPI: { getResourceIntelligence: requests.intelligence } })); +vi.mock('@/api/actionAudit', () => ({ ActionAuditAPI: { listActionAudits: requests.audits } })); +vi.mock('@/api/resourceOperatorState', () => ({ + getResourceOperatorState: vi.fn(async () => null), +})); + +const snapshot = (overrides: Partial = {}): Resource => ({ + id: 'pbs-a', + type: 'pbs', + name: 'PBS A', + displayName: 'PBS A', + platformId: 'pbs-a', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: 'online', + lastSeen: 1_790_982_000_000, + metricsTarget: { resourceType: 'agent', resourceId: 'history-a' }, + cpu: { current: 12 }, + ...overrides, +}); + +beforeEach(() => { + syncAIRuntimeSettings({ discovery_enabled: false } as Parameters< + typeof syncAIRuntimeSettings + >[0]); + requests.facets.mockResolvedValue({ capabilities: [], relationships: [], recentChanges: [] }); + requests.intelligence.mockResolvedValue(null); + requests.audits.mockResolvedValue({ audits: [], count: 0, available: false }); + requests.discovery.mockResolvedValue(null); +}); +afterEach(() => { + cleanup(); + resetAIRuntimeState(); + vi.clearAllMocks(); +}); + +describe('ResourceDetailDrawer snapshot ownership', () => { + it('withdraws former Discovery identification while a replacement source key is pending', async () => { + let finish!: (value: unknown) => void; + requests.discovery + .mockResolvedValueOnce({ service_name: 'Old service', confidence: 0.9 }) + .mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + const initial = snapshot({ + discoveryTarget: { + resourceType: 'agent', + agentId: 'agent-a', + resourceId: 'agent-a', + hostname: 'pbs-a', + }, + }); + const [resource, setResource] = createSignal(initial); + const Probe = () => { + const state = useResourceDetailDrawerDerivedState({ + get resource() { + return resource(); + }, + debugEnabled: () => false, + discoveryFeatureEnabled: () => true, + resourceIntelligence: () => null, + }); + return ( + + {state.discoveryIdentifiedSummary()?.serviceName} + + ); + }; + render(() => ); + await screen.findByText('Old service'); + setResource({ + ...initial, + discoveryTarget: { ...initial.discoveryTarget!, agentId: 'agent-b', resourceId: 'agent-b' }, + }); + expect(screen.getByTestId('identified-service')).toBeEmptyDOMElement(); + await waitFor(() => + expect(requests.discovery).toHaveBeenLastCalledWith('agent', 'agent-b', 'agent-b'), + ); + finish({ service_name: 'Current service', confidence: 0.9 }); + await screen.findByText('Current service'); + expect(screen.queryByText('Old service')).not.toBeInTheDocument(); + }); + + it('updates derived identity, current metrics and the History target without remounting a same-ID drawer', () => { + const [resource, setResource] = createSignal(snapshot()); + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + fireEvent.change(screen.getByRole('combobox', { name: 'History range' }), { + target: { value: '7d' }, + }); + const history = screen.getByTestId('snapshot-history'); + setResource( + snapshot({ + displayName: 'PBS A current', + cpu: { current: 77 }, + metricsTarget: { resourceType: 'agent', resourceId: 'history-a-current' }, + }), + ); + expect(screen.getByRole('heading', { name: 'PBS A current' })).toBeInTheDocument(); + expect(screen.getByTestId('snapshot-history')).toBe(history); + expect(history).toHaveAttribute('data-target', 'history-a-current'); + expect(history).toHaveAttribute('data-cpu', '77'); + expect(history).toHaveAttribute('data-range', '7d'); + }); + + it('withdraws a lost metrics target on an immutable snapshot and recovers the selected tab when it returns', () => { + const [resource, setResource] = createSignal(snapshot()); + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + setResource(snapshot({ metricsTarget: undefined })); + expect(screen.queryByTestId('snapshot-history')).not.toBeInTheDocument(); + expect(screen.getByText('Metrics history is unavailable.')).toBeInTheDocument(); + setResource(snapshot()); + expect(screen.getByTestId('snapshot-history')).toHaveAttribute('data-target', 'history-a'); + expect(screen.getByRole('tab', { name: 'History' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('updates availability evidence in Overview instead of retaining the initial snapshot', () => { + const initial = snapshot({ + type: 'network-endpoint', + platformType: 'availability', + metricsTarget: undefined, + availability: { + protocol: 'https', + address: 'check-a.invalid', + port: 443, + available: true, + latencyMillis: 12, + }, + }); + const [resource, setResource] = createSignal(initial); + const { container } = render(() => ( + + )); + expect(screen.getByTestId('availability-probe-status')).toHaveTextContent('Up'); + setResource({ + ...initial, + availability: { ...initial.availability!, available: false, latencyMillis: 0 }, + }); + expect(screen.getByTestId('availability-probe-status')).toHaveTextContent('Down'); + expect(container).not.toHaveTextContent('12ms'); + }); + + it('disposes per-resource disclosure state and late facet results when a different ID is selected', async () => { + let completeOld!: (value: unknown) => void; + requests.facets.mockReturnValueOnce(new Promise((resolve) => (completeOld = resolve))); + const [resource, setResource] = createSignal(snapshot()); + const { container } = render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + setResource( + snapshot({ + id: 'pbs-b', + name: 'PBS B', + displayName: 'PBS B', + metricsTarget: { resourceType: 'agent', resourceId: 'history-b' }, + }), + ); + expect(screen.getByRole('heading', { name: 'PBS B' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByTestId('snapshot-history')).not.toBeInTheDocument(); + await waitFor(() => expect(requests.facets).toHaveBeenCalledWith('pbs-b', { limit: 25 })); + completeOld({ + capabilities: [], + relationships: [], + recentChanges: [ + { id: 'old', reason: 'Former resource change', observedAt: '2026-10-02T23:00:00Z' }, + ], + }); + await Promise.resolve(); + expect(container).not.toHaveTextContent('Former resource change'); + expect(container).not.toHaveTextContent('PBS A'); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + expect(screen.getByTestId('snapshot-history')).toHaveAttribute('data-target', 'history-b'); + }); + + it('keeps the reconciled-store client and same-ID tab continuity working', () => { + const [resource, setResource] = createStore(snapshot()); + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + const history = screen.getByTestId('snapshot-history'); + setResource(reconcile(snapshot({ cpu: { current: 35 } }))); + expect(screen.getByTestId('snapshot-history')).toBe(history); + expect(history).toHaveAttribute('data-cpu', '35'); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 8a0bc69064..52fcbcc4ff 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -117,35 +117,34 @@ interface UseResourceDetailDrawerDerivedStateOptions { export const useResourceDetailDrawerDerivedState = ( options: UseResourceDetailDrawerDerivedStateOptions, ) => { + const resource = () => options.resource; const { - resource, resolveResourceLabel: resolveResourceLabelInput, debugEnabled, discoveryFeatureEnabled, resourceIntelligence, } = options; - const displayName = createMemo(() => getPreferredInfrastructureDisplayName(resource)); - const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource) ?? ''); + const displayName = createMemo(() => getPreferredInfrastructureDisplayName(resource())); + const kubernetesClusterName = createMemo(() => getPreferredResourceClusterName(resource()) ?? ''); const resolveResourceLabel = (resourceId: string): string => resolveResourceLabelInput?.(resourceId)?.trim() || resourceId; - const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource.status })); - const healthIssue = createMemo(() => getResourceHealthIssuePresentation(resource)); - const lastSeen = createMemo(() => formatRelativeTime(resource.lastSeen)); - const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(resource.lastSeen)); + const statusIndicator = createMemo(() => getAgentStatusIndicator({ status: resource().status })); + const healthIssue = createMemo(() => getResourceHealthIssuePresentation(resource())); + const lastSeen = createMemo(() => formatRelativeTime(resource().lastSeen)); + const lastSeenAbsolute = createMemo(() => formatAbsoluteTime(resource().lastSeen)); - const platformBadge = createMemo(() => getPlatformBadge(resource.platformType)); - const sourceBadge = createMemo(() => getSourceBadge(resource.sourceType)); - const typeBadge = createMemo(() => getTypeBadge(resource.type)); - const platformData = createMemo(() => resource.platformData as PlatformData | undefined); - const unifiedSourceBadges = createMemo(() => - getUnifiedSourceBadges( - Array.isArray(resource.sources) && resource.sources.length > 0 - ? resource.sources - : (platformData()?.sources ?? []), - ), - ); - const systemIdentityBadges = createMemo(() => getInfrastructureSystemIdentityBadges(resource)); + const platformBadge = createMemo(() => getPlatformBadge(resource().platformType)); + const sourceBadge = createMemo(() => getSourceBadge(resource().sourceType)); + const typeBadge = createMemo(() => getTypeBadge(resource().type)); + const platformData = createMemo(() => resource().platformData as PlatformData | undefined); + const unifiedSourceBadges = createMemo(() => { + const sources = resource().sources; + return getUnifiedSourceBadges( + Array.isArray(sources) && sources.length > 0 ? sources : (platformData()?.sources ?? []), + ); + }); + const systemIdentityBadges = createMemo(() => getInfrastructureSystemIdentityBadges(resource())); const hasUnifiedSources = createMemo(() => unifiedSourceBadges().length > 0); const headerBadges = createMemo(() => dedupeResourceBadges([ @@ -157,41 +156,42 @@ export const useResourceDetailDrawerDerivedState = ( : [platformBadge(), sourceBadge()]), ]), ); - const policyBadges = createMemo(() => getResourcePolicyBadges(resource.policy)); - const policyRedactions = createMemo(() => getResourcePolicyRedactionLabels(resource.policy)); + const policyBadges = createMemo(() => getResourcePolicyBadges(resource().policy)); + const policyRedactions = createMemo(() => getResourcePolicyRedactionLabels(resource().policy)); const governanceSummary = createMemo(() => - requiresGovernedResourceDisplay(resource.policy) - ? getResourcePolicyGovernedSummary(resource) - : (resource.aiSafeSummary?.trim() ?? ''), + requiresGovernedResourceDisplay(resource().policy) + ? getResourcePolicyGovernedSummary(resource()) + : (resource().aiSafeSummary?.trim() ?? ''), ); const hasGovernanceData = createMemo( () => - !hasDefaultResourcePolicyPosture(resource.policy) && + !hasDefaultResourcePolicyPosture(resource().policy) && (policyBadges().length > 0 || Boolean(governanceSummary())), ); const agentMeta = createMemo( - () => resource.agent ?? (platformData()?.agent as AgentPlatformData | undefined), + () => resource().agent ?? (platformData()?.agent as AgentPlatformData | undefined), ); const kubernetesMeta = createMemo( - () => resource.kubernetes ?? (platformData()?.kubernetes as KubernetesPlatformData | undefined), + () => + resource().kubernetes ?? (platformData()?.kubernetes as KubernetesPlatformData | undefined), ); const vmwareData = createMemo( - () => resource.vmware ?? (platformData()?.vmware as ResourceVMwareMeta | undefined), + () => resource().vmware ?? (platformData()?.vmware as ResourceVMwareMeta | undefined), ); const kubernetesCapabilityBadges = createMemo(() => buildKubernetesCapabilityBadges(kubernetesMeta()?.metricCapabilities), ); - const proxmoxNode = createMemo(() => toNodeFromProxmox(resource)); + const proxmoxNode = createMemo(() => toNodeFromProxmox(resource())); const proxmoxNetworkInterfaces = createMemo( () => - resource.proxmox?.networkInterfaces ?? + resource().proxmox?.networkInterfaces ?? (platformData()?.proxmox as { networkInterfaces?: HostNetworkInterface[] } | undefined) ?.networkInterfaces ?? [], ); - const agentInfo = createMemo(() => toAgentFromResource(resource, agentMeta())); + const agentInfo = createMemo(() => toAgentFromResource(resource(), agentMeta())); const temperatureRows = createMemo(() => buildTemperatureRows(agentInfo()?.sensors)); const customSensorRows = createMemo(() => buildCustomSensorRows(agentInfo()?.sensors)); @@ -225,7 +225,7 @@ export const useResourceDetailDrawerDerivedState = ( const resourceDependents = createMemo(() => resourceIntelligence()?.dependents ?? []); const resourceCorrelations = createMemo(() => resourceIntelligence()?.correlations ?? []); const resourceRelationships = createMemo( - () => options.resourceRelationships?.() ?? resource.relationships ?? [], + () => options.resourceRelationships?.() ?? resource().relationships ?? [], ); const hasMeaningfulResourceIntelligence = createMemo(() => { const intel = resourceIntelligence(); @@ -257,8 +257,9 @@ export const useResourceDetailDrawerDerivedState = ( if (intel && hasMeaningfulResourceIntelligence()) { summary.push(formatResourceAnalysisSummary(intel.health.grade, intel.health.score)); } - if (resource.policy?.routing.scope && !hasDefaultResourcePolicyPosture(resource.policy)) { - summary.push(`Routing ${getResourceRoutingScopeLabel(resource.policy.routing.scope)}`); + const policy = resource().policy; + if (policy?.routing.scope && !hasDefaultResourcePolicyPosture(policy)) { + summary.push(`Routing ${getResourceRoutingScopeLabel(policy.routing.scope)}`); } return summary.join(' ยท '); @@ -287,7 +288,7 @@ export const useResourceDetailDrawerDerivedState = ( ); const sourceSummary = createMemo(() => buildSourceSummary(mergedSources(), sourceStatus())); - const identityView = createMemo(() => buildResourceIdentityView(resource)); + const identityView = createMemo(() => buildResourceIdentityView(resource())); const identityAliasValues = createMemo(() => identityView().identityAliasValues); const identityIpValues = createMemo(() => identityView().identityIpValues); const primaryIdentityRows = createMemo(() => identityView().primaryIdentityRows); @@ -295,7 +296,7 @@ export const useResourceDetailDrawerDerivedState = ( const aliasPreviewValues = createMemo(() => identityView().aliasPreviewValues); const hasAliasOverflow = createMemo(() => identityView().hasAliasOverflow); const hasMergedSources = createMemo(() => mergedSources().length > 1); - const discoveryConfig = createMemo(() => toDiscoveryConfig(resource)); + const discoveryConfig = createMemo(() => toDiscoveryConfig(resource())); const discoveryContextSummary = createMemo(() => buildDiscoveryContextSummary(discoveryConfig())); const discoverySourceKey = createMemo(() => { if (!discoveryFeatureEnabled()) return null; @@ -314,6 +315,7 @@ export const useResourceDetailDrawerDerivedState = ( source: discoverySourceKey, initialValue: null, cacheKey: (key) => `resource-detail-discovery:${key.type}:${key.agent}:${key.resource}`, + retainPreviousValueOnSourceChange: false, fetcher: async (key) => { try { return await getDiscovery(key.type, key.agent, key.resource); @@ -342,43 +344,43 @@ export const useResourceDetailDrawerDerivedState = ( const hasHostDetails = createMemo(() => hostDetailCards().length > 0); const hostDetailSummary = createMemo(() => buildHostDetailSummary(hostDetailCards())); const hasServiceDetails = createMemo( - () => resource.type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()), + () => resource().type === 'docker-host' || Boolean(pbsData()) || Boolean(pmgData()), ); const serviceDetailsSummary = createMemo(() => { return getServiceDetailsSummary({ - resourceType: resource.type, + resourceType: resource().type, docker: dockerHostData(), pbs: pbsData(), pmg: pmgData(), }); }); const vmwareDetailSections = createMemo(() => - buildVMwareDetailSections(resource.type, vmwareData()), + buildVMwareDetailSections(resource().type, vmwareData()), ); const hasVMwareDetails = createMemo(() => vmwareDetailSections().length > 0); const vmwareDetailsSummary = createMemo(() => - buildVMwareDetailsSummary(resource.type, vmwareData()), + buildVMwareDetailsSummary(resource().type, vmwareData()), ); - const trueNASDetailSections = createMemo(() => buildTrueNASDetailSections(resource)); + const trueNASDetailSections = createMemo(() => buildTrueNASDetailSections(resource())); const hasTrueNASDetails = createMemo(() => trueNASDetailSections().length > 0); - const trueNASDetailsSummary = createMemo(() => buildTrueNASDetailsSummary(resource)); - const kubernetesDetailSections = createMemo(() => buildKubernetesDetailSections(resource)); + const trueNASDetailsSummary = createMemo(() => buildTrueNASDetailsSummary(resource())); + const kubernetesDetailSections = createMemo(() => buildKubernetesDetailSections(resource())); const hasKubernetesDetails = createMemo(() => kubernetesDetailSections().length > 0); - const kubernetesDetailsSummary = createMemo(() => buildKubernetesDetailsSummary(resource)); - const metricsHistoryTarget = createMemo(() => getResourceMetricsHistoryTarget(resource)); + const kubernetesDetailsSummary = createMemo(() => buildKubernetesDetailsSummary(resource())); + const metricsHistoryTarget = createMemo(() => getResourceMetricsHistoryTarget(resource())); const metricsHistoryCurrentMetrics = createMemo(() => - getResourceMetricsHistoryCurrentMetrics(resource), + getResourceMetricsHistoryCurrentMetrics(resource()), ); - const metricsHistoryGroups = createMemo(() => getResourceMetricsHistoryGroups(resource)); - const hasMetricsHistory = createMemo(() => resourceSupportsMetricsHistory(resource)); + const metricsHistoryGroups = createMemo(() => getResourceMetricsHistoryGroups(resource())); + const hasMetricsHistory = createMemo(() => resourceSupportsMetricsHistory(resource())); const hasDiscoveryTab = createMemo( () => discoveryFeatureEnabled() && - isPulseAgentPlatformResource(resource) && + isPulseAgentPlatformResource(resource()) && Boolean(discoveryConfig()), ); - const relatedLinks = createMemo(() => buildRelatedLinks(resource, displayName())); + const relatedLinks = createMemo(() => buildRelatedLinks(resource(), displayName())); const accessSummary = createMemo(() => buildAccessSummary({ hasWebInterface: Boolean(discoveryConfig()), @@ -396,7 +398,7 @@ export const useResourceDetailDrawerDerivedState = ( const identityMatchInfo = createMemo(() => buildIdentityMatchInfo(platformData())); const debugBundle = createMemo(() => buildResourceDebugBundle({ - resource, + resource: resource(), platformData: platformData(), sourceStatus: sourceStatus(), identityMatchInfo: identityMatchInfo(), @@ -410,14 +412,14 @@ export const useResourceDetailDrawerDerivedState = ( ...(hasMetricsHistory() ? [{ id: 'history' as DrawerTab, label: 'History' }] : []), { id: 'manage' as DrawerTab, label: 'Manage' }, ...(hasDiscoveryTab() ? [{ id: 'discovery' as DrawerTab, label: 'Discovery' }] : []), - ...(resource.type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []), - ...(resource.type === 'k8s-cluster' + ...(resource().type === 'pmg' ? [{ id: 'mail' as DrawerTab, label: 'Mail' }] : []), + ...(resource().type === 'k8s-cluster' ? [{ id: 'namespaces' as DrawerTab, label: 'Namespaces' }] : []), - ...(resource.type === 'k8s-cluster' + ...(resource().type === 'k8s-cluster' ? [{ id: 'deployments' as DrawerTab, label: 'Deployments' }] : []), - ...(resource.type === 'docker-host' && dockerSwarmClusterKey() + ...(resource().type === 'docker-host' && dockerSwarmClusterKey() ? [{ id: 'swarm' as DrawerTab, label: 'Swarm' }] : []), ]; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts index 7ae6dd26a8..1313a32174 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts @@ -35,10 +35,10 @@ type TimelineFacetRequest = { export const useResourceDetailDrawerHistoryState = ( options: UseResourceDetailDrawerHistoryStateOptions, ) => { - const { resource } = options; + const resource = () => options.resource; const enableRemoteHistory = options.enableRemoteHistory ?? true; - const resourceFacetId = createMemo(() => resource.id.trim()); + const resourceFacetId = createMemo(() => resource().id.trim()); const [timelineKindFilter, setTimelineKindFilter] = createSignal(''); const [timelineSourceTypeFilter, setTimelineSourceTypeFilter] = createSignal< ResourceChangeSourceType | '' @@ -124,16 +124,16 @@ export const useResourceDetailDrawerHistoryState = ( const refetchTimelineFacets = timelineFacetsState.refetch; const resourceTimeline = createMemo( - () => resourceFacets()?.recentChanges ?? resource.recentChanges ?? [], + () => resourceFacets()?.recentChanges ?? resource().recentChanges ?? [], ); const resourceFacetCapabilities = createMemo( - () => resourceFacets()?.capabilities ?? resource.capabilities ?? [], + () => resourceFacets()?.capabilities ?? resource().capabilities ?? [], ); const resourceFacetRelationships = createMemo( - () => resourceFacets()?.relationships ?? resource.relationships ?? [], + () => resourceFacets()?.relationships ?? resource().relationships ?? [], ); const resourceFacetCounts = createMemo( - () => resourceFacets()?.counts ?? resource.facetCounts ?? null, + () => resourceFacets()?.counts ?? resource().facetCounts ?? null, ); const historyFacetBundle = createMemo(() => timelineFacetRequest() ? (timelineFacets() ?? resourceFacets()) : resourceFacets(), diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts index 21f7610453..d1e7a34913 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts @@ -30,7 +30,10 @@ export interface UseResourceDetailDrawerStateOptions { } export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerStateOptions) => { - const { resource, resolveResourceLabel: resolveResourceLabelInput } = options; + // Read the caller's getter inside each computation, supporting replacement + // snapshots as well as the stable store proxies used by platform rows. + const resource = () => options.resource; + const resolveResourceLabelInput = options.resolveResourceLabel; const [activeTab, setActiveTab] = createSignal('overview'); const { discoveryFeatureEnabled } = useDiscoveryFeatureAvailability(); const [metricsHistoryRange, setMetricsHistoryRange] = createSignal( @@ -59,11 +62,15 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta const [k8sDeploymentsPrefillNamespace, setK8sDeploymentsPrefillNamespace] = createSignal(''); const history = useResourceDetailDrawerHistoryState({ - resource, + get resource() { + return resource(); + }, enableRemoteHistory: options.presentation !== 'table-row', }); const derived = useResourceDetailDrawerDerivedState({ - resource, + get resource() { + return resource(); + }, resolveResourceLabel: resolveResourceLabelInput, debugEnabled, discoveryFeatureEnabled, @@ -95,7 +102,7 @@ export const useResourceDetailDrawerState = (options: UseResourceDetailDrawerSta // tab is unavailable, so a same-resource change must not reset the selection. let lastResourceId: string | undefined; createEffect(() => { - const resourceId = resource.id; + const resourceId = resource().id; const available = new Set(derived.tabs().map((tab) => tab.id)); if (resourceId !== lastResourceId) { if (!available.has(activeTab())) { diff --git a/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx b/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx index 8315ea08f8..7e2d934df9 100644 --- a/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx +++ b/frontend-modern/src/features/standalone/AvailabilityFleetView.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, createSignal, type Component } from 'solid-js'; +import { For, Show, createEffect, createMemo, createSignal, type Component } from 'solid-js'; import RefreshCwIcon from 'lucide-solid/icons/refresh-cw'; import { ResourceDetailDrawer } from '@/components/Infrastructure/ResourceDetailDrawer'; import { Button } from '@/components/shared/Button'; @@ -167,7 +167,15 @@ export const AvailabilityFleetView: Component<{ probeAgentOptions?: readonly ProbeAgentOption[]; onRetryHistory?: () => void; }> = (props) => { - const [selectedResource, setSelectedResource] = createSignal(); + const [selectedResourceId, setSelectedResourceId] = createSignal(); + const selectedResource = createMemo(() => + props.resources.find((resource) => resource.id === selectedResourceId()), + ); + // A removed check is no longer evidence for the open detail. Clear its + // selection as well, so a later reappearance cannot silently reopen it. + createEffect(() => { + if (selectedResourceId() && !selectedResource()) setSelectedResourceId(undefined); + }); const resolveResourceLabel = (resourceId: string): string | undefined => props.resources.find((resource) => resource.id === resourceId)?.name; @@ -242,7 +250,7 @@ export const AvailabilityFleetView: Component<{ class="group min-w-0 rounded-md border border-border bg-surface p-3 text-left shadow-sm transition hover:border-blue-400/60 hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500" aria-label={`Open details for ${resource.name}`} data-availability-fleet-tile={resource.id} - onClick={() => setSelectedResource(resource)} + onClick={() => setSelectedResourceId(resource.id)} >
@@ -314,7 +322,7 @@ export const AvailabilityFleetView: Component<{ setSelectedResource(undefined)} + onClose={() => setSelectedResourceId(undefined)} /> )} diff --git a/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx b/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx index b4f185890b..f31851a32f 100644 --- a/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/AvailabilityFleetView.test.tsx @@ -1,12 +1,17 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createSignal } from 'solid-js'; import type { AvailabilityHistoryTarget } from '@/api/availabilityHistory'; import type { Resource } from '@/types/resource'; import { AvailabilityFleetView } from '../AvailabilityFleetView'; vi.mock('@/components/Infrastructure/ResourceDetailDrawer', () => ({ ResourceDetailDrawer: (props: { resource: Resource; onClose?: () => void }) => ( -
+
@@ -92,6 +97,28 @@ const history = (index: number): AvailabilityHistoryTarget => ({ afterEach(cleanup); describe('AvailabilityFleetView', () => { + it('keeps open details bound to the selected ID in the current fleet, not the clicked snapshot', () => { + const original = resource(1); + const [resources, setResources] = createSignal([original]); + render(() => ( + + )); + fireEvent.click(screen.getByRole('button', { name: 'Open details for Service 1' })); + setResources([{ ...original, status: 'offline' }]); + expect(screen.getByTestId('resource-detail-drawer')).toHaveAttribute( + 'data-resource-status', + 'offline', + ); + setResources([]); + expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument(); + setResources([original]); + expect(screen.queryByTestId('resource-detail-drawer')).not.toBeInTheDocument(); + }); + it('renders fifty keyboard-accessible attention tiles with non-color history labels', () => { const resources = Array.from({ length: 50 }, (_, index) => resource(index)); const historyByTarget = new Map( From 286c8a1f510134002c90ac919ce98273fb4f3e9e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:45:25 +0100 Subject: [PATCH 02/86] Make write and CPU troubleshooting bounded and evidence-preserving Give existing help readers scoped process/container samples, honest units and unchanged-workload baselines. Exercise the exact copied recipes, including stopped services, missing counters and failed or empty container reads, without touching a database or service. Contract-Neutral: Existing-guide documentation and recipe tests only; no runtime, API, persistence, access or retention change. Change-source: pulse-maintainer --- docs/TROUBLESHOOTING.md | 93 ++++++++ .../public/docs/TROUBLESHOOTING.md | 93 ++++++++ .../test_performance_troubleshooting_docs.py | 209 ++++++++++++++++++ 3 files changed, 395 insertions(+) create mode 100644 scripts/tests/test_performance_troubleshooting_docs.py diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 032d808db2..9b11c341cd 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -216,6 +216,99 @@ leave the original host unchanged. Follow [Clone identity recovery](UNIFIED_AGENT.md#duplicate-agents) for the configuration precedence, systemd example and checks after restart. +#### Excessive CPU, writes or database growth + +First distinguish **Pulse server activity**, agent activity and total host or +storage-device activity. A database's size is retained space, not the number of +bytes written: repeated updates can produce high writes without growing the file. +Keep the original running version, uptime, measurement time and workload before +changing anything. A different binary can migrate persistent data; switching +back to an older version does not make that data a clean older-version baseline. + +- For CPU, record the measuring tool, allocated CPUs or container quota and + whether its percentage represents one core or the whole allocation. A `ps` + `%CPU` value is an average since process start, not a recent sampling window. +- Note fleet size, polling interval and whether Pulse dashboards are open. If + safe, close all Pulse tabs and compare another equal-length window, without + restarting the server or changing polling. A decrease narrows the workload; + it does not establish the cause or prove monitoring is healthy. +- If the host is unresponsive or storage is nearly full, do not prolong the + failure to collect a benchmark. Keep existing observations and report the + interruption instead. Do not enable Debug or repeatedly export diagnostics + merely to measure performance; those actions add work and can change the result. + +For a responsive **Linux systemd / Proxmox LXC** install, the following reads +two process-I/O samples, waiting 60 seconds between them. Run it inside the +Pulse container for LXC, not on the Proxmox host. Substitute the actual service +name (`pulse-backend` on some older installs). Use an account authorised to +read the process counters; no service restart or database access is needed. + +```bash +# systemd / Proxmox LXC: bounded process-write samples +( + set -e + for sample in 1 2; do + date -u +'%Y-%m-%dT%H:%M:%SZ' + pid=$(systemctl show pulse --property=MainPID --value) + case "$pid" in + ''|0|*[!0-9]*) printf 'No running Pulse PID; sample unavailable.\n' >&2; exit 1 ;; + esac + TZ=UTC ps -p "$pid" -o pid=,lstart= + sudo awk ' + $1 == "write_bytes:" || $1 == "cancelled_write_bytes:" { print; fields++ } + END { if (fields != 2) exit 1 } + ' "/proc/$pid/io" + if [ "$sample" -eq 1 ]; then sleep 60; fi + done +) +``` + +Compare `write_bytes` only when both samples have the same PID and process +start time, no restart occurred, and the counter did not decrease. Divide the +byte difference by the **actual elapsed seconds**. This is storage-accounted +process I/O, not filesystem growth or physical SSD wear; cancelled writes and +background writeback can differ from device measurements. Keep +`cancelled_write_bytes` alongside it, not as proof of bytes reaching the drive. +An extrapolated GB/day rate is a projection of that short window, not a measured +day's total. Preserve the window and units with the result. + +For **Docker / Compose**, run this on the Docker host, replacing `pulse` with +the running container name. It reads only the start time and selected statistics, +not the container environment or configuration. + +```bash +# Docker: bounded container statistics +( + set -e + for sample in 1 2; do + date -u +'%Y-%m-%dT%H:%M:%SZ' + docker inspect --format 'Started={{.State.StartedAt}}' pulse + stats=$(docker stats --no-stream --format \ + 'CPU={{.CPUPerc}} Memory={{.MemUsage}} BlockIO={{.BlockIO}}' pulse) + if [ -z "$stats" ]; then + printf 'Container statistics unavailable; no zero inferred.\n' >&2 + exit 1 + fi + printf '%s\n' "$stats" + if [ "$sample" -eq 1 ]; then sleep 60; fi + done +) +``` + +Docker **BlockIO** is cumulative read / write activity, not bytes per second; +compare its write side only across the same uninterrupted container run. Its +displayed units are rounded. Container, process and whole-device counters have +different scopes and must not be added together or compared as interchangeable +measurements. A failed, denied or empty read is unavailable, not zero activity. + +Keep persistent data on durable storage. Do not delete or truncate history, +incident, queue or audit files, remove database indexes, or move the data +directory to tmpfs as a diagnostic workaround. These actions can lose evidence +or protections without fixing the writer. Do not post databases, profiles, full +`/proc` dumps, container configuration or raw environments. Share only the bounded +measurements, workload, versions and a relevant manually redacted error; follow +[Getting Help](#-getting-help) for any additional evidence. + ### Notifications #### No alert when Pulse, power or internet goes down diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index 032d808db2..9b11c341cd 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -216,6 +216,99 @@ leave the original host unchanged. Follow [Clone identity recovery](UNIFIED_AGENT.md#duplicate-agents) for the configuration precedence, systemd example and checks after restart. +#### Excessive CPU, writes or database growth + +First distinguish **Pulse server activity**, agent activity and total host or +storage-device activity. A database's size is retained space, not the number of +bytes written: repeated updates can produce high writes without growing the file. +Keep the original running version, uptime, measurement time and workload before +changing anything. A different binary can migrate persistent data; switching +back to an older version does not make that data a clean older-version baseline. + +- For CPU, record the measuring tool, allocated CPUs or container quota and + whether its percentage represents one core or the whole allocation. A `ps` + `%CPU` value is an average since process start, not a recent sampling window. +- Note fleet size, polling interval and whether Pulse dashboards are open. If + safe, close all Pulse tabs and compare another equal-length window, without + restarting the server or changing polling. A decrease narrows the workload; + it does not establish the cause or prove monitoring is healthy. +- If the host is unresponsive or storage is nearly full, do not prolong the + failure to collect a benchmark. Keep existing observations and report the + interruption instead. Do not enable Debug or repeatedly export diagnostics + merely to measure performance; those actions add work and can change the result. + +For a responsive **Linux systemd / Proxmox LXC** install, the following reads +two process-I/O samples, waiting 60 seconds between them. Run it inside the +Pulse container for LXC, not on the Proxmox host. Substitute the actual service +name (`pulse-backend` on some older installs). Use an account authorised to +read the process counters; no service restart or database access is needed. + +```bash +# systemd / Proxmox LXC: bounded process-write samples +( + set -e + for sample in 1 2; do + date -u +'%Y-%m-%dT%H:%M:%SZ' + pid=$(systemctl show pulse --property=MainPID --value) + case "$pid" in + ''|0|*[!0-9]*) printf 'No running Pulse PID; sample unavailable.\n' >&2; exit 1 ;; + esac + TZ=UTC ps -p "$pid" -o pid=,lstart= + sudo awk ' + $1 == "write_bytes:" || $1 == "cancelled_write_bytes:" { print; fields++ } + END { if (fields != 2) exit 1 } + ' "/proc/$pid/io" + if [ "$sample" -eq 1 ]; then sleep 60; fi + done +) +``` + +Compare `write_bytes` only when both samples have the same PID and process +start time, no restart occurred, and the counter did not decrease. Divide the +byte difference by the **actual elapsed seconds**. This is storage-accounted +process I/O, not filesystem growth or physical SSD wear; cancelled writes and +background writeback can differ from device measurements. Keep +`cancelled_write_bytes` alongside it, not as proof of bytes reaching the drive. +An extrapolated GB/day rate is a projection of that short window, not a measured +day's total. Preserve the window and units with the result. + +For **Docker / Compose**, run this on the Docker host, replacing `pulse` with +the running container name. It reads only the start time and selected statistics, +not the container environment or configuration. + +```bash +# Docker: bounded container statistics +( + set -e + for sample in 1 2; do + date -u +'%Y-%m-%dT%H:%M:%SZ' + docker inspect --format 'Started={{.State.StartedAt}}' pulse + stats=$(docker stats --no-stream --format \ + 'CPU={{.CPUPerc}} Memory={{.MemUsage}} BlockIO={{.BlockIO}}' pulse) + if [ -z "$stats" ]; then + printf 'Container statistics unavailable; no zero inferred.\n' >&2 + exit 1 + fi + printf '%s\n' "$stats" + if [ "$sample" -eq 1 ]; then sleep 60; fi + done +) +``` + +Docker **BlockIO** is cumulative read / write activity, not bytes per second; +compare its write side only across the same uninterrupted container run. Its +displayed units are rounded. Container, process and whole-device counters have +different scopes and must not be added together or compared as interchangeable +measurements. A failed, denied or empty read is unavailable, not zero activity. + +Keep persistent data on durable storage. Do not delete or truncate history, +incident, queue or audit files, remove database indexes, or move the data +directory to tmpfs as a diagnostic workaround. These actions can lose evidence +or protections without fixing the writer. Do not post databases, profiles, full +`/proc` dumps, container configuration or raw environments. Share only the bounded +measurements, workload, versions and a relevant manually redacted error; follow +[Getting Help](#-getting-help) for any additional evidence. + ### Notifications #### No alert when Pulse, power or internet goes down diff --git a/scripts/tests/test_performance_troubleshooting_docs.py b/scripts/tests/test_performance_troubleshooting_docs.py new file mode 100644 index 0000000000..329b5a2c77 --- /dev/null +++ b/scripts/tests/test_performance_troubleshooting_docs.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Exercise the guide's bounded I/O recipes with synthetic OS/container readers. + +The real awk filters run against fixtures. No Pulse process, database, Docker +daemon or host service is inspected. These checks establish recipe behaviour, +not native write rates, CPU relief or an operational repair. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +DOC = ROOT / "docs/TROUBLESHOOTING.md" + + +def section() -> str: + return DOC.read_text(encoding="utf-8").split( + "#### Excessive CPU, writes or database growth\n", 1 + )[1].split("\n### Notifications\n", 1)[0] + + +def recipes() -> dict[str, str]: + blocks = re.findall(r"```bash\n(.*?)```", section(), re.DOTALL) + if len(blocks) != 2: + raise AssertionError("expected two bounded deployment recipes") + return {"docker" if "# Docker:" in block else "systemd": block for block in blocks} + + +# All external commands record exact arguments. sudo is a test-only adapter: +# it replaces only the documented /proc/PID/io input and executes real awk. +READER = '''#!/usr/bin/env python3 +import json, os, pathlib, subprocess, sys +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +root = pathlib.Path(os.environ['RECIPE_FIXTURE']) +log = root / 'calls.jsonl' +previous = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] +count = sum(call['name'] == name for call in previous) +with log.open('a') as stream: + stream.write(json.dumps({'name': name, 'args': args}) + '\\n') +if name == 'date': + print('2026-10-03T00:00:00Z' if count == 0 else '2026-10-03T00:01:00Z') +elif name == 'sleep': + assert args == ['60'] +elif name == 'systemctl': + print(os.environ.get('FIXTURE_PID', '4321')) + sys.exit(int(os.environ.get('SYSTEMCTL_EXIT', '0'))) +elif name == 'ps': + assert os.environ['TZ'] == 'UTC' + print('4321 Sat Oct 3 00:00:00 2026') + sys.exit(int(os.environ.get('PS_EXIT', '0'))) +elif name == 'sudo': + assert args[0] == 'awk' + assert args[-1] == '/proc/4321/io' + target = root / ('missing' if os.environ.get('IO_MISSING') else f'io-{count}') + result = subprocess.run([os.environ['REAL_AWK'], *args[1:-1], str(target)]) + sys.exit(result.returncode) +elif name == 'docker': + if args[0] == 'inspect': + print('Started=2026-10-02T20:00:00Z') + elif args[0] == 'stats': + text = os.environ.get('DOCKER_STATS', 'CPU=12.50% Memory=100MiB / 1GiB BlockIO=1MB / 12MB') + if text: + print(text) + else: + raise AssertionError('unsupported docker command') + setting = 'DOCKER_INSPECT_EXIT' if args[0] == 'inspect' else 'DOCKER_STATS_EXIT' + sys.exit(int(os.environ.get(setting, '0'))) +else: + raise AssertionError('unexpected command') +''' + + +class PerformanceTroubleshootingDocsTest(unittest.TestCase): + def exercise(self, deployment: str, *, counters: tuple[str, str] | None = None, + **settings: str): + import shutil + + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + for name in ("date", "sleep", "systemctl", "ps", "sudo", "docker"): + command = directory / name + command.write_text(READER, encoding="utf-8") + command.chmod(0o700) + counters = counters or ( + "rchar: 987654\nwchar: 999999\nwrite_bytes: 1000\ncancelled_write_bytes: 20\n", + "rchar: 999999\nwchar: 999999\nwrite_bytes: 13000\ncancelled_write_bytes: 30\n", + ) + for index, text in enumerate(counters): + (directory / f"io-{index}").write_text(text, encoding="utf-8") + env = dict(os.environ, PATH=f"{directory}:{os.environ['PATH']}", + RECIPE_FIXTURE=str(directory), REAL_AWK=shutil.which("awk"), **settings) + result = subprocess.run(["bash", "-c", recipes()[deployment]], env=env, + text=True, capture_output=True, timeout=10) + calls = [json.loads(line) for line in (directory / "calls.jsonl").read_text().splitlines()] + return result, calls + + def test_shipped_document_matches_the_exercised_source(self): + self.assertEqual(DOC.read_bytes(), + (ROOT / "frontend-modern/public/docs/TROUBLESHOOTING.md").read_bytes()) + + def test_systemd_samples_only_write_counters_and_process_identity(self): + result, calls = self.exercise("systemd") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual([call["name"] for call in calls], + ["date", "systemctl", "ps", "sudo", "sleep", + "date", "systemctl", "ps", "sudo"]) + for call in calls: + if call["name"] == "systemctl": + self.assertEqual(call["args"], ["show", "pulse", "--property=MainPID", "--value"]) + elif call["name"] == "ps": + self.assertEqual(call["args"], ["-p", "4321", "-o", "pid=,lstart="]) + self.assertIn("write_bytes: 1000", result.stdout) + self.assertIn("write_bytes: 13000", result.stdout) + self.assertIn("cancelled_write_bytes: 20", result.stdout) + self.assertNotIn("rchar", result.stdout) + self.assertNotIn("wchar", result.stdout) + + def test_unavailable_pid_never_reads_a_process_or_sleeps(self): + for pid in ("", "0", "not-a-pid", "4321 extra"): + with self.subTest(pid=pid): + result, calls = self.exercise("systemd", FIXTURE_PID=pid) + self.assertNotEqual(result.returncode, 0) + self.assertIn("sample unavailable", result.stderr) + self.assertEqual([c["name"] for c in calls], ["date", "systemctl"]) + + def test_service_and_process_read_failures_stop_the_collection(self): + for settings, expected in (({"SYSTEMCTL_EXIT": "3"}, ["date", "systemctl"]), + ({"PS_EXIT": "4"}, ["date", "systemctl", "ps"])): + with self.subTest(settings=settings): + result, calls = self.exercise("systemd", **settings) + self.assertNotEqual(result.returncode, 0) + self.assertEqual([c["name"] for c in calls], expected) + + def test_missing_process_io_is_failure_not_zero_writes(self): + result, calls = self.exercise("systemd", IO_MISSING="1") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("write_bytes:", result.stdout) + self.assertNotIn("sleep", [c["name"] for c in calls]) + + def test_incomplete_process_counters_do_not_become_a_success(self): + for text in ("", "rchar: 999\n", "write_bytes: 1000\n"): + with self.subTest(text=text): + result, calls = self.exercise("systemd", counters=(text, text)) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("sleep", [c["name"] for c in calls]) + + def test_decreasing_counters_are_retained_not_turned_into_a_rate(self): + result, _ = self.exercise("systemd", counters=( + "write_bytes: 13000\ncancelled_write_bytes: 30\n", + "write_bytes: 1000\ncancelled_write_bytes: 20\n", + )) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("write_bytes: 13000", result.stdout) + self.assertIn("write_bytes: 1000", result.stdout) + self.assertNotRegex(result.stdout, r"GB/day|bytes/s|bytes: -[0-9]+") + + def test_docker_reads_only_start_time_and_selected_statistics_twice(self): + result, calls = self.exercise("docker") + self.assertEqual(result.returncode, 0, result.stderr) + expected = [ + ["inspect", "--format", "Started={{.State.StartedAt}}", "pulse"], + ["stats", "--no-stream", "--format", + "CPU={{.CPUPerc}} Memory={{.MemUsage}} BlockIO={{.BlockIO}}", "pulse"], + ] + self.assertEqual([c["args"] for c in calls if c["name"] == "docker"], expected * 2) + self.assertEqual([c["name"] for c in calls], + ["date", "docker", "docker", "sleep", "date", "docker", "docker"]) + self.assertEqual(result.stdout.count("BlockIO=1MB / 12MB"), 2) + + def test_empty_docker_statistics_are_unavailable_not_zero(self): + result, calls = self.exercise("docker", DOCKER_STATS="") + self.assertNotEqual(result.returncode, 0) + self.assertIn("no zero inferred", result.stderr) + self.assertNotIn("sleep", [c["name"] for c in calls]) + + def test_docker_reader_failure_stops_before_another_sample(self): + for settings, expected in ( + ({"DOCKER_INSPECT_EXIT": "2"}, ["date", "docker"]), + ({"DOCKER_STATS_EXIT": "3"}, ["date", "docker", "docker"]), + ): + with self.subTest(settings=settings): + result, calls = self.exercise("docker", **settings) + self.assertEqual(result.returncode, int(next(iter(settings.values())))) + self.assertEqual([c["name"] for c in calls], expected) + + def test_guidance_keeps_scope_units_baselines_and_data_safety(self): + guide = " ".join(section().split()) + for phrase in ("same PID and process start time", "actual elapsed seconds", + "not a measured day's total", "not bytes per second", + "not a recent sampling window", "different scopes", + "inside the Pulse container", "does not make that data a clean", + "Do not enable Debug", "remove database indexes", "tmpfs", + "Do not post databases", "manually redacted error"): + self.assertIn(phrase, guide) + for recipe in recipes().values(): + self.assertNotRegex(recipe, r"\b(?:restart|kill|rm|truncate|sqlite3|curl|printenv)\b|--follow|--token") + + +if __name__ == "__main__": + unittest.main() From f56e28e6afd2c4586fbaf3db761a488c9615ca6b Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:05:16 +0100 Subject: [PATCH 03/86] Own demo update and recovery through sustained host observation Compose the existing signed stable demo paths with the Relay host lock, durable systemd ownership, full persistent estate capture and five-minute forward and recovery watches. Preserve uncertain attempts and failed rollback, refuse destructive headroom pruning, and keep native installed acceptance separate from connected source controls. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 504 ++++++++++++++++++ .github/scripts/dispatch-demo-runtime.py | 113 ++++ .github/scripts/recover-demo-runtime.sh | 395 +------------- .../tests/test_demo_runtime_transaction.py | 443 +++++++++++++++ .github/workflows/recover-demo-server.yml | 34 +- .github/workflows/update-demo-server.yml | 326 +---------- .../v6/internal/DEMO_RUNTIME_TRANSACTION.md | 60 +++ .../subsystems/deployment-installability.md | 32 ++ .../v6/internal/subsystems/registry.json | 28 +- .../installtests/build_release_assets_test.go | 43 +- scripts/installtests/demo_transaction_test.go | 56 ++ 11 files changed, 1278 insertions(+), 756 deletions(-) create mode 100644 .github/scripts/demo-runtime-transaction.py create mode 100644 .github/scripts/dispatch-demo-runtime.py create mode 100644 .github/scripts/tests/test_demo_runtime_transaction.py create mode 100644 docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md create mode 100644 scripts/installtests/demo_transaction_test.go diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py new file mode 100644 index 0000000000..60193e378e --- /dev/null +++ b/.github/scripts/demo-runtime-transaction.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +"""Demo-only host transaction. Called by the reviewed CI dispatcher, not a worker tool.""" +from __future__ import annotations + +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import signal +import socket +import stat +import subprocess +import sys +import time +from urllib.parse import urlsplit + +WINDOW = 300 +INTERVAL = 5 +READY_TIMEOUT = 60 +SERVICES = ("pulse", "pulse-relay", "caddy") +ROOT = Path("/var/lib/pulse-deploy/demo") +LOCK = Path("/var/lib/pulse-deploy/relay/deploy.lock") +PATHS = {"binary": Path("/opt/pulse/bin/pulse"), + "unit": Path("/etc/systemd/system/pulse.service"), + "dropins": Path("/etc/systemd/system/pulse.service.d"), + "data": Path("/etc/pulse")} +RELAY_HEALTH = "https://relay.pulserelay.pro/health" +COUNT_KEYS = ("nodes", "vms_per_node", "lxcs_per_node", "docker_hosts", + "docker_containers", "generic_hosts", "k8s_clusters", "k8s_nodes", + "k8s_pods", "k8s_deployments") +DURATION_KEYS = ("seed_duration", "sample_interval", "update_interval") +# Only generated demo operational memory is reset on the already-unhealthy +# recovery route. Its complete original estate remains in the private snapshot. +DEMO_HISTORY = ("ai_incidents.json", "ai_incidents.json.tmp", + "alerts/alert-history.json", "alerts/alert-history.backup.json", + "alerts/alert-history.json.imported", "alerts/alert-history.backup.json.imported", + "alerts/events.db", "alerts/events.db-shm", "alerts/events.db-wal") +CRASH = re.compile(r"\bpanic\b|\bfatal error\b|segmentation fault|core dumped|watchdog timeout", re.I) +TERMINAL = {"committed", "healthy_noop", "rolled_back", "refused"} + + +class Failure(Exception): + """Only fixed stage names, never command output or private response bodies.""" + + +def atomic_json(path, value): + tmp = path.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as stream: + os.chmod(tmp, 0o600) + json.dump(value, stream, sort_keys=True) + stream.flush() + os.fsync(stream.fileno()) + os.replace(tmp, path) + fd = os.open(path.parent, os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def validate(request): + if set(request) != {"mode", "hostname", "local_url", "public_url", "version", "profile", "control_sha", "run_id", "run_attempt", "installer_sha256"}: + raise Failure("request-shape") + if request["mode"] not in {"update", "recover"}: + raise Failure("request-mode") + for field, pattern in (("version", r"v[0-9]+\.[0-9]+\.[0-9]+"), + ("hostname", r"[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}"), + ("control_sha", r"[0-9a-f]{40}"), + ("run_id", r"[1-9][0-9]*"), ("run_attempt", r"[1-9][0-9]*")): + if not isinstance(request[field], str) or not re.fullmatch(pattern, request[field]): + raise Failure("request-identity") + for field, public in (("local_url", False), ("public_url", True)): + try: + url = urlsplit(request[field]) + valid = (url.scheme == ("https" if public else "http") + and url.hostname and not url.username and not url.password + and not url.query and not url.fragment + and (url.path == "/api/health" if public else url.path in {"", "/"}) + and (public or url.hostname in {"localhost", "127.0.0.1", "::1"}) + and 0 < (url.port or (443 if public else 80)) < 65536) + except (TypeError, ValueError): + valid = False + if not valid: + raise Failure("request-url") + profile = request["profile"] + if not isinstance(profile, dict) or set(profile) != set(COUNT_KEYS + DURATION_KEYS): + raise Failure("request-profile") + for name, value in profile.items(): + pattern = r"[1-9][0-9]{0,3}" if name in COUNT_KEYS else r"[1-9][0-9]{0,3}[smhd]" + if not isinstance(value, str) or not re.fullmatch(pattern, value): + raise Failure("request-profile") + digest = request["installer_sha256"] + if (request["mode"] == "update" and not re.fullmatch(r"[0-9a-f]{64}", digest)) or (request["mode"] == "recover" and digest != ""): + raise Failure("request-installer") + + +def copy_path(src, dst): + """Copy without following symlinks, retaining ownership as well as modes.""" + info = src.lstat() + if stat.S_ISLNK(info.st_mode): + dst.symlink_to(os.readlink(src)) + elif stat.S_ISDIR(info.st_mode): + dst.mkdir(mode=0o700) + for child in src.iterdir(): + copy_path(child, dst / child.name) + shutil.copystat(src, dst, follow_symlinks=False) + elif stat.S_ISREG(info.st_mode): + shutil.copy2(src, dst, follow_symlinks=False) + else: + raise Failure("snapshot-special-file") + os.chown(dst, info.st_uid, info.st_gid, follow_symlinks=False) + if not stat.S_ISLNK(info.st_mode): + fd = os.open(dst, os.O_RDONLY | (os.O_DIRECTORY if stat.S_ISDIR(info.st_mode) else 0)) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def remove_path(path): + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + + +def estate_hash(paths): + rows = [] + for label, root in sorted(paths.items()): + exists = root.exists() or root.is_symlink() + if not exists: + rows.append((label, "absent")) + continue + for path in [root] + (sorted(root.rglob("*")) if root.is_dir() and not root.is_symlink() else []): + info = path.lstat() + kind = stat.S_IFMT(info.st_mode) + content = os.readlink(path) if stat.S_ISLNK(info.st_mode) else "" + if stat.S_ISREG(info.st_mode): + digest = hashlib.sha256() + with open(path, "rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + content = digest.hexdigest() + rows.append((label, str(path.relative_to(root)), kind, stat.S_IMODE(info.st_mode), info.st_uid, info.st_gid, content)) + return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest() + + +class Host: + now = staticmethod(time.monotonic) + sleep = staticmethod(time.sleep) + + def command(self, argv, *, timeout=20, env=None): + try: + result = subprocess.run(argv, capture_output=True, timeout=timeout, env=env, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise Failure("command-unavailable") from exc + if result.returncode or len(result.stdout) > 1024 * 1024: + raise Failure("command-failed") + return result.stdout.decode("utf-8", errors="strict") + + def identity(self, hostname): + if socket.gethostname() != hostname or os.geteuid() != 0: + raise Failure("host-identity") + unit = self.command(["systemctl", "show", "pulse", "--property=FragmentPath,User,Group,ExecStart,DropInPaths", "--no-pager"]) + fields = dict(line.split("=", 1) for line in unit.splitlines() if "=" in line) + if (fields.get("FragmentPath") != str(PATHS["unit"]) + or fields.get("User") != "pulse" or fields.get("Group") != "pulse" + or str(PATHS["binary"]) not in fields.get("ExecStart", "")): + raise Failure("service-identity") + for name in ("binary", "unit", "data"): + if PATHS[name].is_symlink() or not PATHS[name].exists(): + raise Failure("estate-identity") + if PATHS["dropins"].is_symlink(): + raise Failure("dropin-identity") + for path in fields.get("DropInPaths", "").split(): + if Path(path).parent != PATHS["dropins"] or Path(path).is_symlink(): + raise Failure("unsupported-dropin") + if self.command(["systemctl", "is-enabled", "pulse"]).strip() != "enabled": + raise Failure("service-disabled") + + def state(self): + states = {} + for service in SERVICES: + text = self.command(["systemctl", "show", service, "--property=ActiveState,SubState,MainPID,NRestarts", "--no-pager"]) + fields = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) + if (set(fields) != {"ActiveState", "SubState", "MainPID", "NRestarts"} + or not fields["MainPID"].isdigit() or not fields["NRestarts"].isdigit()): + raise Failure("service-observation") + states[service] = fields + return states + + def status(self, url): + # No redirects, response bodies, credential arguments or ambient curl config. + try: + result = self.command(["curl", "--disable", "--silent", "--connect-timeout", "3", "--max-time", "8", "--output", "/dev/null", "--write-out", "%{http_code}", "--", url], timeout=10) + except Failure: + return "000" + return result if re.fullmatch(r"[1-5][0-9]{2}", result) else "000" + + def version(self, url): + try: + body = self.command(["curl", "--disable", "--silent", "--fail", "--connect-timeout", "3", "--max-time", "8", "--", url.rstrip("/") + "/api/version"], timeout=10) + if len(body) > 8192: + raise Failure("version-observation") + value = json.loads(body)["version"] + if not isinstance(value, str) or not re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+", value): + raise Failure("version-observation") + return value.lstrip("v") + except (ValueError, KeyError, TypeError) as exc: + raise Failure("version-observation") from exc + + def cursors(self): + cursors = {} + for service in SERVICES: + text = self.command(["journalctl", "-u", service, "-n", "1", "--output=json", "--show-cursor", "--no-pager"]) + matches = re.findall(r"^-- cursor: ([^\s]+)$", text, re.M) + if len(matches) != 1: + raise Failure("journal-cursor") + cursors[service] = matches[0] + return cursors + + def check_journal(self, cursors): + for service, cursor in cursors.items(): + text = self.command(["journalctl", "-u", service, "--after-cursor", cursor, "--output=json", "--no-pager"]) + for line in text.splitlines(): + try: + entry = json.loads(line) + if not isinstance(entry.get("MESSAGE"), str) or not isinstance(entry.get("__CURSOR"), str): + raise Failure("journal-observation") + except (ValueError, TypeError, AttributeError) as exc: + raise Failure("journal-observation") from exc + if CRASH.search(entry["MESSAGE"]): + raise Failure("new-service-crash") + cursors[service] = entry["__CURSOR"] + + def stop(self): + self.command(["systemctl", "stop", "pulse"], timeout=90) + + def start(self): + self.command(["systemctl", "daemon-reload"]) + self.command(["systemctl", "start", "pulse"], timeout=90) + + def install(self, attempt, request): + installer = attempt / "installer.sh" + if hashlib.sha256(installer.read_bytes()).hexdigest() != request["installer_sha256"]: + raise Failure("installer-identity") + self.command(["bash", str(installer), "--version", request["version"]], timeout=900, + env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/root", "PULSE_SERVICE_NAME": "pulse"}) + + def profile_values(self, request): + values = {"DEMO_MODE": "true", "PULSE_MOCK_MODE": "true", "PULSE_MOCK_RANDOM_METRICS": "true", + "PULSE_MOCK_STOPPED_PERCENT": "6", "PULSE_MOCK_SEED_METRICS_STORE": "false"} + for key, value in request["profile"].items(): + key = {"seed_duration": "trends_seed_duration", "sample_interval": "trends_sample_interval"}.get(key, key) + values["PULSE_MOCK_" + key.upper()] = value + return values + + def profile_matches(self, request): + text = (PATHS["data"] / ".env").read_text().splitlines() + values = self.profile_values(request) + return (all(text.count(key + "=" + value) == 1 for key, value in values.items()) + and "demo_fixtures" in json.loads((PATHS["data"] / "billing.json").read_text()).get("capabilities", [])) + + def profile(self, request, unhealthy): + env_file = PATHS["data"] / ".env" + billing_file = PATHS["data"] / "billing.json" + if env_file.is_symlink() or billing_file.is_symlink(): + raise Failure("configuration-symlink") + values = self.profile_values(request) + text = env_file.read_text() + for key, value in values.items(): + pattern = re.compile(r"^[ \t]*" + re.escape(key) + r"=.*$", re.M) + text = pattern.sub(key + "=" + value, text) if pattern.search(text) else text.rstrip("\n") + "\n" + key + "=" + value + "\n" + env_file.write_text(text) + os.chmod(env_file, 0o600) + billing = json.loads(billing_file.read_text()) + if not isinstance(billing, dict) or not isinstance(billing.get("capabilities", []), list): + raise Failure("billing-shape") + billing["capabilities"] = sorted(set(billing.get("capabilities", []) + ["demo_fixtures"])) + billing.pop("integrity", None) + billing_file.write_text(json.dumps(billing)) + os.chmod(billing_file, 0o600) + if unhealthy: + for relative in DEMO_HISTORY: + path = PATHS["data"] / relative + # Never follow a writable parent symlink to a path outside the estate. + if any(p.is_symlink() for p in path.parents if p != PATHS["data"].parent): + raise Failure("history-symlink") + if path.is_symlink(): + raise Failure("history-symlink") + if path.is_file(): + path.unlink() + + +def active(state): + return state["ActiveState"] == "active" and state["SubState"] == "running" and int(state["MainPID"]) > 0 + + +class Transaction: + def __init__(self, host, request, attempt): + self.host, self.request, self.attempt = host, request, attempt + self.receipt = {"schema_version": 2, "status": "intent", "mutated": False, + "control_sha": request["control_sha"], "run_id": request["run_id"], + "run_attempt": request["run_attempt"], "mode": request["mode"], + "expected_version": request["version"], "installer_sha256": request["installer_sha256"], + "forward": {}, "recovery": {}, "recovery_required": False} + + def save(self, status): + self.receipt["status"] = status + atomic_json(self.attempt / "receipt.json", self.receipt) + + def urls(self): + return (self.request["local_url"].rstrip("/") + "/api/health", self.request["public_url"], RELAY_HEALTH) + + def healthy(self, states=None): + return all(active(s) for s in (states or self.host.state()).values()) and all(self.host.status(url) == "200" for url in self.urls()) + + def ready(self, cursors): + deadline = self.host.now() + READY_TIMEOUT + while True: + self.host.check_journal(cursors) + if self.healthy(): + return + if self.host.now() >= deadline: + raise Failure("listener-readiness") + self.host.sleep(INTERVAL) + + def watch(self, phase, expected_version, cursors): + baseline = self.host.state() + started = self.host.now() + evidence = self.receipt[phase] + evidence.update({"required_seconds": WINDOW, "samples": 0, "elapsed_seconds": 0}) + while True: + self.host.check_journal(cursors) + state = self.host.state() + if state != baseline or not self.healthy(state): + raise Failure("sustained-health") + if self.host.version(self.request["local_url"]) != expected_version: + raise Failure("runtime-version") + evidence["samples"] += 1 + elapsed = self.host.now() - started + evidence["elapsed_seconds"] = elapsed + if elapsed >= WINDOW: + return + self.host.sleep(min(INTERVAL, WINDOW - elapsed)) + + def capture(self): + snapshot = self.attempt / "snapshot" + size = sum(path.lstat().st_size for root in PATHS.values() if root.exists() + for path in ([root] + (list(root.rglob("*")) if root.is_dir() else [])) + if path.is_file() and not path.is_symlink()) + # Both our independent estate and the signed installer's own backup need + # space. Refuse shortage; never delete backups or databases for headroom. + if any(shutil.disk_usage(parent).free < 2 * size + 64 * 1024 * 1024 + for parent in (self.attempt, PATHS["data"].parent)): + raise Failure("snapshot-headroom") + snapshot.mkdir(mode=0o700) + manifest = {} + for label, path in PATHS.items(): + manifest[label] = path.exists() or path.is_symlink() + if manifest[label]: + copy_path(path, snapshot / label) + atomic_json(snapshot / "paths.json", manifest) + digest = estate_hash(PATHS) + self.receipt["snapshot_sha256"] = digest + self.receipt["snapshot_retained"] = True + return digest + + def restore(self): + snapshot = self.attempt / "snapshot" + for label, exists in json.loads((snapshot / "paths.json").read_text()).items(): + remove_path(PATHS[label]) + if exists: + copy_path(snapshot / label, PATHS[label]) + if estate_hash(PATHS) != self.receipt["snapshot_sha256"]: + raise Failure("restored-estate-identity") + + def run(self): + old_handlers = {} + healthy_baseline = False + stopped = False + original_version = "" + states = {} + + def cancel(_signum, _frame): + raise Failure("cancelled-forward") + + try: + validate(self.request) + self.host.identity(self.request["hostname"]) + for entry in ROOT.glob("attempts/*"): + if entry == self.attempt: + continue + receipt = entry / "receipt.json" + if not receipt.exists() or json.loads(receipt.read_text()).get("status") not in TERMINAL: + raise Failure("prior-recovery-required") + states = self.host.state() + if not all(active(states[name]) for name in ("pulse-relay", "caddy")) or self.host.status(RELAY_HEALTH) != "200": + raise Failure("cohost-unhealthy") + healthy_baseline = self.healthy(states) + if healthy_baseline: + original_version = self.host.version(self.request["local_url"]) + if self.request["mode"] == "recover" and original_version != self.request["version"].lstrip("v"): + raise Failure("recovery-source-mismatch") + elif self.request["mode"] == "update": + raise Failure("update-baseline-unhealthy") + self.receipt["healthy_baseline"] = healthy_baseline + # The no-op is observed too; an immediate healthy sample is not success. + if self.request["mode"] == "recover" and healthy_baseline and self.host.profile_matches(self.request): + self.watch("forward", original_version, self.host.cursors()) + self.save("healthy_noop") + return 0 + old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} + for sig in old_handlers: + signal.signal(sig, cancel) + self.save("capturing") + stopped = True + self.host.stop() + self.capture() + self.save("applying") + self.receipt["mutated"] = True + cursors = self.host.cursors() + if self.request["mode"] == "update": + self.host.install(self.attempt, self.request) + # The installer may start the unit; finish profile work quiescent. + self.host.stop() + self.host.profile(self.request, not healthy_baseline) + self.host.start() + self.ready(cursors) + self.watch("forward", self.request["version"].lstrip("v"), cursors) + # No unrelated Relay/Caddy restart is owned by this transaction. + final = self.host.state() + for service in ("pulse-relay", "caddy"): + if final[service] != states[service]: + raise Failure("cohost-identity-changed") + self.save("committed") + return 0 + except Exception as exc: + self.receipt["failure"] = str(exc) if isinstance(exc, Failure) else "transaction-observation" + # Owned restoration, observation and the terminal receipt survive cancellation. + for sig in old_handlers: + signal.signal(sig, signal.SIG_IGN) + if stopped: + try: + self.save("recovering") + self.host.stop() + if "snapshot_sha256" in self.receipt: + self.restore() + if not healthy_baseline: + self.receipt["recovery_required"] = True + self.receipt["rollback"] = "unavailable-unhealthy-baseline" + self.save("recovery_required") + else: + cursors = self.host.cursors() + self.host.start() + self.ready(cursors) + self.watch("recovery", original_version, cursors) + final = self.host.state() + if any(final[name] != states[name] for name in ("pulse-relay", "caddy")): + raise Failure("cohost-identity-changed") + self.receipt["rollback"] = "verified" + self.save("rolled_back") + except Exception as recovery: + self.receipt["rollback"] = "failed" + self.receipt["recovery_failure"] = str(recovery) if isinstance(recovery, Failure) else "recovery-observation" + self.receipt["recovery_required"] = True + self.save("rollback_failed") + else: + self.save("refused") + return 1 + finally: + for sig, handler in old_handlers.items(): + signal.signal(sig, handler) + + +def main(): + # Attempt paths are generated by the fixed CI bootstrap, not CLI overrides. + if len(sys.argv) != 2: + return 2 + attempt = Path(sys.argv[1]) + if attempt.parent != ROOT / "attempts" or not re.fullmatch(r"[0-9a-f]{64}", attempt.name) or attempt.is_symlink(): + return 2 + request = json.loads((attempt / "request.json").read_text()) + transaction = Transaction(Host(), request, attempt) + fd = None + try: + LOCK.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return transaction.run() + except (OSError, ValueError): + transaction.receipt["failure"] = "host-lock-unavailable" + transaction.save("refused") + return 1 + finally: + if fd is not None: + os.close(fd) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/dispatch-demo-runtime.py b/.github/scripts/dispatch-demo-runtime.py new file mode 100644 index 0000000000..499602fd17 --- /dev/null +++ b/.github/scripts/dispatch-demo-runtime.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""The two existing demo CI paths share one retained, independently owned child.""" +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + +BOOTSTRAP = r''' +import base64, fcntl, hashlib, json, os, pathlib, subprocess, sys, time +root = pathlib.Path('/var/lib/pulse-deploy/demo') +payload = json.loads(sys.stdin.read(512 * 1024)) +assert os.geteuid() == 0 and set(payload) == {'request', 'source', 'installer'} +source = base64.b64decode(payload['source'], validate=True) +installer = base64.b64decode(payload['installer'], validate=True) +assert len(source) < 128 * 1024 and len(installer) < 256 * 1024 +assert (hashlib.sha256(installer).hexdigest() == payload['request']['installer_sha256']) if installer else (payload['request']['installer_sha256'] == '') +identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest() +root.mkdir(mode=0o700, parents=True, exist_ok=True) +assert not root.is_symlink() and root.stat().st_uid == 0 +os.chmod(root, 0o700) +(root / 'attempts').mkdir(mode=0o700, exist_ok=True) +assert not (root / 'attempts').is_symlink() +fd = os.open(root / 'submit.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) +fcntl.flock(fd, fcntl.LOCK_EX) +attempt = root / 'attempts' / identity +if not attempt.exists(): + attempt.mkdir(mode=0o700) + for name, data in [('transaction.py', source), ('installer.sh', installer), + ('request.json', json.dumps(payload['request']).encode()), + ('intent.json', json.dumps({'schema_version': 1, 'identity': identity, 'state': 'intent'}).encode())]: + with open(attempt / name, 'xb') as stream: + os.chmod(attempt / name, 0o600) + stream.write(data); stream.flush(); os.fsync(stream.fileno()) + # Intent is durable before starting the unit. An uncertain launch is never replayed. + dfd = os.open(attempt, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd) + try: + child = subprocess.run(['systemd-run', '--quiet', '--collect', + '--unit=pulse-demo-' + identity[:32], + '--property=RuntimeMaxSec=45min', + '--property=TimeoutStopSec=20min', '--property=KillMode=mixed', + '/usr/bin/python3', str(attempt / 'transaction.py'), str(attempt)], + capture_output=True, timeout=30) + launch = {'schema_version': 1, 'state': 'submitted' if child.returncode == 0 else 'uncertain', + 'unit': 'pulse-demo-' + identity[:32]} + except (OSError, subprocess.TimeoutExpired): + launch = {'schema_version': 1, 'state': 'uncertain'} + with open(attempt / 'launch.json', 'x') as stream: + os.chmod(attempt / 'launch.json', 0o600) + json.dump(launch, stream); stream.flush(); os.fsync(stream.fileno()) +os.close(fd) +# Closing or losing this observer never stops the independently owned unit. +deadline = time.monotonic() + 4000 +terminal = {'committed', 'healthy_noop', 'rolled_back', 'rollback_failed', 'recovery_required', 'refused'} +while time.monotonic() < deadline: + try: + receipt = json.loads((attempt / 'receipt.json').read_text()) + except (FileNotFoundError, ValueError): + receipt = None + if receipt and receipt.get('status') in terminal: + receipt['request_identity'] = identity + print(json.dumps(receipt)) + sys.exit(0 if receipt['status'] in {'committed', 'healthy_noop'} else 1) + launch = attempt / 'launch.json' + if launch.exists() and json.loads(launch.read_text()).get('state') == 'uncertain': + break + time.sleep(5) +print(json.dumps({'schema_version': 2, 'status': 'uncertain', 'request_identity': identity, + 'failure': 'terminal-receipt-unavailable', 'recovery_required': True})) +sys.exit(1) +''' + + +def payload(mode, environ=os.environ): + profile = {key: environ['MOCK_' + key.upper()] for key in + ('nodes', 'vms_per_node', 'lxcs_per_node', 'docker_hosts', 'docker_containers', + 'generic_hosts', 'k8s_clusters', 'k8s_nodes', 'k8s_pods', 'k8s_deployments', + 'seed_duration', 'sample_interval', 'update_interval')} + installer = Path('/tmp/pulse-install.sh').read_bytes() if mode == 'update' else b'' + request = {'mode': mode, 'hostname': environ['DEMO_EXPECTED_HOSTNAME'], + 'local_url': environ['DEMO_LOCAL_BASE_URL'], 'public_url': environ['DEMO_PUBLIC_HEALTH_URL'], + 'version': environ['EXPECTED_VERSION'], 'profile': profile, + 'control_sha': environ['GITHUB_SHA'], 'run_id': environ['GITHUB_RUN_ID'], + 'run_attempt': environ['GITHUB_RUN_ATTEMPT'], + 'installer_sha256': hashlib.sha256(installer).hexdigest() if installer else ''} + return {'request': request, 'source': base64.b64encode(Path(__file__).with_name('demo-runtime-transaction.py').read_bytes()).decode(), + 'installer': base64.b64encode(installer).decode()} + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] not in {'update', 'recover'}: + return 2 + # Same project CI SSH identity and pinned host verification as before. This + # is not an installed worker dispatcher or a new project credential. + command = ['ssh', '-i', str(Path.home() / '.ssh/id_ed25519'), '-o', 'IdentitiesOnly=yes', + '-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(Path.home() / '.ssh/known_hosts'), + os.environ['DEMO_SERVER_USER'] + '@' + os.environ['DEMO_SERVER_HOST'], + 'sudo python3 -c ' + shlex.quote(BOOTSTRAP)] + try: + result = subprocess.run(command, input=json.dumps(payload(sys.argv[1])).encode(), timeout=4100, check=False) + except (OSError, subprocess.TimeoutExpired): + print(json.dumps({'schema_version': 2, 'status': 'uncertain', 'failure': 'observer-unavailable', 'recovery_required': True})) + return 1 + return result.returncode + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/scripts/recover-demo-runtime.sh b/.github/scripts/recover-demo-runtime.sh index 132c5cf48f..81612502a7 100755 --- a/.github/scripts/recover-demo-runtime.sh +++ b/.github/scripts/recover-demo-runtime.sh @@ -1,395 +1,4 @@ #!/usr/bin/env bash set -euo pipefail - -if [ "$#" -ne 16 ]; then - echo "usage: recover-demo-runtime.sh EXPECTED_HOSTNAME LOCAL_BASE_URL EXPECTED_VERSION MOCK_NODES MOCK_VMS_PER_NODE MOCK_LXCS_PER_NODE MOCK_DOCKER_HOSTS MOCK_DOCKER_CONTAINERS MOCK_GENERIC_HOSTS MOCK_K8S_CLUSTERS MOCK_K8S_NODES MOCK_K8S_PODS MOCK_K8S_DEPLOYMENTS MOCK_SEED_DURATION MOCK_SAMPLE_INTERVAL MOCK_UPDATE_INTERVAL" >&2 - exit 2 -fi - -EXPECTED_HOSTNAME="$1" -LOCAL_BASE_URL="${2%/}" -EXPECTED_VERSION="${3#v}" -MOCK_NODES="$4" -MOCK_VMS_PER_NODE="$5" -MOCK_LXCS_PER_NODE="$6" -MOCK_DOCKER_HOSTS="$7" -MOCK_DOCKER_CONTAINERS="$8" -MOCK_GENERIC_HOSTS="$9" -MOCK_K8S_CLUSTERS="${10}" -MOCK_K8S_NODES="${11}" -MOCK_K8S_PODS="${12}" -MOCK_K8S_DEPLOYMENTS="${13}" -MOCK_SEED_DURATION="${14}" -MOCK_SAMPLE_INTERVAL="${15}" -MOCK_UPDATE_INTERVAL="${16}" -SERVICE_NAME="pulse" -RELAY_SERVICE_NAME="pulse-relay" -EXPECTED_BINARY="/opt/pulse/bin/pulse" -EXPECTED_UNIT="/etc/systemd/system/pulse.service" -MUTATED=false -BACKUP_DIR="" -HISTORY_BACKUP_ARCHIVE="" -DEMO_HISTORY_PATHS=( - ai_incidents.json - ai_incidents.json.tmp - alerts/alert-history.json - alerts/alert-history.backup.json - alerts/alert-history.json.imported - alerts/alert-history.backup.json.imported - alerts/events.db - alerts/events.db-shm - alerts/events.db-wal -) - -for fixture_count in \ - "$MOCK_NODES" \ - "$MOCK_VMS_PER_NODE" \ - "$MOCK_LXCS_PER_NODE" \ - "$MOCK_DOCKER_HOSTS" \ - "$MOCK_DOCKER_CONTAINERS" \ - "$MOCK_GENERIC_HOSTS" \ - "$MOCK_K8S_CLUSTERS" \ - "$MOCK_K8S_NODES" \ - "$MOCK_K8S_PODS" \ - "$MOCK_K8S_DEPLOYMENTS"; do - [[ "$fixture_count" =~ ^[1-9][0-9]*$ ]] -done -[[ "$MOCK_SEED_DURATION" =~ ^[1-9][0-9]*[smhd]$ ]] -[[ "$MOCK_SAMPLE_INTERVAL" =~ ^[1-9][0-9]*[smh]$ ]] -[[ "$MOCK_UPDATE_INTERVAL" =~ ^[1-9][0-9]*[smh]$ ]] - -log() { - printf '[demo-recovery] %s\n' "$*" >&2 -} - -unit_hash() { - sudo sha256sum "$EXPECTED_UNIT" | awk '{print $1}' -} - -binary_hash() { - sudo sha256sum "$EXPECTED_BINARY" | awk '{print $1}' -} - -dropin_manifest_hash() { - local paths path - paths="$(systemctl show "$SERVICE_NAME" --property=DropInPaths --value)" - if [ -z "$paths" ]; then - printf '%s' 'none' | sha256sum | awk '{print $1}' - return - fi - for path in $paths; do - sudo test -f "$path" - printf '%s\t' "$path" - sudo sha256sum "$path" | awk '{print $1}' - done | sort | sha256sum | awk '{print $1}' -} - -runtime_config_hash() { - local path - for path in /etc/pulse/.env /etc/pulse/billing.json; do - sudo test -f "$path" - printf '%s\t' "$path" - sudo sha256sum "$path" | awk '{print $1}' - done | sha256sum | awk '{print $1}' -} - -set_env_value() { - local key="$1" - local value="$2" - local env_file="/etc/pulse/.env" - if sudo grep -Eq "^[[:space:]]*${key}=" "$env_file"; then - sudo sed -i "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$env_file" - else - printf '\n%s=%s\n' "$key" "$value" | sudo tee -a "$env_file" >/dev/null - fi -} - -runtime_profile_matches() { - sudo grep -Fxq "PULSE_MOCK_NODES=${MOCK_NODES}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_VMS_PER_NODE=${MOCK_VMS_PER_NODE}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_LXCS_PER_NODE=${MOCK_LXCS_PER_NODE}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_DOCKER_HOSTS=${MOCK_DOCKER_HOSTS}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_DOCKER_CONTAINERS=${MOCK_DOCKER_CONTAINERS}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_GENERIC_HOSTS=${MOCK_GENERIC_HOSTS}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_K8S_CLUSTERS=${MOCK_K8S_CLUSTERS}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_K8S_NODES=${MOCK_K8S_NODES}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_K8S_PODS=${MOCK_K8S_PODS}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_K8S_DEPLOYMENTS=${MOCK_K8S_DEPLOYMENTS}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_SEED_METRICS_STORE=false" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_TRENDS_SEED_DURATION=${MOCK_SEED_DURATION}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_TRENDS_SAMPLE_INTERVAL=${MOCK_SAMPLE_INTERVAL}" /etc/pulse/.env && - sudo grep -Fxq "PULSE_MOCK_UPDATE_INTERVAL=${MOCK_UPDATE_INTERVAL}" /etc/pulse/.env -} - -cleanup_config_backup() { - [ -n "$BACKUP_DIR" ] || return 0 - sudo unlink "$BACKUP_DIR/runtime.env" 2>/dev/null || true - sudo unlink "$BACKUP_DIR/billing.json" 2>/dev/null || true - if [ -n "$HISTORY_BACKUP_ARCHIVE" ]; then - sudo unlink "$HISTORY_BACKUP_ARCHIVE" 2>/dev/null || true - HISTORY_BACKUP_ARCHIVE="" - fi - rmdir "$BACKUP_DIR" 2>/dev/null || true - BACKUP_DIR="" -} - -clear_demo_operational_history() { - local rel_path - local -a existing=() - for rel_path in "${DEMO_HISTORY_PATHS[@]}"; do - if sudo test -f "/etc/pulse/${rel_path}"; then - existing+=("$rel_path") - fi - done - ((${#existing[@]} > 0)) || return 0 - - HISTORY_BACKUP_ARCHIVE="$BACKUP_DIR/demo-operational-history.tar" - sudo tar --create --preserve-permissions \ - --file "$HISTORY_BACKUP_ARCHIVE" --directory /etc/pulse -- "${existing[@]}" - for rel_path in "${existing[@]}"; do - sudo unlink "/etc/pulse/${rel_path}" - done -} - -restore_demo_operational_history() { - local rel_path - [ -n "$HISTORY_BACKUP_ARCHIVE" ] || return 0 - sudo test -f "$HISTORY_BACKUP_ARCHIVE" || return 0 - for rel_path in "${DEMO_HISTORY_PATHS[@]}"; do - sudo unlink "/etc/pulse/${rel_path}" 2>/dev/null || true - done - sudo tar --extract --preserve-permissions \ - --file "$HISTORY_BACKUP_ARCHIVE" --directory /etc/pulse -} - -restore_runtime_config() { - [ -n "$BACKUP_DIR" ] || return 0 - sudo cp --archive "$BACKUP_DIR/runtime.env" /etc/pulse/.env - sudo cp --archive "$BACKUP_DIR/billing.json" /etc/pulse/billing.json -} - -local_version() { - curl --connect-timeout 3 --max-time 8 -fsS \ - "${LOCAL_BASE_URL}/api/version" | jq -er '.version' -} - -local_healthy() { - curl --connect-timeout 3 --max-time 8 -fsS \ - "${LOCAL_BASE_URL}/api/health" >/dev/null && local_version >/dev/null -} - -emit_evidence() { - local status="$1" - local before_pid="$2" - local after_pid="$3" - local before_relay_pid="$4" - local after_relay_pid="$5" - local before_binary_sha="$6" - local after_binary_sha="$7" - local before_unit_sha="$8" - local after_unit_sha="$9" - local before_dropins_sha="${10}" - local after_dropins_sha="${11}" - local version="${12}" - local before_config_sha="${13}" - local after_config_sha="${14}" - jq -n \ - --arg status "$status" \ - --argjson mutated "$MUTATED" \ - --arg hostname "$(hostname)" \ - --arg service "$SERVICE_NAME" \ - --arg before_pid "$before_pid" \ - --arg after_pid "$after_pid" \ - --arg relay_service "$RELAY_SERVICE_NAME" \ - --arg before_relay_pid "$before_relay_pid" \ - --arg after_relay_pid "$after_relay_pid" \ - --arg binary "$EXPECTED_BINARY" \ - --arg before_binary_sha256 "$before_binary_sha" \ - --arg after_binary_sha256 "$after_binary_sha" \ - --arg unit "$EXPECTED_UNIT" \ - --arg before_unit_sha256 "$before_unit_sha" \ - --arg after_unit_sha256 "$after_unit_sha" \ - --arg before_dropins_sha256 "$before_dropins_sha" \ - --arg after_dropins_sha256 "$after_dropins_sha" \ - --arg before_runtime_config_sha256 "$before_config_sha" \ - --arg after_runtime_config_sha256 "$after_config_sha" \ - --arg version "$version" \ - --arg expected_version "$EXPECTED_VERSION" \ - --arg mock_nodes "$MOCK_NODES" \ - --arg mock_vms_per_node "$MOCK_VMS_PER_NODE" \ - --arg mock_lxcs_per_node "$MOCK_LXCS_PER_NODE" \ - --arg mock_docker_hosts "$MOCK_DOCKER_HOSTS" \ - --arg mock_docker_containers "$MOCK_DOCKER_CONTAINERS" \ - --arg mock_generic_hosts "$MOCK_GENERIC_HOSTS" \ - --arg mock_k8s_clusters "$MOCK_K8S_CLUSTERS" \ - --arg mock_k8s_nodes "$MOCK_K8S_NODES" \ - --arg mock_k8s_pods "$MOCK_K8S_PODS" \ - --arg mock_k8s_deployments "$MOCK_K8S_DEPLOYMENTS" \ - --arg mock_seed_duration "$MOCK_SEED_DURATION" \ - --arg mock_sample_interval "$MOCK_SAMPLE_INTERVAL" \ - --arg mock_update_interval "$MOCK_UPDATE_INTERVAL" \ - '{schema_version: 1, status: $status, mutated: $mutated, - hostname: $hostname, service: $service, - before_pid: $before_pid, after_pid: $after_pid, - relay_service: $relay_service, - before_relay_pid: $before_relay_pid, - after_relay_pid: $after_relay_pid, - binary: $binary, - before_binary_sha256: $before_binary_sha256, - after_binary_sha256: $after_binary_sha256, - unit: $unit, - before_unit_sha256: $before_unit_sha256, - after_unit_sha256: $after_unit_sha256, - before_dropins_sha256: $before_dropins_sha256, - after_dropins_sha256: $after_dropins_sha256, - before_runtime_config_sha256: $before_runtime_config_sha256, - after_runtime_config_sha256: $after_runtime_config_sha256, - version: $version, expected_version: $expected_version, - runtime_profile: {mock_nodes: $mock_nodes, - mock_vms_per_node: $mock_vms_per_node, - mock_lxcs_per_node: $mock_lxcs_per_node, - mock_docker_hosts: $mock_docker_hosts, - mock_docker_containers: $mock_docker_containers, - mock_generic_hosts: $mock_generic_hosts, - mock_k8s_clusters: $mock_k8s_clusters, - mock_k8s_nodes: $mock_k8s_nodes, - mock_k8s_pods: $mock_k8s_pods, - mock_k8s_deployments: $mock_k8s_deployments, - mock_seed_duration: $mock_seed_duration, - mock_sample_interval: $mock_sample_interval, - mock_update_interval: $mock_update_interval}}' -} - -[ "$(hostname)" = "$EXPECTED_HOSTNAME" ] || { - log "target hostname does not match the governed demo environment" - exit 1 -} -[ "$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value)" = "$EXPECTED_UNIT" ] -[ "$(systemctl show "$SERVICE_NAME" --property=User --value)" = "pulse" ] -[ "$(systemctl show "$SERVICE_NAME" --property=Group --value)" = "pulse" ] -[ "$(systemctl is-enabled "$SERVICE_NAME")" = "enabled" ] -[ "$(systemctl is-active "$RELAY_SERVICE_NAME")" = "active" ] -systemctl show "$SERVICE_NAME" --property=ExecStart --value | grep -Fq "$EXPECTED_BINARY" -sudo test -x "$EXPECTED_BINARY" -sudo test -f "$EXPECTED_UNIT" - -BEFORE_PID="$(systemctl show "$SERVICE_NAME" --property=MainPID --value)" -BEFORE_RELAY_PID="$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value)" -BEFORE_BINARY_SHA="$(binary_hash)" -BEFORE_UNIT_SHA="$(unit_hash)" -BEFORE_DROPINS_SHA="$(dropin_manifest_hash)" -BEFORE_CONFIG_SHA="$(runtime_config_hash)" - -if local_healthy && runtime_profile_matches; then - VERSION="$(local_version)" - [ "${VERSION#v}" = "$EXPECTED_VERSION" ] - emit_evidence healthy_noop "$BEFORE_PID" "$BEFORE_PID" \ - "$BEFORE_RELAY_PID" "$BEFORE_RELAY_PID" \ - "$BEFORE_BINARY_SHA" "$BEFORE_BINARY_SHA" \ - "$BEFORE_UNIT_SHA" "$BEFORE_UNIT_SHA" \ - "$BEFORE_DROPINS_SHA" "$BEFORE_DROPINS_SHA" "$VERSION" \ - "$BEFORE_CONFIG_SHA" "$BEFORE_CONFIG_SHA" - exit 0 -fi - -rollback_on_error() { - local rc=$? - local failed_pid - trap - ERR - if [ "$MUTATED" = true ]; then - failed_pid="$(systemctl show "$SERVICE_NAME" --property=MainPID --value || true)" - if [[ "$failed_pid" =~ ^[1-9][0-9]*$ ]] && sudo kill -0 "$failed_pid" 2>/dev/null; then - # The HTTP listener never opened, so pprof is unavailable. SIGQUIT gives - # one bounded Go goroutine dump in the service journal before the already - # failed process is stopped; it does not widen recovery to any other unit. - log "capturing failed Pulse startup goroutines before compensation" - sudo kill -QUIT "$failed_pid" || true - sleep 2 - fi - log "recovery validation failed; compensating by stopping only pulse.service" - sudo systemctl stop "$SERVICE_NAME" || true - restore_runtime_config || true - restore_demo_operational_history || true - fi - emit_evidence compensated_unavailable "$BEFORE_PID" \ - "$(systemctl show "$SERVICE_NAME" --property=MainPID --value || true)" \ - "$BEFORE_RELAY_PID" \ - "$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value || true)" \ - "$BEFORE_BINARY_SHA" "$(binary_hash || true)" \ - "$BEFORE_UNIT_SHA" "$(unit_hash || true)" \ - "$BEFORE_DROPINS_SHA" "$(dropin_manifest_hash || true)" "" \ - "$BEFORE_CONFIG_SHA" "$(runtime_config_hash || true)" - cleanup_config_backup - exit "$rc" -} -BACKUP_DIR="$(mktemp -d)" -sudo cp --archive /etc/pulse/.env "$BACKUP_DIR/runtime.env" -sudo cp --archive /etc/pulse/billing.json "$BACKUP_DIR/billing.json" -trap rollback_on_error ERR - -log "applying the target-compatible demo runtime profile and restarting only pulse.service" -MUTATED=true -set_env_value PULSE_MOCK_NODES "$MOCK_NODES" -set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE" -set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE" -set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS" -set_env_value PULSE_MOCK_DOCKER_CONTAINERS "$MOCK_DOCKER_CONTAINERS" -set_env_value PULSE_MOCK_GENERIC_HOSTS "$MOCK_GENERIC_HOSTS" -set_env_value PULSE_MOCK_K8S_CLUSTERS "$MOCK_K8S_CLUSTERS" -set_env_value PULSE_MOCK_K8S_NODES "$MOCK_K8S_NODES" -set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS" -set_env_value PULSE_MOCK_K8S_DEPLOYMENTS "$MOCK_K8S_DEPLOYMENTS" -# Persistent mock-store backfill is reserved for the isolated local-dev data -# directory. The governed demo already has persistent runtime data, so a stale -# opt-in here can turn startup into an unbounded SQLite backfill. -set_env_value PULSE_MOCK_SEED_METRICS_STORE false -set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION" -set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL" -set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL" -sudo chown pulse:pulse /etc/pulse/.env -sudo chmod 600 /etc/pulse/.env -# Alert lifecycle and AI incident history on this host are generated demo -# operational memory. A failed 50-node bootstrap can leave thousands of -# lifecycle events whose v6.4 replay queues one serialized incident save per -# event before HTTP opens. Back up and clear only those fixed demo-history -# files, including the retired JSON sources that repopulate a cleared event -# log; compensation restores them if the bounded profile still fails. -clear_demo_operational_history -sudo systemctl restart "$SERVICE_NAME" - -for _ in $(seq 1 12); do - if [ "$(systemctl is-active "$SERVICE_NAME" || true)" = "active" ] && local_healthy; then - break - fi - sleep 5 -done - -[ "$(systemctl is-active "$SERVICE_NAME")" = "active" ] -[ "$(systemctl show "$SERVICE_NAME" --property=SubState --value)" = "running" ] -local_healthy -runtime_profile_matches - -AFTER_PID="$(systemctl show "$SERVICE_NAME" --property=MainPID --value)" -AFTER_RELAY_PID="$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value)" -AFTER_BINARY_SHA="$(binary_hash)" -AFTER_UNIT_SHA="$(unit_hash)" -AFTER_DROPINS_SHA="$(dropin_manifest_hash)" -AFTER_CONFIG_SHA="$(runtime_config_hash)" -VERSION="$(local_version)" - -[ "$AFTER_PID" -gt 0 ] -[ "$AFTER_PID" != "$BEFORE_PID" ] -[ "$AFTER_RELAY_PID" = "$BEFORE_RELAY_PID" ] -[ "$AFTER_BINARY_SHA" = "$BEFORE_BINARY_SHA" ] -[ "$AFTER_UNIT_SHA" = "$BEFORE_UNIT_SHA" ] -[ "$AFTER_DROPINS_SHA" = "$BEFORE_DROPINS_SHA" ] -[ "${VERSION#v}" = "$EXPECTED_VERSION" ] - -trap - ERR -cleanup_config_backup -emit_evidence recovered "$BEFORE_PID" "$AFTER_PID" \ - "$BEFORE_RELAY_PID" "$AFTER_RELAY_PID" \ - "$BEFORE_BINARY_SHA" "$AFTER_BINARY_SHA" \ - "$BEFORE_UNIT_SHA" "$AFTER_UNIT_SHA" \ - "$BEFORE_DROPINS_SHA" "$AFTER_DROPINS_SHA" "$VERSION" \ - "$BEFORE_CONFIG_SHA" "$AFTER_CONFIG_SHA" +# CI observer only; remote systemd owns mutation, restoration and both watches. +exec python3 .github/scripts/dispatch-demo-runtime.py recover diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py new file mode 100644 index 0000000000..36bb810f44 --- /dev/null +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -0,0 +1,443 @@ +import copy +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import signal +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +SCRIPTS = Path(__file__).resolve().parents[1] + + +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +engine = load("demo_transaction", "demo-runtime-transaction.py") +dispatcher = load("demo_dispatcher", "dispatch-demo-runtime.py") + + +def request(mode="recover"): + return {"mode": mode, "hostname": "fixture", "local_url": "http://127.0.0.1:7655", + "public_url": "https://demo.example/api/health", "version": "v6.4.5", + "control_sha": "a" * 40, "run_id": "123", "run_attempt": "1", + "installer_sha256": hashlib.sha256(b"signed-installer-fixture").hexdigest() if mode == "update" else "", + "profile": {**{name: "2" for name in engine.COUNT_KEYS}, + "seed_duration": "2h", "sample_interval": "5m", "update_interval": "15s"}} + + +class FixtureHost(engine.Host): + """Real filesystem estate with a virtual *unchanged* 300-second clock. + + Commands/services/journal/HTTP are adapters, not native or installed proof. + """ + def __init__(self): + self.clock = 0 + self.pid = 100 + self.running = True + self.runtime_version = "6.4.5" + self.starts = 0 + self.stops = 0 + self.install_count = 0 + self.fail_at = None + self.failed = False + self.rollback_failure = False + self.unhealthy = False + self.journal_failure = False + self.ready_until = 0 + self.cancel_at = None + self.cancel_in_recovery = False + self.co_host_change = False + self.co_pid = 200 + self.transaction = None + + def now(self): + return self.clock + + def sleep(self, seconds): + self.clock += seconds + + def identity(self, hostname): + if hostname != "fixture": + raise engine.Failure("host-identity") + + def state(self): + state = {"ActiveState": "active", "SubState": "running", "MainPID": str(self.pid), "NRestarts": "0"} + result = {"pulse": state, "pulse-relay": {**state, "MainPID": "200"}, "caddy": {**state, "MainPID": str(self.co_pid)}} + if not self.running: + result["pulse"] = {**state, "ActiveState": "inactive", "SubState": "dead", "MainPID": "0"} + return result + + def status(self, url): + if url == engine.RELAY_HEALTH: + return "200" + if self.clock < self.ready_until or self.unhealthy: + return "503" + if self.failed and self.rollback_failure: + return "503" + return "200" if self.running else "000" + + def version(self, url): + return self.runtime_version + + def cursors(self): + return {name: str(self.clock) for name in engine.SERVICES} + + def check_journal(self, cursors): + phase = self.transaction.receipt["status"] if self.transaction else "" + if self.journal_failure: + raise engine.Failure("journal-observation") + if self.co_host_change and self.clock >= 55: + self.co_pid = 201 + if self.cancel_at is not None and self.clock >= self.cancel_at and not self.failed: + self.failed = True + handler = signal.getsignal(signal.SIGTERM) + handler(signal.SIGTERM, None) + if self.cancel_in_recovery and phase == "recovering" and self.clock >= 350: + # Exercise the actual installed handler decision; do not signal the + # source-proof executor or kill another process to prove it. + assert signal.getsignal(signal.SIGTERM) == signal.SIG_IGN + self.cancel_in_recovery = False + if self.fail_at is not None and self.clock >= self.fail_at and not self.failed: + self.failed = True + raise engine.Failure("new-service-crash") + + def stop(self): + self.stops += 1 + self.running = False + + def start(self): + self.starts += 1 + self.pid += 1 + self.running = True + if self.transaction.receipt["status"] == "recovering": + self.runtime_version = "6.4.5" + self.unhealthy = False + + def install(self, attempt, req): + if hashlib.sha256((attempt / "installer.sh").read_bytes()).hexdigest() != req["installer_sha256"]: + raise engine.Failure("installer-identity") + self.install_count += 1 + self.runtime_version = req["version"].lstrip("v") + engine.PATHS["binary"].write_bytes(b"new executable") + engine.PATHS["unit"].write_bytes(b"new unit") + (engine.PATHS["data"] / "persisted-history").write_bytes(b"forward history") + + def profile(self, req, unhealthy): + super().profile(req, unhealthy) + if self.unhealthy: + self.unhealthy = False + + +class TransactionTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.paths = {name: root / "estate" / name for name in engine.PATHS} + for name, path in self.paths.items(): + path.parent.mkdir(parents=True, exist_ok=True) + if name in {"dropins", "data"}: + path.mkdir() + else: + path.write_bytes(b"old " + name.encode()) + path.chmod(0o755 if name == "binary" else 0o644) + (self.paths["dropins"] / "limits.conf").write_bytes(b"old limits") + (self.paths["data"] / ".env").write_text("PRIVATE_TOKEN=not-logged\n") + (self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"private"}') + (self.paths["data"] / "persisted-history").write_bytes(b"original history") + (self.paths["data"] / "alerts").mkdir() + (self.paths["data"] / "alerts/events.db").write_bytes(b"synthetic opaque persistent file") + self.root = root / "control" + self.attempt = self.root / "attempts" / ("a" * 64) + self.attempt.mkdir(parents=True) + (self.attempt / "installer.sh").write_bytes(b"signed-installer-fixture") + self.addCleanup(patch.stopall) + patch.object(engine, "ROOT", self.root).start() + patch.object(engine, "PATHS", self.paths).start() + self.before = engine.estate_hash(self.paths) + self.host = FixtureHost() + + def run_transaction(self, mode="recover", version=None): + req = request(mode) + if version: + req["version"] = version + tx = engine.Transaction(self.host, req, self.attempt) + self.host.transaction = tx + result = tx.run() + self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + return result, tx.receipt + + def test_healthy_profile_change_keeps_data_and_watches_complete_window(self): + code, receipt = self.run_transaction() + self.assertEqual(code, 0) + self.assertEqual(receipt["status"], "committed") + self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) + self.assertEqual(receipt["forward"]["samples"], 61) + self.assertEqual((self.paths["data"] / "alerts/events.db").read_bytes(), b"synthetic opaque persistent file") + self.assertTrue((self.attempt / "snapshot/data/persisted-history").exists()) + self.assertNotIn("not-logged", json.dumps(receipt)) + + def test_healthy_noop_requires_profile_and_full_watch_without_restart(self): + self.host.profile(request(), False) + code, receipt = self.run_transaction() + self.assertEqual(code, 0) + self.assertEqual(receipt["status"], "healthy_noop") + self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) + self.assertFalse(receipt["mutated"]) + self.assertEqual(self.host.stops, 0) + + def test_update_restores_complete_changed_executable_unit_and_data_after_late_failure(self): + self.host.fail_at = 55 + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual(code, 1) + self.assertEqual(receipt["status"], "rolled_back") + self.assertEqual(receipt["failure"], "new-service-crash") + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + self.assertEqual(engine.estate_hash(self.paths), self.before) + self.assertEqual(self.host.runtime_version, "6.4.5") + + def test_failure_at_299_seconds_still_collects_entire_recovery_and_cancellation(self): + self.host.fail_at = 299 + self.host.cancel_in_recovery = True + previous = signal.getsignal(signal.SIGTERM) + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual(code, 1) + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + self.assertGreaterEqual(self.host.clock, 600) + self.assertFalse(self.host.cancel_in_recovery) + self.assertEqual(signal.getsignal(signal.SIGTERM), previous) + self.assertEqual(engine.estate_hash(self.paths), self.before) + + def test_forward_cancel_owns_full_recovery(self): + self.host.cancel_at = 55 + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual(code, 1) + self.assertEqual(receipt["failure"], "cancelled-forward") + self.assertEqual(receipt["status"], "rolled_back") + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + + def test_failed_rollback_retains_estate_and_blocks_next_transaction(self): + self.host.fail_at = 55 + self.host.rollback_failure = True + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual(code, 1) + self.assertEqual(receipt["status"], "rollback_failed") + self.assertTrue(receipt["snapshot_retained"]) + next_attempt = self.root / "attempts" / ("b" * 64) + next_attempt.mkdir() + self.attempt = next_attempt + self.host.rollback_failure = False + code, receipt = self.run_transaction() + self.assertEqual(receipt["failure"], "prior-recovery-required") + self.assertFalse(receipt["mutated"]) + + def test_unhealthy_recovery_retains_original_generated_history(self): + self.host.unhealthy = True + code, receipt = self.run_transaction() + self.assertEqual(code, 0) + self.assertFalse(receipt["healthy_baseline"]) + self.assertFalse((self.paths["data"] / "alerts/events.db").exists()) + self.assertEqual((self.attempt / "snapshot/data/alerts/events.db").read_bytes(), b"synthetic opaque persistent file") + self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) + + def test_failed_unhealthy_recovery_is_not_a_healthy_rollback(self): + self.host.unhealthy = True + self.host.fail_at = 55 + code, receipt = self.run_transaction() + self.assertEqual(code, 1) + self.assertEqual(receipt["status"], "recovery_required") + self.assertEqual(receipt["rollback"], "unavailable-unhealthy-baseline") + self.assertEqual(engine.estate_hash(self.paths), self.before) + self.assertFalse(self.host.running) + + def test_update_refuses_unhealthy_baseline_without_stopping_or_pruning(self): + self.host.unhealthy = True + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual(code, 1) + self.assertEqual(receipt["failure"], "update-baseline-unhealthy") + self.assertEqual(self.host.stops, 0) + self.assertEqual(engine.estate_hash(self.paths), self.before) + + def test_recovery_refuses_different_healthy_source(self): + code, receipt = self.run_transaction(version="v6.4.6") + self.assertEqual(code, 1) + self.assertEqual(receipt["failure"], "recovery-source-mismatch") + self.assertEqual(self.host.stops, 0) + + def test_listener_readiness_does_not_spend_the_watch_window(self): + self.host.ready_until = 30 + code, receipt = self.run_transaction() + self.assertEqual(code, 0) + self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) + self.assertGreaterEqual(self.host.clock, 330) + self.assertEqual(self.host.starts, 1) + + def test_missing_journal_fails_closed_and_retains_failure(self): + self.host.journal_failure = True + code, receipt = self.run_transaction() + self.assertEqual(code, 1) + self.assertEqual(receipt["status"], "rollback_failed") + self.assertEqual(receipt["recovery_failure"], "journal-observation") + + def test_cohost_restart_is_not_silently_accepted_as_recovery(self): + self.host.co_host_change = True + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual(code, 1) + self.assertEqual(receipt["status"], "rollback_failed") + self.assertEqual(receipt["recovery_failure"], "cohost-identity-changed") + + def test_snapshot_headroom_failure_never_deletes_persistent_files(self): + with patch.object(engine.shutil, "disk_usage", return_value=type("Usage", (), {"free": 0})()): + code, receipt = self.run_transaction() + self.assertEqual(code, 1) + self.assertEqual(receipt["status"], "rolled_back") + self.assertFalse(receipt["mutated"]) + self.assertEqual(receipt["failure"], "snapshot-headroom") + self.assertEqual(engine.estate_hash(self.paths), self.before) + + def test_linked_history_cannot_remove_files_outside_estate(self): + (self.paths["data"] / "alerts/events.db").unlink() + outside = self.root / "private-file" + outside.write_text("keep") + (self.paths["data"] / "alerts/events.db").symlink_to(outside) + self.host.unhealthy = True + code, receipt = self.run_transaction() + self.assertEqual(code, 1) + self.assertEqual(receipt["failure"], "history-symlink") + self.assertEqual(outside.read_text(), "keep") + + def test_shared_host_lock_refusal_is_terminal_and_has_no_mutation(self): + lock = self.root / "host.lock" + fd = os.open(lock, os.O_CREAT | os.O_RDWR, 0o600) + self.addCleanup(os.close, fd) + engine.fcntl.flock(fd, engine.fcntl.LOCK_EX | engine.fcntl.LOCK_NB) + (self.attempt / "request.json").write_text(json.dumps(request())) + with patch.object(engine, "LOCK", lock), patch.object(engine.sys, "argv", ["transaction", str(self.attempt)]): + self.assertEqual(engine.main(), 1) + receipt = json.loads((self.attempt / "receipt.json").read_text()) + self.assertEqual(receipt["status"], "refused") + self.assertFalse(receipt["mutated"]) + self.assertEqual(engine.estate_hash(self.paths), self.before) + + +class InputAndCommandTest(unittest.TestCase): + def test_valid_current_envelope(self): + engine.validate(request()) + engine.validate(request("update")) + + def test_no_candidate_prerelease_arbitrary_service_url_or_profile(self): + cases = [("mode", "shell"), ("version", "v6.4.6-rc.1"), ("control_sha", "main"), + ("local_url", "http://other-host:7655"), ("public_url", "http://demo.example/api/health"), + ("public_url", "https://user:secret@demo.example/api/health"), + ("public_url", "https://demo.example/api/health?token=secret"), + ("installer_sha256", "arbitrary")] + for name, value in cases: + with self.subTest(name=name, value=value): + req = request(); req[name] = value + with self.assertRaises(engine.Failure): + engine.validate(req) + req = request(); req["service"] = "license" + with self.assertRaises(engine.Failure): + engine.validate(req) + req = request(); req["profile"]["nodes"] = "2; restart caddy" + with self.assertRaises(engine.Failure): + engine.validate(req) + + def test_http_observation_rejects_redirect_and_transport_failure(self): + host = engine.Host() + with patch.object(host, "command", return_value="302") as command: + self.assertEqual(host.status("https://demo.example/api/health"), "302") + self.assertNotIn("--location", command.call_args.args[0]) + self.assertIn("--disable", command.call_args.args[0]) + self.assertIn("/dev/null", command.call_args.args[0]) + with patch.object(host, "command", side_effect=engine.Failure("command-failed")): + self.assertEqual(host.status("https://demo.example/api/health"), "000") + + def test_malformed_or_unavailable_journal_is_not_no_crash(self): + host = engine.Host() + for text in ["not-json", '{"MESSAGE":"fine"}', '{"MESSAGE":[],"__CURSOR":"c"}']: + with self.subTest(text=text), patch.object(host, "command", return_value=text): + with self.assertRaises(engine.Failure): + host.check_journal({"pulse": "c"}) + with patch.object(host, "command", return_value='{"MESSAGE":"panic: fixture","__CURSOR":"d"}'): + with self.assertRaisesRegex(engine.Failure, "new-service-crash"): + host.check_journal({"pulse": "c"}) + + def test_journal_cursor_advances_without_returning_private_message(self): + host = engine.Host(); cursors = {"pulse": "c"} + with patch.object(host, "command", return_value='{"MESSAGE":"private normal message","__CURSOR":"d"}'): + host.check_journal(cursors) + self.assertEqual(cursors, {"pulse": "d"}) + + def test_no_journal_cursor_and_malformed_service_state_fail_closed(self): + host = engine.Host() + with patch.object(host, "command", return_value=""): + with self.assertRaises(engine.Failure): + host.cursors() + with self.assertRaises(engine.Failure): + host.state() + + +class DispatchLifecycleTest(unittest.TestCase): + def test_observer_budget_covers_child_and_recovery_and_no_wait_owned_by_ssh(self): + source = dispatcher.BOOTSTRAP + self.assertIn("RuntimeMaxSec=45min", source) + self.assertIn("TimeoutStopSec=20min", source) + self.assertIn("KillMode=mixed", source) + self.assertIn("+ 4000", source) + self.assertGreater(4000, 45 * 60 + 20 * 60) + self.assertNotIn("'--wait'", source) + self.assertNotIn("systemctl', 'stop'", source) + + def test_bootstrap_retains_intent_and_repeated_or_uncertain_launch_does_not_replay(self): + # Execute the real bootstrap with only its filesystem/root/systemd/clock + # adapters rebound, including a lost submission response. No host tools. + for uncertain in (False, True): + with self.subTest(uncertain=uncertain), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "state" + source = dispatcher.BOOTSTRAP.replace("pathlib.Path('/var/lib/pulse-deploy/demo')", repr(root)) + # repr(Path) is not a Python literal: bind just the fixed path. + source = dispatcher.BOOTSTRAP.replace("'/var/lib/pulse-deploy/demo'", repr(str(root))) + source = source.replace("root.stat().st_uid == 0", "root.stat().st_uid == os.getuid()") + clock = [0] + calls = [] + payload = {"request": request(), "source": "c291cmNl", "installer": ""} + + def launch(argv, **kwargs): + calls.append(argv) + attempt = next((root / "attempts").iterdir()) + if uncertain: + raise subprocess.TimeoutExpired("systemd-run", 30) + (attempt / "receipt.json").write_text(json.dumps({"status": "committed"})) + return subprocess.CompletedProcess(argv, 0) + + class Input: + def read(self, _limit): + return json.dumps(payload) + + with patch.object(os, "geteuid", return_value=0), patch.object(subprocess, "run", side_effect=launch), \ + patch.object(dispatcher.sys, "stdin", Input()), patch("time.monotonic", side_effect=lambda: clock[0]), \ + patch("time.sleep", side_effect=lambda seconds: clock.__setitem__(0, clock[0] + seconds)), patch("builtins.print"): + for _ in range(2): + with self.assertRaises(SystemExit): + exec(compile(source, "fixed-bootstrap", "exec"), {}) + self.assertEqual(len(calls), 1) + attempt = next((root / "attempts").iterdir()) + self.assertTrue((attempt / "intent.json").exists()) + self.assertEqual((attempt / "request.json").stat().st_mode & 0o777, 0o600) + self.assertEqual(attempt.stat().st_mode & 0o777, 0o700) + if uncertain: + self.assertEqual(json.loads((attempt / "launch.json").read_text())["state"], "uncertain") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/recover-demo-server.yml b/.github/workflows/recover-demo-server.yml index c482081c59..b0dc92f2c0 100644 --- a/.github/workflows/recover-demo-server.yml +++ b/.github/workflows/recover-demo-server.yml @@ -15,7 +15,7 @@ concurrency: jobs: recover: runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 90 environment: demo-stable env: DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }} @@ -117,16 +117,8 @@ jobs: MOCK_UPDATE_INTERVAL: ${{ steps.runtime_profile.outputs.mock_update_interval }} run: | set -euo pipefail - ssh -i ~/.ssh/id_ed25519 \ - -o IdentitiesOnly=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile=~/.ssh/known_hosts \ - "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \ - "bash -s -- $(printf '%q ' "$DEMO_EXPECTED_HOSTNAME" "$DEMO_LOCAL_BASE_URL" "$EXPECTED_VERSION" "$MOCK_NODES" "$MOCK_VMS_PER_NODE" "$MOCK_LXCS_PER_NODE" "$MOCK_DOCKER_HOSTS" "$MOCK_DOCKER_CONTAINERS" "$MOCK_GENERIC_HOSTS" "$MOCK_K8S_CLUSTERS" "$MOCK_K8S_NODES" "$MOCK_K8S_PODS" "$MOCK_K8S_DEPLOYMENTS" "$MOCK_SEED_DURATION" "$MOCK_SAMPLE_INTERVAL" "$MOCK_UPDATE_INTERVAL")" \ - < .github/scripts/recover-demo-runtime.sh \ - > demo-runtime-recovery.json - jq -e '.schema_version == 1 and (.status == "recovered" or .status == "healthy_noop")' \ - demo-runtime-recovery.json + bash .github/scripts/recover-demo-runtime.sh > demo-runtime-recovery.json + jq -e '.schema_version == 2 and (.status == "committed" or .status == "healthy_noop") and .forward.elapsed_seconds >= 300' demo-runtime-recovery.json echo "mutated=$(jq -r '.mutated' demo-runtime-recovery.json)" >> "$GITHUB_OUTPUT" - name: Capture bounded Pulse failure diagnostics @@ -199,22 +191,9 @@ jobs: export PULSE_DEMO_AUTH_PASS="${DEMO_AUTH_PASS:-demo}" ./scripts/run_demo_public_browser_smoke.sh - - name: Compensate failed mutated recovery - if: failure() && steps.recovery.outputs.mutated == 'true' - env: - DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} - DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} - run: | - set -euo pipefail - BEFORE="$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \ - "systemctl show pulse --property=ActiveState,SubState,MainPID --value | paste -sd, -")" - ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \ - "sudo systemctl stop pulse" - AFTER="$(ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \ - "systemctl show pulse --property=ActiveState,SubState,MainPID --value | paste -sd, -")" - jq -n --arg before "$BEFORE" --arg after "$AFTER" \ - '{schema_version: 1, status: "compensated_unavailable", before: $before, after: $after}' \ - > demo-runtime-rollback.json + # The retained systemd transaction already owns compensation. A later + # browser/network failure is evidence to reconcile, not authority to stop + # an unrelated or already-committed Pulse process in a second SSH session. - name: Retain bounded recovery evidence if: always() @@ -223,7 +202,6 @@ jobs: name: stable-demo-runtime-recovery-${{ github.run_id }} path: | demo-runtime-recovery.json - demo-runtime-rollback.json demo-runtime-diagnostics.txt if-no-files-found: warn retention-days: 14 diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 996f7bda56..214115ebbe 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -189,7 +189,7 @@ jobs: needs: resolve if: needs.resolve.outputs.skip != 'true' runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 90 environment: ${{ needs.resolve.outputs.environment_name }} env: DEMO_EXPECTED_HOSTNAME: ${{ vars.DEMO_EXPECTED_HOSTNAME }} @@ -450,309 +450,41 @@ jobs: echo "::error::Verification-only check found a demo version mismatch; refusing to update the host." exit 1 - - name: Prepare demo host storage - if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true' - env: - DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} - DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} - WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }} - run: | - set -euo pipefail - SERVICE_NAME="${WORKFLOW_OUTPUT_1}" - REMOTE_SCRIPT=$(cat <<'EOF' - set -euo pipefail - SERVICE_NAME="$1" - CONFIG_DIR="/etc/${SERVICE_NAME:-pulse}" - BACKUP_PARENT="$(dirname "$CONFIG_DIR")" - BACKUP_BASENAME="$(basename "$CONFIG_DIR")" - KEEP_BACKUPS=2 - BACKUP_MARGIN_KB=$((64 * 1024)) - - echo "Demo host filesystem before cleanup:" - df -h "$BACKUP_PARENT" /tmp || true - - mapfile -t old_backups < <( - sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%T@ %p\n' 2>/dev/null \ - | sort -nr \ - | awk -v keep="$KEEP_BACKUPS" 'NR > keep {sub(/^[^ ]+ /, ""); print}' - ) - if ((${#old_backups[@]} > 0)); then - printf 'Removing %d old demo backup directorie(s).\n' "${#old_backups[@]}" - printf '%s\0' "${old_backups[@]}" | sudo xargs -0 rm -rf -- - fi - - if [[ -d "$CONFIG_DIR" ]]; then - config_kb="$(sudo du -sk "$CONFIG_DIR" | awk 'NR == 1 {print $1}')" - free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')" - required_kb=$((config_kb + BACKUP_MARGIN_KB)) - if ((free_kb < required_kb)); then - mapfile -t remaining_backups < <( - sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%T@ %p\n' 2>/dev/null \ - | sort -n \ - | awk '{sub(/^[^ ]+ /, ""); print}' - ) - for backup_dir in "${remaining_backups[@]}"; do - printf 'Removing demo backup to restore install headroom: %s\n' "$backup_dir" - sudo rm -rf -- "$backup_dir" - free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')" - if ((free_kb >= required_kb)); then - break - fi - done - fi - pruned_volatile=false - if ((free_kb < required_kb)); then - echo "Pruning demo volatile runtime stores to restore install headroom." - if command -v systemctl >/dev/null 2>&1; then - sudo systemctl stop "$SERVICE_NAME" || true - fi - mapfile -d '' volatile_paths < <( - sudo find "$CONFIG_DIR" -xdev -type f \( \ - -name "metrics.db" -o \ - -name "metrics.db-shm" -o \ - -name "metrics.db-wal" -o \ - -name "notification_queue.db" -o \ - -name "notification_queue.db-shm" -o \ - -name "notification_queue.db-wal" -o \ - -name "deploy.db" -o \ - -name "deploy.db-shm" -o \ - -name "deploy.db-wal" \ - \) -print0 2>/dev/null || true - ) - for volatile_path in "${volatile_paths[@]}"; do - printf 'Removing demo volatile store: %s\n' "$(basename "$volatile_path")" - sudo rm -f -- "$volatile_path" - pruned_volatile=true - done - config_kb="$(sudo du -sk "$CONFIG_DIR" | awk 'NR == 1 {print $1}')" - free_kb="$(df -Pk "$BACKUP_PARENT" | awk 'NR == 2 {print $4}')" - required_kb=$((config_kb + BACKUP_MARGIN_KB)) - fi - if ((free_kb < required_kb)); then - echo "::error::Demo host does not have enough free space to back up $CONFIG_DIR before install." - echo "Config: ${config_kb}KB, available: ${free_kb}KB, required: ${required_kb}KB" - sudo find "$BACKUP_PARENT" -maxdepth 1 -type d -name "${BACKUP_BASENAME}.backup.*" -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort || true - if [[ "$pruned_volatile" == "true" ]] && command -v systemctl >/dev/null 2>&1; then - sudo systemctl start "$SERVICE_NAME" || true - fi - exit 1 - fi - fi - - echo "Demo host filesystem after cleanup:" - df -h "$BACKUP_PARENT" /tmp || true - EOF - ) - ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" "bash -s -- $(printf '%q ' "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT" - - - name: Upload tagged installer - if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true' - env: - DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} - DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} - run: | - set -euo pipefail - scp -i ~/.ssh/id_ed25519 /tmp/pulse-install.sh "$DEMO_SERVER_USER@$DEMO_SERVER_HOST:/tmp/pulse-install.sh" - - - name: Update demo server - if: inputs.verify_only != true && steps.current.outputs.skip_current != 'true' - env: - DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} - DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} - WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} - WORKFLOW_OUTPUT_2: ${{ steps.config.outputs.service_name }} - run: | - set -euo pipefail - TAG="${WORKFLOW_OUTPUT_1}" - SERVICE_NAME="${WORKFLOW_OUTPUT_2}" - REMOTE_SCRIPT=$(cat <<'EOF' - set -euo pipefail - TAG="$1" - SERVICE_NAME="$2" - INSTALLER_ENV=() - if [ -n "$SERVICE_NAME" ]; then - INSTALLER_ENV+=("PULSE_SERVICE_NAME=$SERVICE_NAME") - fi - sudo env "${INSTALLER_ENV[@]}" bash /tmp/pulse-install.sh --version "$TAG" - rm -f /tmp/pulse-install.sh - EOF - ) - ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \ - "bash -s -- $(printf '%q ' "$TAG" "$SERVICE_NAME")" <<<"$REMOTE_SCRIPT" - - - name: Restore demo runtime configuration + - name: Apply guarded demo transaction if: inputs.verify_only != true env: DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} - WORKFLOW_OUTPUT_1: ${{ steps.config.outputs.service_name }} - WORKFLOW_OUTPUT_2: ${{ needs.resolve.outputs.mock_nodes }} - WORKFLOW_OUTPUT_3: ${{ needs.resolve.outputs.mock_vms_per_node }} - WORKFLOW_OUTPUT_4: ${{ needs.resolve.outputs.mock_lxcs_per_node }} - WORKFLOW_OUTPUT_5: ${{ needs.resolve.outputs.mock_docker_hosts }} - WORKFLOW_OUTPUT_6: ${{ needs.resolve.outputs.mock_docker_containers }} - WORKFLOW_OUTPUT_7: ${{ needs.resolve.outputs.mock_generic_hosts }} - WORKFLOW_OUTPUT_8: ${{ needs.resolve.outputs.mock_k8s_clusters }} - WORKFLOW_OUTPUT_9: ${{ needs.resolve.outputs.mock_k8s_nodes }} - WORKFLOW_OUTPUT_10: ${{ needs.resolve.outputs.mock_k8s_pods }} - WORKFLOW_OUTPUT_11: ${{ needs.resolve.outputs.mock_k8s_deployments }} - WORKFLOW_OUTPUT_12: ${{ needs.resolve.outputs.mock_seed_duration }} - WORKFLOW_OUTPUT_13: ${{ needs.resolve.outputs.mock_sample_interval }} - WORKFLOW_OUTPUT_14: ${{ needs.resolve.outputs.mock_update_interval }} + EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }} + CURRENT_MATCHES: ${{ steps.current.outputs.skip_current }} + MOCK_NODES: ${{ needs.resolve.outputs.mock_nodes }} + MOCK_VMS_PER_NODE: ${{ needs.resolve.outputs.mock_vms_per_node }} + MOCK_LXCS_PER_NODE: ${{ needs.resolve.outputs.mock_lxcs_per_node }} + MOCK_DOCKER_HOSTS: ${{ needs.resolve.outputs.mock_docker_hosts }} + MOCK_DOCKER_CONTAINERS: ${{ needs.resolve.outputs.mock_docker_containers }} + MOCK_GENERIC_HOSTS: ${{ needs.resolve.outputs.mock_generic_hosts }} + MOCK_K8S_CLUSTERS: ${{ needs.resolve.outputs.mock_k8s_clusters }} + MOCK_K8S_NODES: ${{ needs.resolve.outputs.mock_k8s_nodes }} + MOCK_K8S_PODS: ${{ needs.resolve.outputs.mock_k8s_pods }} + MOCK_K8S_DEPLOYMENTS: ${{ needs.resolve.outputs.mock_k8s_deployments }} + MOCK_SEED_DURATION: ${{ needs.resolve.outputs.mock_seed_duration }} + MOCK_SAMPLE_INTERVAL: ${{ needs.resolve.outputs.mock_sample_interval }} + MOCK_UPDATE_INTERVAL: ${{ needs.resolve.outputs.mock_update_interval }} run: | set -euo pipefail - SERVICE_NAME="${WORKFLOW_OUTPUT_1}" - MOCK_NODES="${WORKFLOW_OUTPUT_2}" - MOCK_VMS_PER_NODE="${WORKFLOW_OUTPUT_3}" - MOCK_LXCS_PER_NODE="${WORKFLOW_OUTPUT_4}" - MOCK_DOCKER_HOSTS="${WORKFLOW_OUTPUT_5}" - MOCK_DOCKER_CONTAINERS="${WORKFLOW_OUTPUT_6}" - MOCK_GENERIC_HOSTS="${WORKFLOW_OUTPUT_7}" - MOCK_K8S_CLUSTERS="${WORKFLOW_OUTPUT_8}" - MOCK_K8S_NODES="${WORKFLOW_OUTPUT_9}" - MOCK_K8S_PODS="${WORKFLOW_OUTPUT_10}" - MOCK_K8S_DEPLOYMENTS="${WORKFLOW_OUTPUT_11}" - MOCK_SEED_DURATION="${WORKFLOW_OUTPUT_12}" - MOCK_SAMPLE_INTERVAL="${WORKFLOW_OUTPUT_13}" - MOCK_UPDATE_INTERVAL="${WORKFLOW_OUTPUT_14}" - REMOTE_SCRIPT=$(cat <<'EOF' - set -euo pipefail - SERVICE_NAME="${1:-pulse}" - DEMO_LOCAL_BASE_URL="$2" - MOCK_NODES="$3" - MOCK_VMS_PER_NODE="$4" - MOCK_LXCS_PER_NODE="$5" - MOCK_DOCKER_HOSTS="$6" - MOCK_DOCKER_CONTAINERS="$7" - MOCK_GENERIC_HOSTS="$8" - MOCK_K8S_CLUSTERS="$9" - MOCK_K8S_NODES="${10}" - MOCK_K8S_PODS="${11}" - MOCK_K8S_DEPLOYMENTS="${12}" - MOCK_SEED_DURATION="${13}" - MOCK_SAMPLE_INTERVAL="${14}" - MOCK_UPDATE_INTERVAL="${15}" - CONFIG_DIR="/etc/${SERVICE_NAME:-pulse}" - SERVICE_USER="$(systemctl show "$SERVICE_NAME" --property=User --value 2>/dev/null || true)" - SERVICE_GROUP="$(systemctl show "$SERVICE_NAME" --property=Group --value 2>/dev/null || true)" - SERVICE_USER="${SERVICE_USER:-pulse}" - SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}" + MODE=update + if [ "$CURRENT_MATCHES" = "true" ]; then MODE=recover; fi + python3 .github/scripts/dispatch-demo-runtime.py "$MODE" > demo-runtime-transaction.json + jq -e '.schema_version == 2 and (.status == "committed" or .status == "healthy_noop") and .forward.elapsed_seconds >= 300' demo-runtime-transaction.json - resolve_config_dir() { - local fallback="$CONFIG_DIR" - local unit_env="" - local fragment="" - unit_env="$(systemctl show "$SERVICE_NAME" --property=Environment --value 2>/dev/null || true)" - for entry in $unit_env; do - case "$entry" in - PULSE_DATA_DIR=*) - printf '%s\n' "${entry#PULSE_DATA_DIR=}" - return 0 - ;; - esac - done - - fragment="$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value 2>/dev/null || true)" - if [ -n "$fragment" ] && sudo test -r "$fragment"; then - local unit_data_dir - unit_data_dir="$(sudo sed -n -E 's/^Environment="?PULSE_DATA_DIR=([^"]*)"?$/\1/p' "$fragment" | tail -1)" - if [ -n "$unit_data_dir" ]; then - printf '%s\n' "$unit_data_dir" - return 0 - fi - fi - - printf '%s\n' "$fallback" - } - - CONFIG_DIR="$(resolve_config_dir)" - ENV_FILE="$CONFIG_DIR/.env" - - set_env_value() { - local key="$1" - local value="$2" - sudo mkdir -p "$CONFIG_DIR" - if sudo test -f "$ENV_FILE"; then - if sudo grep -Eq "^[[:space:]]*${key}=" "$ENV_FILE"; then - sudo sed -i "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$ENV_FILE" - else - printf '\n%s=%s\n' "$key" "$value" | sudo tee -a "$ENV_FILE" >/dev/null - fi - else - printf '# Pulse demo runtime configuration\n%s=%s\n' "$key" "$value" | sudo tee "$ENV_FILE" >/dev/null - fi - } - - ensure_demo_fixture_entitlement() { - local billing_file="$CONFIG_DIR/billing.json" - local tmp_file - tmp_file="$(mktemp)" - sudo mkdir -p "$CONFIG_DIR" - - if sudo test -s "$billing_file" && sudo jq ' - .capabilities = (((.capabilities // []) + ["demo_fixtures"]) | unique) - | .limits = (.limits // {}) - | .meters_enabled = (.meters_enabled // []) - | .plan_version = (if (.plan_version // "") == "" then "community" else .plan_version end) - | .subscription_state = (if (.subscription_state // "") == "" then "active" else .subscription_state end) - | del(.integrity) - ' "$billing_file" > "$tmp_file"; then - : - else - cat > "$tmp_file" <<'JSON' - {"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"} - JSON - fi - - sudo tee "$billing_file" < "$tmp_file" >/dev/null - rm -f "$tmp_file" - sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$billing_file" || sudo chown "$SERVICE_USER" "$billing_file" || true - sudo chmod 600 "$billing_file" - echo "Demo fixture entitlement ensured in governed demo billing state." - } - - set_env_value DEMO_MODE true - set_env_value PULSE_MOCK_MODE true - set_env_value PULSE_MOCK_NODES "$MOCK_NODES" - set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE" - set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE" - set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS" - set_env_value PULSE_MOCK_DOCKER_CONTAINERS "$MOCK_DOCKER_CONTAINERS" - set_env_value PULSE_MOCK_GENERIC_HOSTS "$MOCK_GENERIC_HOSTS" - set_env_value PULSE_MOCK_K8S_CLUSTERS "$MOCK_K8S_CLUSTERS" - set_env_value PULSE_MOCK_K8S_NODES "$MOCK_K8S_NODES" - set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS" - set_env_value PULSE_MOCK_K8S_DEPLOYMENTS "$MOCK_K8S_DEPLOYMENTS" - set_env_value PULSE_MOCK_RANDOM_METRICS true - set_env_value PULSE_MOCK_STOPPED_PERCENT 6 - # Never carry the isolated local-dev metrics-store opt-in onto the - # persistent public demo data directory. - set_env_value PULSE_MOCK_SEED_METRICS_STORE false - set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION" - set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL" - set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL" - - ensure_demo_fixture_entitlement - - sudo chown "$SERVICE_USER:$SERVICE_GROUP" "$ENV_FILE" || sudo chown "$SERVICE_USER" "$ENV_FILE" || true - sudo chmod 600 "$ENV_FILE" - sudo systemctl daemon-reload - sudo systemctl restart "$SERVICE_NAME" - - for attempt in $(seq 1 30); do - if curl -fsS "${DEMO_LOCAL_BASE_URL}/api/health" >/dev/null; then - echo "Demo service restarted with governed demo runtime configuration." - exit 0 - fi - sleep 2 - done - - echo "::error::Demo service did not become healthy after restoring demo runtime configuration." - exit 1 - EOF - ) - ssh -i ~/.ssh/id_ed25519 "$DEMO_SERVER_USER@$DEMO_SERVER_HOST" \ - "bash -s -- $(printf '%q ' "$SERVICE_NAME" "$DEMO_LOCAL_BASE_URL" "$MOCK_NODES" "$MOCK_VMS_PER_NODE" "$MOCK_LXCS_PER_NODE" "$MOCK_DOCKER_HOSTS" "$MOCK_DOCKER_CONTAINERS" "$MOCK_GENERIC_HOSTS" "$MOCK_K8S_CLUSTERS" "$MOCK_K8S_NODES" "$MOCK_K8S_PODS" "$MOCK_K8S_DEPLOYMENTS" "$MOCK_SEED_DURATION" "$MOCK_SAMPLE_INTERVAL" "$MOCK_UPDATE_INTERVAL")" <<<"$REMOTE_SCRIPT" + - name: Retain guarded demo transaction evidence + if: always() && inputs.verify_only != true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: stable-demo-transaction-${{ github.run_id }} + path: demo-runtime-transaction.json + if-no-files-found: warn + retention-days: 14 - name: Verify demo runtime env: diff --git a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md new file mode 100644 index 0000000000..af5e4322da --- /dev/null +++ b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md @@ -0,0 +1,60 @@ +# Stable demo transaction and acceptance + +This continues the existing update/recovery route. It was built for a single +demo workflow and first-success health; the actual host also runs Relay and +Caddy. An immediate healthy sample does not cover a delayed failure, and a +workflow queue does not exclude another repository's host deployment. + +## Implemented boundary + +- Both existing mutating workflows submit the same reviewed Python engine. + Update still requires the immutable stable activation and customer-promotion + lease, exact tagged installer and trusted release signing key. Verification + only never submits it. Recovery never installs a different executable. +- The independently owned systemd child holds the existing Relay deployment + file lock through capture, installation/profile work, forward observation, + complete restoration and recovery observation. It does not restart Relay or + Caddy, upgrade a package, lift the Caddy hold or activate frozen commercial copy. +- The complete quiescent Pulse executable/unit/drop-in/data estate is retained + privately before mutation. No backup or database is deleted for space. The + unhealthy demo-only route retains the original generated operational history + before its existing bounded-profile reset. No snapshot is automatically erased. +- Readiness has a separate 60-second limit. Forward, healthy-noop and restored + paths require 300 seconds of actual local/public/Relay HTTP 200, expected + version, stable service PID/restarts and available new-journal observations. + Transport errors, redirects, missing state/journals and new crashes fail. + Recovery uses fresh cursors; a retained forward failure remains a failure. +- Failed/unhealthy/uncertain recovery remains owned and blocks subsequent + mutation. A prior failed baseline is not a healthy rollback. No crash dump is + induced. Receipt fields contain fixed diagnostics and identities, never raw + journal/HTTP bodies, environment values or snapshot contents. +- Source-bound intent precedes systemd submission. An identical request reads + its receipt and cannot start another child after a lost response. SSH loss + does not terminate the child. TERM/INT/HUP cannot abort owned recovery. The + 45-minute runtime, 20-minute stop, 4,000-second remote observer, 4,100-second + client and 90-minute workflow budgets cover both observation windows. + +## Available validation and remaining result + +`TestDemoTransactionConnectedRecoveryControls` executes the real engine and +bootstrap using a real private filesystem estate and virtual-clock command, +service, journal and HTTP adapters. Tests retain delayed 55/299-second failure, +changed executable/unit/data rollback, cancellation, failed and unhealthy +recovery, headroom refusal, shared-lock contention, source mismatch, journal +failure, cohost restart, source-bound repeated/lost submission and no-op proof. +The production window is not shortened. This is **not native acceptance**. + +Before treating this route as restored, the exact reviewed source must return +native disposable systemd/SSH-loss receipts and signed published-installer +forward/reverse data/identity results. Then reconcile actual demo workflow and +public browser readback through the release owner; source tests do not establish +an installed recovery. Delivery owns that continuation and the remaining ingress +migration. Unrestricted root/Tailscale administration can still bypass a source +transaction; this candidate is not host enforcement. Workers neither install +policy/credentials nor replay a successful publication to test it. + +Retained failed/unknown estates require the operational owner's reconciliation; +do not remove a lock, intent or snapshot merely to permit a fresh sample. A +later frontend/network failure is not authority for a second SSH session to +stop a committed or unrelated service. Keep the signed packet, failure and +native/browser acceptance distinct in the release judgment. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index efa5d4e8c8..78533e9a3f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -15,6 +15,36 @@ ## Purpose +### Demo transactions share the service host, not just a workflow queue + +Stable demo update and recovery keep their existing publication/activation, +exact-tag installer signing key, Tailscale, pinned SSH and environment boundaries. +The mutating paths now share the Relay host's existing +`/var/lib/pulse-deploy/relay/deploy.lock` and one retained systemd transaction. +Every successful path, including a healthy no-op, needs a complete 300-second +local/public/Relay health, version, service PID/restart and new-journal-crash +window. Readiness is separate. Missing observations fail closed. + +Before installation or runtime-profile mutation, stop only Pulse and durably +capture its executable, unit/drop-ins and entire persistent data estate. Refuse +insufficient space rather than prune backups/databases. A failed healthy change +restores that complete estate and observes another 300 seconds; the original +operation still fails. An unhealthy recovery baseline is never claimed as a +healthy rollback. Retain its original generated demo history, all snapshots and +failed/uncertain terminal evidence privately. An unresolved estate blocks later +mutation. No source-worker production capability, package upgrade, Relay copy +activation or credential is added. + +SSH observes, but never owns or cancels, the systemd child. Durable intent +precedes submission; the exact request identity is not replayed after a lost +response. Forward cancellation initiates restoration; recovery ignores +TERM/INT/HUP until its watch and terminal receipt are retained. Child, stop, +observer and workflow deadlines cover both full windows. A later browser or +network check must not stop a committed or unrelated service in another SSH +session. These connected source controls do not establish native systemd, +signed installed forward/reverse acceptance, ingress enforcement or customer +recovery. See [demo transaction acceptance](../DEMO_RUNTIME_TRANSACTION.md). + ### Superseded pull request validation Build and Test and Core E2E cancel an older run for the same pull request when @@ -732,6 +762,8 @@ release-latency optimization. 23. `.github/workflows/update-demo-server.yml` 23a. `.github/workflows/recover-demo-server.yml` 23b. `.github/scripts/recover-demo-runtime.sh` +23c. `.github/scripts/demo-runtime-transaction.py` +23d. `.github/scripts/dispatch-demo-runtime.py` 23c. `.github/scripts/resolve-demo-runtime-profile.sh` 24. `.github/workflows/validate-release-assets.yml` 25. `.github/workflows/install-sh-smoke.yml` diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 5b59559345..69c3b883c3 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4515,6 +4515,8 @@ ".dockerignore", ".github/ISSUE_TEMPLATE/v6_rc_feedback.yml", ".github/scripts/check-demo-reachability.sh", + ".github/scripts/demo-runtime-transaction.py", + ".github/scripts/dispatch-demo-runtime.py", ".github/scripts/internal-api-test-seconds.txt", ".github/scripts/record-internal-api-test-seconds.py", ".github/scripts/recover-demo-runtime.sh", @@ -4544,6 +4546,9 @@ ".github/workflows/retry-release-convergence.yml", ".github/workflows/update-demo-server.yml", ".github/workflows/validate-release-assets.yml", + "Dockerfile", + "Makefile", + "VERSION", "cmd/pulse-control-plane/main.go", "cmd/pulse-control-plane/provider_msp.go", "cmd/pulse-control-plane/provider_msp_backup.go", @@ -4553,13 +4558,12 @@ "cmd/pulse-control-plane/provider_msp_recover.go", "cmd/pulse-control-plane/provider_msp_status.go", "docker-compose.yml", - "Dockerfile", "docs/MSP.md", + "docs/RELEASE_NOTES.md", + "docs/UPGRADE_v6.md", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", - "docs/RELEASE_NOTES.md", - "docs/UPGRADE_v6.md", "frontend-modern/package-lock.json", "frontend-modern/package.json", "frontend-modern/public/docs/MSP.md", @@ -4579,7 +4583,6 @@ "internal/cloudcp/provider_msp_backup.go", "internal/cloudcp/provider_msp_recovery.go", "internal/cloudcp/tenant_runtime_rollout.go", - "Makefile", "package-lock.json", "package.json", "pulse-enterprise:.github/workflows/build-pro-release.yml", @@ -4654,17 +4657,16 @@ "scripts/uninstall-sensor-proxy.sh", "scripts/validate-release.sh", "scripts/verify-github-release-integrity.sh", - "tests/integration/playwright.config.ts", "tests/integration/QUICK_START.md", "tests/integration/README.md", + "tests/integration/playwright.config.ts", "tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs", "tests/integration/scripts/hosted-mobile-token-runtime.mjs", "tests/integration/scripts/hosted-tenant-runtime.mjs", "tests/integration/scripts/managed-dev-runtime.mjs", "tests/integration/scripts/relay-mobile-token-helper.go", "tests/integration/tests/helpers.ts", - "tests/integration/tests/runtime-defaults.ts", - "VERSION" + "tests/integration/tests/runtime-defaults.ts" ], "release_cycle_artifact_globs": [ "docs/RELEASE_NOTES.md", @@ -4749,6 +4751,8 @@ "match_files": [ ".github/ISSUE_TEMPLATE/v6_rc_feedback.yml", ".github/scripts/check-demo-reachability.sh", + ".github/scripts/demo-runtime-transaction.py", + ".github/scripts/dispatch-demo-runtime.py", ".github/scripts/recover-demo-runtime.sh", ".github/scripts/resolve-demo-runtime-profile.sh", ".github/scripts/setup-demo-ssh.sh", @@ -4771,11 +4775,12 @@ ".github/workflows/retry-release-convergence.yml", ".github/workflows/update-demo-server.yml", ".github/workflows/validate-release-assets.yml", + "VERSION", + "docs/RELEASE_NOTES.md", + "docs/UPGRADE_v6.md", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", - "docs/RELEASE_NOTES.md", - "docs/UPGRADE_v6.md", "frontend-modern/public/docs/UPGRADE_v6.md", "frontend-modern/src/components/UpdateBanner.tsx", "frontend-modern/src/components/WhatsNewCard.tsx", @@ -4803,17 +4808,18 @@ "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh", "scripts/trigger-stable-patch.sh", - "scripts/verify-github-release-integrity.sh", - "VERSION" + "scripts/verify-github-release-integrity.sh" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + ".github/scripts/tests/test_demo_runtime_transaction.py", "frontend-modern/src/components/__tests__/whatsNewModel.test.ts", "frontend-modern/src/utils/__tests__/docsLinks.test.ts", "pulse-enterprise:scripts/validate-pro-release-line_test.sh", "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", + "scripts/installtests/demo_transaction_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 94688451c0..fff9fd54fd 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2306,7 +2306,11 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { if err != nil { t.Fatalf("read demo runtime profile resolver: %v", err) } - workflow := string(workflowBytes) + "\n" + string(profileBytes) + transactionBytes, err := os.ReadFile(repoFile(".github", "scripts", "demo-runtime-transaction.py")) + if err != nil { + t.Fatal(err) + } + workflow := string(workflowBytes) + "\n" + string(profileBytes) + "\n" + string(transactionBytes) required := []string{ `- name: Tailscale`, `uses: tailscale/github-action@780049a30b6ff5c378a9e7b389d15ece7a204888 # v4.1.3`, @@ -2319,7 +2323,6 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `workflow_call:`, `verify_only:`, `Waiting for activated release assets to be available`, - `bash /tmp/pulse-install.sh --version "$TAG"`, `Refuse mutation during verification-only checks`, `uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0`, `go run ./scripts/release_update_key.go public-key-ssh`, @@ -2327,15 +2330,10 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `Verify target host identity`, `bash .github/scripts/setup-demo-ssh.sh`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, - `Prepare demo host storage`, - `KEEP_BACKUPS=2`, - `Removing demo backup to restore install headroom: %s`, - `Pruning demo volatile runtime stores to restore install headroom.`, - `sudo find "$CONFIG_DIR" -xdev -type f`, - `-name "metrics.db"`, - `Removing demo volatile store: %s`, - `Demo host does not have enough free space to back up $CONFIG_DIR before install.`, - `Restore demo runtime configuration`, + `Apply guarded demo transaction`, + `python3 .github/scripts/dispatch-demo-runtime.py "$MODE"`, + `Retain guarded demo transaction evidence`, + `.forward.elapsed_seconds >= 300`, `Resolve target-compatible demo runtime profile`, `git grep -q 'mockEagerHistoryPVEGuestLimit'`, `git grep -q 'UpdateMetricCohort'`, @@ -2361,22 +2359,13 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `MOCK_SEED_DURATION=2h`, `MOCK_SAMPLE_INTERVAL=5m`, `MOCK_UPDATE_INTERVAL=15s`, - `resolve_config_dir`, - `set_env_value DEMO_MODE true`, - `set_env_value PULSE_MOCK_MODE true`, - `set_env_value PULSE_MOCK_NODES "$MOCK_NODES"`, - `set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE"`, - `set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE"`, - `set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS"`, - `set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS"`, - `set_env_value PULSE_MOCK_SEED_METRICS_STORE false`, - `set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION"`, - `set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL"`, - `set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL"`, - `ensure_demo_fixture_entitlement`, - `"demo_fixtures"`, - `del(.integrity)`, - `Demo fixture entitlement ensured in governed demo billing state.`, + `WINDOW = 300`, + `/var/lib/pulse-deploy/relay/deploy.lock`, + `snapshot-headroom`, + `restored-estate-identity`, + `demo_fixtures`, + `PULSE_MOCK_SEED_METRICS_STORE`, + `installer-identity`, `/api/license/runtime-capabilities`, `Mock mode enabled`, `Demo server mock mode did not enable after entitlement sync`, diff --git a/scripts/installtests/demo_transaction_test.go b/scripts/installtests/demo_transaction_test.go new file mode 100644 index 0000000000..f53ed0abc0 --- /dev/null +++ b/scripts/installtests/demo_transaction_test.go @@ -0,0 +1,56 @@ +package installtests + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestDemoTransactionConnectedRecoveryControls(t *testing.T) { + cmd := exec.Command("python3", repoFile(".github", "scripts", "tests", "test_demo_runtime_transaction.py")) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("demo connected transaction controls failed: %v\n%s", err, output) + } + if !strings.Contains(string(output), "\nOK\n") { + t.Fatalf("demo controls did not return a complete unittest verdict: %s", output) + } +} + +func TestDemoTransactionKeepsSignedAdmissionAndVerificationOnlyReadOnly(t *testing.T) { + workflow, err := os.ReadFile(repoFile(".github", "workflows", "update-demo-server.yml")) + if err != nil { + t.Fatal(err) + } + text := string(workflow) + start := strings.Index(text, "- name: Apply guarded demo transaction") + end := strings.Index(text[start:], "- name: Retain guarded demo transaction evidence") + if start < 0 || end < 0 { + t.Fatal("missing retained demo transaction entry") + } + entry := text[start : start+end] + if !strings.Contains(entry, "if: inputs.verify_only != true") { + t.Fatal("verification-only invocation must never submit a transaction") + } + for _, forbidden := range []string{"Pruning demo volatile runtime stores", "Removing demo backup to restore install headroom", "sudo systemctl restart"} { + if strings.Contains(text, forbidden) { + t.Fatalf("unguarded demo mutation remains: %s", forbidden) + } + } + for _, required := range []string{"Require exact committed activation marker for mutation", "release-activation.json", "Stable demo mutation refuses mutable, inactive, or prerelease tag", "PINNED_RELEASE_SSH_PUBLIC_KEY", "git show \"refs/tags/${TAG}:install.sh\"", "Verify public browser smoke"} { + if !strings.Contains(text, required) { + t.Fatalf("lost signed/admission/customer verification boundary: %s", required) + } + } + recovery, err := os.ReadFile(repoFile(".github", "workflows", "recover-demo-server.yml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(recovery), "sudo systemctl stop pulse") || strings.Contains(string(recovery), "Compensate failed mutated recovery") { + t.Fatal("later workflow failure must not stop a committed or unrelated service") + } + if !strings.Contains(string(recovery), ".forward.elapsed_seconds >= 300") || !strings.Contains(string(recovery), "if: always()") { + t.Fatal("recovery must validate and retain the actual terminal observation") + } +} From 751fb67523012658c305fd0dae12fba4cfe67c4c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:12:47 +0100 Subject: [PATCH 04/86] Bind demo controls without broadening unrelated release proof Keep new source paths on their own connected proof policy after the full lookup suite exposed six broadened metadata expectations. Preserve terminal no-op cancellation evidence and the runtime file owner. Contract-Neutral: Correct connected observation and verification routing within the existing demo operations and authority. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 9 ++++++--- .../tests/test_demo_runtime_transaction.py | 13 ++++++++++--- .../v6/internal/subsystems/registry.json | 19 +++++++++++++++---- scripts/installtests/demo_transaction_test.go | 5 ++++- .../release_control/subsystem_lookup_test.py | 12 ++++++++++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 60193e378e..5fa81dda04 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -275,6 +275,8 @@ class Host: pattern = re.compile(r"^[ \t]*" + re.escape(key) + r"=.*$", re.M) text = pattern.sub(key + "=" + value, text) if pattern.search(text) else text.rstrip("\n") + "\n" + key + "=" + value + "\n" env_file.write_text(text) + data_owner = PATHS["data"].stat() + os.chown(env_file, data_owner.st_uid, data_owner.st_gid) os.chmod(env_file, 0o600) billing = json.loads(billing_file.read_text()) if not isinstance(billing, dict) or not isinstance(billing.get("capabilities", []), list): @@ -282,6 +284,7 @@ class Host: billing["capabilities"] = sorted(set(billing.get("capabilities", []) + ["demo_fixtures"])) billing.pop("integrity", None) billing_file.write_text(json.dumps(billing)) + os.chown(billing_file, data_owner.st_uid, data_owner.st_gid) os.chmod(billing_file, 0o600) if unhealthy: for relative in DEMO_HISTORY: @@ -408,14 +411,14 @@ class Transaction: elif self.request["mode"] == "update": raise Failure("update-baseline-unhealthy") self.receipt["healthy_baseline"] = healthy_baseline + old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} + for sig in old_handlers: + signal.signal(sig, cancel) # The no-op is observed too; an immediate healthy sample is not success. if self.request["mode"] == "recover" and healthy_baseline and self.host.profile_matches(self.request): self.watch("forward", original_version, self.host.cursors()) self.save("healthy_noop") return 0 - old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} - for sig in old_handlers: - signal.signal(sig, cancel) self.save("capturing") stopped = True self.host.stop() diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 36bb810f44..6050a380c8 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -1,4 +1,3 @@ -import copy import hashlib import importlib.util import json @@ -195,6 +194,16 @@ class TransactionTest(unittest.TestCase): self.assertFalse(receipt["mutated"]) self.assertEqual(self.host.stops, 0) + def test_noop_cancellation_retains_terminal_failure_without_stopping_service(self): + self.host.profile(request(), False) + self.host.cancel_at = 55 + code, receipt = self.run_transaction() + self.assertEqual(code, 1) + self.assertEqual(receipt["status"], "refused") + self.assertEqual(receipt["failure"], "cancelled-forward") + self.assertEqual(self.host.stops, 0) + self.assertFalse(receipt["mutated"]) + def test_update_restores_complete_changed_executable_unit_and_data_after_late_failure(self): self.host.fail_at = 55 code, receipt = self.run_transaction("update", "v6.4.6") @@ -404,8 +413,6 @@ class DispatchLifecycleTest(unittest.TestCase): for uncertain in (False, True): with self.subTest(uncertain=uncertain), tempfile.TemporaryDirectory() as directory: root = Path(directory) / "state" - source = dispatcher.BOOTSTRAP.replace("pathlib.Path('/var/lib/pulse-deploy/demo')", repr(root)) - # repr(Path) is not a Python literal: bind just the fixed path. source = dispatcher.BOOTSTRAP.replace("'/var/lib/pulse-deploy/demo'", repr(str(root))) source = source.replace("root.stat().st_uid == 0", "root.stat().st_uid == os.getuid()") clock = [0] diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 69c3b883c3..9cf02a0271 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4751,8 +4751,6 @@ "match_files": [ ".github/ISSUE_TEMPLATE/v6_rc_feedback.yml", ".github/scripts/check-demo-reachability.sh", - ".github/scripts/demo-runtime-transaction.py", - ".github/scripts/dispatch-demo-runtime.py", ".github/scripts/recover-demo-runtime.sh", ".github/scripts/resolve-demo-runtime-profile.sh", ".github/scripts/setup-demo-ssh.sh", @@ -4813,13 +4811,11 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ - ".github/scripts/tests/test_demo_runtime_transaction.py", "frontend-modern/src/components/__tests__/whatsNewModel.test.ts", "frontend-modern/src/utils/__tests__/docsLinks.test.ts", "pulse-enterprise:scripts/validate-pro-release-line_test.sh", "pulse-pro:scripts/tests/test_validate_paid_runtime_distribution.py", "scripts/installtests/build_release_assets_test.go", - "scripts/installtests/demo_transaction_test.go", "scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py", "scripts/release_control/internal/release_preflight_test.py", "scripts/release_control/mobile_release_gate_test.py", @@ -5173,6 +5169,21 @@ "scripts/installtests/install_sh_test.go", "scripts/installtests/safe_profile_migration_test.go" ] + }, + { + "id": "demo-runtime-transaction", + "label": "demo shared-host transaction and retained recovery proof", + "match_prefixes": [], + "match_files": [ + ".github/scripts/demo-runtime-transaction.py", + ".github/scripts/dispatch-demo-runtime.py" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "scripts/installtests/demo_transaction_test.go", + ".github/scripts/tests/test_demo_runtime_transaction.py" + ] } ], "match_files": null diff --git a/scripts/installtests/demo_transaction_test.go b/scripts/installtests/demo_transaction_test.go index f53ed0abc0..ae800da558 100644 --- a/scripts/installtests/demo_transaction_test.go +++ b/scripts/installtests/demo_transaction_test.go @@ -25,8 +25,11 @@ func TestDemoTransactionKeepsSignedAdmissionAndVerificationOnlyReadOnly(t *testi } text := string(workflow) start := strings.Index(text, "- name: Apply guarded demo transaction") + if start < 0 { + t.Fatal("missing retained demo transaction entry") + } end := strings.Index(text[start:], "- name: Retain guarded demo transaction evidence") - if start < 0 || end < 0 { + if end < 0 { t.Fatal("missing retained demo transaction entry") } entry := text[start : start+end] diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index a4dc01c378..d77aece65c 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -100,6 +100,18 @@ def _contract_reference(contract_path: str, needle: str, runtime_path: str) -> d class SubsystemLookupTest(unittest.TestCase): + + def test_demo_transaction_sources_have_connected_proof_without_widening_release_metadata(self): + for path in (".github/scripts/demo-runtime-transaction.py", ".github/scripts/dispatch-demo-runtime.py"): + with self.subTest(path=path): + result = lookup_paths([path]) + subsystem = next(item for item in result["subsystems"] if item["id"] == "deployment-installability") + self.assertEqual(subsystem["matched_verification_policies"][0]["id"], "demo-runtime-transaction") + self.assertEqual(sorted(subsystem["required_verification"]["exact_files"]), [ + ".github/scripts/tests/test_demo_runtime_transaction.py", + "scripts/installtests/demo_transaction_test.go", + ]) + def git(self, repo_root: Path, *args: str) -> None: subprocess.run( ["git", *args], From b3937a4803469bd555239eca1c4f2628862497ec Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:13:46 +0100 Subject: [PATCH 05/86] Use the actual lookup receipt shape in demo ownership proof Assert the existing file-level verification receipt so the new routing control exercises real ownership instead of assuming an unavailable summary field. Contract-Neutral: Test-only assertion of existing subsystem lookup output; no runtime or authority change. Change-source: pulse-maintainer --- scripts/release_control/subsystem_lookup_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index d77aece65c..1fae39c773 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -105,9 +105,11 @@ class SubsystemLookupTest(unittest.TestCase): for path in (".github/scripts/demo-runtime-transaction.py", ".github/scripts/dispatch-demo-runtime.py"): with self.subTest(path=path): result = lookup_paths([path]) - subsystem = next(item for item in result["subsystems"] if item["id"] == "deployment-installability") - self.assertEqual(subsystem["matched_verification_policies"][0]["id"], "demo-runtime-transaction") - self.assertEqual(sorted(subsystem["required_verification"]["exact_files"]), [ + self.assertEqual(result["unowned_runtime_files"], []) + self.assertEqual({item["subsystem"] for item in result["impacted_subsystems"]}, {"deployment-installability"}) + requirement = result["files"][0]["matches"][0]["verification_requirement"] + self.assertEqual(requirement["id"], "demo-runtime-transaction") + self.assertEqual(sorted(requirement["exact_files"]), [ ".github/scripts/tests/test_demo_runtime_transaction.py", "scripts/installtests/demo_transaction_test.go", ]) From 8f42497b8f3bc2a4f46dc5d9f1d9cd867e731298 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:16:42 +0100 Subject: [PATCH 06/86] Keep delayed alert recovery scoped to its resolved occurrence Capture the resolved alert before asynchronous callbacks and carry its start/end identity through monitor recovery. Cancel only that occurrence in grouping, cooldown and persistent firing rows, retaining newer incidents, operational links, destination receipts and failed attempts. Keep the explicit identifier-wide cancellation API and legacy callback IDs. Add snapshot, restart and connected queue-to-HTTP regression controls. Change-source: pulse-maintainer --- .../v6/internal/subsystems/agent-lifecycle.md | 11 ++ .../v6/internal/subsystems/alerts.md | 27 ++++ .../v6/internal/subsystems/monitoring.md | 21 +++ .../v6/internal/subsystems/notifications.md | 19 +++ internal/alerts/active_lifecycle.go | 6 +- internal/alerts/alerts_test.go | 14 +- internal/alerts/callbacks.go | 71 ++++++--- internal/alerts/canonical_lifecycle.go | 4 +- internal/alerts/canonical_metric.go | 2 +- internal/alerts/concurrency_test.go | 2 +- internal/alerts/config_runtime.go | 2 +- internal/alerts/guest.go | 2 +- internal/alerts/metric_runtime.go | 2 +- internal/alerts/operational_contract_test.go | 52 +++++++ internal/monitoring/monitor.go | 4 +- .../monitoring/monitor_alert_handling_test.go | 136 ++++++++++++++++++ internal/monitoring/monitor_alerts.go | 47 ++++-- .../monitor_notification_startup_test.go | 39 +++++ internal/notifications/notifications.go | 32 ++++- internal/notifications/queue.go | 31 +++- .../queue_occurrence_cancellation_test.go | 110 ++++++++++++++ 21 files changed, 570 insertions(+), 64 deletions(-) create mode 100644 internal/notifications/queue_occurrence_cancellation_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 4c18ec8692..4754a53d0d 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -8433,3 +8433,14 @@ update commands and credential authority unchanged. `internal/api/onboarding_handlers.go` changed only the human-readable pairing readiness messages so they name Settings > Pulse Mobile. No agent registration, lifecycle or install path changed. + +### Monitor recovery callback ownership + +The monitor's external alert wiring uses a captured resolved-occurrence +snapshot for notification cancellation. Re-enrolled or refiring resources keep +their new occurrence's queued notification even when an older recovery callback +arrives late under the same ID. Agent registration, deletion/tombstones, +authorisation and continuity evidence are unchanged. The connected callback +wiring control in `monitor_notification_startup_test.go` verifies the shared +monitor boundary against real alert evaluation and local HTTP delivery; it does +not claim host re-enrolment or installed-agent acceptance. diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 4b002b9e4b..14bdd0b99f 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -3203,3 +3203,30 @@ upgrade prompt, and never load or save relay push settings. Licensed instances keep the minimum-severity control and the link to Pulse Mobile settings, with the 31 March 2027 retirement date in the copy. Webhook, email, Apprise and dead-man destinations are unaffected. + +### Occurrence-qualified asynchronous recovery delivery + +Every resolution funnel passes the actual `ResolvedAlert` to the callback bus. +`SetResolvedAlertCallback` captures a deep-cloned alert plus its resolution time +before asynchronous execution, rather than reconstructing an occurrence from a +reusable ID later. Legacy ID-only subscribers retain their public IDs; a panic +in one recovery consumer does not prevent the other consumers from running. + +The live monitor uses this snapshot for AI recovery and notification delivery. +`CancelResolvedAlert` removes only matching ID/start-time grouping entries and +cooldown state. `CancelByAlertOccurrence` applies the same match to persistent +pending, sending, failed and dead-letter firing rows, preserving newer +occurrences, unrelated grouped members and their operational links. Missing +start-time identity is a no-op, never a request for identifier-wide cancellation. +The existing explicit ID-wide cancellation API remains available. Delivery +gates, pending-only suppression counts, destination receipts, attempt history, +quiet hours and best-effort treatment of already-started sends are unchanged. + +`operational_contract_test.go` verifies snapshot isolation across a +blocked callback and changed ID-indexed history, legacy identity and panic +isolation. `queue_occurrence_cancellation_test.go` verifies nanosecond-distinct +recurrences, grouping/cooldown preservation, missing identity and restart +readback for all four cancellable row states without deleting failed audits or +new-occurrence destination receipts. The monitor's connected tests use real PBS +evaluation, normal callbacks, the persistent queue and a local HTTP destination; +these receipts are not native provider or installed-release acceptance. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 6265060bec..6f5caeb5a0 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -4634,3 +4634,24 @@ proof lives in `internal/truenas/provider_test.go` (`TestMaxTrueNASSystemTemperaturePrefersCoresOverSynthesisedAggregate`). This is source-level proof, not reporter acceptance or appliance confirmation (#2122). + +### Recovery callbacks retain the resolved occurrence + +`wireExternalAlertCallbacks` binds the occurrence-qualified recovery callback, +not the legacy ID-only callback. The monitor uses its immutable alert/start/end +snapshot for AI recovery and for exact notification cancellation, so a delayed +recovery cannot remove a newer firing admitted under the same reusable ID. +AI receives a separate clone. An ID-only websocket removal is omitted when the +manager already exposes that ID as active; the regular state stream continues +to own current alert truth. The legacy local ID adapter resolves a snapshot once +and fails closed when no resolved record exists; it is not the live delivery +binding. No browser payload, retention or notification routing contract changes. + +`monitor_alert_handling_test.go` and +`monitor_notification_startup_test.go` covers a delayed ID-adapter cancellation +with queue restart/readback, and a real PBS breach/recovery/new breach held at +the callback boundary. Both require the replacement firing's exact ID/start to +reach a loopback HTTP destination with one sent row and one cancelled old row. +No clock, reducer map or configured cooldown is altered. This establishes local +source-connected delivery, not the outstanding natural flapping cooldown check, +a native appliance result or containing stable availability. diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 83ced5ecfe..5b5b782ab2 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -1054,3 +1054,22 @@ regression establish frontend control preservation, not provider delivery. Formatting-only follow-up retains this warning-level contract. The production-component browser matrix was rerun after formatting at desktop and narrow widths, including all/critical/warning save/reload, cancel, and warning webhook creation. + +### Resolution cancellation is occurrence-qualified + +The monitor's delayed recovery path calls `CancelResolvedAlert` with the +captured ID/start-time snapshot. It removes only that occurrence's pending +in-memory grouping members and cooldown record. Persistent +`CancelByAlertOccurrence` shares the existing per-ID delivery gates and +cancellation machinery, but matches both ID and start time: pending, sending, +failed and dead-letter firing rows are cancelled or rewritten without removing +a recurring incident or unrelated members/operational links in a mixed row. +Missing occurrence identity is a no-op. The explicit identifier-wide API, audit +history, destination receipts, retry budget, recovery rows and best-effort +in-flight semantics remain unchanged. + +`queue_occurrence_cancellation_test.go` verifies all four row states after a +fresh database reopen, nanosecond-distinct occurrences, operational-link and +failed-attempt preservation, missing-identity safety, grouping/cooldown retention +and the unchanged ID-wide API. Monitor connected controls additionally require +HTTP acceptance of the surviving occurrence through the autonomous queue. diff --git a/internal/alerts/active_lifecycle.go b/internal/alerts/active_lifecycle.go index 6b90e6ec6d..3771f98205 100644 --- a/internal/alerts/active_lifecycle.go +++ b/internal/alerts/active_lifecycle.go @@ -115,7 +115,7 @@ func (m *Manager) clearAlert(alertID string) { m.addRecentlyResolvedUnlocked(resolvedAlert) - m.safeCallResolvedAlertCallback(alert, publicID, false) + m.safeCallResolvedAlertCallback(resolvedAlert, publicID, false) log.Info(). Str("alertID", publicID). @@ -738,7 +738,7 @@ func (m *Manager) resolveDiscreteRecoveryNoLock(resourceID, specKey, alertID str resolvedAlert := m.newResolvedAlert(alert, time.Now(), nil) m.addRecentlyResolvedWithPrimaryLock(resolvedAlert) - m.safeCallResolvedAlertCallback(alert, alertID, true) + m.safeCallResolvedAlertCallback(resolvedAlert, alertID, true) log.Info(). Str(strings.ToLower(resourceKind), resourceName). @@ -790,7 +790,7 @@ func (m *Manager) clearAlertNoLock(alertID string) { m.addRecentlyResolvedWithPrimaryLock(resolvedAlert) - m.safeCallResolvedAlertCallback(alert, publicID, true) + m.safeCallResolvedAlertCallback(resolvedAlert, publicID, true) log.Info(). Str("alertID", publicID). diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index ccafc38ed5..68daa1b65c 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -7032,7 +7032,7 @@ func TestSafeCallResolvedAlertCallback(t *testing.T) { receivedID = alertID }) - m.safeCallResolvedAlertCallback(&Alert{ID: "test-alert-123"}, "test-alert-123", false) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "test-alert-123"}}, "test-alert-123", false) if receivedID != "test-alert-123" { t.Errorf("expected alert ID 'test-alert-123', got %q", receivedID) @@ -7051,7 +7051,7 @@ func TestSafeCallResolvedAlertCallback(t *testing.T) { close(done) }) - m.safeCallResolvedAlertCallback(&Alert{ID: "async-alert"}, "async-alert", true) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "async-alert"}}, "async-alert", true) select { case <-done: @@ -7078,7 +7078,7 @@ func TestSafeCallResolvedAlertCallback(t *testing.T) { CanonicalState: buildCanonicalStateID("pve1:node1:101", "powered-state:powered-off"), } - m.safeCallResolvedAlertCallback(alert, alert.CanonicalState, false) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: alert}, alert.CanonicalState, false) if receivedID != alert.CanonicalState { t.Errorf("expected canonical alert ID %q, got %q", alert.CanonicalState, receivedID) @@ -7091,8 +7091,8 @@ func TestSafeCallResolvedAlertCallback(t *testing.T) { // No callback set // Should not panic - m.safeCallResolvedAlertCallback(&Alert{ID: "test-alert"}, "test-alert", false) - m.safeCallResolvedAlertCallback(&Alert{ID: "test-alert"}, "test-alert", true) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "test-alert"}}, "test-alert", false) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "test-alert"}}, "test-alert", true) }) t.Run("recovers from panic in sync callback", func(t *testing.T) { @@ -7104,7 +7104,7 @@ func TestSafeCallResolvedAlertCallback(t *testing.T) { }) // Should not panic the caller - m.safeCallResolvedAlertCallback(&Alert{ID: "panic-test"}, "panic-test", false) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "panic-test"}}, "panic-test", false) }) t.Run("recovers from panic in async callback", func(t *testing.T) { @@ -7117,7 +7117,7 @@ func TestSafeCallResolvedAlertCallback(t *testing.T) { panic("async panic") }) - m.safeCallResolvedAlertCallback(&Alert{ID: "async-panic"}, "async-panic", true) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "async-panic"}}, "async-panic", true) select { case <-done: diff --git a/internal/alerts/callbacks.go b/internal/alerts/callbacks.go index e2378ecb0e..4a6a7a9d05 100644 --- a/internal/alerts/callbacks.go +++ b/internal/alerts/callbacks.go @@ -23,13 +23,14 @@ type LifecycleEvent struct { type callbackBus struct { mu sync.RWMutex - onAlert func(alert *Alert) - alertSubs map[int]func(alert *Alert) - onAlertForAI func(alert *Alert) - alertForAISubs map[int]func(alert *Alert) - onResolved func(alertID string) - resolvedSubs map[int]func(alertID string) - lifecycleSubs map[int]func(event LifecycleEvent) + onAlert func(alert *Alert) + alertSubs map[int]func(alert *Alert) + onAlertForAI func(alert *Alert) + alertForAISubs map[int]func(alert *Alert) + onResolved func(alertID string) + resolvedSubs map[int]func(alertID string) + onResolvedAlert func(resolved *ResolvedAlert) + lifecycleSubs map[int]func(event LifecycleEvent) onAcknowledged func(alert *Alert, user string) onUnacknowledged func(alert *Alert, user string) @@ -146,6 +147,18 @@ func (b *callbackBus) setResolvedCallback(cb func(alertID string)) { b.onResolved = cb } +func (b *callbackBus) setResolvedAlertCallback(cb func(resolved *ResolvedAlert)) { + b.mu.Lock() + defer b.mu.Unlock() + b.onResolvedAlert = cb +} + +func (b *callbackBus) resolvedAlertCallback() func(resolved *ResolvedAlert) { + b.mu.RLock() + defer b.mu.RUnlock() + return b.onResolvedAlert +} + func (b *callbackBus) subscribeResolvedCallback(cb func(alertID string)) func() { if cb == nil { return func() {} @@ -324,6 +337,14 @@ func (m *Manager) SetResolvedCallback(cb func(alertID string)) { m.callbacks.setResolvedCallback(cb) } +// SetResolvedAlertCallback installs the occurrence-qualified recovery consumer. +// The snapshot is captured before asynchronous dispatch; consumers must not +// look up a reusable alert ID to reconstruct the resolved occurrence later. +// Legacy ID-only callbacks remain independent and retain their public IDs. +func (m *Manager) SetResolvedAlertCallback(cb func(resolved *ResolvedAlert)) { + m.callbacks.setResolvedAlertCallback(cb) +} + // SubscribeResolvedCallback registers an additional resolved-alert callback // without replacing the legacy single callback slot. The returned function // removes the subscription when called. @@ -402,31 +423,43 @@ func (m *Manager) getEscalateCallback() func(alert *Alert, level int) { // safeCallResolvedAlertCallback invokes onResolved with panic recovery while // preserving canonical state as the internal identity and emitting the public // alert ID to external callbacks for compatibility. -func (m *Manager) safeCallResolvedAlertCallback(alert *Alert, fallbackID string, async bool) { +func (m *Manager) safeCallResolvedAlertCallback(resolved *ResolvedAlert, fallbackID string, async bool) { + var alert *Alert + if resolved != nil { + alert = resolved.Alert + } // Record the resolution before the callback guard: this funnel is the // resolve seam for every lifecycle path, with or without subscribers. m.recordAlertEvent(eventlog.TypeResolved, alert, fallbackID, "", "Alert resolved.", nil) callbacks := m.getResolvedCallbacks() - if len(callbacks) == 0 { + resolvedCallback := m.callbacks.resolvedAlertCallback() + if len(callbacks) == 0 && resolvedCallback == nil { return } publicID := exportedAlertID(alert, fallbackID) trackingKey := canonicalTrackingKeyForAlert(alert) + var snapshot *ResolvedAlert + if resolved != nil && alert != nil { + snapshot = &ResolvedAlert{Alert: cloneAlertForOutput(alert), ResolvedTime: resolved.ResolvedTime} + } callbackFunc := func() { - defer func() { - if r := recover(); r != nil { - log.Error(). - Interface("panic", r). - Str("alertID", publicID). - Str("trackingKey", trackingKey). - Msg("Panic in onResolved callback") - } - }() + callSafely := func(callback func()) { + defer func() { + if r := recover(); r != nil { + log.Error().Interface("panic", r).Str("alertID", publicID). + Str("trackingKey", trackingKey).Msg("Panic in onResolved callback") + } + }() + callback() + } for _, callback := range callbacks { - callback(publicID) + callSafely(func() { callback(publicID) }) + } + if resolvedCallback != nil && snapshot != nil { + callSafely(func() { resolvedCallback(snapshot) }) } } diff --git a/internal/alerts/canonical_lifecycle.go b/internal/alerts/canonical_lifecycle.go index d1c8e738b5..b6f8386273 100644 --- a/internal/alerts/canonical_lifecycle.go +++ b/internal/alerts/canonical_lifecycle.go @@ -603,7 +603,7 @@ func (m *Manager) evaluateCanonicalLifecycleAlert(params canonicalLifecycleAlert recoveryEvidenceRef, ) m.addRecentlyResolvedWithPrimaryLock(resolvedAlert) - m.safeCallResolvedAlertCallback(existing, storageKey, true) + m.safeCallResolvedAlertCallback(resolvedAlert, storageKey, true) return result, true } @@ -863,7 +863,7 @@ func (m *Manager) evaluateCanonicalStatefulAlert(params canonicalStatefulAlertPa recoveryEvidenceRef, ) m.addRecentlyResolvedWithPrimaryLock(resolvedAlert) - m.safeCallResolvedAlertCallback(existing, storageKey, true) + m.safeCallResolvedAlertCallback(resolvedAlert, storageKey, true) return result, true } } diff --git a/internal/alerts/canonical_metric.go b/internal/alerts/canonical_metric.go index 1cceeaa0de..fcfa75baa5 100644 --- a/internal/alerts/canonical_metric.go +++ b/internal/alerts/canonical_metric.go @@ -407,7 +407,7 @@ func (m *Manager) evaluateCanonicalMetricAlert(spec alertspecs.ResourceAlertSpec m.removeActiveAlertNoLock(storageKey) m.saveActiveAlertsAsync("canonical metric resolution") m.addRecentlyResolvedWithPrimaryLock(resolvedAlert) - m.safeCallResolvedAlertCallback(existingAlert, storageKey, true) + m.safeCallResolvedAlertCallback(resolvedAlert, storageKey, true) } } diff --git a/internal/alerts/concurrency_test.go b/internal/alerts/concurrency_test.go index 7f9cc69bda..7d88d2db33 100644 --- a/internal/alerts/concurrency_test.go +++ b/internal/alerts/concurrency_test.go @@ -96,7 +96,7 @@ func TestManagerCallbacksConcurrentSetAndUse(t *testing.T) { defer wg.Done() <-start for i := 0; i < iterations; i++ { - manager.safeCallResolvedAlertCallback(&Alert{ID: "concurrency-alert"}, "concurrency-alert", false) + manager.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "concurrency-alert"}}, "concurrency-alert", false) manager.safeCallAcknowledgedCallback(alert, "test-user") manager.safeCallUnacknowledgedCallback(alert, "test-user") manager.safeCallEscalateCallback(alert, 1) diff --git a/internal/alerts/config_runtime.go b/internal/alerts/config_runtime.go index 73b9fe2891..17d0ecf11e 100644 --- a/internal/alerts/config_runtime.go +++ b/internal/alerts/config_runtime.go @@ -608,7 +608,7 @@ func (m *Manager) reevaluateActiveAlertsLocked() { Str("alertID", alertID). Msg("Alert auto-resolved after configuration change") - m.safeCallResolvedAlertCallback(resolvedAlert.Alert, alertID, true) + m.safeCallResolvedAlertCallback(resolvedAlert, alertID, true) } } diff --git a/internal/alerts/guest.go b/internal/alerts/guest.go index 5eb502e55c..b185ea1d30 100644 --- a/internal/alerts/guest.go +++ b/internal/alerts/guest.go @@ -551,7 +551,7 @@ func (m *Manager) clearGuestPoweredOffAlert(guestID, name string) { // Send recovery notification (async to avoid deadlock because callback acquires m.mu.RLock // via ShouldSuppressResolvedNotification, and we currently hold m.mu.Lock) - m.safeCallResolvedAlertCallback(alert, effectiveAlertID(alert, alertID), true) + m.safeCallResolvedAlertCallback(resolvedAlert, effectiveAlertID(alert, alertID), true) // Log recovery log.Info(). diff --git a/internal/alerts/metric_runtime.go b/internal/alerts/metric_runtime.go index 6000fad5d8..6d1ad3cba2 100644 --- a/internal/alerts/metric_runtime.go +++ b/internal/alerts/metric_runtime.go @@ -612,7 +612,7 @@ func (m *Manager) checkMetric(resourceID, resourceName, node, instance, resource Float64("value", value). Bool("wasAcknowledged", existingAlert.Acknowledged). Msg("Alert resolved with hysteresis") - m.safeCallResolvedAlertCallback(existingAlert, alertID, true) + m.safeCallResolvedAlertCallback(resolvedAlert, alertID, true) } func sanitizeAlertKey(label string) string { diff --git a/internal/alerts/operational_contract_test.go b/internal/alerts/operational_contract_test.go index e0e5c68a31..444cb7aba3 100644 --- a/internal/alerts/operational_contract_test.go +++ b/internal/alerts/operational_contract_test.go @@ -692,3 +692,55 @@ func TestMemoryUnavailablePreservesActiveAlertsUntilTrustedRecovery(t *testing.T }) } } + +func TestResolvedOccurrenceCallbackCapturesBeforeDispatch(t *testing.T) { + m := newTestManager(t) + start := time.Now().Add(-time.Hour) + end := start.Add(time.Minute) + resolved := &ResolvedAlert{Alert: &Alert{ID: "reusable", StartTime: start, + Metadata: map[string]interface{}{"generation": "old"}}, ResolvedTime: end} + entered, release := make(chan struct{}), make(chan struct{}) + defer close(release) + m.SetResolvedCallback(func(id string) { + if id != "reusable" { + t.Errorf("legacy ID = %s", id) + } + close(entered) + <-release + }) + got := make(chan *ResolvedAlert, 1) + m.SetResolvedAlertCallback(func(r *ResolvedAlert) { got <- r }) + m.safeCallResolvedAlertCallback(resolved, resolved.Alert.ID, true) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("legacy callback did not start") + } + // Both the source object and the ID-indexed history can change while the + // consumer waits. Neither is a source for the already-admitted snapshot. + resolved.Alert.StartTime = start.Add(time.Hour) + resolved.Alert.Metadata["generation"] = "new" + resolved.ResolvedTime = end.Add(time.Hour) + m.addRecentlyResolvedUnlocked(resolved) + // Unblock without closing twice in cleanup. + release <- struct{}{} + select { + case r := <-got: + if !r.Alert.StartTime.Equal(start) || !r.ResolvedTime.Equal(end) || r.Alert.Metadata["generation"] != "old" { + t.Fatalf("callback reconstructed the wrong occurrence: %+v", r) + } + case <-time.After(time.Second): + t.Fatal("no occurrence callback") + } +} + +func TestResolvedOccurrenceCallbackSurvivesLegacyPanic(t *testing.T) { + m := newTestManager(t) + m.SetResolvedCallback(func(string) { panic("legacy consumer failure") }) + called := false + m.SetResolvedAlertCallback(func(r *ResolvedAlert) { called = r.Alert.ID == "reusable" }) + m.safeCallResolvedAlertCallback(&ResolvedAlert{Alert: &Alert{ID: "reusable"}}, "reusable", false) + if !called { + t.Fatal("legacy panic prevented occurrence-qualified recovery") + } +} diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index e2cf45886b..4d97279cda 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2248,8 +2248,8 @@ func (m *Monitor) wireExternalAlertCallbacks(wsHub *websocket.Hub) { callback(alert) } }) - m.alertManager.SetResolvedCallback(func(alertID string) { - m.handleAlertResolved(alertID) + m.alertManager.SetResolvedAlertCallback(func(resolved *alerts.ResolvedAlert) { + m.handleResolvedAlert(resolved) // Don't broadcast full state here - it causes a cascade with many guests. // The frontend gets updated alerts through the regular broadcast ticker. }) diff --git a/internal/monitoring/monitor_alert_handling_test.go b/internal/monitoring/monitor_alert_handling_test.go index c0b8b27947..312a2d89f0 100644 --- a/internal/monitoring/monitor_alert_handling_test.go +++ b/internal/monitoring/monitor_alert_handling_test.go @@ -1205,3 +1205,139 @@ func TestMonitorLifecycleRefireReopensRetainedOccurrence(t *testing.T) { }) } } + +type occurrenceHTTPReceipt struct { + Alerts []*alerts.Alert `json:"alerts"` +} + +func occurrenceNotifier(t *testing.T, dir string, endpoint string) *notifications.NotificationManager { + t.Helper() + n := notifications.NewNotificationManagerWithDeferredQueue("", dir) + t.Cleanup(n.Stop) + if err := n.UpdateAllowedPrivateCIDRs("127.0.0.1/32,::1/128"); err != nil { + t.Fatal(err) + } + n.AddWebhook(notifications.WebhookConfig{ID: "ops", Enabled: true, URL: endpoint, Service: "generic"}) + n.SetGroupingWindow(0) + n.SetNotifyOnResolve(false) + return n +} + +func occurrenceManager(t *testing.T) (*alerts.Manager, models.PBSInstance) { + t.Helper() + a := alerts.NewManagerWithDataDir(t.TempDir()) + t.Cleanup(a.Stop) + cfg := a.GetConfig() + cfg.Enabled, cfg.ActivationState, cfg.FlappingEnabled = true, alerts.ActivationActive, false + cfg.Schedule.QuietHours.Enabled = false + cfg.TimeThresholds["pbs"] = 0 + cfg.PBSDefaults.CPU = &alerts.HysteresisThreshold{Trigger: 80, Clear: 75} + a.UpdateConfig(cfg) + return a, models.PBSInstance{ID: "recurring-pbs", Name: "backup", Host: "pbs.invalid", Status: "online", ConnectionHealth: "healthy", CPU: 99} +} + +func occurrenceEndpoint(t *testing.T) (string, <-chan occurrenceHTTPReceipt) { + t.Helper() + receipts := make(chan occurrenceHTTPReceipt, 8) + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var receipt occurrenceHTTPReceipt + if err := json.NewDecoder(r.Body).Decode(&receipt); err != nil { + t.Errorf("decode HTTP acceptance: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + receipts <- receipt + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(s.Close) + return s.URL, receipts +} + +func awaitOccurrenceRows(t *testing.T, n *notifications.NotificationManager, count int) []*notifications.QueuedNotification { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + rows, err := n.GetQueue().GetPending(10) + if err != nil { + t.Fatal(err) + } + if len(rows) == count { + return rows + } + if time.Now().After(deadline) { + t.Fatalf("pending rows = %d, want %d", len(rows), count) + } + time.Sleep(time.Millisecond) + } +} + +func assertOccurrenceHTTP(t *testing.T, n *notifications.NotificationManager, receipts <-chan occurrenceHTTPReceipt, current *alerts.Alert) { + t.Helper() + n.StartQueueProcessing() + select { + case got := <-receipts: + if len(got.Alerts) != 1 || got.Alerts[0].ID != current.ID || !got.Alerts[0].StartTime.Equal(current.StartTime) { + t.Fatalf("wrong HTTP occurrence: %+v", got) + } + t.Logf("HTTP 200 accepted current occurrence %s at %s", current.ID, current.StartTime.Format(time.RFC3339Nano)) + case <-time.After(12 * time.Second): + t.Fatal("current firing did not reach the local HTTP destination") + } + deadline := time.Now().Add(3 * time.Second) + for { + stats, err := n.GetQueueStats() + if err != nil { + t.Fatal(err) + } + if stats["sent"] == 1 && stats["cancelled"] == 1 && stats["pending"] == 0 && stats["sending"] == 0 { + t.Logf("persistent queue completion: %v", stats) + break + } + if time.Now().After(deadline) { + t.Fatalf("queue completion = %v", stats) + } + time.Sleep(time.Millisecond) + } + select { + case extra := <-receipts: + t.Fatalf("obsolete or duplicate delivery: %+v", extra) + case <-time.After(200 * time.Millisecond): + } +} + +// This existing-interface control also runs against the exact predecessor. +// It models a delayed ID callback after the next occurrence has entered the +// real persistent queue. It does not assert native timing or cause a refire. +func TestMonitorDelayedIDResolutionPreservesNewQueuedOccurrence(t *testing.T) { + endpoint, receipts := occurrenceEndpoint(t) + dir := t.TempDir() + n := occurrenceNotifier(t, dir, endpoint) + a, pbs := occurrenceManager(t) + a.CheckPBS(pbs) + active := a.GetActiveAlerts() + if len(active) != 1 { + t.Fatalf("PBS firing = %+v", active) + } + old := active[0].Clone() + pbs.CPU = 0 + a.CheckPBS(pbs) + if r := a.GetResolvedAlert(old.ID); r == nil || !r.Alert.StartTime.Equal(old.StartTime) { + t.Fatal("missing resolved PBS snapshot") + } + current := old.Clone() + current.StartTime = current.StartTime.Add(time.Nanosecond) + n.SendAlert(old) + n.SendAlert(current) + awaitOccurrenceRows(t, n, 2) + m := &Monitor{alertManager: a, notificationMgr: n} + m.handleAlertResolved(old.ID) + rows, err := n.GetQueue().GetPending(10) + if err != nil || len(rows) != 1 || len(rows[0].Alerts) != 1 || !rows[0].Alerts[0].StartTime.Equal(current.StartTime) { + t.Fatalf("delayed recovery cancelled the replacement occurrence: rows=%+v err=%v", rows, err) + } + // Resume the autonomous processor only after reopening the same disk state. + n.Stop() + n = occurrenceNotifier(t, dir, endpoint) + assertOccurrenceHTTP(t, n, receipts, current) +} diff --git a/internal/monitoring/monitor_alerts.go b/internal/monitoring/monitor_alerts.go index bb9a7af975..b84a4e2489 100644 --- a/internal/monitoring/monitor_alerts.go +++ b/internal/monitoring/monitor_alerts.go @@ -139,7 +139,9 @@ func (m *Monitor) SetAlertResolvedAICallback(callback func(*alerts.Alert)) { if m.alertManager == nil { return } + m.mu.Lock() m.alertResolvedAICallback = callback + m.mu.Unlock() log.Info().Msg("alert-resolved AI callback registered") } @@ -220,41 +222,56 @@ func (m *Monitor) handleAlertFired(alert *alerts.Alert) { } func (m *Monitor) handleAlertResolved(alertID string) { - var resolvedAlert *alerts.ResolvedAlert + // Compatibility for ID-only callers. The live monitor is wired to the + // occurrence snapshot callback, not a lookup after asynchronous dispatch. + if m.alertManager == nil { + return + } + m.handleResolvedAlert(m.alertManager.GetResolvedAlert(alertID)) +} + +func (m *Monitor) handleResolvedAlert(resolvedAlert *alerts.ResolvedAlert) { + if resolvedAlert == nil || resolvedAlert.Alert == nil { + return + } + alertID := resolvedAlert.Alert.ID if m.wsHub != nil { - m.wsHub.BroadcastAlertResolvedToTenant(m.GetOrgID(), alertID) + // The legacy websocket message has only an ID. Do not remove a current + // firing occurrence that is already visible under that reusable ID. + active := false + if m.alertManager != nil { + _, active = m.alertManager.DiagnoseAlertDelivery(alertID) + } + if !active { + m.wsHub.BroadcastAlertResolvedToTenant(m.GetOrgID(), alertID) + } } // Always trigger AI callback, regardless of notification suppression. - if m.alertResolvedAICallback != nil { - if resolvedAlert == nil { - resolvedAlert = m.alertManager.GetResolvedAlert(alertID) - } - if resolvedAlert != nil && resolvedAlert.Alert != nil { - go m.alertResolvedAICallback(resolvedAlert.Alert) - } + m.mu.RLock() + aiCallback := m.alertResolvedAICallback + m.mu.RUnlock() + if aiCallback != nil { + go aiCallback(resolvedAlert.Alert.Clone()) } // Handle notifications โ€” recovery notifications respect quiet hours. // If the original alert would have been suppressed during quiet hours, // the recovery notification is also suppressed to avoid noise. if m.notificationMgr != nil { - firingNeverDelivered := m.notificationMgr.CancelAlert(alertID) + firingNeverDelivered := m.notificationMgr.CancelResolvedAlert(resolvedAlert.Alert) if m.notificationMgr.GetNotifyOnResolve() { - if resolvedAlert == nil { - resolvedAlert = m.alertManager.GetResolvedAlert(alertID) - } if resolvedAlert != nil && resolvedAlert.Alert != nil { if firingNeverDelivered { // The firing notification was still in the grouping window // or waiting in the queue (e.g. quiet-hours replay) when the - // alert resolved, and CancelAlert just cancelled it. A + // alert resolved, and exact cancellation just cancelled it. A // recovery for an alert the user never saw fire is noise. log.Info(). Str("alertID", alertID). Msg("Resolved notification suppressed because the firing notification was cancelled before delivery") - } else if m.alertManager.ShouldSuppressResolvedNotification(resolvedAlert.Alert) { + } else if m.alertManager != nil && m.alertManager.ShouldSuppressResolvedNotification(resolvedAlert.Alert) { log.Info(). Str("alertID", alertID). Msg("Resolved notification suppressed during quiet hours") diff --git a/internal/monitoring/monitor_notification_startup_test.go b/internal/monitoring/monitor_notification_startup_test.go index 34897b605a..0dc557f8a4 100644 --- a/internal/monitoring/monitor_notification_startup_test.go +++ b/internal/monitoring/monitor_notification_startup_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "sync" "sync/atomic" "testing" "time" @@ -179,3 +180,41 @@ func TestNewRestoresSavedNotificationChoices(t *testing.T) { }) } } + +// Real PBS breach -> recovery -> new breach, with one callback deliberately +// held at its asynchronous consumer boundary. No clocks or manager maps change. +func TestMonitorResolvedSnapshotKeepsRefiringPBSDelivery(t *testing.T) { + endpoint, receipts := occurrenceEndpoint(t) + n := occurrenceNotifier(t, t.TempDir(), endpoint) + a, pbs := occurrenceManager(t) + m := &Monitor{alertManager: a, notificationMgr: n} + m.wireExternalAlertCallbacks(nil) + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) }) + unsubscribe := a.SubscribeResolvedCallback(func(string) { close(entered); <-release }) + t.Cleanup(unsubscribe) + a.CheckPBS(pbs) + first := awaitOccurrenceRows(t, n, 1)[0].Alerts[0] + pbs.CPU = 0 + a.CheckPBS(pbs) + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("resolution callback did not start") + } + pbs.CPU = 95 + a.CheckPBS(pbs) + awaitOccurrenceRows(t, n, 2) + active := a.GetActiveAlerts() + if len(active) != 1 || active[0].ID != first.ID || active[0].StartTime.Equal(first.StartTime) { + t.Fatalf("not a new recurring PBS occurrence: %+v", active) + } + current := active[0].Clone() + releaseOnce.Do(func() { close(release) }) + remaining := awaitOccurrenceRows(t, n, 1) + if !remaining[0].Alerts[0].StartTime.Equal(current.StartTime) { + t.Fatal("delayed resolution kept the wrong occurrence") + } + assertOccurrenceHTTP(t, n, receipts, current) +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 17189a5043..7b93dbf590 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1580,9 +1580,24 @@ func (n *NotificationManager) dispatchResolvedAlerts(alertList []*alerts.Alert) // the grouping window or waiting in the queue), so sending a recovery // notification would reference an alert that was never announced. func (n *NotificationManager) CancelAlert(alertID string) bool { + return n.cancelAlert(alertID, nil) +} + +// CancelResolvedAlert cancels firing work for one resolved occurrence, without +// deleting a newer occurrence's grouping entries or delivery cooldown. +// ID-wide cancellation remains available for explicit administrative callers. +func (n *NotificationManager) CancelResolvedAlert(alert *alerts.Alert) bool { + if alert == nil || alert.ID == "" || alert.StartTime.IsZero() { + return false + } + return n.cancelAlert(alert.ID, &alert.StartTime) +} + +func (n *NotificationManager) cancelAlert(alertID string, start *time.Time) bool { n.mu.Lock() queue := n.queue - _, firingDelivered := n.lastNotified[alertID] + record, firingDelivered := n.lastNotified[alertID] + firingDelivered = firingDelivered && (start == nil || record.alertStart.Equal(*start)) removed := 0 if len(n.pendingAlerts) > 0 { @@ -1591,7 +1606,7 @@ func (n *NotificationManager) CancelAlert(alertID string) bool { if pending == nil { continue } - if pending.ID == alertID { + if pending.ID == alertID && (start == nil || pending.StartTime.Equal(*start)) { removed++ continue } @@ -1613,14 +1628,21 @@ func (n *NotificationManager) CancelAlert(alertID string) bool { } // Clean up cooldown record for resolved alert - delete(n.lastNotified, alertID) + if start == nil || record.alertStart.Equal(*start) { + delete(n.lastNotified, alertID) + } + remaining := len(n.pendingAlerts) n.mu.Unlock() // Cancel any queued notifications containing this alert cancelledPending := 0 if queue != nil { var err error - cancelledPending, err = queue.CancelByAlertIdentifiers([]string{alertID}) + if start == nil { + cancelledPending, err = queue.CancelByAlertIdentifiers([]string{alertID}) + } else { + cancelledPending, err = queue.CancelByAlertOccurrence(alertID, *start) + } if err != nil { log.Error().Err(err).Str("alertID", alertID).Msg("failed to cancel queued notifications") } @@ -1630,7 +1652,7 @@ func (n *NotificationManager) CancelAlert(alertID string) bool { log.Debug(). Str("alertID", alertID). - Int("remaining", len(n.pendingAlerts)). + Int("remaining", remaining). Bool("firingNeverDelivered", firingNeverDelivered). Msg("removed resolved alert from pending notifications and cooldown map") diff --git a/internal/notifications/queue.go b/internal/notifications/queue.go index 47c28fda14..9835972871 100644 --- a/internal/notifications/queue.go +++ b/internal/notifications/queue.go @@ -2150,6 +2150,30 @@ func calculateBackoff(attempt int) time.Duration { // these do not contribute to the pending-only return count. func (nq *NotificationQueue) CancelByAlertIdentifiers(alertIdentifiers []string) (int, error) { alertIdentifiers = normalizeAlertIdentifiers(alertIdentifiers) + identifiers := make(map[string]struct{}, len(alertIdentifiers)) + for _, id := range alertIdentifiers { + identifiers[id] = struct{}{} + } + return nq.cancelAlertNotifications(alertIdentifiers, func(alert *alerts.Alert) bool { + _, matched := identifiers[alert.ID] + return matched + }) +} + +// CancelByAlertOccurrence retires only the resolved occurrence's firing work. +// Missing occurrence identity must never widen into ID-wide cancellation. +// The same delivery gates, terminal-row handling and audit retention apply. +func (nq *NotificationQueue) CancelByAlertOccurrence(alertID string, start time.Time) (int, error) { + alertID = strings.TrimSpace(alertID) + if alertID == "" || start.IsZero() { + return 0, nil + } + return nq.cancelAlertNotifications([]string{alertID}, func(alert *alerts.Alert) bool { + return alert.ID == alertID && alert.StartTime.Equal(start) + }) +} + +func (nq *NotificationQueue) cancelAlertNotifications(alertIdentifiers []string, matches func(*alerts.Alert) bool) (int, error) { if len(alertIdentifiers) == 0 { return 0, nil } @@ -2175,11 +2199,6 @@ func (nq *NotificationQueue) CancelByAlertIdentifiers(alertIdentifiers []string) return 0, fmt.Errorf("failed to query notifications for cancellation: %w", err) } - alertIdentifierSet := make(map[string]struct{}) - for _, id := range alertIdentifiers { - alertIdentifierSet[id] = struct{}{} - } - type queuedAlertCancellation struct { notificationID string remaining []*alerts.Alert @@ -2244,7 +2263,7 @@ func (nq *NotificationQueue) CancelByAlertIdentifiers(alertIdentifiers []string) remainingAlerts = append(remainingAlerts, alert) continue } - if _, exists := alertIdentifierSet[alert.ID]; exists { + if matches(alert) { matchedAlertCount++ if alert.OperationalRecord != nil && alert.LatestTransition != nil { removedLinkKeys[alert.OperationalRecord.ID+"\x00"+alert.LatestTransition.ID] = struct{}{} diff --git a/internal/notifications/queue_occurrence_cancellation_test.go b/internal/notifications/queue_occurrence_cancellation_test.go new file mode 100644 index 0000000000..713dbe0e38 --- /dev/null +++ b/internal/notifications/queue_occurrence_cancellation_test.go @@ -0,0 +1,110 @@ +package notifications + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" +) + +func TestQueueOccurrenceCancellationPreservesRecurrenceAcrossRestart(t *testing.T) { + for _, status := range []NotificationQueueStatus{QueueStatusPending, QueueStatusSending, QueueStatusFailed, QueueStatusDLQ} { + t.Run(string(status), func(t *testing.T) { + dir := t.TempDir() + q, err := NewNotificationQueue(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = q.Stop() }) + start := time.Now().UTC().Add(-time.Hour) + old := quietReplayAlert("reusable", alerts.AlertLevelWarning, start) + current := quietReplayAlert("reusable", alerts.AlertLevelCritical, start.Add(time.Nanosecond)) + current.OperationalRecord.ID += "-new" + current.LatestTransition.ID += "-new" + peer := quietReplayAlert("peer", alerts.AlertLevelWarning, start) + for _, row := range []*QueuedNotification{ + {ID: "old", Type: "webhook", Alerts: []*alerts.Alert{old}}, + {ID: "new", Type: "webhook", Alerts: []*alerts.Alert{current}}, + {ID: "mixed", Type: "webhook", Alerts: []*alerts.Alert{old, current, peer}}, + {ID: "recovery", Type: "webhook_resolved", Alerts: []*alerts.Alert{old}}, + {ID: "unknown", Type: "webhook", Alerts: []*alerts.Alert{{ID: old.ID}}}, + } { + row.Config = []byte("{}") + row.DestinationID = "webhook:ops" + row.MaxAttempts = 3 + if err := q.Enqueue(row); err != nil { + t.Fatal(err) + } + if err := q.UpdateStatus(row.ID, status, "retained-attempt"); err != nil { + t.Fatal(err) + } + if err := q.RecordAudit(row, false, "retained-attempt"); err != nil { + t.Fatal(err) + } + } + if err := q.RecordDeliveryReceipt(current.ID, current.StartTime, "webhook:ops", time.Now()); err != nil { + t.Fatal(err) + } + for _, identity := range []struct { + id string + start time.Time + }{{"", start}, {old.ID, time.Time{}}} { + if count, err := q.CancelByAlertOccurrence(identity.id, identity.start); err != nil || count != 0 { + t.Fatalf("missing identity widened cancellation: %d, %v", count, err) + } + } + count, err := q.CancelByAlertOccurrence(old.ID, old.StartTime) + wantCount := 0 + if status == QueueStatusPending { + wantCount = 2 + } + if err != nil || count != wantCount { + t.Fatalf("cancel count = %d, %v, want %d", count, err, wantCount) + } + if err := q.Stop(); err != nil { + t.Fatal(err) + } + q, err = NewNotificationQueue(dir) + if err != nil { + t.Fatal(err) + } + oldRow, mixed := loadQuietReplay(t, q, "old"), loadQuietReplay(t, q, "mixed") + if oldRow.Status != QueueStatusCancelled || mixed.Status != status || len(mixed.Alerts) != 2 || + !mixed.Alerts[0].StartTime.Equal(current.StartTime) || mixed.Alerts[1].ID != peer.ID || len(mixed.Links) != 2 || + mixed.Links[0].OperationalRecordID != current.OperationalRecord.ID || mixed.Links[1].OperationalRecordID != peer.OperationalRecord.ID { + t.Fatalf("wrong retained rows/links: old=%+v mixed=%+v", oldRow, mixed) + } + for _, id := range []string{"new", "recovery", "unknown"} { + if row := loadQuietReplay(t, q, id); row.Status != status || len(row.Alerts) != 1 { + t.Fatalf("unrelated row %s changed: %+v", id, row) + } + } + if has, err := q.HasDeliveryReceipt(current.ID, current.StartTime, "webhook:ops"); err != nil || !has { + t.Fatalf("current occurrence lost its receipt: %t, %v", has, err) + } + var audits int + if err := q.db.QueryRow(`SELECT count(*) FROM notification_audit WHERE success = 0`).Scan(&audits); err != nil || audits != 5 { + t.Fatalf("retained failed attempts = %d, %v", audits, err) + } + }) + } +} + +func TestCancelResolvedAlertPreservesNewGroupingAndCooldown(t *testing.T) { + start := time.Now().Add(-time.Minute) + old := &alerts.Alert{ID: "reusable", StartTime: start} + current := &alerts.Alert{ID: old.ID, StartTime: start.Add(time.Nanosecond)} + record := notificationRecord{alertStart: current.StartTime, lastSent: time.Now(), level: alerts.AlertLevelCritical} + n := &NotificationManager{pendingAlerts: []*alerts.Alert{old, current}, lastNotified: map[string]notificationRecord{old.ID: record}} + if n.CancelResolvedAlert(&alerts.Alert{ID: old.ID}) || len(n.pendingAlerts) != 2 { + t.Fatal("missing occurrence cancelled pending work") + } + if !n.CancelResolvedAlert(old) || len(n.pendingAlerts) != 1 || n.pendingAlerts[0] != current || n.lastNotified[current.ID] != record { + t.Fatal("delayed recovery removed current grouping or cooldown state") + } + // Keep the explicit, identifier-wide cancellation contract for its callers. + n.CancelAlert(current.ID) + if len(n.pendingAlerts) != 0 || len(n.lastNotified) != 0 { + t.Fatal("ID-wide cancellation regressed") + } +} From 2c57a54ec4cba0ffd108465b74a582bf61b35bec Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:20:39 +0100 Subject: [PATCH 07/86] Preserve sending-row crash recovery in the occurrence fixture Assert surviving row status before restart, then expect the existing sending-to-pending crash recovery after reopening the queue. The initial source-dtamnmrn fixture failure is retained; runtime source and delivery policy are unchanged. Change-source: pulse-maintainer --- .../queue_occurrence_cancellation_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/notifications/queue_occurrence_cancellation_test.go b/internal/notifications/queue_occurrence_cancellation_test.go index 713dbe0e38..e2e5c7495b 100644 --- a/internal/notifications/queue_occurrence_cancellation_test.go +++ b/internal/notifications/queue_occurrence_cancellation_test.go @@ -61,6 +61,9 @@ func TestQueueOccurrenceCancellationPreservesRecurrenceAcrossRestart(t *testing. if err != nil || count != wantCount { t.Fatalf("cancel count = %d, %v, want %d", count, err, wantCount) } + if row := loadQuietReplay(t, q, "mixed"); row.Status != status || len(row.Alerts) != 2 { + t.Fatalf("cancellation changed surviving row status: %+v", row) + } if err := q.Stop(); err != nil { t.Fatal(err) } @@ -68,14 +71,20 @@ func TestQueueOccurrenceCancellationPreservesRecurrenceAcrossRestart(t *testing. if err != nil { t.Fatal(err) } + reopenStatus := status + if status == QueueStatusSending { + // Existing crash recovery requeues interrupted sends. Exact + // cancellation must preserve that behaviour for survivors. + reopenStatus = QueueStatusPending + } oldRow, mixed := loadQuietReplay(t, q, "old"), loadQuietReplay(t, q, "mixed") - if oldRow.Status != QueueStatusCancelled || mixed.Status != status || len(mixed.Alerts) != 2 || + if oldRow.Status != QueueStatusCancelled || mixed.Status != reopenStatus || len(mixed.Alerts) != 2 || !mixed.Alerts[0].StartTime.Equal(current.StartTime) || mixed.Alerts[1].ID != peer.ID || len(mixed.Links) != 2 || mixed.Links[0].OperationalRecordID != current.OperationalRecord.ID || mixed.Links[1].OperationalRecordID != peer.OperationalRecord.ID { t.Fatalf("wrong retained rows/links: old=%+v mixed=%+v", oldRow, mixed) } for _, id := range []string{"new", "recovery", "unknown"} { - if row := loadQuietReplay(t, q, id); row.Status != status || len(row.Alerts) != 1 { + if row := loadQuietReplay(t, q, id); row.Status != reopenStatus || len(row.Alerts) != 1 { t.Fatalf("unrelated row %s changed: %+v", id, row) } } From 1c9440546afbb1d17432b10814d078ddb7a07aee Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:29:51 +0100 Subject: [PATCH 08/86] Use destination receipts for delayed partial-delivery recovery A cancelled retry and a lost RAM cooldown marker do not mean that no destination received the old firing. Keep quiet-hours and recovery controls, then let persistent occurrence/destination receipts select the actual recipients. Add an HTTP 200/503, restart and recurrence control so the newer firing survives and only the previously informed destination receives the old recovery. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 6 + .../v6/internal/subsystems/monitoring.md | 9 ++ .../v6/internal/subsystems/notifications.md | 5 + .../monitoring/monitor_alert_handling_test.go | 116 ++++++++++++++++++ internal/monitoring/monitor_alerts.go | 25 ++-- 5 files changed, 144 insertions(+), 17 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 14bdd0b99f..fefabf9828 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -3230,3 +3230,9 @@ readback for all four cancellable row states without deleting failed audits or new-occurrence destination receipts. The monitor's connected tests use real PBS evaluation, normal callbacks, the persistent queue and a local HTTP destination; these receipts are not native provider or installed-release acceptance. + +Pending cancellation is not proof that every destination missed the firing. +The monitor delegates recovery eligibility to persisted occurrence/destination +receipts even when an old retry remains or restart has lost RAM cooldown state. +The partial-destination monitor control establishes a real local HTTP 200/503 +split and preserves the newer firing and the old recipient's recovery. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 6f5caeb5a0..9660e69b59 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -4655,3 +4655,12 @@ reach a loopback HTTP destination with one sent row and one cancelled old row. No clock, reducer map or configured cooldown is altered. This establishes local source-connected delivery, not the outstanding natural flapping cooldown check, a native appliance result or containing stable availability. + +Recovery eligibility is destination-receipt-owned, not the cancellation helper's +pending-work count or the RAM cooldown marker. After partial delivery/restart, +a cancelled retry at one destination cannot suppress a recovery to another +that actually accepted the same occurrence. The connected +`TestMonitorDelayedPartialResolutionKeepsDestinationRecovery` retains a real +HTTP 200/503 split, reopens the persistent queue, and requires the old accepted +destination's recovery plus the new firing, with no recovery to the unannounced +destination. Quiet hours and disabled recovery controls still apply. diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 5b5b782ab2..068c67b93a 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -1073,3 +1073,8 @@ fresh database reopen, nanosecond-distinct occurrences, operational-link and failed-attempt preservation, missing-identity safety, grouping/cooldown retention and the unchanged ID-wide API. Monitor connected controls additionally require HTTP acceptance of the surviving occurrence through the autonomous queue. + +The live monitor does not interpret pending-only cancellation counts as +recipient acceptance. Per-occurrence/per-destination receipts remain the sole +recovery admission proof, including partial delivery and lost RAM markers after +restart; an unannounced destination receives no recovery. diff --git a/internal/monitoring/monitor_alert_handling_test.go b/internal/monitoring/monitor_alert_handling_test.go index 312a2d89f0..33c98887b5 100644 --- a/internal/monitoring/monitor_alert_handling_test.go +++ b/internal/monitoring/monitor_alert_handling_test.go @@ -1207,6 +1207,7 @@ func TestMonitorLifecycleRefireReopensRetainedOccurrence(t *testing.T) { } type occurrenceHTTPReceipt struct { + Event string `json:"event"` Alerts []*alerts.Alert `json:"alerts"` } @@ -1341,3 +1342,118 @@ func TestMonitorDelayedIDResolutionPreservesNewQueuedOccurrence(t *testing.T) { n = occurrenceNotifier(t, dir, endpoint) assertOccurrenceHTTP(t, n, receipts, current) } + +// One destination accepted the old firing and another still has a retry. After +// restart, that pending work (and the lost RAM cooldown marker) is not evidence +// that no recipient saw it. Recovery must use exact persisted receipts. +func TestMonitorDelayedPartialResolutionKeepsDestinationRecovery(t *testing.T) { + endpoint, receipts := occurrenceEndpoint(t) + var failedFirings, unwantedRecoveries atomic.Int32 + broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var receipt occurrenceHTTPReceipt + if err := json.NewDecoder(r.Body).Decode(&receipt); err != nil { + t.Errorf("decode failing destination: %v", err) + } + if receipt.Event == "resolved" { + unwantedRecoveries.Add(1) + } else { + failedFirings.Add(1) + } + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(broken.Close) + dir := t.TempDir() + open := func() *notifications.NotificationManager { + n := occurrenceNotifier(t, dir, endpoint) + n.SetNotifyOnResolve(true) + n.AddWebhook(notifications.WebhookConfig{ID: "unannounced", Enabled: true, URL: broken.URL, Service: "generic"}) + return n + } + n := open() + a, pbs := occurrenceManager(t) + a.CheckPBS(pbs) + old := a.GetActiveAlerts()[0].Clone() + n.SendAlert(old) + n.StartQueueProcessing() + select { + case got := <-receipts: + if got.Event != "" || len(got.Alerts) != 1 || !got.Alerts[0].StartTime.Equal(old.StartTime) { + t.Fatalf("wrong initial firing: %+v", got) + } + case <-time.After(12 * time.Second): + t.Fatal("initial firing did not reach accepting destination") + } + deadline := time.Now().Add(3 * time.Second) + for { + stats, err := n.GetQueueStats() + if err != nil { + t.Fatal(err) + } + if stats["sent"] == 1 && stats["pending"] == 1 && failedFirings.Load() > 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("partial delivery not retained: %v", stats) + } + time.Sleep(time.Millisecond) + } + n.Stop() + pbs.CPU = 0 + a.CheckPBS(pbs) + n = open() // deferred processor; prior destination receipts are now on disk only + pbs.CPU = 95 + a.CheckPBS(pbs) + active := a.GetActiveAlerts() + if len(active) != 1 || active[0].ID != old.ID || active[0].StartTime.Equal(old.StartTime) { + t.Fatalf("not a new occurrence: %+v", active) + } + current := active[0].Clone() + n.SendAlert(current) + m := &Monitor{alertManager: a, notificationMgr: n} + m.handleAlertResolved(old.ID) + n.StartQueueProcessing() + seenFiring, seenRecovery := false, false + for i := 0; i < 2; i++ { + select { + case got := <-receipts: + if len(got.Alerts) != 1 || got.Alerts[0].ID != old.ID { + t.Fatalf("wrong delivery: %+v", got) + } + switch got.Event { + case "resolved": + if seenRecovery || !got.Alerts[0].StartTime.Equal(old.StartTime) { + t.Fatalf("wrong recovery occurrence: %+v", got) + } + seenRecovery = true + case "": + if seenFiring || !got.Alerts[0].StartTime.Equal(current.StartTime) { + t.Fatalf("wrong firing occurrence: %+v", got) + } + seenFiring = true + default: + t.Fatalf("unknown event: %+v", got) + } + case <-time.After(12 * time.Second): + t.Fatal("lost current firing or old accepted destination's recovery after restart") + } + } + deadline = time.Now().Add(3 * time.Second) + for { + stats, err := n.GetQueueStats() + if err != nil { + t.Fatal(err) + } + if stats["sent"] == 3 && stats["cancelled"] == 1 && stats["pending"]+stats["sending"]+stats["failed"]+stats["dlq"] == 1 { + t.Logf("partial destination recovery retained exact old receipt and new firing: %v", stats) + break + } + if time.Now().After(deadline) { + t.Fatalf("partial recovery queue completion: %v", stats) + } + time.Sleep(time.Millisecond) + } + if unwantedRecoveries.Load() != 0 { + t.Fatal("unannounced destination received a recovery") + } +} diff --git a/internal/monitoring/monitor_alerts.go b/internal/monitoring/monitor_alerts.go index b84a4e2489..826f9971b7 100644 --- a/internal/monitoring/monitor_alerts.go +++ b/internal/monitoring/monitor_alerts.go @@ -260,24 +260,15 @@ func (m *Monitor) handleResolvedAlert(resolvedAlert *alerts.ResolvedAlert) { // If the original alert would have been suppressed during quiet hours, // the recovery notification is also suppressed to avoid noise. if m.notificationMgr != nil { - firingNeverDelivered := m.notificationMgr.CancelResolvedAlert(resolvedAlert.Alert) + m.notificationMgr.CancelResolvedAlert(resolvedAlert.Alert) if m.notificationMgr.GetNotifyOnResolve() { - if resolvedAlert != nil && resolvedAlert.Alert != nil { - if firingNeverDelivered { - // The firing notification was still in the grouping window - // or waiting in the queue (e.g. quiet-hours replay) when the - // alert resolved, and exact cancellation just cancelled it. A - // recovery for an alert the user never saw fire is noise. - log.Info(). - Str("alertID", alertID). - Msg("Resolved notification suppressed because the firing notification was cancelled before delivery") - } else if m.alertManager != nil && m.alertManager.ShouldSuppressResolvedNotification(resolvedAlert.Alert) { - log.Info(). - Str("alertID", alertID). - Msg("Resolved notification suppressed during quiet hours") - } else { - go m.notificationMgr.SendResolvedAlert(resolvedAlert) - } + if m.alertManager != nil && m.alertManager.ShouldSuppressResolvedNotification(resolvedAlert.Alert) { + log.Info().Str("alertID", alertID).Msg("Resolved notification suppressed during quiet hours") + } else { + // Only occurrence/destination receipts establish who saw the + // firing. A pending retry or lost in-memory cooldown marker + // after restart must not suppress recovery for other recipients. + go m.notificationMgr.SendResolvedAlert(resolvedAlert) } } else { log.Info(). From 4df3e8974ec0e0931dd5edc877d29449c24c935a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:36:36 +0100 Subject: [PATCH 09/86] Add disposable native acceptance for owned demo recovery Exercise actual systemd and Caddy with a changed executable, distinct persistent data, delayed synthetic failure, observer loss and TERM during full rollback. Keep 300-second clocks, exact-source and cleanup receipts; no production credential or worker system privilege is added. Update the connected recovery contract and policy assertions, and retain bounded service identity observations. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 11 +- .github/scripts/dispatch-demo-runtime.py | 4 +- .github/scripts/tests/demo_runtime_native.py | 218 ++++++++++++++++++ .../tests/test_demo_runtime_transaction.py | 50 ++++ .github/workflows/demo-runtime-native.yml | 43 ++++ .../v6/internal/DEMO_RUNTIME_TRANSACTION.md | 15 ++ .../subsystems/deployment-installability.md | 13 ++ .../v6/internal/subsystems/registry.json | 6 +- scripts/installtests/demo_transaction_test.go | 34 +++ .../release_promotion_policy_test.py | 99 +++----- .../release_control/subsystem_lookup_test.py | 3 +- 11 files changed, 425 insertions(+), 71 deletions(-) create mode 100644 .github/scripts/tests/demo_runtime_native.py create mode 100644 .github/workflows/demo-runtime-native.yml diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 5fa81dda04..436e74b46d 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -224,7 +224,7 @@ class Host: def check_journal(self, cursors): for service, cursor in cursors.items(): - text = self.command(["journalctl", "-u", service, "--after-cursor", cursor, "--output=json", "--no-pager"]) + text = self.command(["journalctl", "-u", service, "--after-cursor", cursor, "--output=json", "--quiet", "--no-pager"]) for line in text.splitlines(): try: entry = json.loads(line) @@ -335,13 +335,16 @@ class Transaction: baseline = self.host.state() started = self.host.now() evidence = self.receipt[phase] - evidence.update({"required_seconds": WINDOW, "samples": 0, "elapsed_seconds": 0}) + evidence.update({"required_seconds": WINDOW, "samples": 0, "elapsed_seconds": 0, + "initial_services": baseline}) while True: self.host.check_journal(cursors) state = self.host.state() + evidence["last_services"] = state if state != baseline or not self.healthy(state): raise Failure("sustained-health") - if self.host.version(self.request["local_url"]) != expected_version: + evidence["observed_version"] = self.host.version(self.request["local_url"]) + if evidence["observed_version"] != expected_version: raise Failure("runtime-version") evidence["samples"] += 1 elapsed = self.host.now() - started @@ -411,6 +414,8 @@ class Transaction: elif self.request["mode"] == "update": raise Failure("update-baseline-unhealthy") self.receipt["healthy_baseline"] = healthy_baseline + self.receipt["baseline_services"] = states + self.receipt["baseline_version"] = original_version old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)} for sig in old_handlers: signal.signal(sig, cancel) diff --git a/.github/scripts/dispatch-demo-runtime.py b/.github/scripts/dispatch-demo-runtime.py index 499602fd17..a56e018ff0 100644 --- a/.github/scripts/dispatch-demo-runtime.py +++ b/.github/scripts/dispatch-demo-runtime.py @@ -38,7 +38,8 @@ if not attempt.exists(): os.chmod(attempt / name, 0o600) stream.write(data); stream.flush(); os.fsync(stream.fileno()) # Intent is durable before starting the unit. An uncertain launch is never replayed. - dfd = os.open(attempt, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd) + for directory in (attempt, root / 'attempts', root, root.parent): + dfd = os.open(directory, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd) try: child = subprocess.run(['systemd-run', '--quiet', '--collect', '--unit=pulse-demo-' + identity[:32], @@ -53,6 +54,7 @@ if not attempt.exists(): with open(attempt / 'launch.json', 'x') as stream: os.chmod(attempt / 'launch.json', 0o600) json.dump(launch, stream); stream.flush(); os.fsync(stream.fileno()) + dfd = os.open(attempt, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd) os.close(fd) # Closing or losing this observer never stops the independently owned unit. deadline = time.monotonic() + 4000 diff --git a/.github/scripts/tests/demo_runtime_native.py b/.github/scripts/tests/demo_runtime_native.py new file mode 100644 index 0000000000..b004b1cf05 --- /dev/null +++ b/.github/scripts/tests/demo_runtime_native.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Secret-free, fresh public CI only. Never run this fixture on a service host.""" +import base64 +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import pwd +import shutil +import socket +import subprocess +import time + +SCRIPTS = Path(__file__).resolve().parents[1] +RESULT = Path.cwd() / "demo-native-result.json" +ROOT = Path("/var/lib/pulse-deploy/demo") +OWNED = [Path("/etc/pulse"), Path("/opt/pulse"), Path("/var/lib/pulse-deploy"), + Path("/etc/systemd/system/pulse.service"), Path("/etc/systemd/system/pulse-relay.service"), + Path("/usr/local/share/ca-certificates/pulse-demo-native.crt")] + + +def command(argv, timeout=120): + result = subprocess.run(argv, capture_output=True, timeout=timeout) + if result.returncode: + raise RuntimeError("fixture-command-failed") + return result.stdout + + +def binary(version, fail_after=0, port=17655): + return ('''#!/usr/bin/python3 +import http.server,json,os,threading,time +VERSION = %r +def fail(): + time.sleep(%r) + print('panic: synthetic disposable service fault',flush=True) + os._exit(1) +if %r: threading.Thread(target=fail,daemon=True).start() +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body=json.dumps({'version':VERSION} if self.path=='/api/version' else {'status':'healthy'}).encode() + self.send_response(200); self.end_headers(); self.wfile.write(body) + def log_message(self,*args): pass +http.server.HTTPServer(('127.0.0.1',%r),Handler).serve_forever() +''' % (version, fail_after, fail_after, port)).encode() + + +def unit(executable, user="pulse"): + return f"[Unit]\nDescription=Disposable demo native fixture\n[Service]\nUser={user}\nGroup={user}\nExecStart={executable}\nRestart=no\n[Install]\nWantedBy=multi-user.target\n" + + +def installer(version, fail_after=0): + source = binary(version, fail_after) + return ("#!/usr/bin/python3\nfrom pathlib import Path\n" + "import base64\n" + f"p=Path('/opt/pulse/bin/pulse');p.write_bytes(base64.b64decode({base64.b64encode(source).decode()!r}));p.chmod(0o755)\n" + "p=Path('/etc/systemd/system/pulse.service');p.write_text(p.read_text()+'# candidate fixture\\n')\n" + f"Path('/etc/pulse/persistent-marker').write_text({'candidate data ' + version!r})\n").encode() + + +def fixture_installer(version, fail_after=0): + # The real engine invokes bash like the published installer. This fixture + # deliberately changes executable, unit and persistent data without download. + script = installer(version, fail_after) + return ("#!/bin/bash\nset -eu\npython3 - <<'PY'\n" + script.decode().split("\n", 1)[1] + "\nPY\n").encode() + + +def wait(predicate, limit): + deadline = time.monotonic() + limit + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(1) + raise RuntimeError("fixture-observation-timeout") + + +def main(): + result = {"schema_version": 1, "control_sha": os.environ.get("GITHUB_SHA"), + "run_id": os.environ.get("GITHUB_RUN_ID"), "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"), + "cases": [], "cleanup_complete": False, "signed_published_installer_acceptance": False} + processes = [] + admitted = False + original_caddy = None + caddyfile = Path("/etc/caddy/Caddyfile") + try: + result["stage"] = "disposable-runner-admission" + # Environment fields are context, not permission: combine them with a + # fresh empty estate, effective root, disposable hosted VM and exact job. + if (os.geteuid() != 0 or os.environ.get("GITHUB_ACTIONS") != "true" + or os.environ.get("RUNNER_ENVIRONMENT") != "github-hosted" + or socket.gethostname() in {"pulse-relay", "pulse-license", "pulse-dev"} + or any(path.exists() or path.is_symlink() for path in OWNED)): + raise RuntimeError("not-an-empty-disposable-public-ci-runner") + admitted = True + result["stage"] = "prepare-native-fixtures" + original_caddy = caddyfile.read_bytes() + command(["systemctl", "show", "--property=Version"]) + if not subprocess.run(["id", "pulse"], capture_output=True).returncode == 0: + command(["useradd", "--system", "--user-group", "--no-create-home", "pulse"]) + owner = pwd.getpwnam("pulse") + Path("/opt/pulse/bin").mkdir(parents=True) + Path("/etc/pulse").mkdir(mode=0o755) + os.chown("/etc/pulse", owner.pw_uid, owner.pw_gid) + Path("/etc/pulse/.env").write_text("DEMO_MODE=true\n") + Path("/etc/pulse/billing.json").write_text('{"capabilities":[]}') + Path("/etc/pulse/persistent-marker").write_text("original data") + for path in Path("/etc/pulse").iterdir(): + os.chown(path, owner.pw_uid, owner.pw_gid) + Path("/opt/pulse/bin/pulse").write_bytes(binary("1.0.0")); Path("/opt/pulse/bin/pulse").chmod(0o755) + Path("/opt/pulse/bin/relay").write_bytes(binary("1.0.0", port=17656)); Path("/opt/pulse/bin/relay").chmod(0o755) + for name, executable in (("pulse", "/opt/pulse/bin/pulse"), ("pulse-relay", "/opt/pulse/bin/relay")): + Path(f"/etc/systemd/system/{name}.service").write_text(unit(executable)) + command(["systemctl", "daemon-reload"]) + command(["systemctl", "enable", "--now", "pulse", "pulse-relay"]) + cert = Path("/etc/caddy/demo-native.crt"); key = Path("/etc/caddy/demo-native.key") + command(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=localhost", "-addext", "subjectAltName=IP:127.0.0.1", + "-addext", "basicConstraints=critical,CA:TRUE", + "-keyout", str(key), "-out", str(cert)]) + key.chmod(0o640); shutil.chown(key, group="caddy") + shutil.copyfile(cert, OWNED[-1]); command(["update-ca-certificates"]) + caddyfile.write_text("{\n admin off\n auto_https off\n}\n" + + f"https://127.0.0.1:18443 {{\n tls {cert} {key}\n reverse_proxy 127.0.0.1:17655\n}}\n" + + f"https://127.0.0.1:18444 {{\n tls {cert} {key}\n reverse_proxy 127.0.0.1:17656\n}}\n") + command(["systemctl", "restart", "caddy"]) + spec = importlib.util.spec_from_file_location("dispatcher", SCRIPTS / "dispatch-demo-runtime.py") + dispatcher = importlib.util.module_from_spec(spec); spec.loader.exec_module(dispatcher) + original = (SCRIPTS / "demo-runtime-transaction.py").read_bytes() + # Only the cohost endpoint is rebound. Transaction, paths, locking, + # systemd, HTTP/TLS, estate, clocks and watches remain real and unchanged. + endpoint = b'https://relay.pulserelay.pro/health' + assert original.count(endpoint) == 1 + source = original.replace(endpoint, b'https://127.0.0.1:18444/health') + result["original_engine_sha256"] = hashlib.sha256(original).hexdigest() + result["fixture_engine_sha256"] = hashlib.sha256(source).hexdigest() + result["bootstrap_sha256"] = hashlib.sha256(dispatcher.BOOTSTRAP.encode()).hexdigest() + result["driver_sha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + result["tools"] = {"systemd": command(["systemctl", "--version"]).decode().splitlines()[0], + "caddy": command(["caddy", "version"]).decode().strip()} + profile = {name: "2" for name in ("nodes", "vms_per_node", "lxcs_per_node", "docker_hosts", "docker_containers", "generic_hosts", "k8s_clusters", "k8s_nodes", "k8s_pods", "k8s_deployments")} + profile.update(seed_duration="2h", sample_interval="5m", update_interval="15s") + + def submit(version, fault=0): + install = fixture_installer(version, fault) + request = {"mode": "update", "hostname": socket.gethostname(), "local_url": "http://127.0.0.1:17655", + "public_url": "https://127.0.0.1:18443/api/health", "version": "v" + version, + "profile": profile, "control_sha": os.environ["GITHUB_SHA"], + "run_id": os.environ["GITHUB_RUN_ID"], "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], + "installer_sha256": hashlib.sha256(install).hexdigest()} + payload = {"request": request, "source": base64.b64encode(source).decode(), "installer": base64.b64encode(install).decode()} + identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + proc = subprocess.Popen(["python3", "-c", dispatcher.BOOTSTRAP], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + processes.append(proc); proc.stdin.write(json.dumps(payload).encode()); proc.stdin.close() + return proc, ROOT / "attempts" / identity, payload + + # Readiness is real, including authenticated TLS through actual Caddy. + result["stage"] = "native-listener-readiness" + wait(lambda: subprocess.run(["curl", "--disable", "--silent", "--fail", "https://127.0.0.1:18443/api/health"], capture_output=True).returncode == 0, 60) + proc, attempt, payload = submit("1.0.1") + result["stage"] = "changed-executable-forward" + proc.wait(timeout=900) + receipt = json.loads((attempt / "receipt.json").read_text()); result["cases"].append(receipt) + assert proc.returncode == 0 and receipt["status"] == "committed" and receipt["forward"]["elapsed_seconds"] >= 300 + assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1" + # A later candidate fails at 55s; lose the observer while the owned + # systemd child continues, then TERM during actual full recovery. + proc, attempt, payload = submit("1.0.2", 55) + result["stage"] = "delayed-fault-observer-loss" + wait(lambda: (attempt / "receipt.json").exists() and json.loads((attempt / "receipt.json").read_text())["status"] == "applying", 60) + proc.terminate(); proc.wait(timeout=30) + command(["systemctl", "is-active", "pulse-demo-" + attempt.name[:32]]) + wait(lambda: json.loads((attempt / "receipt.json").read_text())["status"] == "recovering", 180) + result["stage"] = "term-during-owned-recovery" + command(["systemctl", "kill", "--kill-whom=main", "--signal=TERM", "pulse-demo-" + attempt.name[:32]]) + replay = subprocess.run(["python3", "-c", dispatcher.BOOTSTRAP], input=json.dumps(payload).encode(), capture_output=True, timeout=900) + receipt = json.loads((attempt / "receipt.json").read_text()); result["cases"].append(receipt) + assert replay.returncode == 1 and receipt["status"] == "rolled_back" and receipt["recovery"]["elapsed_seconds"] >= 300 + assert receipt["failure"] in {"new-service-crash", "sustained-health"} + assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1" + version = json.loads(command(["curl", "--disable", "--silent", "--fail", "http://127.0.0.1:17655/api/version"]))["version"] + assert version == "1.0.1" + result["stage"] = "complete" + result["passed"] = True + except Exception as error: + result["passed"] = False + result["failure_type"] = type(error).__name__ + result["failure_code"] = str(error) if isinstance(error, RuntimeError) else "fixture-observation-or-assertion" + finally: + if admitted: + try: + for proc in processes: + if proc.poll() is None: + proc.terminate(); proc.wait(timeout=30) + # Stop only retained children belonging to this fresh fixture. + if (ROOT / "attempts").exists(): + for attempt in (ROOT / "attempts").iterdir(): + subprocess.run(["systemctl", "stop", "pulse-demo-" + attempt.name[:32]], capture_output=True, timeout=1300) + command(["systemctl", "disable", "--now", "pulse", "pulse-relay"]) + command(["systemctl", "stop", "caddy"]) + if original_caddy is not None: + caddyfile.write_bytes(original_caddy) + for path in OWNED: + if path.is_dir(): shutil.rmtree(path) + elif path.exists(): path.unlink() + for path in (Path("/etc/caddy/demo-native.crt"), Path("/etc/caddy/demo-native.key")): + if path.exists(): path.unlink() + command(["systemctl", "daemon-reload"]); command(["update-ca-certificates", "--fresh"]) + result["cleanup_complete"] = not any(path.exists() for path in OWNED) + except Exception as cleanup: + result["cleanup_failure_type"] = type(cleanup).__name__ + result["passed"] = False + RESULT.write_text(json.dumps(result, indent=2) + "\n") + RESULT.chmod(0o644) + return 0 if result.get("passed") and result["cleanup_complete"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 6050a380c8..c2fab87420 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -22,6 +22,7 @@ def load(name, filename): engine = load("demo_transaction", "demo-runtime-transaction.py") dispatcher = load("demo_dispatcher", "dispatch-demo-runtime.py") +native = load("demo_native", "tests/demo_runtime_native.py") def request(mode="recover"): @@ -181,6 +182,13 @@ class TransactionTest(unittest.TestCase): self.assertEqual(receipt["status"], "committed") self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) self.assertEqual(receipt["forward"]["samples"], 61) + self.assertEqual(receipt["baseline_services"]["pulse"]["MainPID"], "100") + self.assertEqual(receipt["forward"]["initial_services"], receipt["forward"]["last_services"]) + self.assertEqual(receipt["forward"]["observed_version"], "6.4.5") + self.assertEqual(self.host.install_count, 0) + for name in ("binary", "unit", "dropins"): + self.assertEqual(engine.estate_hash({name: self.paths[name]}), + engine.estate_hash({name: self.attempt / "snapshot" / name})) self.assertEqual((self.paths["data"] / "alerts/events.db").read_bytes(), b"synthetic opaque persistent file") self.assertTrue((self.attempt / "snapshot/data/persisted-history").exists()) self.assertNotIn("not-logged", json.dumps(receipt)) @@ -387,6 +395,13 @@ class InputAndCommandTest(unittest.TestCase): host.check_journal(cursors) self.assertEqual(cursors, {"pulse": "d"}) + def test_available_empty_journal_window_has_no_non_json_header(self): + host = engine.Host(); cursors = {"pulse": "c"} + with patch.object(host, "command", return_value="") as command: + host.check_journal(cursors) + self.assertIn("--quiet", command.call_args.args[0]) + self.assertEqual(cursors, {"pulse": "c"}) + def test_no_journal_cursor_and_malformed_service_state_fail_closed(self): host = engine.Host() with patch.object(host, "command", return_value=""): @@ -446,5 +461,40 @@ class DispatchLifecycleTest(unittest.TestCase): self.assertEqual(json.loads((attempt / "launch.json").read_text())["state"], "uncertain") +class NativeAdmissionTest(unittest.TestCase): + def test_native_driver_refuses_wrong_context_before_any_service_command(self): + with tempfile.TemporaryDirectory() as directory: + result_path = Path(directory) / "result.json" + existing = Path(directory) / "existing-estate" + existing.mkdir() + cases = [(1000, "true", "github-hosted", []), + (0, "false", "github-hosted", []), + (0, "true", "self-hosted", []), + (0, "true", "github-hosted", [existing])] + for uid, actions, environment, estate in cases: + with self.subTest(uid=uid, actions=actions, environment=environment, occupied=bool(estate)), \ + patch.object(native, "RESULT", result_path), patch.object(native, "OWNED", estate), \ + patch.object(os, "geteuid", return_value=uid), \ + patch.dict(os.environ, {"GITHUB_ACTIONS": actions, "RUNNER_ENVIRONMENT": environment}), \ + patch.object(native.socket, "gethostname", return_value="disposable-fixture"), \ + patch.object(native, "command", side_effect=AssertionError("must not execute a service command")) as command: + self.assertEqual(native.main(), 1) + command.assert_not_called() + result = json.loads(result_path.read_text()) + self.assertFalse(result["passed"]) + self.assertFalse(result["cleanup_complete"]) + self.assertFalse(result["signed_published_installer_acceptance"]) + self.assertEqual(result["failure_code"], "not-an-empty-disposable-public-ci-runner") + self.assertTrue(existing.is_dir()) + + def test_synthetic_changed_executable_and_data_are_distinct_between_steps(self): + compile(native.binary("1.0.1"), "fixture-runtime", "exec") + compile(native.installer("1.0.1"), "fixture-installer", "exec") + self.assertNotEqual(native.binary("1.0.1"), native.binary("1.0.2", 55)) + self.assertIn(b"candidate data 1.0.1", native.installer("1.0.1")) + self.assertIn(b"candidate data 1.0.2", native.installer("1.0.2", 55)) + self.assertNotIn(b"curl", native.fixture_installer("1.0.1")) + + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/demo-runtime-native.yml b/.github/workflows/demo-runtime-native.yml new file mode 100644 index 0000000000..a8928e5e5b --- /dev/null +++ b/.github/workflows/demo-runtime-native.yml @@ -0,0 +1,43 @@ +name: Demo transaction native acceptance +on: + pull_request: + paths: + - '.github/scripts/demo-runtime-transaction.py' + - '.github/scripts/dispatch-demo-runtime.py' + - '.github/scripts/tests/demo_runtime_native.py' + - '.github/workflows/demo-runtime-native.yml' + push: + branches: [main] + paths: + - '.github/scripts/demo-runtime-transaction.py' + - '.github/scripts/dispatch-demo-runtime.py' + - '.github/scripts/tests/demo_runtime_native.py' + - '.github/workflows/demo-runtime-native.yml' +permissions: + contents: read +concurrency: + group: demo-native-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + native: + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Check out exact source without retained credentials + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Prepare Caddy only in the disposable public CI runner + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends caddy openssl + - name: Exercise real systemd Caddy and retained child recovery + run: sudo --preserve-env=GITHUB_ACTIONS,RUNNER_ENVIRONMENT,GITHUB_SHA,GITHUB_RUN_ID,GITHUB_RUN_ATTEMPT python3 .github/scripts/tests/demo_runtime_native.py + - name: Retain sanitised terminal receipts and cleanup + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: demo-native-${{ github.run_id }} + path: demo-native-result.json + if-no-files-found: error + retention-days: 14 diff --git a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md index af5e4322da..106e939ffc 100644 --- a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md +++ b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md @@ -44,6 +44,21 @@ recovery, headroom refusal, shared-lock contention, source mismatch, journal failure, cohost restart, source-bound repeated/lost submission and no-op proof. The production window is not shortened. This is **not native acceptance**. +`demo-runtime-native.yml` now carries the next executable acceptance rather +than requiring a worker to obtain system mode: exact PR/main source, a fresh +secret-free public hosted runner, actual systemd/Caddy and disposable trusted +TLS. The successful changed-executable step observes the full 300 seconds. A +different executable and data then fail at 55 seconds; the original observer +is lost, the same retained request observes its child without replay, TERM is +sent during restoration, and the old executable/data must return through a +full real-clock recovery window. Sanitised artifacts bind control/driver/ +engine/bootstrap identity, service PID/restart observations and cleanup. The +fixture rebinds only Relay's fixed health URL to its local Caddy endpoint; the +original and fixture hashes remain distinct. This is a synthetic installer, +not signed published-installer or customer acceptance. Its source exists; +**no native pass is asserted by this document**. Read the exact-head terminal +job and artifact before judging that result. + Before treating this route as restored, the exact reviewed source must return native disposable systemd/SSH-loss receipts and signed published-installer forward/reverse data/identity results. Then reconcile actual demo workflow and diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 78533e9a3f..f53a0ffd5d 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -45,6 +45,19 @@ session. These connected source controls do not establish native systemd, signed installed forward/reverse acceptance, ingress enforcement or customer recovery. See [demo transaction acceptance](../DEMO_RUNTIME_TRANSACTION.md). +The secret-free public `demo-runtime-native.yml` checks the exact PR/push source +on a fresh hosted runner, using actual systemd and Caddy with disposable TLS and +synthetic executable/data fixtures. Its two real-window cases exercise a +changed executable, a 55-second delayed synthetic failure, observer loss and +TERM during full restoration. It records original/fixture source identities, +actual bounded service observations and cleanup; a missing or failed record is +not acceptance. Only the fixed Relay health URL is rebound to that fixture. +This neither exercises a signed published installer nor proves customer or +production recovery. The driver refuses a non-empty or non-disposable estate +before service operations. Neither this driver nor worker source tests install +host policy. Production activation still requires independent review and the +operational owner's acceptance of the native result and published-source path. + ### Superseded pull request validation Build and Test and Core E2E cancel an older run for the same pull request when diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 9cf02a0271..f3ec7681fb 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4524,11 +4524,13 @@ ".github/scripts/resolve-demo-runtime-profile.sh", ".github/scripts/select-internal-api-shard.sh", ".github/scripts/setup-demo-ssh.sh", + ".github/scripts/tests/demo_runtime_native.py", ".github/workflows/backfill-release-assets.yml", ".github/workflows/build-and-test.yml", ".github/workflows/build-release-candidate.yml", ".github/workflows/compile-release-payload.yml", ".github/workflows/create-release.yml", + ".github/workflows/demo-runtime-native.yml", ".github/workflows/dependency-advisory-watch.yml", ".github/workflows/deploy-demo-server.yml", ".github/workflows/helm-pages.yml", @@ -5176,7 +5178,9 @@ "match_prefixes": [], "match_files": [ ".github/scripts/demo-runtime-transaction.py", - ".github/scripts/dispatch-demo-runtime.py" + ".github/scripts/dispatch-demo-runtime.py", + ".github/scripts/tests/demo_runtime_native.py", + ".github/workflows/demo-runtime-native.yml" ], "allow_same_subsystem_tests": false, "test_prefixes": [], diff --git a/scripts/installtests/demo_transaction_test.go b/scripts/installtests/demo_transaction_test.go index ae800da558..d2d2be0683 100644 --- a/scripts/installtests/demo_transaction_test.go +++ b/scripts/installtests/demo_transaction_test.go @@ -57,3 +57,37 @@ func TestDemoTransactionKeepsSignedAdmissionAndVerificationOnlyReadOnly(t *testi t.Fatal("recovery must validate and retain the actual terminal observation") } } + +func TestDemoNativeAcceptanceIsSecretFreeDisposableAndKeepsRealWindows(t *testing.T) { + data, err := os.ReadFile(repoFile(".github", "workflows", "demo-runtime-native.yml")) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, required := range []string{ + "pull_request:", "branches: [main]", "contents: read", "runs-on: ubuntu-24.04", "timeout-minutes: 35", + "persist-credentials: false", "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "demo_runtime_native.py", "if: always()", "demo-native-result.json", "if-no-files-found: error", + } { + if !strings.Contains(text, required) { + t.Fatalf("lost disposable native CI/source or terminal-receipt boundary: %s", required) + } + } + for _, forbidden := range []string{"secrets.", "pull_request_target", "self-hosted", "contents: write", "id-token: write"} { + if strings.Contains(text, forbidden) { + t.Fatalf("native fixture gained credentials, privileged trigger or non-disposable target: %s", forbidden) + } + } + driver, err := os.ReadFile(repoFile(".github", "scripts", "tests", "demo_runtime_native.py")) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"not-an-empty-disposable-public-ci-runner", "dispatcher.BOOTSTRAP", "proc.terminate()", "--signal=TERM", "original_engine_sha256", "fixture_engine_sha256", "cleanup_complete", "signed_published_installer_acceptance", "elapsed_seconds\"] >= 300"} { + if !strings.Contains(string(driver), required) { + t.Fatalf("lost native fixture observation or limitation: %s", required) + } + } + if strings.Contains(string(driver), "WINDOW =") || strings.Contains(string(driver), "time.monotonic =") || strings.Contains(string(driver), "time.sleep =") { + t.Fatal("native acceptance must not shorten production windows or replace real clocks") + } +} diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 5f4278134e..a933737fd4 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -2749,73 +2749,42 @@ class ReleasePromotionPolicyTest(unittest.TestCase): def test_stable_demo_recovery_is_fixed_current_bits_only(self) -> None: workflow = read(".github/workflows/recover-demo-server.yml") recovery = read(".github/scripts/recover-demo-runtime.sh") + transaction = read(".github/scripts/demo-runtime-transaction.py") + dispatcher = read(".github/scripts/dispatch-demo-runtime.py") - self.assertIn("workflow_dispatch:", workflow) - self.assertNotIn("workflow_call:", workflow) - self.assertNotIn("inputs:", workflow) - self.assertIn("environment: demo-stable", workflow) - self.assertIn("contents: read", workflow) - self.assertIn("cancel-in-progress: false", workflow) - self.assertIn('gh api "repos/${GITHUB_REPOSITORY}/releases/latest"', workflow) - self.assertIn("Resolve target-compatible recovery profile", workflow) - self.assertIn("resolve-demo-runtime-profile.sh", workflow) - self.assertIn(".github/scripts/recover-demo-runtime.sh", workflow) - self.assertIn("Verify public health", workflow) - self.assertIn("Verify public frontend parity", workflow) - self.assertIn("./scripts/run_demo_public_browser_smoke.sh", workflow) - self.assertIn("Compensate failed mutated recovery", workflow) - self.assertIn("Capture bounded Pulse failure diagnostics", workflow) - self.assertIn("demo-runtime-diagnostics.txt", workflow) - self.assertIn("journalctl -u pulse --since '-30 minutes'", workflow) - self.assertIn("journalctl -u pulse --since '-10 minutes'", workflow) - self.assertIn("tail -400", workflow) - self.assertIn("sudo systemctl stop pulse", workflow) - self.assertIn("Retain bounded recovery evidence", workflow) - self.assertNotIn("release-convergence.yml", workflow) - self.assertNotIn("install.sh", workflow) - self.assertNotIn("scp ", workflow) + for required in ("workflow_dispatch:", "environment: demo-stable", "contents: read", + "cancel-in-progress: false", 'gh api "repos/${GITHUB_REPOSITORY}/releases/latest"', + "Resolve target-compatible recovery profile", "resolve-demo-runtime-profile.sh", + ".github/scripts/recover-demo-runtime.sh", "Verify public health", + "Verify public frontend parity", "./scripts/run_demo_public_browser_smoke.sh", + "Capture bounded Pulse failure diagnostics", "demo-runtime-diagnostics.txt", + "journalctl -u pulse --since '-30 minutes'", "journalctl -u pulse --since '-10 minutes'", + "tail -400", "Retain bounded recovery evidence", ".forward.elapsed_seconds >= 300"): + self.assertIn(required, workflow) + for forbidden in ("workflow_call:", "inputs:", "release-convergence.yml", "install.sh", "scp ", + "Compensate failed mutated recovery", "sudo systemctl stop pulse"): + self.assertNotIn(forbidden, workflow) + self.assertIn('exec python3 .github/scripts/dispatch-demo-runtime.py recover', recovery) + self.assertIn("if mode == 'update' else b''", dispatcher) + self.assertIn("/var/lib/pulse-deploy/relay/deploy.lock", transaction) + self.assertIn('if self.request["mode"] == "update":', transaction) + self.assertIn('self.host.install(self.attempt, self.request)', transaction) + self.assertIn('raise Failure("recovery-source-mismatch")', transaction) + self.assertIn('copy_path(path, snapshot / label)', transaction) + self.assertIn('copy_path(snapshot / label, PATHS[label])', transaction) + self.assertIn('estate_hash(PATHS) != self.receipt["snapshot_sha256"]', transaction) + self.assertIn('WINDOW = 300', transaction) + self.assertIn('self.watch("recovery", original_version, cursors)', transaction) + self.assertIn('signal.signal(sig, signal.SIG_IGN)', transaction) + self.assertIn('self.save("rolled_back")', transaction) + self.assertIn('self.save("rollback_failed")', transaction) + for required in ("alerts/events.db", "alerts/alert-history.json.imported", + "alerts/alert-history.backup.json.imported", "ai_incidents.json", "snapshot_retained"): + self.assertIn(required, transaction) + self.assertNotIn('kill -QUIT', transaction + recovery + workflow) + self.assertNotIn('eval ', transaction + recovery) + self.assertNotIn("'--wait'", dispatcher) - self.assertIn('if [ "$#" -ne 16 ]', recovery) - self.assertIn('SERVICE_NAME="pulse"', recovery) - self.assertIn('RELAY_SERVICE_NAME="pulse-relay"', recovery) - self.assertIn('EXPECTED_BINARY="/opt/pulse/bin/pulse"', recovery) - self.assertIn('EXPECTED_UNIT="/etc/systemd/system/pulse.service"', recovery) - self.assertIn('set_env_value PULSE_MOCK_NODES "$MOCK_NODES"', recovery) - self.assertIn('set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE"', recovery) - self.assertIn('set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE"', recovery) - self.assertIn('set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS"', recovery) - self.assertIn('set_env_value PULSE_MOCK_DOCKER_CONTAINERS "$MOCK_DOCKER_CONTAINERS"', recovery) - self.assertIn('set_env_value PULSE_MOCK_GENERIC_HOSTS "$MOCK_GENERIC_HOSTS"', recovery) - self.assertIn('set_env_value PULSE_MOCK_K8S_CLUSTERS "$MOCK_K8S_CLUSTERS"', recovery) - self.assertIn('set_env_value PULSE_MOCK_K8S_NODES "$MOCK_K8S_NODES"', recovery) - self.assertIn('set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS"', recovery) - self.assertIn('set_env_value PULSE_MOCK_K8S_DEPLOYMENTS "$MOCK_K8S_DEPLOYMENTS"', recovery) - self.assertIn("set_env_value PULSE_MOCK_SEED_METRICS_STORE false", recovery) - self.assertIn('grep -Fxq "PULSE_MOCK_SEED_METRICS_STORE=false"', recovery) - self.assertIn('set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION"', recovery) - self.assertIn('set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL"', recovery) - self.assertIn('set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL"', recovery) - self.assertIn('sudo systemctl restart "$SERVICE_NAME"', recovery) - self.assertIn('sudo systemctl stop "$SERVICE_NAME"', recovery) - self.assertIn('sudo kill -QUIT "$failed_pid"', recovery) - self.assertIn("clear_demo_operational_history", recovery) - self.assertIn("restore_demo_operational_history", recovery) - self.assertIn("alerts/events.db", recovery) - self.assertIn("alerts/alert-history.json.imported", recovery) - self.assertIn("alerts/alert-history.backup.json.imported", recovery) - self.assertIn("ai_incidents.json", recovery) - self.assertIn("demo-operational-history.tar", recovery) - self.assertNotIn('rm -f "/etc/pulse/', recovery) - self.assertIn('AFTER_RELAY_PID" = "$BEFORE_RELAY_PID', recovery) - self.assertIn('AFTER_BINARY_SHA" = "$BEFORE_BINARY_SHA', recovery) - self.assertIn('AFTER_UNIT_SHA" = "$BEFORE_UNIT_SHA', recovery) - self.assertIn('AFTER_DROPINS_SHA" = "$BEFORE_DROPINS_SHA', recovery) - self.assertNotIn('AFTER_CONFIG_SHA" = "$BEFORE_CONFIG_SHA', recovery) - self.assertIn("/etc/pulse/.env /etc/pulse/billing.json", recovery) - self.assertIn("restore_runtime_config", recovery) - self.assertIn("sudo cp --archive", recovery) - self.assertNotIn("eval ", recovery) - self.assertNotIn("sudo bash", recovery) def test_blocked_record_tracks_current_target_and_candidate_version(self) -> None: blocked_record_surface = { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 1fae39c773..8521b32545 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -102,7 +102,8 @@ def _contract_reference(contract_path: str, needle: str, runtime_path: str) -> d class SubsystemLookupTest(unittest.TestCase): def test_demo_transaction_sources_have_connected_proof_without_widening_release_metadata(self): - for path in (".github/scripts/demo-runtime-transaction.py", ".github/scripts/dispatch-demo-runtime.py"): + for path in (".github/scripts/demo-runtime-transaction.py", ".github/scripts/dispatch-demo-runtime.py", + ".github/scripts/tests/demo_runtime_native.py", ".github/workflows/demo-runtime-native.yml"): with self.subTest(path=path): result = lookup_paths([path]) self.assertEqual(result["unowned_runtime_files"], []) From d0d621a7c2d65c46dcf876986c30a20264fe9b08 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:38:16 +0100 Subject: [PATCH 10/86] Exercise detector admission in partial-delivery recovery proof Drive firings through the existing monitor callback instead of sending them directly through the notifier. This records the detector notification state required by the unchanged recovery policy; keep that policy and all failed attempts intact. Change-source: pulse-maintainer --- .../monitoring/monitor_alert_handling_test.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/monitoring/monitor_alert_handling_test.go b/internal/monitoring/monitor_alert_handling_test.go index 33c98887b5..498bd35b8d 100644 --- a/internal/monitoring/monitor_alert_handling_test.go +++ b/internal/monitoring/monitor_alert_handling_test.go @@ -1372,9 +1372,17 @@ func TestMonitorDelayedPartialResolutionKeepsDestinationRecovery(t *testing.T) { } n := open() a, pbs := occurrenceManager(t) + m := &Monitor{alertManager: a, notificationMgr: n} + // Use the live firing callback so the detector records notification policy + // state. Direct notifier calls would not establish LastNotified, and existing + // recovery policy would correctly suppress that unannounced detector alert. + a.SetAlertCallback(m.handleAlertFired) a.CheckPBS(pbs) old := a.GetActiveAlerts()[0].Clone() - n.SendAlert(old) + if old.LastNotified == nil { + t.Fatal("detector did not admit the firing notification") + } + awaitOccurrenceRows(t, n, 2) n.StartQueueProcessing() select { case got := <-receipts: @@ -1402,6 +1410,8 @@ func TestMonitorDelayedPartialResolutionKeepsDestinationRecovery(t *testing.T) { pbs.CPU = 0 a.CheckPBS(pbs) n = open() // deferred processor; prior destination receipts are now on disk only + m = &Monitor{alertManager: a, notificationMgr: n} + a.SetAlertCallback(m.handleAlertFired) pbs.CPU = 95 a.CheckPBS(pbs) active := a.GetActiveAlerts() @@ -1409,8 +1419,7 @@ func TestMonitorDelayedPartialResolutionKeepsDestinationRecovery(t *testing.T) { t.Fatalf("not a new occurrence: %+v", active) } current := active[0].Clone() - n.SendAlert(current) - m := &Monitor{alertManager: a, notificationMgr: n} + awaitOccurrenceRows(t, n, 2) m.handleAlertResolved(old.ID) n.StartQueueProcessing() seenFiring, seenRecovery := false, false From c9a0f0bf6ae19cb4cb7a2ed20e2d7e435550dafb Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:39:20 +0100 Subject: [PATCH 11/86] Keep stored disk History independent of current collection Use stable disk-family chart entries so missing current SMART or temperature fields cannot hide stored observations, and matching snapshots do not remount charts or restart reads. Preserve existing identity, live collection status and entitlement boundaries; cover cancellation, measured zero, empty series and desktop/phone presentation. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 12 + .../internal/subsystems/storage-recovery.md | 26 ++ .../browser-tests/disk-history-collection.cjs | 303 ++++++++++++++++++ .../disk-history-collection.html | 12 + .../browser-tests/disk-history-collection.tsx | 75 +++++ frontend-modern/browser-verification.json | 49 ++- .../DiskDetail.history-collection.test.tsx | 260 +++++++++++++++ .../Storage/__tests__/DiskDetail.test.tsx | 13 + .../__tests__/useDiskDetailModel.test.ts | 16 + ...DetailPresentation.branchcov0724pm.test.ts | 17 +- .../storageBackups/diskDetailPresentation.ts | 82 +++-- 11 files changed, 789 insertions(+), 76 deletions(-) create mode 100644 frontend-modern/browser-tests/disk-history-collection.cjs create mode 100644 frontend-modern/browser-tests/disk-history-collection.html create mode 100644 frontend-modern/browser-tests/disk-history-collection.tsx create mode 100644 frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5572c24252..6b766e3ef5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,18 @@ ## Purpose +### Stable physical-disk History composition + +Disk detail composes shared HistoryChart owners from stable disk-family catalog +entries, independently of the latest optional temperature/SMART fields. Matching +snapshots must not recreate chart DOM, reset inspection or restart reads. Stored +series retain their own sample provenance; current collection unavailability is +still reported in Overview, and empty history is not a manufactured zero. The +shared target/range owner continues to cancel old selections and reject late +responses. `DiskDetail.history-collection.test.tsx` and the production +`browser-tests/disk-history-collection.cjs` fixture verify these boundaries with +synthetic data; neither establishes native NAS health or installed acceptance. + Storage pool Configuration key/value rows keep capacity absence distinct from measured zero: unavailable bytes and percentages render as `n/a`, while valid independent observations survive a missing total. Live partial snapshots must diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 97ffabafe7..c0a85bd7b9 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,32 @@ ## Purpose +### Disk History is independent of current collection + +The existing physical-disk History catalog is selected by disk family, not by +whether the latest snapshot contains temperature or extended SMART values. +Temperature and ATA reallocated-sector charts, or temperature and NVMe +endurance/spare charts, query only the already-resolved canonical disk target. +A missing current field must not hide stored observations. An empty series is +shown as no history samples, never a fabricated zero or current-value fallback. +Overview continues to report current collection availability; explicit +unavailable/unsupported I/O still suppresses the existing live I/O section. + +Catalog entries retain their identity across matching snapshots so ordinary +current-temperature changes cannot remount charts, drop inspection or restart +History reads. The shared chart owner still invalidates reads and inspection +when the explicit target or range changes; a late cancelled response cannot +populate its replacement. This changes neither disk correlation nor collector, +retention, permission or entitlement policy, and proves no appliance recovery. + +Verification: `DiskDetail.test.tsx` and `useDiskDetailModel.test.ts` pin the +catalog and matching-snapshot boundary; `DiskDetail.history-collection.test.tsx` +uses the actual chart owner to cover absent current fields, stored zero versus +empty, NVMe/ATA catalogs, request counts, preserved inspection and late former +target responses. `frontend-modern/browser-tests/disk-history-collection.cjs` +checks the production components/client/CSS with synthetic API data on desktop +Chromium and touch/dark phone WebKit, not native NAS or installed acceptance. + ### Independent physical disk temperature Disk Overview displays a finite positive reported temperature even when the diff --git a/frontend-modern/browser-tests/disk-history-collection.cjs b/frontend-modern/browser-tests/disk-history-collection.cjs new file mode 100644 index 0000000000..c8492ba1e4 --- /dev/null +++ b/frontend-modern/browser-tests/disk-history-collection.cjs @@ -0,0 +1,303 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +const parent = false; +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join( + root, + 'node_modules', + parent ? 'disk-history-parent' : 'disk-history-proof', + ); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), + server: { host: '127.0.0.1', port: 5251, strictPort: true }, + }); + const playwright = require('playwright/package.json').version; + const expected = JSON.parse( + fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'), + ).packages['node_modules/@playwright/test'].version; + assert.equal(playwright, expected); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + }); + const requests = [], + errors = [], + checks = []; + const observedAt = Date.now() - 60000; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5251') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const query = Object.fromEntries(url.searchParams); + requests.push(query); + const value = + query.metric === 'smart_temp' + ? query.resourceId.includes('nas-b') + ? 55 + : 42 + : query.metric === 'smart_percentage_used' + ? 12 + : query.metric === 'smart_available_spare' + ? 97 + : 0; + const empty = query.range === '1h' && query.metric === 'smart_temp'; + return route.fulfill({ + json: { + resourceType: query.resourceType, + resourceId: query.resourceId, + metric: query.metric, + range: query.range, + start: Date.now() - 60000, + end: Date.now(), + source: 'store', + points: empty ? [] : [{ timestamp: observedAt, value, min: value, max: value }], + }, + }); + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { enabled: false, data: [] }, + }); + }); + const check = async (label, predicate) => { + try { + await predicate(); + checks.push({ label, passed: true }); + } catch (error) { + checks.push({ label, passed: false, error: error.message }); + } + }; + const screenshot = async (state) => { + const file = `${name}-${state}.png`; + await page.screenshot({ + path: path.join(artifacts, file), + fullPage: true, + animations: 'disabled', + }); + observations.push({ + browser: name, + state, + screenshot: file, + visibleCharts: await page.locator('canvas[role="img"]').allTextContents(), + selectedTabs: await page + .getByRole('tab') + .evaluateAll((tabs) => + tabs.map((tab) => ({ + label: tab.textContent, + selected: tab.getAttribute('aria-selected'), + border: getComputedStyle(tab).borderBottomColor, + })), + ), + overflow: await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth, + ), + body: await page.locator('main').innerText(), + }); + }; + const thermal = () => page.getByRole('img', { name: 'Temperature chart' }); + const description = async () => + thermal().evaluate( + (el) => document.getElementById(el.getAttribute('aria-describedby')).textContent, + ); + await page.goto('http://127.0.0.1:5251/browser-tests/disk-history-collection.html'); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('tab', { name: 'History' }).click(); + await page.waitForTimeout(300); + await check('Stored ATA history is accessible before any current fields', async () => { + assert.equal(await page.locator('canvas[role="img"]').count(), 2); + assert.match(await description(), /42ยฐC/); + }); + await check('History selection matches its visible content', async () => { + assert.equal( + await page.getByRole('tab', { name: 'History' }).getAttribute('aria-selected'), + 'true', + ); + }); + await screenshot('missing-current'); + await page.getByRole('button', { name: 'Restore current fields' }).click(); + await thermal().waitFor(); + await page.getByRole('combobox', { name: 'Disk history range' }).selectOption('7d'); + await page.waitForFunction( + () => + document + .querySelector('canvas[aria-label="Temperature chart"]') + ?.getAttribute('aria-describedby') && + document + .getElementById( + document + .querySelector('canvas[aria-label="Temperature chart"]') + .getAttribute('aria-describedby'), + ) + .textContent.includes('42ยฐC'), + ); + if (phone) await thermal().tap({ position: { x: 80, y: 60 } }); + else { + await thermal().focus(); + await thermal().press('End'); + } + await page.waitForTimeout(100); + await check('Stored zero and exact temperature can be inspected', async () => { + assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2); + assert.match( + await page + .locator('[data-history-chart-tooltip]') + .allTextContents() + .then((parts) => parts.join(' ')), + /0 sectors/, + ); + }); + await thermal().evaluate((el) => (el.dataset.proofOwner = 'original')); + const reads = requests.length; + await page.getByRole('button', { name: 'Update current fields' }).click(); + await page.waitForTimeout(200); + await check('Same-ID snapshot keeps mounted chart and makes no extra reads', async () => { + assert.equal(await thermal().getAttribute('data-proof-owner'), 'original'); + assert.equal(requests.length, reads); + assert.equal(await page.getByRole('combobox').inputValue(), '7d'); + }); + await screenshot('updated-current'); + await page.getByRole('button', { name: 'Lose current fields' }).click(); + await page.waitForTimeout(200); + await check( + 'Missing current fields do not withdraw stored readings or invent live I/O', + async () => { + assert.equal(await thermal().count(), 1); + assert.equal(await thermal().getAttribute('data-proof-owner'), 'original'); + assert.match(await description(), /42ยฐC/); + assert.equal(requests.length, reads); + assert.equal(await page.getByText('Live I/O (30m)').count(), 0); + }, + ); + await screenshot('missing-again'); + if (!parent) { + await page.getByRole('tab', { name: 'Overview' }).click(); + await check( + 'Overview reports unavailable collection rather than the old temperature', + async () => { + assert.equal( + await page + .getByText('Temperature is temporarily unavailable: collection deadline exceeded') + .isVisible(), + true, + ); + assert.equal(await page.getByText('42ยฐC', { exact: true }).count(), 0); + }, + ); + await page.getByRole('tab', { name: 'History' }).click(); + await page.getByRole('combobox').selectOption('1h'); + await page.getByText('No history samples in this time range.').waitFor(); + await check( + 'Empty temperature is not zero and reallocated zero remains stored', + async () => { + assert.doesNotMatch(await description(), /0ยฐC/); + assert.equal(await page.locator('canvas[role="img"]').count(), 2); + }, + ); + await screenshot('empty-temperature'); + await page.getByRole('combobox').selectOption('7d'); + await page.getByRole('button', { name: 'Select NVMe B' }).click(); + await page.getByRole('img', { name: 'Available Spare chart' }).waitFor(); + await page.waitForTimeout(200); + await check( + 'NVMe History uses explicit B target and its own metrics without live fields', + async () => { + assert.equal(await page.locator('canvas[role="img"]').count(), 3); + assert.equal( + await page.getByRole('img', { name: 'Reallocated Sectors chart' }).count(), + 0, + ); + assert.match(await description(), /55ยฐC/); + const own = requests.filter((r) => r.resourceId === 'disk:nas-b:nvme0n1'); + assert.equal( + own.some((r) => r.metric === 'smart_available_spare'), + true, + ); + assert.equal( + own.every((r) => r.resourceType === 'disk'), + true, + ); + }, + ); + await screenshot('nvme'); + const beforeClear = requests.length; + await page.getByRole('button', { name: 'Clear identity' }).click(); + await page.waitForTimeout(200); + await check( + 'Unresolvable identity withdraws History and does not guess a target', + async () => { + assert.equal(await page.getByRole('tab', { name: 'History' }).count(), 0); + assert.equal(await page.locator('canvas[role="img"]').count(), 0); + assert.equal(requests.length, beforeClear); + }, + ); + await screenshot('no-identity'); + } + await check('No page errors, off-origin data reads or horizontal overflow', async () => { + assert.deepEqual(errors, []); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), + false, + ); + assert.equal( + requests.some((r) => ['diskread', 'diskwrite', 'disk'].includes(r.metric)), + false, + ); + }); + observations.push({ browser: name, version: browser.version(), checks, requests, errors }); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify({ parent, playwright, expected, observations }, null, 2), + ); + } + const failed = observations.flatMap((o) => o.checks ?? []).filter((c) => !c.passed); + console.log( + JSON.stringify({ + parent, + checks: observations.flatMap((o) => o.checks ?? []).length, + failed: failed.map((c) => c.label), + }), + ); + if (!parent) assert.equal(failed.length, 0); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/disk-history-collection.html b/frontend-modern/browser-tests/disk-history-collection.html new file mode 100644 index 0000000000..1d1a1db082 --- /dev/null +++ b/frontend-modern/browser-tests/disk-history-collection.html @@ -0,0 +1,12 @@ + + + + + + Disk History collection verification + + +
+ + + diff --git a/frontend-modern/browser-tests/disk-history-collection.tsx b/frontend-modern/browser-tests/disk-history-collection.tsx new file mode 100644 index 0000000000..8c467a1c9d --- /dev/null +++ b/frontend-modern/browser-tests/disk-history-collection.tsx @@ -0,0 +1,75 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { DiskDetail } from '../src/components/Storage/DiskDetail'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +// Only snapshots are synthetic: detail, history, client, inspection and CSS are production code. +const disk = (missing = false, nvme = false, hot = false): Resource => ({ + id: nvme ? 'disk-b' : 'disk-a', + name: nvme ? 'NVMe B' : 'Archive A', + displayName: nvme ? 'NVMe B' : 'Archive A', + type: 'physical_disk', + platformId: nvme ? 'nas-b' : 'nas-a', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + lastSeen: Date.now(), + identity: { hostname: nvme ? 'nas-b' : 'nas-a' }, + metricsTarget: { + resourceType: 'disk', + resourceId: nvme ? 'disk:nas-b:nvme0n1' : 'disk:nas-a:sda', + }, + physicalDisk: { + devPath: nvme ? '/dev/nvme0n1' : '/dev/sda', + model: nvme ? 'NVMe B' : 'Archive HDD A', + serial: nvme ? 'SERIAL-B' : 'SERIAL-A', + diskType: nvme ? 'nvme' : 'hdd', + temperature: missing ? undefined : hot ? 65 : 42, + smart: missing + ? undefined + : nvme + ? { percentageUsed: 12, availableSpare: 97 } + : { reallocatedSectors: 0 }, + collection: { + temperature: { + state: missing ? 'unavailable' : 'available', + reason: missing ? 'collection deadline exceeded' : undefined, + }, + io: { state: 'unsupported', reason: 'per-member counters unavailable' }, + }, + }, +}); +const [snapshot, setSnapshot] = createSignal(disk(true)); +const controls = [ + ['Restore current fields', () => setSnapshot(disk())], + ['Update current fields', () => setSnapshot(disk(false, false, true))], + ['Lose current fields', () => setSnapshot(disk(true))], + ['Select NVMe B', () => setSnapshot(disk(true, true))], + [ + 'Clear identity', + () => + setSnapshot({ + ...disk(true), + id: '', + metricsTarget: undefined, + physicalDisk: { ...disk(true).physicalDisk!, serial: '', wwn: '' }, + }), + ], +] as const; +render( + () => ( +
+

Physical disk History

+
+ {controls.map(([label, action]) => ( + + ))} +
+ +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 79959c2dd1..7555748f01 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,27 +1,16 @@ { "version": 1, "result": "passed", - "base_sha": "90c436c83d616eccbe544bf3737b13c3b6251e53", - "verified_at": "2026-10-02T23:38:43Z", + "base_sha": "b6a42d8a4f329a1f3d7359d8d64b17c385006ed7", + "verified_at": "2026-10-03T00:38:42Z", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts", - "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx" + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "63429552c461f36d4f5af06ac2ecd612b2a84630bf82e2e5d8311653519585d1", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "0e313b23f06c2c5fa139f2a8f1acd70299a047ab20da88016ec1aa01b5a50b4d", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "c7afd1510b7249021eb2af2a7dcd5fe71e5183bed019ade505489a2f46eb9635", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "fa04600d900f51395493d1d3a51d578d84d13adb64cc5a159322a61877854bd0", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts": "1ac0ffb9ee287a9da04fbf24e9953dbcbf4e55de906241e7702266808da2243d", - "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "161617fb877870ce0729dcb8312762b81d6eb4bd5cb3835023fed543b88ff02d" + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "31c71a2d696f1b74d55b855220885619bbeebc0b75a1f34354cc5b794ba49375" }, "routes": [ - "PBS resource detail History in existing platform tables", - "Availability fleet open resource detail" + "Existing Storage physical disk detail History" ], "viewports": [ { @@ -34,19 +23,23 @@ } ], "states": [ - "Same-ID replacement updates PBS heading, current CPU and history target without remounting the History body or losing the 7-day selection", - "Lost metrics target withdraws the History body and returning target restores the selected tab", - "Selecting another canonical resource starts on its own Overview and uses only its explicit History target", - "An open availability check changes from Up to Down with the current fleet snapshot", - "Selecting Check B after Check A changes header, endpoint and availability together", - "Removal of the selected check closes details; reintroduction does not reopen it", - "Desktop light Chromium and phone dark WebKit have no page overflow or uncaught page errors" + "Stored ATA temperature and measured-zero reallocated sectors render when current temperature and SMART are absent", + "Matching current snapshot updates keep the same chart owners and add zero History reads", + "Current field loss keeps stored samples and selected 7-day range; no unavailable live I/O is shown", + "Overview reports present collection unavailability instead of the earlier stored temperature", + "Empty temperature history is an explicit no-samples state, not a zero, while stored reallocated zero remains visible", + "NVMe uses its own explicit disk target and stored temperature/endurance/spare series without current SMART fields", + "No resolvable identity withdraws History and issues no guessed-target request", + "Keyboard desktop and touch phone inspection show real stored readings", + "All screenshots inspected with animations disabled; selected tab, light/dark layouts and page width agree with content", + "Playwright 1.56.1 matches the integration lock; no page errors or off-origin data reads" ], "interactions": [ - "Select History and 7-day range", - "Replace PBS snapshot, withdraw and restore metrics target", - "Select PBS B and open its History", - "Open Check A, publish failed replacement, then select Check B", - "Remove and restore selected check" + "Open History from unavailable current Overview", + "Restore, update and remove current fields", + "Change 24h/7d/1h ranges", + "Inspect by keyboard or phone tap", + "Select NVMe B", + "Clear disk identity" ] } diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx new file mode 100644 index 0000000000..bfbd263fd4 --- /dev/null +++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.history-collection.test.tsx @@ -0,0 +1,260 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ChartsAPI, + type MetricsHistoryParams, + type SingleMetricHistoryResponse, +} from '@/api/charts'; +import { DiskDetail } from '../DiskDetail'; +import type { Resource } from '@/types/resource'; + +vi.mock('@/api/charts', () => ({ ChartsAPI: { getMetricsHistory: vi.fn() } })); +vi.mock('@/stores/license', () => ({ + isRangeLocked: () => false, + loadRuntimeCapabilities: vi.fn(), + maxHistoryDays: () => 7, +})); +vi.mock('@/stores/alertsActivation', () => ({ + useAlertsActivation: () => ({ getDiskTemperatureThresholds: () => null }), +})); +const request = vi.mocked(ChartsAPI.getMetricsHistory); +const context = { + clearRect: vi.fn(), + setTransform: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + arc: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + setLineDash: vi.fn(), + measureText: () => ({ width: 20 }), +}; +const disk = (missing = false, nvme = false): Resource => ({ + id: 'disk-a', + type: 'physical_disk', + name: 'Archive disk', + displayName: 'Archive disk', + platformId: 'nas-a', + platformType: 'truenas', + sourceType: 'api', + status: 'online', + lastSeen: 1000, + identity: { hostname: 'nas-a' }, + metricsTarget: { resourceType: 'disk', resourceId: 'disk:nas-a:sda' }, + physicalDisk: { + devPath: nvme ? '/dev/nvme0n1' : '/dev/sda', + model: nvme ? 'NVMe' : 'Archive HDD', + serial: 'SERIAL-A', + diskType: nvme ? 'nvme' : 'hdd', + temperature: missing ? undefined : 42, + smart: missing + ? undefined + : nvme + ? { percentageUsed: 12, availableSpare: 97 } + : { reallocatedSectors: 0 }, + collection: { + temperature: { + state: missing ? 'unavailable' : 'available', + reason: missing ? 'collection deadline exceeded' : undefined, + }, + io: { state: 'unsupported', reason: 'per-member counters unavailable' }, + }, + }, +}); +const response = ( + params: MetricsHistoryParams, + value = params.metric === 'smart_temp' ? 42 : 0, +): SingleMetricHistoryResponse => ({ + resourceType: params.resourceType, + resourceId: params.resourceId, + metric: params.metric!, + range: params.range!, + start: 1000, + end: 2000, + source: 'store', + points: [{ timestamp: 1000, value, min: value, max: value }], +}); +const description = (canvas: HTMLElement) => + document.getElementById(canvas.getAttribute('aria-describedby')!)!; +const openHistory = () => fireEvent.click(screen.getByRole('tab', { name: 'History' })); + +beforeEach(() => { + request.mockReset().mockImplementation(async (params) => response(params)); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + context as unknown as CanvasRenderingContext2D, + ); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('Disk History independently of current collection', () => { + it('loads stored ATA readings when current temperature and SMART are unavailable', async () => { + render(() => ); + expect(screen.getByText(/Temperature is temporarily unavailable/)).toBeInTheDocument(); + openHistory(); + await waitFor(() => + expect(screen.getByRole('img', { name: 'Temperature chart' })).toBeInTheDocument(), + ); + const thermal = screen.getByRole('img', { name: 'Temperature chart' }); + await waitFor(() => expect(description(thermal)).toHaveTextContent('42ยฐC')); + expect( + description(screen.getByRole('img', { name: 'Reallocated Sectors chart' })), + ).toHaveTextContent('0 sectors'); + expect( + request.mock.calls.every( + ([params]) => params.resourceType === 'disk' && params.resourceId === 'disk:nas-a:sda', + ), + ).toBe(true); + expect(request.mock.calls.map(([params]) => params.metric)).not.toContain('diskread'); + expect(screen.queryByText('Live I/O (30m)')).not.toBeInTheDocument(); + }); + + it('loads stored NVMe temperature and endurance without current SMART fields', async () => { + render(() => ); + openHistory(); + await waitFor(() => expect(screen.getAllByRole('img')).toHaveLength(3)); + expect(screen.getByRole('img', { name: 'Life Used chart' })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'Available Spare chart' })).toBeInTheDocument(); + expect( + screen.queryByRole('img', { name: 'Reallocated Sectors chart' }), + ).not.toBeInTheDocument(); + await waitFor(() => + expect(description(screen.getByRole('img', { name: 'Temperature chart' }))).toHaveTextContent( + '42ยฐC', + ), + ); + }); + + it('preserves mounted charts and inspection without extra reads on matching snapshots', async () => { + const [snapshot, setSnapshot] = createSignal(disk()); + render(() => ); + openHistory(); + fireEvent.change(screen.getByRole('combobox', { name: 'Disk history range' }), { + target: { value: '7d' }, + }); + const thermal = screen.getByRole('img', { name: 'Temperature chart' }); + await waitFor(() => expect(description(thermal)).toHaveTextContent('42ยฐC')); + fireEvent.focus(thermal); + expect(document.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42ยฐC'); + const reads = request.mock.calls.length; + setSnapshot({ ...disk(), physicalDisk: { ...disk().physicalDisk!, temperature: 65 } }); + expect(screen.getByRole('img', { name: 'Temperature chart' })).toBe(thermal); + expect(document.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42ยฐC'); + expect(screen.getByRole('combobox', { name: 'Disk history range' })).toHaveValue('7d'); + expect(request).toHaveBeenCalledTimes(reads); + }); + + it('keeps stored observations mounted after current fields disappear', async () => { + const [snapshot, setSnapshot] = createSignal(disk()); + render(() => ); + openHistory(); + const thermal = screen.getByRole('img', { name: 'Temperature chart' }); + await waitFor(() => expect(description(thermal)).toHaveTextContent('42ยฐC')); + const reads = request.mock.calls.length; + setSnapshot(disk(true)); + expect(screen.getByRole('img', { name: 'Temperature chart' })).toBe(thermal); + expect(description(thermal)).toHaveTextContent('42ยฐC'); + expect(screen.getByRole('img', { name: 'Reallocated Sectors chart' })).toBeInTheDocument(); + expect(request).toHaveBeenCalledTimes(reads); + fireEvent.click(screen.getByRole('tab', { name: 'Overview' })); + expect(screen.getByText(/Temperature is temporarily unavailable/)).toBeVisible(); + expect(screen.queryByText('42ยฐC')).not.toBeInTheDocument(); + }); + + it('ignores an old target completion and uses only the explicit new disk target', async () => { + const pending: { + params: MetricsHistoryParams; + resolve: (value: SingleMetricHistoryResponse) => void; + }[] = []; + request.mockImplementation( + (params) => new Promise((resolve) => pending.push({ params, resolve })), + ); + const [snapshot, setSnapshot] = createSignal(disk()); + render(() => ); + openHistory(); + await waitFor(() => + expect( + pending.filter(({ params }) => params.resourceId === 'disk:nas-a:sda').length, + ).toBeGreaterThan(0), + ); + setSnapshot({ + ...disk(), + metricsTarget: { resourceType: 'disk', resourceId: 'disk:nas-b:sda' }, + }); + await waitFor(() => + expect( + pending.filter(({ params }) => params.resourceId === 'disk:nas-b:sda').length, + ).toBeGreaterThan(0), + ); + for (const item of pending.filter(({ params }) => params.resourceId === 'disk:nas-b:sda')) + item.resolve(response(item.params, 55)); + await waitFor(() => + expect(description(screen.getByRole('img', { name: 'Temperature chart' }))).toHaveTextContent( + '55ยฐC', + ), + ); + for (const item of pending.filter(({ params }) => params.resourceId === 'disk:nas-a:sda')) { + expect(item.params.signal?.aborted).toBe(true); + item.resolve(response(item.params, 99)); + } + await Promise.resolve(); + expect(description(screen.getByRole('img', { name: 'Temperature chart' }))).toHaveTextContent( + '55ยฐC', + ); + expect( + description(screen.getByRole('img', { name: 'Temperature chart' })), + ).not.toHaveTextContent('99ยฐC'); + }); + + it('distinguishes empty stored series from measured zero with no live fallback', async () => { + request.mockImplementation(async (params) => ({ + ...response(params), + points: params.metric === 'smart_temp' ? [] : response(params, 0).points, + })); + render(() => ); + openHistory(); + await waitFor(() => + expect(screen.getByText('No history samples in this time range.')).toBeInTheDocument(), + ); + expect( + description(screen.getByRole('img', { name: 'Temperature chart' })), + ).not.toHaveTextContent('0ยฐC'); + await waitFor(() => + expect( + description(screen.getByRole('img', { name: 'Reallocated Sectors chart' })), + ).toHaveTextContent('0 sectors'), + ); + }); + + it('makes no history request without a resolvable disk identity', () => { + render(() => ( + + )); + expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument(); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx index 701ada6248..cfa8a0f5d0 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx @@ -146,4 +146,17 @@ describe('DiskDetail', () => { screen.getByText('Detailed SMART attributes are not available for this disk.'), ).toHaveAttribute('role', 'status'); }); + + it('offers the stored disk-family catalog when current SMART and temperature disappear', () => { + const disk = buildDisk(); + delete disk.physicalDisk!.temperature; + delete disk.physicalDisk!.smart; + disk.physicalDisk!.collection = { io: { state: 'unsupported' } }; + render(() => ); + fireEvent.click(screen.getByRole('tab', { name: 'History' })); + expect(screen.getAllByTestId('history-chart').map((chart) => chart.textContent)).toEqual([ + 'disk:agent-tower:sda:smart_temp:24h', + 'disk:agent-tower:sda:smart_reallocated_sectors:24h', + ]); + }); }); diff --git a/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts index 322e3024d2..fdf27e5f76 100644 --- a/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useDiskDetailModel.test.ts @@ -108,4 +108,20 @@ describe('useDiskDetailModel', () => { 'Disk I/O is unsupported: per-member counters unavailable', ]); }); + + it('keeps catalog entries stable across matching current-field snapshots', () => { + const initial = buildDisk(); + const [disk, setDisk] = createSignal(initial); + const { result } = renderHook(() => useDiskDetailModel({ disk })); + const charts = result.historyCharts(); + setDisk({ ...initial, physicalDisk: { ...initial.physicalDisk!, temperature: 65 } }); + expect(result.historyCharts()).toBe(charts); + setDisk({ + ...initial, + physicalDisk: { ...initial.physicalDisk!, temperature: undefined, smart: undefined }, + }); + expect(result.historyCharts()).toBe(charts); + expect(result.historyResourceId()).toBe('agent-tower:sda'); + expect(result.attributeCards()).toEqual([]); + }); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts index 3b5af97539..39c67706d2 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.branchcov0724pm.test.ts @@ -135,8 +135,8 @@ describe('diskDetailPresentation.branchcov0724pm', () => { }); }); - describe('getDiskDetailHistoryCharts SATA reallocated-sectors arm (L192)', () => { - it('emits only the smart_reallocated_sectors chart for a SATA disk with the field set', () => { + describe('getDiskDetailHistoryCharts disk-family catalog', () => { + it('includes stored temperature and reallocated sectors independently of current values', () => { const charts = getDiskDetailHistoryCharts( makeDiskData({ type: 'hdd', @@ -144,8 +144,8 @@ describe('diskDetailPresentation.branchcov0724pm', () => { smartAttributes: { reallocatedSectors: 3 }, }), ); - expect(charts.map((c) => c.metric)).toEqual(['smart_reallocated_sectors']); - expect(charts[0]).toMatchObject({ + expect(charts.map((c) => c.metric)).toEqual(['smart_temp', 'smart_reallocated_sectors']); + expect(charts[1]).toMatchObject({ metric: 'smart_reallocated_sectors', label: 'Reallocated Sectors', unit: 'sectors', @@ -160,8 +160,13 @@ describe('diskDetailPresentation.branchcov0724pm', () => { expect(charts.find((c) => c.metric === 'smart_reallocated_sectors')).toBeUndefined(); }); - it('returns an empty chart list when smartAttributes is absent', () => { - expect(getDiskDetailHistoryCharts(makeDiskData({ type: 'hdd' }))).toEqual([]); + it('keeps stored charts available when current SMART fields are absent', () => { + expect( + getDiskDetailHistoryCharts(makeDiskData({ type: 'hdd' })).map((chart) => chart.metric), + ).toEqual(['smart_temp', 'smart_reallocated_sectors']); + expect( + getDiskDetailHistoryCharts(makeDiskData({ type: 'NVMe' })).map((chart) => chart.metric), + ).toEqual(['smart_temp', 'smart_percentage_used', 'smart_available_spare']); }); }); }); diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts index 87af184ce3..12cc6a8f12 100644 --- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts @@ -173,48 +173,46 @@ export function getDiskDetailAttributeCards( return cards; } +const DISK_TEMPERATURE_HISTORY_CHART: DiskDetailHistoryChartConfig = { + metric: 'smart_temp', + label: 'Temperature', + unit: 'C', + color: '#ef4444', +}; + +const ATA_DISK_HISTORY_CHARTS: readonly DiskDetailHistoryChartConfig[] = [ + DISK_TEMPERATURE_HISTORY_CHART, + { + metric: 'smart_reallocated_sectors', + label: 'Reallocated Sectors', + unit: 'sectors', + color: '#f59e0b', + }, +]; + +const NVME_DISK_HISTORY_CHARTS: readonly DiskDetailHistoryChartConfig[] = [ + DISK_TEMPERATURE_HISTORY_CHART, + { + metric: 'smart_percentage_used', + label: 'Life Used', + unit: '%', + color: '#f59e0b', + }, + { + metric: 'smart_available_spare', + label: 'Available Spare', + unit: '%', + color: '#10b981', + }, +]; + export function getDiskDetailHistoryCharts( disk: PhysicalDiskPresentationData, -): DiskDetailHistoryChartConfig[] { - const attrs = disk.smartAttributes; - const charts: DiskDetailHistoryChartConfig[] = []; - const isNvme = disk.type?.toLowerCase() === 'nvme'; - - if (disk.temperature > 0) { - charts.push({ - metric: 'smart_temp', - label: 'Temperature', - unit: 'C', - color: '#ef4444', - }); - } - - if (!isNvme && attrs?.reallocatedSectors != null) { - charts.push({ - metric: 'smart_reallocated_sectors', - label: 'Reallocated Sectors', - unit: 'sectors', - color: '#f59e0b', - }); - } - - if (isNvme && attrs?.percentageUsed != null) { - charts.push({ - metric: 'smart_percentage_used', - label: 'Life Used', - unit: '%', - color: '#f59e0b', - }); - } - - if (isNvme && attrs?.availableSpare != null) { - charts.push({ - metric: 'smart_available_spare', - label: 'Available Spare', - unit: '%', - color: '#10b981', - }); - } - - return charts; +): readonly DiskDetailHistoryChartConfig[] { + // Missing current collection does not erase stored observations. Ask for the + // existing disk-family catalog and let the store return samples or an honest + // empty series; never synthesize a value from the current snapshot. + // Stable entries also keep Solid's reference-keyed For from remounting charts + // and restarting History reads on every ordinary disk snapshot replacement. + return disk.type?.toLowerCase() === 'nvme' ? NVME_DISK_HISTORY_CHARTS : ATA_DISK_HISTORY_CHARTS; } From 360721cc910b91b83bf39aa46cd130bf64266c1a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:44:42 +0100 Subject: [PATCH 12/86] Bind demo cohost observation to the real Relay health route Current operational source exposes healthz and takes the shared deployment lock. Pin that route and make the disposable service return 404 for unknown paths so native acceptance cannot manufacture a healthy cohost observation. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 2 +- .github/scripts/tests/demo_runtime_native.py | 9 ++++++--- .github/scripts/tests/test_demo_runtime_transaction.py | 3 +++ .../v6/internal/subsystems/deployment-installability.md | 3 +++ 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 436e74b46d..6f12dfebf2 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -27,7 +27,7 @@ PATHS = {"binary": Path("/opt/pulse/bin/pulse"), "unit": Path("/etc/systemd/system/pulse.service"), "dropins": Path("/etc/systemd/system/pulse.service.d"), "data": Path("/etc/pulse")} -RELAY_HEALTH = "https://relay.pulserelay.pro/health" +RELAY_HEALTH = "https://relay.pulserelay.pro/healthz" COUNT_KEYS = ("nodes", "vms_per_node", "lxcs_per_node", "docker_hosts", "docker_containers", "generic_hosts", "k8s_clusters", "k8s_nodes", "k8s_pods", "k8s_deployments") diff --git a/.github/scripts/tests/demo_runtime_native.py b/.github/scripts/tests/demo_runtime_native.py index b004b1cf05..0b7fc6a73b 100644 --- a/.github/scripts/tests/demo_runtime_native.py +++ b/.github/scripts/tests/demo_runtime_native.py @@ -38,11 +38,14 @@ def fail(): if %r: threading.Thread(target=fail,daemon=True).start() class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): + expected = {'/healthz'} if %r == 17656 else {'/api/health','/api/version'} + if self.path not in expected: + self.send_response(404); self.end_headers(); return body=json.dumps({'version':VERSION} if self.path=='/api/version' else {'status':'healthy'}).encode() self.send_response(200); self.end_headers(); self.wfile.write(body) def log_message(self,*args): pass http.server.HTTPServer(('127.0.0.1',%r),Handler).serve_forever() -''' % (version, fail_after, fail_after, port)).encode() +''' % (version, fail_after, fail_after, port, port)).encode() def unit(executable, user="pulse"): @@ -128,9 +131,9 @@ def main(): original = (SCRIPTS / "demo-runtime-transaction.py").read_bytes() # Only the cohost endpoint is rebound. Transaction, paths, locking, # systemd, HTTP/TLS, estate, clocks and watches remain real and unchanged. - endpoint = b'https://relay.pulserelay.pro/health' + endpoint = b'https://relay.pulserelay.pro/healthz' assert original.count(endpoint) == 1 - source = original.replace(endpoint, b'https://127.0.0.1:18444/health') + source = original.replace(endpoint, b'https://127.0.0.1:18444/healthz') result["original_engine_sha256"] = hashlib.sha256(original).hexdigest() result["fixture_engine_sha256"] = hashlib.sha256(source).hexdigest() result["bootstrap_sha256"] = hashlib.sha256(dispatcher.BOOTSTRAP.encode()).hexdigest() diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index c2fab87420..86bc90e60c 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -347,6 +347,9 @@ class TransactionTest(unittest.TestCase): class InputAndCommandTest(unittest.TestCase): + def test_cohost_check_uses_the_existing_relay_health_route(self): + self.assertEqual(engine.RELAY_HEALTH, "https://relay.pulserelay.pro/healthz") + def test_valid_current_envelope(self): engine.validate(request()) engine.validate(request("update")) diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index f53a0ffd5d..c24547706f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -24,6 +24,9 @@ The mutating paths now share the Relay host's existing Every successful path, including a healthy no-op, needs a complete 300-second local/public/Relay health, version, service PID/restart and new-journal-crash window. Readiness is separate. Missing observations fail closed. +The fixed Relay observation is its existing `/healthz` endpoint, not a guessed +new route; disposable acceptance also rejects an unknown route rather than +responding healthy to every URL. Before installation or runtime-profile mutation, stop only Pulse and durably capture its executable, unit/drop-ins and entire persistent data estate. Refuse From 7ec246381fdf0a58c8b695a0982e34e04cb6545f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:45:34 +0100 Subject: [PATCH 13/86] Check retained demo profile inside its owning transaction The broader release-policy check still expected the removed shell configuration step. Assert its real transaction entry and runtime profile/entitlement preservation while keeping activation, exact stable tag, network and public browser checks unchanged. Contract-Neutral: Align verification with the already implemented demo transaction; no runtime or authority change. Change-source: pulse-maintainer --- .../release_promotion_policy_test.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index a933737fd4..e27d53e1b9 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -2589,7 +2589,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("DEMO_EXPECTED_HOSTNAME", demo) self.assertIn("Verify target host identity", demo) self.assertIn("Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.", demo) - self.assertIn("Restore demo runtime configuration", demo) + self.assertIn("Apply guarded demo transaction", demo) + self.assertIn("dispatch-demo-runtime.py", demo) + self.assertIn("Retain guarded demo transaction evidence", demo) self.assertIn("Resolve target-compatible demo runtime profile", demo) self.assertIn("mockEagerHistoryPVEGuestLimit", demo_profile) self.assertIn("UpdateMetricCohort", demo_profile) @@ -2608,19 +2610,17 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("MOCK_SEED_DURATION=2h", demo_profile) self.assertIn("MOCK_SAMPLE_INTERVAL=5m", demo_profile) self.assertIn("MOCK_UPDATE_INTERVAL=15s", demo_profile) - self.assertIn("resolve_config_dir", demo) - self.assertIn("set_env_value DEMO_MODE true", demo) - self.assertIn("set_env_value PULSE_MOCK_MODE true", demo) - self.assertIn('set_env_value PULSE_MOCK_NODES "$MOCK_NODES"', demo) - self.assertIn("set_env_value PULSE_MOCK_SEED_METRICS_STORE false", demo) - self.assertIn('set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION"', demo) - self.assertIn('set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL"', demo) - self.assertIn('set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL"', demo) - self.assertIn("ensure_demo_fixture_entitlement", demo) - self.assertIn('"demo_fixtures"', demo) - self.assertIn("del(.integrity)", demo) - self.assertIn("Demo fixture entitlement ensured in governed demo billing state.", demo) - self.assertIn("Demo service restarted with governed demo runtime configuration.", demo) + # Profile application moved into the owned transaction; keep its + # runtime settings and entitlement, not the superseded shell snippets. + transaction = read(".github/scripts/demo-runtime-transaction.py") + for required in ('"DEMO_MODE": "true"', '"PULSE_MOCK_MODE": "true"', + '"PULSE_MOCK_SEED_METRICS_STORE": "false"', + '"seed_duration": "trends_seed_duration"', '"sample_interval": "trends_sample_interval"', + '"PULSE_MOCK_" + key.upper()', '["demo_fixtures"]', 'billing.pop("integrity", None)', + 'self.host.profile(self.request, not healthy_baseline)', 'self.host.start()'): + self.assertIn(required, transaction) + self.assertIn("MOCK_NODES: ${{ needs.resolve.outputs.mock_nodes }}", demo) + self.assertIn("MOCK_UPDATE_INTERVAL: ${{ needs.resolve.outputs.mock_update_interval }}", demo) self.assertIn("/api/license/runtime-capabilities", demo) self.assertIn("Mock mode enabled", demo) self.assertIn("Demo server mock mode did not enable after entitlement sync", demo) From ee451f8c8c23dc64319dfee281b22d8d679f4d3e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:23:58 +0100 Subject: [PATCH 14/86] Restore canonical ordering for demo registry entries Candidate preflight rejected three path lists that used ASCII or insertion order instead of the registry's case-insensitive order. Sort only those lists and exercise the committed registry in the audit suite so fixture-only passes cannot miss the same mistake. Contract-Neutral: Canonical registry path-list ordering and regression coverage only; ownership, verification requirements and runtime behaviour are unchanged. Change-source: pulse-maintainer --- .../v6/internal/subsystems/registry.json | 26 +++++++++---------- .../release_control/registry_audit_test.py | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index f3ec7681fb..b58adc709b 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4548,9 +4548,6 @@ ".github/workflows/retry-release-convergence.yml", ".github/workflows/update-demo-server.yml", ".github/workflows/validate-release-assets.yml", - "Dockerfile", - "Makefile", - "VERSION", "cmd/pulse-control-plane/main.go", "cmd/pulse-control-plane/provider_msp.go", "cmd/pulse-control-plane/provider_msp_backup.go", @@ -4560,12 +4557,13 @@ "cmd/pulse-control-plane/provider_msp_recover.go", "cmd/pulse-control-plane/provider_msp_status.go", "docker-compose.yml", + "Dockerfile", "docs/MSP.md", - "docs/RELEASE_NOTES.md", - "docs/UPGRADE_v6.md", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", + "docs/RELEASE_NOTES.md", + "docs/UPGRADE_v6.md", "frontend-modern/package-lock.json", "frontend-modern/package.json", "frontend-modern/public/docs/MSP.md", @@ -4585,6 +4583,7 @@ "internal/cloudcp/provider_msp_backup.go", "internal/cloudcp/provider_msp_recovery.go", "internal/cloudcp/tenant_runtime_rollout.go", + "Makefile", "package-lock.json", "package.json", "pulse-enterprise:.github/workflows/build-pro-release.yml", @@ -4659,16 +4658,17 @@ "scripts/uninstall-sensor-proxy.sh", "scripts/validate-release.sh", "scripts/verify-github-release-integrity.sh", + "tests/integration/playwright.config.ts", "tests/integration/QUICK_START.md", "tests/integration/README.md", - "tests/integration/playwright.config.ts", "tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs", "tests/integration/scripts/hosted-mobile-token-runtime.mjs", "tests/integration/scripts/hosted-tenant-runtime.mjs", "tests/integration/scripts/managed-dev-runtime.mjs", "tests/integration/scripts/relay-mobile-token-helper.go", "tests/integration/tests/helpers.ts", - "tests/integration/tests/runtime-defaults.ts" + "tests/integration/tests/runtime-defaults.ts", + "VERSION" ], "release_cycle_artifact_globs": [ "docs/RELEASE_NOTES.md", @@ -4775,12 +4775,11 @@ ".github/workflows/retry-release-convergence.yml", ".github/workflows/update-demo-server.yml", ".github/workflows/validate-release-assets.yml", - "VERSION", - "docs/RELEASE_NOTES.md", - "docs/UPGRADE_v6.md", "docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md", "docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md", "docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md", + "docs/RELEASE_NOTES.md", + "docs/UPGRADE_v6.md", "frontend-modern/public/docs/UPGRADE_v6.md", "frontend-modern/src/components/UpdateBanner.tsx", "frontend-modern/src/components/WhatsNewCard.tsx", @@ -4808,7 +4807,8 @@ "scripts/trigger-release-dry-run.sh", "scripts/trigger-release.sh", "scripts/trigger-stable-patch.sh", - "scripts/verify-github-release-integrity.sh" + "scripts/verify-github-release-integrity.sh", + "VERSION" ], "allow_same_subsystem_tests": false, "test_prefixes": [], @@ -5185,8 +5185,8 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ - "scripts/installtests/demo_transaction_test.go", - ".github/scripts/tests/test_demo_runtime_transaction.py" + ".github/scripts/tests/test_demo_runtime_transaction.py", + "scripts/installtests/demo_transaction_test.go" ] } ], diff --git a/scripts/release_control/registry_audit_test.py b/scripts/release_control/registry_audit_test.py index 1118e6c6fb..803a075b08 100644 --- a/scripts/release_control/registry_audit_test.py +++ b/scripts/release_control/registry_audit_test.py @@ -47,6 +47,32 @@ class RegistryAuditTest(unittest.TestCase): args = parse_args(["--check", "--repo-scope", "pulse"]) self.assertEqual(args.repo_scope, ["pulse"]) + def test_live_registry_path_lists_are_casefold_sorted(self) -> None: + # Fixture-only audit tests cannot catch ordering mistakes in the + # committed registry. Check its real path lists using the same + # case-insensitive contract as candidate preflight, not ASCII order. + registry = registry_audit.load_registry_payload() + for subsystem in registry["subsystems"]: + lists = { + field: subsystem[field] + for field in ("owned_prefixes", "owned_files") + } + verification = subsystem["verification"] + lists.update({ + f"verification.{field}": verification[field] + for field in ("test_prefixes", "exact_files") + }) + for policy in verification["path_policies"]: + lists.update({ + f"{policy['id']}.{field}": policy[field] + for field in ( + "match_prefixes", "match_files", "test_prefixes", "exact_files" + ) + }) + for field, paths in lists.items(): + with self.subTest(subsystem=subsystem["id"], field=field): + self.assertEqual(paths, sorted(paths, key=str.casefold)) + def test_trust_gate_regression_proofs_are_registered_to_exact_policies(self) -> None: registry = registry_audit.load_registry_payload() rules = {rule["id"]: rule for rule in registry["subsystems"]} From 0dcd592ef2f8bc3a7fec0b46cc581e1d878b361f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:28:48 +0100 Subject: [PATCH 15/86] Keep notification cooldowns ordered by alert occurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A late provider completion must retain its own firing receipt without replacing a newer occurrenceโ€™s repeat-delivery cooldown. Preserve the latest completion and highest delivered severity within one occurrence, keeping genuine new occurrences and severity increases eligible. Exercise reverse HTTP completion, duplicate admission and durable recovery receipts alongside legacy ordering controls. Change-source: pulse-maintainer --- .../v6/internal/subsystems/notifications.md | 18 ++ .../cooldown_receipt_order_test.go | 163 ++++++++++++++++++ internal/notifications/notifications.go | 25 ++- 3 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 internal/notifications/cooldown_receipt_order_test.go diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 068c67b93a..1ddb0616ee 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -15,6 +15,24 @@ ## Purpose +### Completion order cannot replace a newer occurrence's cooldown + +Firing completions still record an independent delivery receipt for their own +occurrence and destination. The reusable-ID cooldown, however, belongs to the +newest known occurrence: a late older completion or unknown legacy start cannot +evict it. Within the same occurrence, successful completion times and delivered +severity only advance. A genuinely newer occurrence begins its own severity +history. No queue schema, retry budget, recovery selection or schedule changes. + +`TestNotificationCooldownReceiptOrdering` verifies older, unknown and equal-start +completion ordering, including legacy zero starts. The connected +`TestLateFiringHTTPReceiptPreservesCurrentCooldown` holds an older HTTP response +while the newer firing completes through the persistent sender. It verifies that +the ordinary repeat adds no queue row, a current severity increase still reaches +the receiver, and both occurrence/destination receipts survive queue reopen. +This is loopback source acceptance, not native delivery, restart cooldown +persistence, a natural schedule cycle or resolution of the original flood reports. + ### Queue recovery handler ownership after reload Router monitor replacement must refresh the existing queue/DLQ handler as well diff --git a/internal/notifications/cooldown_receipt_order_test.go b/internal/notifications/cooldown_receipt_order_test.go new file mode 100644 index 0000000000..6650194479 --- /dev/null +++ b/internal/notifications/cooldown_receipt_order_test.go @@ -0,0 +1,163 @@ +package notifications + +import ( + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/stretchr/testify/require" +) + +func TestNotificationCooldownReceiptOrdering(t *testing.T) { + start := time.Date(2026, 10, 3, 1, 0, 0, 123, time.UTC) + for _, tc := range []struct { + name string + priorStart time.Time + lateStart time.Time + lateSent time.Time + }{ + {"older_occurrence", start, start.Add(-time.Nanosecond), start.Add(3 * time.Minute)}, + {"unknown_legacy_occurrence", start, time.Time{}, start.Add(3 * time.Minute)}, + {"earlier_same_occurrence_completion", start, start, start.Add(time.Minute)}, + {"earlier_legacy_completion", time.Time{}, time.Time{}, start.Add(time.Minute)}, + } { + t.Run(tc.name, func(t *testing.T) { + m := NewNotificationManagerWithDeferredQueue("", t.TempDir()) + t.Cleanup(m.Stop) + m.SetCooldown(0) // No ordinary repeats for the delivered occurrence. + m.SetGroupingConfig(true, 3600, false, false) + current := &alerts.Alert{ID: "cpu-order", Level: alerts.AlertLevelCritical, StartTime: tc.priorStart} + m.markAlertsNotified([]*alerts.Alert{current}, start.Add(2*time.Minute)) + m.mu.RLock() + before := m.lastNotified[current.ID] + m.mu.RUnlock() + late := current.Clone() + late.StartTime = tc.lateStart + late.Level = alerts.AlertLevelWarning + m.markAlertsNotified([]*alerts.Alert{nil, late}, tc.lateSent) + m.mu.RLock() + after := m.lastNotified[current.ID] + m.mu.RUnlock() + require.Equal(t, before, after, "late receipt replaced the current cooldown") + m.SendAlert(current) + m.mu.RLock() + pending := len(m.pendingAlerts) + m.mu.RUnlock() + require.Zero(t, pending, "delivered current occurrence was admitted again") + }) + } +} + +// Use the actual persistent sender and loopback receiver. Two firing callbacks +// for a reusable ID can complete in reverse occurrence order; no fabricated +// successful-delivery marker or modified scheduling clock is needed here. +func TestLateFiringHTTPReceiptPreservesCurrentCooldown(t *testing.T) { + oldArrived := make(chan struct{}, 1) + releaseOld := make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(releaseOld) }) } + var mu sync.Mutex + var bodies []string + server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read firing: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + mu.Lock() + bodies = append(bodies, string(body)) + mu.Unlock() + if strings.Contains(string(body), "older firing") { + select { + case oldArrived <- struct{}{}: + default: + } + select { + case <-releaseOld: + case <-r.Context().Done(): + return + } + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + dir := t.TempDir() + webhook := WebhookConfig{ID: "ops", URL: server.URL + "/hook", Enabled: true, Service: "generic"} + m := NewNotificationManagerWithDataDir("", dir) + t.Cleanup(m.Stop) + t.Cleanup(unblock) // Release an in-flight provider before waiting for shutdown. + m.webhookClient = server.Client() + require.NoError(t, m.UpdateAllowedPrivateCIDRs("127.0.0.1/32")) + m.SetGroupingConfig(false, 0, false, false) + m.SetCooldown(60) + m.AddWebhook(webhook) + queue := m.GetQueue() + old := &alerts.Alert{ID: "recurring-cpu", ResourceName: "cpu-node", Type: "cpu", + Level: alerts.AlertLevelCritical, Message: "older firing", StartTime: time.Now().UTC().Add(-time.Minute)} + m.SendAlert(old) + select { + case <-oldArrived: + case <-time.After(10 * time.Second): + t.Fatal("old firing did not reach the HTTP receiver") + } + current := old.Clone() + current.StartTime = time.Now().UTC() + current.Level = alerts.AlertLevelWarning + current.Message = "current warning firing" + m.SendAlert(current) + wait := func(condition func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("notification completion timed out") + } + time.Sleep(time.Millisecond) + } + } + wait(func() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.lastNotified[current.ID].alertStart.Equal(current.StartTime) + }) + unblock() + wait(func() bool { + stats, err := queue.GetQueueStats() + require.NoError(t, err) + return stats["sent"] == 2 + }) + // Sending synchronously admits queue work unless the cooldown rejects it. + // Assert admission itself, rather than hoping an excess send stays silent. + m.SendAlert(current) + stats, err := queue.GetQueueStats() + require.NoError(t, err) + require.Equal(t, map[string]int{"sent": 2}, stats, "late old completion admitted a duplicate current firing") + // Old critical severity must not leak into the newer warning occurrence. + current.Level = alerts.AlertLevelCritical + current.Message = "current critical firing" + m.SendAlert(current) + wait(func() bool { + stats, err := queue.GetQueueStats() + require.NoError(t, err) + return stats["sent"] == 3 + }) + mu.Lock() + count := len(bodies) + mu.Unlock() + require.Equal(t, 3, count) + m.Stop() + // The old completion still owns a durable recovery receipt, independently + // of which occurrence owns the transient repeat-delivery cooldown. + reopened := NewNotificationManagerWithDeferredQueue("", dir) + t.Cleanup(reopened.Stop) + for _, alert := range []*alerts.Alert{old, current} { + jobs := buildNotificationDeliveryJobs(EmailConfig{}, []WebhookConfig{webhook}, AppriseConfig{}, []*alerts.Alert{alert}, eventResolved, time.Now()) + require.Len(t, reopened.filterResolvedJobsByDeliveryReceipt(jobs), 1, "firing receipt lost across reopen") + } + t.Log("Two reverse-order HTTP 200 completions retain the current cooldown; repeat adds no row, severity increase delivers, both occurrence receipts survive queue reopen.") +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 7b93dbf590..d508f53bbb 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -1291,14 +1291,29 @@ func (n *NotificationManager) markAlertsNotified(alertsToSend []*alerts.Alert, s continue } level := alert.Level - // Destinations and retries can complete out of order. A late warning - // receipt must not re-enable critical delivery for this occurrence. - if previous, ok := n.lastNotified[alert.ID]; ok && previous.alertStart.Equal(alert.StartTime) && notificationSeverityRank(previous.level) > notificationSeverityRank(level) { - level = previous.level + lastSent := sentAt + if previous, ok := n.lastNotified[alert.ID]; ok { + // Destinations and retries can complete out of order. An older + // occurrence still owns its delivery receipt, but must not replace + // the current occurrence's repeat-delivery cooldown. Unknown legacy + // starts likewise cannot evict a known occurrence. + if previous.alertStart.After(alert.StartTime) { + continue + } + if previous.alertStart.Equal(alert.StartTime) { + // A late warning cannot re-enable critical delivery, and an + // earlier completion cannot shorten the same occurrence's hold. + if notificationSeverityRank(previous.level) > notificationSeverityRank(level) { + level = previous.level + } + if previous.lastSent.After(lastSent) { + lastSent = previous.lastSent + } + } } n.lastNotified[alert.ID] = notificationRecord{ level: level, - lastSent: sentAt, + lastSent: lastSent, alertStart: alert.StartTime, } } From 1a1b59072b6ceb02e3faa42167c26f910ca7a652 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:32:05 +0100 Subject: [PATCH 16/86] Point monitoring help at the current platform navigation Replace retired task-page advice with registered platform routes, TrueNAS tabs and Proxmox backups. Preserve saved connections when troubleshooting missing navigation and distinguish proxy reload failures from missing inventory. Change-source: pulse-maintainer --- docs/PBS.md | 5 +- docs/TROUBLESHOOTING.md | 41 ++++++- docs/TRUENAS.md | 43 ++++--- .../browser-tests/navigation-help.cjs | 110 ++++++++++++++++++ frontend-modern/public/docs/PBS.md | 5 +- .../public/docs/TROUBLESHOOTING.md | 41 ++++++- frontend-modern/public/docs/TRUENAS.md | 43 ++++--- scripts/tests/test_navigation_help_docs.py | 100 ++++++++++++++++ 8 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 frontend-modern/browser-tests/navigation-help.cjs create mode 100644 scripts/tests/test_navigation_help_docs.py diff --git a/docs/PBS.md b/docs/PBS.md index a377d2c602..2c8affcab3 100644 --- a/docs/PBS.md +++ b/docs/PBS.md @@ -152,7 +152,7 @@ It does **not** allow: If you have multiple PBS servers, add each one separately in Settings. Pulse will: - Monitor each server independently -- Show backups from all servers in the unified Recovery view +- Show backups from all servers under **Proxmox โ†’ Backups** (`/proxmox/backups`) - Deduplicate if the same backup appears via both PVE passthrough and direct PBS --- @@ -301,7 +301,8 @@ or change retention to hide a display problem. ## Data Source Indicator -In the Recovery view, PBS backups show a data source indicator: +Under **Proxmox โ†’ Backups** (`/proxmox/backups`), PBS backups show a data source +indicator. There is no current top-level Recovery page: - **"PBS"** badge alone = Direct PBS connection (full data) - **"PBS via PVE"** = Passthrough via PVE storage (limited data) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 9b11c341cd..f7f21b286a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -413,7 +413,7 @@ consequential, manually redacted error. ### TrueNAS #### "TrueNAS service unavailable" -- Ensure TrueNAS was added in **Settings โ†’ TrueNAS** with a valid HTTPS URL, +- Ensure TrueNAS was added in **Settings โ†’ Infrastructure โ†’ Platform connections** with a valid HTTPS URL, API key, and the username that owns the key. - Check that the TrueNAS system is reachable from the Pulse server (default HTTPS port). @@ -424,7 +424,10 @@ consequential, manually redacted error. #### TrueNAS pools/datasets not appearing - TrueNAS data appears in the unified resource model and may take one configured polling cycle (60 seconds by default) to appear. -- Check **Infrastructure** (TrueNAS host), **Storage** (pools/datasets), and **Recovery** (snapshots/replication). +- Open **TrueNAS โ†’ Overview** for the appliance, **TrueNAS โ†’ Storage** + (`/truenas/storage`) for pools, datasets and disks, and **TrueNAS โ†’ Protection** + (`/truenas/protection`) for snapshots and replication. These are tabs within + TrueNAS, not separate top-level Storage or Recovery pages. - For data that stops refreshing, use the [TrueNAS polling checks](TRUENAS.md#stale-truenas-data) before testing or restarting. A stale badge is not proof of an invalid key, and a successful connection test is not proof that collection has recovered. @@ -432,8 +435,38 @@ consequential, manually redacted error. ### Navigation (v6) #### Old bookmarks don't work -- Legacy URLs (`/proxmox`, `/docker`, `/kubernetes`, `/hosts`, `/services`) are not supported in v6. -- Update bookmarks to canonical routes. See [Migration Guide](MIGRATION_UNIFIED_NAV.md). + +Current Pulse uses platform navigation. `/proxmox`, `/docker` and `/kubernetes` +are supported; do not replace them with the retired task-based routes. +Open Pulse at its base URL and use the menu to find the relevant page: + +| Menu | Entry route | +| --- | --- | +| Proxmox | `/proxmox/overview` | +| Docker | `/docker/overview` | +| Kubernetes | `/kubernetes/overview` | +| TrueNAS | `/truenas/overview` | +| vSphere | `/vmware/overview` | +| Machines | `/standalone/machines` | + +The short-lived top-level `/workloads`, `/storage` and `/recovery` layout is +retired. `/infrastructure` now opens the default workspace, not the former +unified host page. For old `/hosts` or `/services` bookmarks, select the current +platform or Machines page instead of assuming an automatic redirect. PBS +backups are under **Proxmox โ†’ Backups**, and TrueNAS snapshots and replication +are under **TrueNAS โ†’ Protection**. + +Platform-connected hosts can appear under their platform rather than Machines. +If a menu or expected resource is missing, check its saved connection and last +successful collection in **Settings โ†’ Infrastructure**; a missing page alone +does not prove a host was deleted. Do not delete connections or re-enrol agents +just to repair a bookmark. + +If menu navigation works but reloading the same URL returns a proxy 404, check +the proxy's route handling using [Reverse Proxy Configuration](REVERSE_PROXY.md). +For the current layout, see [FAQ](FAQ.md#how-is-navigation-organised-in-pulse-v6). +The [unified-navigation migration](MIGRATION_UNIFIED_NAV.md) is historical, +not a guide to the current menu. ### Relay / Mobile diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index ddd5ea8468..54dac2b6e6 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -4,8 +4,9 @@ Pulse v6 includes first-class monitoring for **TrueNAS SCALE** and **TrueNAS COR ## Quick Start -1. Go to **Settings โ†’ TrueNAS**. -2. Click **Add Connection**. +1. Go to **Settings โ†’ Infrastructure**. +2. Click **Add infrastructure** and choose **TrueNAS**. Existing connections + are listed under **Platform connections**. 3. Enter the TrueNAS URL (e.g., `https://truenas.local`), the API key, and the username that owns the key. 4. Click **Test Connection** โ†’ **Save**. @@ -28,27 +29,27 @@ On your TrueNAS system: ## What Gets Monitored -| Data | Unified Page | Details | +| Data | Pulse page / tab | Details | |---|---|---| -| System info (hostname, version, uptime) | Infrastructure | CPU, memory, health status | -| Virtual machines | TrueNAS Overview | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API | -| Apps | TrueNAS Overview | Native app state, image/version, ports, volumes, networks, and runtime container details | -| ZFS Pools | Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) | -| ZFS Datasets | Storage | Used/available space, mount status, read-only flag | -| Physical Disks | Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure/counter evidence when TrueNAS reports it | -| ZFS Snapshots | Recovery | Dataset, creation time, size, referenced data | -| Replication Tasks | Recovery | Source/target datasets, direction, last run status | +| System info (hostname, version, uptime) | TrueNAS โ†’ Overview | CPU, memory, health status | +| Virtual machines | TrueNAS โ†’ VMs | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API | +| Apps | TrueNAS โ†’ Apps | Native app state, image/version, ports, volumes, networks, and runtime container details | +| ZFS Pools | TrueNAS โ†’ Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) | +| ZFS Datasets | TrueNAS โ†’ Storage | Used/available space, mount status, read-only flag | +| Physical Disks | TrueNAS โ†’ Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure/counter evidence when TrueNAS reports it | +| ZFS Snapshots | TrueNAS โ†’ Protection | Dataset, creation time, size, referenced data | +| Replication Tasks | TrueNAS โ†’ Protection | Source/target datasets, direction, last run status | | TrueNAS Alerts | Alerts | Native TrueNAS alert messages and severity levels | ## Unified Resource Mapping TrueNAS resources are mapped into the unified resource model: -- **TrueNAS host** โ†’ appears as a resource with `source: truenas` on the **Infrastructure** page. -- **TrueNAS VMs** โ†’ appear as canonical `vm` workloads on the **TrueNAS** page. -- **TrueNAS apps** โ†’ appear as canonical `app-container` workloads on the **TrueNAS** page. -- **ZFS pools and datasets** โ†’ appear on the **Storage** page. -- **ZFS snapshots and replication** โ†’ appear on the **Recovery** page as recovery points. +- **TrueNAS host** โ†’ appears as a resource with `source: truenas` under **TrueNAS โ†’ Overview** (`/truenas/overview`). +- **TrueNAS VMs** โ†’ appear as canonical `vm` workloads under **TrueNAS โ†’ VMs** (`/truenas/vms`). +- **TrueNAS apps** โ†’ appear as canonical `app-container` workloads under **TrueNAS โ†’ Apps** (`/truenas/apps`). +- **ZFS pools, datasets and physical disks** โ†’ appear under **TrueNAS โ†’ Storage** (`/truenas/storage`). +- **ZFS snapshots and replication** โ†’ appear under **TrueNAS โ†’ Protection** (`/truenas/protection`) as recovery points. - **TrueNAS alerts** โ†’ surfaced on the **Alerts** page alongside Proxmox and other platform alerts. TrueNAS drive-health alerts that identify a specific disk remain disk-health @@ -60,7 +61,11 @@ typed uncorrectable-error or spare-reserve argument, Pulse also projects that value into the disk's SMART details. Pulse does not infer counters from alert text, and current TrueNAS APIs do not expose every raw SMART attribute. -Resources from TrueNAS can be filtered using the **source** filter on any page. +The unified resource model is a backend contract, not a top-level +Infrastructure, Storage or Recovery menu. TrueNAS tabs follow the inventory +Pulse has collected; an absent tab is not proof of successful collection. +For missing or stale data, use the [polling checks](#stale-truenas-data) before +testing the connection or restarting. ## Multiple TrueNAS Systems @@ -91,7 +96,7 @@ All endpoints require admin authentication. ### Testing and adding a connection (API) -The **Settings โ†’ TrueNAS** flow is the simplest option. For an API client, +The **Settings โ†’ Infrastructure โ†’ Platform connections** flow is the simplest option. For an API client, prepare the private Pulse header file as described in the [API authentication guide](API.md). The Pulse API token authenticates to Pulse; the separate TrueNAS API key belongs in the connection's JSON file. @@ -260,4 +265,4 @@ Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is JSON-RPC transport, API-key, and TLS requirements - [Configuration Guide](CONFIGURATION.md#truenas) โ€” environment variables and setup - [ZFS Monitoring](ZFS_MONITORING.md) โ€” Proxmox-native ZFS pool monitoring -- [Recovery](RECOVERY.md) โ€” TrueNAS snapshots in the recovery view +- [Recovery data](RECOVERY.md) โ€” the snapshot and replication data shown under **TrueNAS โ†’ Protection** diff --git a/frontend-modern/browser-tests/navigation-help.cjs b/frontend-modern/browser-tests/navigation-help.cjs new file mode 100644 index 0000000000..91028e5879 --- /dev/null +++ b/frontend-modern/browser-tests/navigation-help.cjs @@ -0,0 +1,110 @@ +// Production Docs/router/styles with the actual shipped Markdown; no backend. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'node_modules', 'navigation-help-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-navigation-help'), + server: { host: '127.0.0.1', port: 5247, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + await page.goto('http://127.0.0.1:5247/browser-tests/docs-fragment-navigation.html?scenario=troubleshooting', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByRole('heading', { name: "Old bookmarks don't work", exact: true }).waitFor(); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + const routes = page.locator('article table').filter({ hasText: '/standalone/machines' }); + assert.equal(await routes.count(), 1); + assert.deepEqual(await routes.locator('tbody tr').allTextContents(), [ + 'Proxmox/proxmox/overview', 'Docker/docker/overview', 'Kubernetes/kubernetes/overview', + 'TrueNAS/truenas/overview', 'vSphere/vmware/overview', 'Machines/standalone/machines', + ]); + const article = await page.locator('article').innerText(); + assert.ok(article.includes('are supported; do not replace them with the retired task-based routes.')); + assert.ok(article.includes('Do not delete connections or re-enrol agents')); + await routes.scrollIntoViewIfNeeded(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-routes.png`) }); + + await page.getByRole('link', { name: 'FAQ', exact: true }).click(); + const faq = page.getByRole('heading', { name: 'How is navigation organised in Pulse v6?', exact: true }); + await faq.waitFor(); + await page.waitForFunction(() => { + const heading = document.getElementById('how-is-navigation-organised-in-pulse-v6'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/FAQ#how-is-navigation-organised-in-pulse-v6')); + + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + await page.getByRole('link', { name: 'TrueNAS SCALE and CORE', exact: true }).click(); + const inventory = page.locator('article table').filter({ hasText: 'Pulse page / tab' }); + await inventory.waitFor(); + const destinations = await inventory.locator('tbody tr td:nth-child(2)').allTextContents(); + assert.deepEqual(destinations, ['TrueNAS โ†’ Overview', 'TrueNAS โ†’ VMs', 'TrueNAS โ†’ Apps', + 'TrueNAS โ†’ Storage', 'TrueNAS โ†’ Storage', 'TrueNAS โ†’ Storage', + 'TrueNAS โ†’ Protection', 'TrueNAS โ†’ Protection', 'Alerts']); + const mapping = await page.locator('article').innerText(); + for (const route of ['/truenas/overview', '/truenas/vms', '/truenas/apps', '/truenas/storage', '/truenas/protection']) + assert.ok(mapping.includes(route)); + await inventory.scrollIntoViewIfNeeded(); + const geometry = await inventory.evaluate((table) => ({ + localScroll: table.parentElement.dataset.docTableScroll !== undefined, + viewport: innerWidth, + outer: document.documentElement.scrollWidth, + table: table.parentElement.scrollWidth, + wrapper: table.parentElement.clientWidth, + })); + assert.ok(geometry.localScroll); + assert.ok(geometry.outer <= geometry.viewport + 1); + if (width === 390) assert.ok(geometry.table > geometry.wrapper); + await page.screenshot({ path: path.join(artifacts, `${engine}-truenas.png`) }); + + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + await page.getByRole('link', { name: 'Proxmox Backup Server', exact: true }).click(); + const pbs = page.getByRole('heading', { name: 'Data Source Indicator', exact: true }); + await pbs.waitFor(); + const pbsText = await page.locator('article').innerText(); + assert.ok(pbsText.includes('Proxmox โ†’ Backups (/proxmox/backups)')); + assert.ok(pbsText.includes('There is no current top-level Recovery page')); + await pbs.scrollIntoViewIfNeeded(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-pbs.png`) }); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, routes: 6, destinations, + geometry, faqFragment: true, pbsPlacement: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'shipped help rendering and navigation, not appliance collection', results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/PBS.md b/frontend-modern/public/docs/PBS.md index a377d2c602..2c8affcab3 100644 --- a/frontend-modern/public/docs/PBS.md +++ b/frontend-modern/public/docs/PBS.md @@ -152,7 +152,7 @@ It does **not** allow: If you have multiple PBS servers, add each one separately in Settings. Pulse will: - Monitor each server independently -- Show backups from all servers in the unified Recovery view +- Show backups from all servers under **Proxmox โ†’ Backups** (`/proxmox/backups`) - Deduplicate if the same backup appears via both PVE passthrough and direct PBS --- @@ -301,7 +301,8 @@ or change retention to hide a display problem. ## Data Source Indicator -In the Recovery view, PBS backups show a data source indicator: +Under **Proxmox โ†’ Backups** (`/proxmox/backups`), PBS backups show a data source +indicator. There is no current top-level Recovery page: - **"PBS"** badge alone = Direct PBS connection (full data) - **"PBS via PVE"** = Passthrough via PVE storage (limited data) diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index 9b11c341cd..f7f21b286a 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -413,7 +413,7 @@ consequential, manually redacted error. ### TrueNAS #### "TrueNAS service unavailable" -- Ensure TrueNAS was added in **Settings โ†’ TrueNAS** with a valid HTTPS URL, +- Ensure TrueNAS was added in **Settings โ†’ Infrastructure โ†’ Platform connections** with a valid HTTPS URL, API key, and the username that owns the key. - Check that the TrueNAS system is reachable from the Pulse server (default HTTPS port). @@ -424,7 +424,10 @@ consequential, manually redacted error. #### TrueNAS pools/datasets not appearing - TrueNAS data appears in the unified resource model and may take one configured polling cycle (60 seconds by default) to appear. -- Check **Infrastructure** (TrueNAS host), **Storage** (pools/datasets), and **Recovery** (snapshots/replication). +- Open **TrueNAS โ†’ Overview** for the appliance, **TrueNAS โ†’ Storage** + (`/truenas/storage`) for pools, datasets and disks, and **TrueNAS โ†’ Protection** + (`/truenas/protection`) for snapshots and replication. These are tabs within + TrueNAS, not separate top-level Storage or Recovery pages. - For data that stops refreshing, use the [TrueNAS polling checks](TRUENAS.md#stale-truenas-data) before testing or restarting. A stale badge is not proof of an invalid key, and a successful connection test is not proof that collection has recovered. @@ -432,8 +435,38 @@ consequential, manually redacted error. ### Navigation (v6) #### Old bookmarks don't work -- Legacy URLs (`/proxmox`, `/docker`, `/kubernetes`, `/hosts`, `/services`) are not supported in v6. -- Update bookmarks to canonical routes. See [Migration Guide](MIGRATION_UNIFIED_NAV.md). + +Current Pulse uses platform navigation. `/proxmox`, `/docker` and `/kubernetes` +are supported; do not replace them with the retired task-based routes. +Open Pulse at its base URL and use the menu to find the relevant page: + +| Menu | Entry route | +| --- | --- | +| Proxmox | `/proxmox/overview` | +| Docker | `/docker/overview` | +| Kubernetes | `/kubernetes/overview` | +| TrueNAS | `/truenas/overview` | +| vSphere | `/vmware/overview` | +| Machines | `/standalone/machines` | + +The short-lived top-level `/workloads`, `/storage` and `/recovery` layout is +retired. `/infrastructure` now opens the default workspace, not the former +unified host page. For old `/hosts` or `/services` bookmarks, select the current +platform or Machines page instead of assuming an automatic redirect. PBS +backups are under **Proxmox โ†’ Backups**, and TrueNAS snapshots and replication +are under **TrueNAS โ†’ Protection**. + +Platform-connected hosts can appear under their platform rather than Machines. +If a menu or expected resource is missing, check its saved connection and last +successful collection in **Settings โ†’ Infrastructure**; a missing page alone +does not prove a host was deleted. Do not delete connections or re-enrol agents +just to repair a bookmark. + +If menu navigation works but reloading the same URL returns a proxy 404, check +the proxy's route handling using [Reverse Proxy Configuration](REVERSE_PROXY.md). +For the current layout, see [FAQ](FAQ.md#how-is-navigation-organised-in-pulse-v6). +The [unified-navigation migration](MIGRATION_UNIFIED_NAV.md) is historical, +not a guide to the current menu. ### Relay / Mobile diff --git a/frontend-modern/public/docs/TRUENAS.md b/frontend-modern/public/docs/TRUENAS.md index ddd5ea8468..54dac2b6e6 100644 --- a/frontend-modern/public/docs/TRUENAS.md +++ b/frontend-modern/public/docs/TRUENAS.md @@ -4,8 +4,9 @@ Pulse v6 includes first-class monitoring for **TrueNAS SCALE** and **TrueNAS COR ## Quick Start -1. Go to **Settings โ†’ TrueNAS**. -2. Click **Add Connection**. +1. Go to **Settings โ†’ Infrastructure**. +2. Click **Add infrastructure** and choose **TrueNAS**. Existing connections + are listed under **Platform connections**. 3. Enter the TrueNAS URL (e.g., `https://truenas.local`), the API key, and the username that owns the key. 4. Click **Test Connection** โ†’ **Save**. @@ -28,27 +29,27 @@ On your TrueNAS system: ## What Gets Monitored -| Data | Unified Page | Details | +| Data | Pulse page / tab | Details | |---|---|---| -| System info (hostname, version, uptime) | Infrastructure | CPU, memory, health status | -| Virtual machines | TrueNAS Overview | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API | -| Apps | TrueNAS Overview | Native app state, image/version, ports, volumes, networks, and runtime container details | -| ZFS Pools | Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) | -| ZFS Datasets | Storage | Used/available space, mount status, read-only flag | -| Physical Disks | Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure/counter evidence when TrueNAS reports it | -| ZFS Snapshots | Recovery | Dataset, creation time, size, referenced data | -| Replication Tasks | Recovery | Source/target datasets, direction, last run status | +| System info (hostname, version, uptime) | TrueNAS โ†’ Overview | CPU, memory, health status | +| Virtual machines | TrueNAS โ†’ VMs | State, CPU, memory, boot mode, devices, and security flags from the TrueNAS VM API | +| Apps | TrueNAS โ†’ Apps | Native app state, image/version, ports, volumes, networks, and runtime container details | +| ZFS Pools | TrueNAS โ†’ Storage | Total/used/free capacity, pool status (ONLINE/DEGRADED/FAULTED) | +| ZFS Datasets | TrueNAS โ†’ Storage | Used/available space, mount status, read-only flag | +| Physical Disks | TrueNAS โ†’ Storage | Model, serial, size, transport type, rotational flag, temperature, and native SMART failure/counter evidence when TrueNAS reports it | +| ZFS Snapshots | TrueNAS โ†’ Protection | Dataset, creation time, size, referenced data | +| Replication Tasks | TrueNAS โ†’ Protection | Source/target datasets, direction, last run status | | TrueNAS Alerts | Alerts | Native TrueNAS alert messages and severity levels | ## Unified Resource Mapping TrueNAS resources are mapped into the unified resource model: -- **TrueNAS host** โ†’ appears as a resource with `source: truenas` on the **Infrastructure** page. -- **TrueNAS VMs** โ†’ appear as canonical `vm` workloads on the **TrueNAS** page. -- **TrueNAS apps** โ†’ appear as canonical `app-container` workloads on the **TrueNAS** page. -- **ZFS pools and datasets** โ†’ appear on the **Storage** page. -- **ZFS snapshots and replication** โ†’ appear on the **Recovery** page as recovery points. +- **TrueNAS host** โ†’ appears as a resource with `source: truenas` under **TrueNAS โ†’ Overview** (`/truenas/overview`). +- **TrueNAS VMs** โ†’ appear as canonical `vm` workloads under **TrueNAS โ†’ VMs** (`/truenas/vms`). +- **TrueNAS apps** โ†’ appear as canonical `app-container` workloads under **TrueNAS โ†’ Apps** (`/truenas/apps`). +- **ZFS pools, datasets and physical disks** โ†’ appear under **TrueNAS โ†’ Storage** (`/truenas/storage`). +- **ZFS snapshots and replication** โ†’ appear under **TrueNAS โ†’ Protection** (`/truenas/protection`) as recovery points. - **TrueNAS alerts** โ†’ surfaced on the **Alerts** page alongside Proxmox and other platform alerts. TrueNAS drive-health alerts that identify a specific disk remain disk-health @@ -60,7 +61,11 @@ typed uncorrectable-error or spare-reserve argument, Pulse also projects that value into the disk's SMART details. Pulse does not infer counters from alert text, and current TrueNAS APIs do not expose every raw SMART attribute. -Resources from TrueNAS can be filtered using the **source** filter on any page. +The unified resource model is a backend contract, not a top-level +Infrastructure, Storage or Recovery menu. TrueNAS tabs follow the inventory +Pulse has collected; an absent tab is not proof of successful collection. +For missing or stale data, use the [polling checks](#stale-truenas-data) before +testing the connection or restarting. ## Multiple TrueNAS Systems @@ -91,7 +96,7 @@ All endpoints require admin authentication. ### Testing and adding a connection (API) -The **Settings โ†’ TrueNAS** flow is the simplest option. For an API client, +The **Settings โ†’ Infrastructure โ†’ Platform connections** flow is the simplest option. For an API client, prepare the private Pulse header file as described in the [API authentication guide](API.md). The Pulse API token authenticates to Pulse; the separate TrueNAS API key belongs in the connection's JSON file. @@ -260,4 +265,4 @@ Set `PULSE_ENABLE_TRUENAS=false` and restart Pulse. Existing connection data is JSON-RPC transport, API-key, and TLS requirements - [Configuration Guide](CONFIGURATION.md#truenas) โ€” environment variables and setup - [ZFS Monitoring](ZFS_MONITORING.md) โ€” Proxmox-native ZFS pool monitoring -- [Recovery](RECOVERY.md) โ€” TrueNAS snapshots in the recovery view +- [Recovery data](RECOVERY.md) โ€” the snapshot and replication data shown under **TrueNAS โ†’ Protection** diff --git a/scripts/tests/test_navigation_help_docs.py b/scripts/tests/test_navigation_help_docs.py new file mode 100644 index 0000000000..3008fe45ee --- /dev/null +++ b/scripts/tests/test_navigation_help_docs.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Keep operator navigation guidance tied to the shipped routes and tab labels. + +These checks cover documentation accuracy, not collection or appliance health. +""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +def doc_text(name: str) -> str: + return (ROOT / "docs" / name).read_text(encoding="utf-8") + + +def source(name: str) -> str: + return (ROOT / "frontend-modern/src" / name).read_text(encoding="utf-8") + + +class NavigationHelpDocsTest(unittest.TestCase): + def test_entry_routes_match_the_registered_platform_routes(self): + guide = doc_text("TROUBLESHOOTING.md").split("#### Old bookmarks don't work", 1)[1] + guide = guide.split("### Relay / Mobile", 1)[0] + links = source("routing/resourceLinks.ts") + app = source("App.tsx") + for label, prefix in (("Proxmox", "PROXMOX"), ("Docker", "DOCKER"), + ("Kubernetes", "KUBERNETES"), ("TrueNAS", "TRUENAS"), + ("vSphere", "VMWARE"), ("Machines", "STANDALONE")): + with self.subTest(platform=label): + path = re.search(rf"export const {prefix}_PATH = '([^']+)';", links).group(1) + tab = re.search(rf"export const {prefix}_DEFAULT_TAB = '([^']+)';", links).group(1) + self.assertIn(f"| {label} | `{path}/{tab}` |", guide) + self.assertIn(f' Date: Sat, 3 Oct 2026 02:35:18 +0100 Subject: [PATCH 17/86] Activate paired firing work in the receipt-ordering fixture The queue discovers one concurrent batch at a time; the first HTTP control incorrectly expected another batch to start while the held response was unfinished. Admit both snapshots through the deferred bootstrap owner and then use normal queue activation. Preserve the runtime policy and timeout, and verify each received message explicitly. Change-source: pulse-maintainer --- .../v6/internal/subsystems/notifications.md | 4 ++- .../cooldown_receipt_order_test.go | 29 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 1ddb0616ee..2903cb9f5a 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -27,7 +27,9 @@ history. No queue schema, retry budget, recovery selection or schedule changes. `TestNotificationCooldownReceiptOrdering` verifies older, unknown and equal-start completion ordering, including legacy zero starts. The connected `TestLateFiringHTTPReceiptPreservesCurrentCooldown` holds an older HTTP response -while the newer firing completes through the persistent sender. It verifies that +while the newer firing completes through the persistent sender. Both snapshots +are admitted before ordinary queue activation, so one discovered batch can +complete concurrently without adding another dispatcher. It verifies that the ordinary repeat adds no queue row, a current severity increase still reaches the receiver, and both occurrence/destination receipts survive queue reopen. This is loopback source acceptance, not native delivery, restart cooldown diff --git a/internal/notifications/cooldown_receipt_order_test.go b/internal/notifications/cooldown_receipt_order_test.go index 6650194479..d5d3f66c07 100644 --- a/internal/notifications/cooldown_receipt_order_test.go +++ b/internal/notifications/cooldown_receipt_order_test.go @@ -88,7 +88,7 @@ func TestLateFiringHTTPReceiptPreservesCurrentCooldown(t *testing.T) { t.Cleanup(server.Close) dir := t.TempDir() webhook := WebhookConfig{ID: "ops", URL: server.URL + "/hook", Enabled: true, Service: "generic"} - m := NewNotificationManagerWithDataDir("", dir) + m := NewNotificationManagerWithDeferredQueue("", dir) t.Cleanup(m.Stop) t.Cleanup(unblock) // Release an in-flight provider before waiting for shutdown. m.webhookClient = server.Client() @@ -100,16 +100,20 @@ func TestLateFiringHTTPReceiptPreservesCurrentCooldown(t *testing.T) { old := &alerts.Alert{ID: "recurring-cpu", ResourceName: "cpu-node", Type: "cpu", Level: alerts.AlertLevelCritical, Message: "older firing", StartTime: time.Now().UTC().Add(-time.Minute)} m.SendAlert(old) - select { - case <-oldArrived: - case <-time.After(10 * time.Second): - t.Fatal("old firing did not reach the HTTP receiver") - } current := old.Clone() current.StartTime = time.Now().UTC() current.Level = alerts.AlertLevelWarning current.Message = "current warning firing" m.SendAlert(current) + // Workers run concurrently within one discovered batch, but discovery waits + // for that batch to finish. Admit both snapshots before normal activation so + // the HTTP ordering is possible without a second dispatcher or clock edits. + m.StartQueueProcessing() + select { + case <-oldArrived: + case <-time.After(10 * time.Second): + t.Fatal("old firing did not reach the HTTP receiver") + } wait := func(condition func() bool) { t.Helper() deadline := time.Now().Add(10 * time.Second) @@ -147,9 +151,18 @@ func TestLateFiringHTTPReceiptPreservesCurrentCooldown(t *testing.T) { return stats["sent"] == 3 }) mu.Lock() - count := len(bodies) + received := append([]string(nil), bodies...) mu.Unlock() - require.Equal(t, 3, count) + require.Len(t, received, 3) + for _, message := range []string{"older firing", "current warning firing", "current critical firing"} { + count := 0 + for _, body := range received { + if strings.Contains(body, message) { + count++ + } + } + require.Equal(t, 1, count, "receiver must see exactly one %s", message) + } m.Stop() // The old completion still owns a durable recovery receipt, independently // of which occurrence owns the transient repeat-delivery cooldown. From 0027f7c9d979b2f69295ba4117f31d6107d6c200 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:35:45 +0100 Subject: [PATCH 18/86] Verify help table cells with the production style configuration Compare individual route cells rather than Markdown whitespace, and resolve Tailwind from the frontend module so desktop and phone proof inspect the actual styled Docs page. Change-source: pulse-maintainer --- frontend-modern/browser-tests/navigation-help.cjs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend-modern/browser-tests/navigation-help.cjs b/frontend-modern/browser-tests/navigation-help.cjs index 91028e5879..2cd5994658 100644 --- a/frontend-modern/browser-tests/navigation-help.cjs +++ b/frontend-modern/browser-tests/navigation-help.cjs @@ -6,6 +6,7 @@ const { chromium, webkit } = require('playwright'); (async () => { const root = '/workspace/frontend-modern'; + process.chdir(root); const artifacts = path.join(root, 'node_modules', 'navigation-help-browser'); fs.mkdirSync(artifacts, { recursive: true }); const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); @@ -39,9 +40,13 @@ const { chromium, webkit } = require('playwright'); if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); const routes = page.locator('article table').filter({ hasText: '/standalone/machines' }); assert.equal(await routes.count(), 1); - assert.deepEqual(await routes.locator('tbody tr').allTextContents(), [ - 'Proxmox/proxmox/overview', 'Docker/docker/overview', 'Kubernetes/kubernetes/overview', - 'TrueNAS/truenas/overview', 'vSphere/vmware/overview', 'Machines/standalone/machines', + const entryRoutes = await routes.locator('tbody tr').evaluateAll((rows) => + rows.map((row) => Array.from(row.cells, (cell) => cell.textContent.trim())), + ); + assert.deepEqual(entryRoutes, [ + ['Proxmox', '/proxmox/overview'], ['Docker', '/docker/overview'], + ['Kubernetes', '/kubernetes/overview'], ['TrueNAS', '/truenas/overview'], + ['vSphere', '/vmware/overview'], ['Machines', '/standalone/machines'], ]); const article = await page.locator('article').innerText(); assert.ok(article.includes('are supported; do not replace them with the retired task-based routes.')); From 63b24757d6b9e52729488a6c5a8f4fffcdb5f23f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:37:27 +0100 Subject: [PATCH 19/86] Accept readable wrapped help tables as well as local scrolling Phone tables can fit through wrapping. Check their container bound, or actual local scroll movement for wide tables, rather than requiring overflow. Change-source: pulse-maintainer --- frontend-modern/browser-tests/navigation-help.cjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend-modern/browser-tests/navigation-help.cjs b/frontend-modern/browser-tests/navigation-help.cjs index 2cd5994658..8acc827772 100644 --- a/frontend-modern/browser-tests/navigation-help.cjs +++ b/frontend-modern/browser-tests/navigation-help.cjs @@ -85,7 +85,15 @@ const { chromium, webkit } = require('playwright'); })); assert.ok(geometry.localScroll); assert.ok(geometry.outer <= geometry.viewport + 1); - if (width === 390) assert.ok(geometry.table > geometry.wrapper); + if (width === 390 && geometry.table > geometry.wrapper + 1) { + const movement = await inventory.evaluate((table) => { + table.parentElement.scrollLeft = 180; + return table.parentElement.scrollLeft; + }); + assert.ok(movement > 0, 'wide tables must remain locally scrollable'); + } else { + assert.ok(geometry.table <= geometry.wrapper + 1, 'wrapped table must fit its container'); + } await page.screenshot({ path: path.join(artifacts, `${engine}-truenas.png`) }); await page.getByRole('link', { name: 'โ† All documentation' }).click(); From 6f5bf963704b187844da05bbec9e4a86ba6c9068 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:45:14 +0100 Subject: [PATCH 20/86] Withdraw History readings when access is denied Treat final HTTP 401/403 as access failures rather than transient refresh outages. Clear displayed samples and inspection, invalidate retained query ranges and remounts, and keep bounded access guidance through scoped recovery. Preserve ordinary outage retention, current-value labelling, abort ownership and license gates with runtime and real-client desktop/touch browser controls. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 28 +- .../subsystems/performance-and-scalability.md | 12 +- .../v6/internal/subsystems/registry.json | 2 + .../browser-tests/history-access.cjs | 325 ++++++++++++++++++ .../browser-tests/history-access.html | 12 + .../browser-tests/history-access.tsx | 57 +++ frontend-modern/browser-verification.json | 53 +-- .../Workloads/GuestDrawerHistory.tsx | 8 +- .../GuestDrawerHistory.refresh.test.tsx | 102 ++++++ .../components/shared/HistoryChartOverlay.tsx | 4 +- .../shared/__tests__/HistoryChart.test.tsx | 52 ++- .../__tests__/useHistoryChartState.test.tsx | 42 +++ .../components/shared/useHistoryChartState.ts | 21 +- .../createNonSuspendingQuery.test.tsx | 128 +++++++ .../src/hooks/createNonSuspendingQuery.ts | 15 +- .../utils/__tests__/apiAccessError.test.ts | 35 ++ frontend-modern/src/utils/apiAccessError.ts | 12 + 17 files changed, 874 insertions(+), 34 deletions(-) create mode 100644 frontend-modern/browser-tests/history-access.cjs create mode 100644 frontend-modern/browser-tests/history-access.html create mode 100644 frontend-modern/browser-tests/history-access.tsx create mode 100644 frontend-modern/src/utils/__tests__/apiAccessError.test.ts create mode 100644 frontend-modern/src/utils/apiAccessError.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6255d4a7d0..f99fcd1271 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -353,7 +353,30 @@ PBS browser runner verifies delayed range and withdrawn-target reads at desktop and phone widths; neither proof establishes installed collection or #1723 relief. Failed same-source history refreshes keep valid previously loaded observations -visible with an explicit warning, rather than hiding the entire chart. Initial +visible with an explicit warning, rather than hiding the entire chart. That +retention applies to transient transport/server failures, never a final HTTP +`401` or `403`. Both the batch drawer and shared canvas reader withdraw stored +samples and inspection on access failure; denied history is not an empty, +collecting, or stale-but-still-readable result. Their guidance is bounded by +numeric status, not transport bodies or guessed diagnostic text. The shared +canvas announces its error, and the drawer keeps its scoped, focusable retry. +Only a successful replacement read restores observations. + +The retained-value query helper also withdraws its active value on a final +`401`/`403` and clears its shared remount cache. Other resource/range entries +were read under the same access context, so they cannot be resurrected after +that context is denied. Advancing the cache generation rejects cache writes +from already-running pre-denial reads. A superseded denied completion cannot +clear the current target or its cache. This does not broadcast a session reset +or claim revocation of independently mounted consumers; their current response +still owns their presentation. Transient failures keep the existing retained +value, and a newly successful read may populate the cache normally. Verification +uses runtime assertions in `createNonSuspendingQuery.test.tsx`, +`GuestDrawerHistory.refresh.test.tsx`, `useHistoryChartState.test.tsx`, +`HistoryChart.test.tsx`, and `browser-tests/history-access.cjs` (both production +readers/client, real same-origin 403/503 responses, cache/range/remount and +fresh-read recovery on desktop Chromium and touch WebKit). These controls do +not establish appliance authorisation, collection or released acceptance. Initial or uncached replacement failures show unavailable history, never borrowed points or a collecting claim. The existing target/range owns every manual refresh; locked or absent targets expose no refresh control. The control remains mounted @@ -674,6 +697,9 @@ that manual-channel consequence must stay visible at desktop and narrow widths. 108. `frontend-modern/src/components/shared/summaryInteractionA11y.ts` 109. `frontend-modern/src/components/shared/SummaryRowActionButton.tsx` 110. `frontend-modern/src/hooks/createNonSuspendingQuery.ts` + Shared API read-access classification is owned by + `frontend-modern/src/utils/apiAccessError.ts`; it recognises only final + numeric 401/403 statuses and never renders transport diagnostics. 111a. `frontend-modern/src/utils/storageSummaryCache.ts` 111. `frontend-modern/src/components/shared/TableCardHeader.tsx` 112. `frontend-modern/src/components/shared/UpgradeLink.tsx` diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 8df6300caa..80cd898e5a 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -921,7 +921,17 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant. single-observation caption rather than a collecting claim or invented trend. Successful empty reads say `No stored history in this range`. Only metrics-store samples may contribute chart geometry, bounds, or hover - inspection, including valid measured zero. This presentation adds no fetch, + inspection, including valid measured zero. Same-source transient failures + retain those observations with a warning, but final HTTP 401/403 responses + withdraw the batch charts, inspection and its retained remount entries. + The shared query cache advances its generation so a pre-denial read cannot + restore a former cached range. Access guidance comes from numeric status, + never raw transport text; the existing target/range-scoped retry remains + focusable while pending, and only a successful read restores history. + Runtime denial/cache/recovery controls are in + `GuestDrawerHistory.refresh.test.tsx` and `createNonSuspendingQuery.test.tsx`, + with real client 403/503 browser controls in `browser-tests/history-access.cjs`. + These are client presentation proofs, not native authorisation or collection. This presentation adds no fetch, point expansion, polling or retention budget. Verify sparse and mixed-series geometry and failed-refresh/source-change behaviour in `GuestDrawerHistory.sparse.test.tsx`, with rendered phone/desktop coverage in diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 5b59559345..e7361f0193 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -5812,6 +5812,7 @@ "match_prefixes": [], "match_files": [ "frontend-modern/src/hooks/createNonSuspendingQuery.ts", + "frontend-modern/src/utils/apiAccessError.ts", "frontend-modern/src/utils/storageSummaryCache.ts" ], "allow_same_subsystem_tests": false, @@ -5819,6 +5820,7 @@ "exact_files": [ "frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx", "frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx", + "frontend-modern/src/utils/__tests__/apiAccessError.test.ts", "frontend-modern/src/utils/__tests__/storageSummaryCache.test.ts" ] }, diff --git a/frontend-modern/browser-tests/history-access.cjs b/frontend-modern/browser-tests/history-access.cjs new file mode 100644 index 0000000000..0a2c6261e2 --- /dev/null +++ b/frontend-modern/browser-tests/history-access.cjs @@ -0,0 +1,325 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +const parent = false; +(async () => { + const root = parent + ? '/workspace/tmp/history-access-parent/frontend-modern' + : '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = `/workspace/tmp/history-access-${parent ? 'parent' : 'final'}-proof`; + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), + server: { host: '127.0.0.1', port: 5261, strictPort: true }, + }); + const playwright = require('playwright/package.json').version; + const expected = JSON.parse( + fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8'), + ).packages['node_modules/@playwright/test'].version; + assert.equal(playwright, expected); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + }); + page.setDefaultTimeout(15000); + await page.clock.install({ time: new Date('2026-10-03T12:00:00Z') }); + let mode = 'success', + value = 42; + const requests = [], + errors = [], + offOrigin = [], + held = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5261') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const query = Object.fromEntries(url.searchParams); + requests.push({ ...query, responseMode: mode }); + if (mode === 'hold') await new Promise((resolve) => held.push(resolve)); + try { + if (mode === '503' || mode === '403') + return await route.fulfill({ + status: Number(mode), + json: { error: 'fixture-only diagnostic body' }, + }); + const points = [0, 1].map((index) => ({ + timestamp: 1791028740000 + index * 60000, + value: value - 1 + index, + min: value - 1 + index, + max: value - 1 + index, + })); + return await route.fulfill({ + json: { + resourceType: query.resourceType, + resourceId: query.resourceId, + range: query.range, + metric: query.metric, + start: 1791028740000, + end: 1791028800000, + source: 'store', + ...(query.metric ? { points } : { metrics: { cpu: points, temperature: points } }), + }, + }); + } catch { + /* An explicitly superseded browser read may already be aborted. */ + } + return; + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { enabled: false, data: [] }, + }); + }); + const check = async (label, predicate) => { + try { + await predicate(); + checks.push({ label, passed: true }); + } catch (error) { + checks.push({ label, passed: false, error: error.message }); + } + }; + const screenshot = async (state) => { + const file = `${name}-${state}.png`; + await page.screenshot({ + path: path.join(artifacts, file), + fullPage: true, + animations: 'disabled', + }); + observations.push({ + browser: name, + state, + screenshot: file, + body: await page.locator('main').innerText(), + overflow: await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + }); + }; + const disk = page.getByTestId('disk'), + service = page.getByTestId('service'); + const thermal = disk.getByRole('img', { name: 'Temperature chart' }); + const description = () => + thermal.evaluate( + (el) => document.getElementById(el.getAttribute('aria-describedby')).textContent, + ); + const storedPaths = () => service.locator('[data-testid="guest-history-plot"] path').count(); + await page.goto('http://127.0.0.1:5261/browser-tests/history-access.html'); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor(); + await page.waitForFunction( + () => + document.querySelector('[data-testid="disk"] canvas')?.getAttribute('aria-describedby') && + document + .getElementById( + document + .querySelector('[data-testid="disk"] canvas') + .getAttribute('aria-describedby'), + ) + .textContent.includes('42ยฐC'), + ); + await service.getByRole('combobox', { name: 'History range' }).selectOption('24h'); + await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor(); + if (phone) await thermal.tap({ position: { x: 100, y: 80 } }); + else { + await thermal.focus(); + await thermal.press('End'); + } + await check('Both readers show only their successful stored samples', async () => { + assert.match(await description(), /42ยฐC/); + assert.equal(await storedPaths(), 2); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1); + }); + await screenshot('loaded'); + mode = '503'; + await page.clock.fastForward(30_000); + await service + .getByRole('status', { name: 'History refresh status' }) + .getByText(/previously loaded/) + .waitFor(); + await check('503 retains observations with a warning, not a collection claim', async () => { + assert.match(await description(), /42ยฐC/); + assert.equal(await storedPaths(), 2); + assert.match( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + /last successful result/, + ); + }); + await screenshot('transient'); + // Re-establish active inspection immediately before access changes. + if (phone) await thermal.tap({ position: { x: 100, y: 80 } }); + else { + await thermal.focus(); + await thermal.press('End'); + } + mode = '403'; + const beforeDenied = requests.length; + await page.clock.fastForward(30_000); + await page.waitForTimeout(250); + await check('The real API client receives a final 403 for both exact targets', async () => { + const denied = requests.slice(beforeDenied).filter((r) => r.responseMode === '403'); + assert(denied.some((r) => r.resourceId === 'disk:nas-a:sda')); + assert(denied.some((r) => r.resourceId === 'pbs-service-a' && !r.metric)); + }); + await check('403 withdraws canvas values, tooltip and announced inspection', async () => { + assert.doesNotMatch(await description(), /42ยฐC/); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0); + assert.match(await disk.getByRole('alert').innerText(), /Access denied/); + assert.equal( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + '', + ); + }); + await check('403 withdraws batch charts, sliders and current-only legends', async () => { + assert.equal(await storedPaths(), 0); + assert.equal(await service.getByRole('slider').count(), 0); + assert.equal(await service.locator('[data-history-current]').count(), 0); + assert.match( + await service.getByRole('status', { name: 'History refresh status' }).innerText(), + /Access denied/, + ); + }); + await screenshot('denied'); + mode = 'hold'; + const retry = service.getByRole('button', { name: 'Retry history' }); + const beforeRetry = requests.length; + await retry.focus(); + await retry.click(); + await retry.press('Enter'); + await page.waitForTimeout(150); + await check( + 'One busy keyboard-safe retry remains scoped and shows no denied samples', + async () => { + assert.equal( + requests.slice(beforeRetry).filter((r) => r.resourceId === 'pbs-service-a').length, + 1, + ); + assert.equal(await retry.getAttribute('aria-disabled'), 'true'); + assert.equal(await retry.evaluate((el) => el === document.activeElement), true); + assert.equal(await storedPaths(), 0); + }, + ); + await screenshot('retry-pending'); + await service.getByRole('combobox', { name: 'History range' }).selectOption('1h'); + await page.waitForTimeout(150); + await check('An earlier cached range cannot resurrect denied observations', async () => + assert.equal(await storedPaths(), 0), + ); + await service.getByRole('button', { name: 'Hide service History' }).click(); + await service.getByRole('button', { name: 'Show service History' }).click(); + await page.waitForTimeout(150); + await check('A remount cannot resurrect the earlier cached range', async () => + assert.equal(await storedPaths(), 0), + ); + await screenshot('remount-pending'); + mode = 'success'; + value = 12; + held.splice(0).forEach((resolve) => resolve()); + await page.clock.fastForward(30_000); + await page.waitForFunction(() => + document + .getElementById( + document.querySelector('[data-testid="disk"] canvas').getAttribute('aria-describedby'), + ) + .textContent.includes('12ยฐC'), + ); + await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor(); + await check( + 'Only a new successful response restores readings and clears access errors', + async () => { + assert.equal(await storedPaths(), 2); + assert.equal(await disk.getByRole('alert').count(), 0); + assert.equal( + await service.getByRole('status', { name: 'History refresh status' }).innerText(), + '', + ); + assert.doesNotMatch(await description(), /42ยฐC/); + assert( + requests.some( + (r) => + r.resourceId === 'pbs-service-a' && + r.range === '1h' && + r.responseMode === 'success', + ), + ); + }, + ); + await screenshot('recovered'); + await check( + 'No diagnostic body, page error, off-origin request or horizontal overflow', + async () => { + assert.doesNotMatch( + await page.locator('main').innerText(), + /fixture-only diagnostic body/, + ); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + false, + ); + }, + ); + observations.push({ + browser: name, + version: browser.version(), + checks, + requests, + errors, + offOrigin, + }); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify({ parent, playwright, expected, observations }, null, 2), + ); + } + const failed = observations.flatMap((o) => o.checks ?? []).filter((c) => !c.passed); + console.log( + JSON.stringify({ + parent, + checks: observations.flatMap((o) => o.checks ?? []).length, + failed: failed.map((c) => c.label), + }), + ); + if (!parent) assert.equal(failed.length, 0); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/history-access.html b/frontend-modern/browser-tests/history-access.html new file mode 100644 index 0000000000..c41d942a61 --- /dev/null +++ b/frontend-modern/browser-tests/history-access.html @@ -0,0 +1,12 @@ + + + + + + History access failure verification + + +
+ + + diff --git a/frontend-modern/browser-tests/history-access.tsx b/frontend-modern/browser-tests/history-access.tsx new file mode 100644 index 0000000000..a1450ea206 --- /dev/null +++ b/frontend-modern/browser-tests/history-access.tsx @@ -0,0 +1,57 @@ +// Production readers/client/styles with synthetic same-origin HTTP responses. +// This fixture proves presentation, not appliance authorisation or collection. +import { Show, createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import type { HistoryTimeRange } from '../src/api/charts'; +import { + GuestDrawerHistory, + GuestDrawerHistoryRangeSelect, +} from '../src/components/Workloads/GuestDrawerHistory'; +import { HistoryChart } from '../src/components/shared/HistoryChart'; +import { HOST_METRICS_HISTORY_GROUPS } from '../src/components/shared/hostMetricsHistoryModel'; +import '../src/index.css'; + +function Fixture() { + const [range, setRange] = createSignal('1h'); + const [visible, setVisible] = createSignal(true); + return ( +
+

History access failure verification

+
+

Disk History

+ +
+
+

PBS service History

+
+ + +
+ + + +
+
+ ); +} +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 7555748f01..353d9f0ce2 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,25 @@ { "version": 1, "result": "passed", - "base_sha": "b6a42d8a4f329a1f3d7359d8d64b17c385006ed7", - "verified_at": "2026-10-03T00:38:42Z", + "base_sha": "a9533eb82a29700ede2d4ad609812ddd01e5f287", + "verified_at": "2026-10-03T01:44:27.336168Z", "changed_paths": [ - "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx", + "frontend-modern/src/components/shared/useHistoryChartState.ts", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts", + "frontend-modern/src/utils/apiAccessError.ts" ], "content_sha256": { - "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "31c71a2d696f1b74d55b855220885619bbeebc0b75a1f34354cc5b794ba49375" + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "c4370a69495c8ccea01a0d92076e4a051e4d06abec0e675e2fb3547d46e87bcb", + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "14c0699b827ec6eb4d85cf9d8fc76d61a0af3dd6565d2396a3f22a03429af104", + "frontend-modern/src/components/shared/useHistoryChartState.ts": "5a9b022656ac7316710675a0742e9b2908c9114ffbed67d6f6962d361bb65bbf", + "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "df293d0484612a6d31dd9f3fa6af2a1160e1e98d66965730a45829b7048a4ba7", + "frontend-modern/src/utils/apiAccessError.ts": "3b34a498959a77014af3c842677fc6646b5e7e6a8b9b332ca8acf91254c01730" }, "routes": [ - "Existing Storage physical disk detail History" + "Existing PBS/Machine/resource drawer History batch reader", + "Existing Storage physical disk shared History reader" ], "viewports": [ { @@ -23,23 +32,25 @@ } ], "states": [ - "Stored ATA temperature and measured-zero reallocated sectors render when current temperature and SMART are absent", - "Matching current snapshot updates keep the same chart owners and add zero History reads", - "Current field loss keeps stored samples and selected 7-day range; no unavailable live I/O is shown", - "Overview reports present collection unavailability instead of the earlier stored temperature", - "Empty temperature history is an explicit no-samples state, not a zero, while stored reallocated zero remains visible", - "NVMe uses its own explicit disk target and stored temperature/endurance/spare series without current SMART fields", - "No resolvable identity withdraws History and issues no guessed-target request", - "Keyboard desktop and touch phone inspection show real stored readings", - "All screenshots inspected with animations disabled; selected tab, light/dark layouts and page width agree with content", - "Playwright 1.56.1 matches the integration lock; no page errors or off-origin data reads" + "Real same-origin 503 responses retain stored samples with explicit warnings in both production readers", + "Real final HTTP 403 responses withdraw samples, tooltip, keyboard announcement, sliders and formerly displayed current-only legends", + "Access-denied guidance fits light desktop Chromium and dark touch WebKit without horizontal overflow", + "The existing focusable scoped retry remains mounted and ignores duplicate activation while busy", + "Earlier cached ranges and remounts cannot resurrect denied stored readings while a replacement is pending", + "Only a new successful read restores History and clears denial; old values do not return", + "Every final screenshot inspected; fixed Playwright 1.56.1 equals integration lock, no page errors or off-origin data requests" ], "interactions": [ - "Open History from unavailable current Overview", - "Restore, update and remove current fields", - "Change 24h/7d/1h ranges", - "Inspect by keyboard or phone tap", - "Select NVMe B", - "Clear disk identity" + "Inspect by desktop keyboard and phone tap", + "Poll through 503 then 403", + "Retry twice while busy", + "Change 24h/1h range", + "Unmount and remount batch History", + "Resume with newly successful observations" + ], + "limitations": [ + "Direct production readers/client with synthetic HTTP responses, not a complete native PBS/NAS drawer, session revocation, appliance collection or release acceptance.", + "401 is covered by runtime regressions; the real API client redirects on 401, not bypassed for browser proof.", + "Independently supplied current readings remain distinctly current during a new pending query; they are not stored History." ] } diff --git a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx index 427b7ec2f6..4eed3c6ba5 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx @@ -30,6 +30,7 @@ import { } from '@/components/shared/historyChartModel'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license'; +import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError'; import { GUEST_DRAWER_HISTORY_DEFAULT_RANGE, @@ -686,9 +687,10 @@ export const GuestDrawerHistory: Component = (props) => > - {hasHistoryPoints() - ? 'History refresh failed. Showing previously loaded history.' - : 'Failed to load history data'} + {getAPIReadAccessErrorMessage(historyQuery.error()) || + (hasHistoryPoints() + ? 'History refresh failed. Showing previously loaded history.' + : 'Failed to load history data')}
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx index e0dfcc7972..db854a9504 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerHistory.refresh.test.tsx @@ -220,3 +220,105 @@ describe('GuestDrawerHistory refresh recovery', () => { expect(fetch).not.toHaveBeenCalled(); }); }); + +const accessTarget = { resourceType: 'agent', resourceId: 'pbs-host' } as const; +const accessResponse = (range: HistoryTimeRange): AllMetricsHistoryResponse => ({ + ...accessTarget, + range, + start: 1000, + end: 2000, + source: 'store', + metrics: { + cpu: [ + { timestamp: 1000, value: 41, min: 41, max: 41 }, + { timestamp: 2000, value: 42, min: 42, max: 42 }, + ], + }, +}); +const accessPaths = (container: HTMLElement) => + container.querySelectorAll('[data-testid="guest-history-plot"] path'); +const settleAccess = () => vi.advanceTimersByTimeAsync(0); + +describe('drawer History access failures', () => { + it.each([ + [401, 'Sign in again or check your API token.'], + [403, 'Access denied. Check your permissions and license plan.'], + ])( + 'removes samples, inspection and current legends after %s; retry stays scoped', + async (status, message) => { + vi.useFakeTimers(); + let deny = false; + let complete!: (value: AllMetricsHistoryResponse) => void; + const fetch = vi.spyOn(ChartsAPI, 'getMetricsHistory').mockImplementation(async (request) => { + if (deny) throw Object.assign(new Error('private transport detail'), { status }); + return accessResponse(request.range!); + }); + const [range, setRange] = createSignal('1h'); + const view = render(() => ( + + )); + await settleAccess(); + setRange('24h'); + await settleAccess(); + expect(accessPaths(view.container)).toHaveLength(1); + const inspection = screen.getByRole('slider', { name: 'Inspect Utilization history' }); + fireEvent.focus(inspection); + expect(view.container).toHaveTextContent('42.0%'); + const region = screen.getByRole('status', { name: 'History refresh status' }); + deny = true; + await vi.advanceTimersByTimeAsync(30_000); + expect(accessPaths(view.container)).toHaveLength(0); + expect(screen.queryByRole('slider')).not.toBeInTheDocument(); + expect(view.container).not.toHaveTextContent('42.0%'); + expect(view.container).not.toHaveTextContent('99.0%'); + expect(view.container).not.toHaveTextContent('private transport detail'); + expect(region).toHaveTextContent(message); + expect(region).not.toHaveTextContent('previously loaded'); + const retry = screen.getByRole('button', { name: 'Retry history' }); + retry.focus(); + fetch.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + fireEvent.click(retry); + fireEvent.click(retry); + expect(retry).toHaveFocus(); + expect(retry).toHaveAttribute('aria-disabled', 'true'); + expect(accessPaths(view.container)).toHaveLength(0); + expect(region).toHaveTextContent(message); + expect(fetch.mock.calls.at(-1)![0]).toMatchObject({ + ...accessTarget, + range: '24h', + maxPoints: 240, + }); + complete(accessResponse('24h')); + await settleAccess(); + expect(accessPaths(view.container)).toHaveLength(1); + expect(region).toBeEmptyDOMElement(); + expect(retry).toHaveFocus(); + }, + ); + + it('does not resurrect another cached range or remount after denial', async () => { + vi.useFakeTimers(); + const fetch = vi + .spyOn(ChartsAPI, 'getMetricsHistory') + .mockImplementation(async (request) => accessResponse(request.range!)); + const [range, setRange] = createSignal('1h'); + const view = render(() => ); + await settleAccess(); + setRange('24h'); + await settleAccess(); + expect(accessPaths(view.container)).toHaveLength(1); + fetch.mockRejectedValueOnce(Object.assign(new Error('denied'), { status: 403 })); + await vi.advanceTimersByTimeAsync(30_000); + fetch.mockImplementation(() => new Promise(() => {})); + setRange('1h'); + expect(accessPaths(view.container)).toHaveLength(0); + view.unmount(); + const next = render(() => ); + expect(accessPaths(next.container)).toHaveLength(0); + }); +}); diff --git a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx index 2244e8d0db..3cad4322ec 100644 --- a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx +++ b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx @@ -48,7 +48,9 @@ export const HistoryChartOverlay: Component = (props)
-

{props.chart.error()}

+
diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index 8729c8a2ba..ab9ba9d221 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -1,7 +1,7 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createSignal } from 'solid-js'; import { ChartsAPI } from '@/api/charts'; -import { fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import historyChartHeaderSource from '@/components/shared/HistoryChartHeader.tsx?raw'; import historyChartHoverGroupSource from '@/components/shared/HistoryChartHoverGroup.tsx?raw'; import historyChartOverlaySource from '@/components/shared/HistoryChartOverlay.tsx?raw'; @@ -571,3 +571,51 @@ describe('HistoryChart', () => { expect(layout.x).toBe(212); }); }); + +describe('History access boundary rendering', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it.each([401, 403])( + 'withdraws painted readings, tooltips and keyboard announcements on %s', + async (status) => { + vi.useFakeTimers(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const request = vi.mocked(ChartsAPI.getMetricsHistory); + request.mockReset(); + request.mockResolvedValueOnce({ + points: [{ timestamp: 1000, value: 42, min: 42, max: 42 }], + source: 'store', + } as never); + const { container } = render(() => ( + + )); + await vi.advanceTimersByTimeAsync(0); + const canvas = screen.getByRole('img', { name: 'Temperature chart' }); + fireEvent.focus(canvas); + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42ยฐC'); + request.mockRejectedValueOnce(Object.assign(new Error('private detail'), { status })); + await vi.advanceTimersByTimeAsync(10_000); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(''); + expect(container).not.toHaveTextContent('42ยฐC'); + expect(container).not.toHaveTextContent('last successful'); + expect(container).not.toHaveTextContent('private detail'); + expect(screen.getByRole('alert')).toHaveTextContent( + status === 401 ? 'Sign in again' : 'Access denied', + ); + expect(screen.queryByText('No history samples in this time range.')).not.toBeInTheDocument(); + fireEvent.keyDown(canvas, { key: 'End' }); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + }, + ); +}); diff --git a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx index 872920316b..571dcd49ac 100644 --- a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx @@ -217,3 +217,45 @@ describe('History request ownership', () => { expect(state.data()).toEqual([]); }); }); + +describe('History access errors', () => { + beforeEach(() => { + vi.useFakeTimers(); + request.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + it.each([ + [401, 'Sign in again or check your API token.'], + [403, 'Access denied. Check your permissions and license plan.'], + ])( + 'withdraws stored samples and source on status %s until a successful read', + async (status, message) => { + request.mockResolvedValueOnce({ points: points(42), source: 'store' } as never); + const { state } = mount(); + await settle(); + request.mockRejectedValueOnce( + Object.assign(new Error('private transport detail'), { status }), + ); + await vi.advanceTimersByTimeAsync(10_000); + expect(state.data()).toEqual([]); + expect(state.source()).toBeNull(); + expect(state.refreshFailed()).toBe(false); + expect(state.error()).toBe(message); + const retry = deferred(); + request.mockReturnValueOnce(retry.promise); + vi.advanceTimersByTime(10_000); + expect(state.data()).toEqual([]); + expect(state.error()).toBe(message); + retry.resolve({ points: points(12), source: 'store' } as never); + await settle(); + expect(state.data()).toEqual(points(12)); + expect(state.error()).toBeNull(); + expect(state.source()).toBe('store'); + }, + ); +}); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index 2ccfa69c54..fb02e328d9 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -1,5 +1,6 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { ChartsAPI, type HistoryTimeRange } from '@/api/charts'; +import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError'; import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license'; import { calculateOptimalPoints } from '@/utils/downsample'; import { setupCanvasDPR } from '@/utils/canvasRenderQueue'; @@ -148,7 +149,6 @@ export function useHistoryChartState( pending = true; controller = new AbortController(); if (!hasLoaded) setLoading(true); - setError(null); try { const result = await ChartsAPI.getMetricsHistory({ resourceType, @@ -161,12 +161,27 @@ export function useHistoryChartState( if (!active) return; setData('points' in result ? (result.points ?? []) : []); setSource(result.source ?? 'store'); + setError(null); setRefreshFailed(false); hasLoaded = true; } catch (err) { if (!active) return; console.error('Failed to fetch metrics history:', err); - if (hasLoaded) setRefreshFailed(true); + const accessError = getAPIReadAccessErrorMessage(err); + if (accessError) { + hasLoaded = false; + pendingTouch = undefined; + touchTimestamp = null; + batch(() => { + setData([]); + setSource(null); + setHoveredPoint(null); + setHoveredTimestamp(null); + setKeyboardInspecting(false); + setRefreshFailed(false); + setError(accessError); + }); + } else if (hasLoaded) setRefreshFailed(true); else setError('Failed to load history data'); } finally { if (active) { diff --git a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx index 488876a2c2..ce8b4af4b4 100644 --- a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx +++ b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx @@ -311,3 +311,131 @@ it.each(['success', 'failure'] as const)( expect(screen.getByTestId('query')).toHaveTextContent(expected); }, ); + +const settle = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; +const denied = (status: number) => Object.assign(new Error('private transport detail'), { status }); +function mount(key: string, fetcher: () => Promise) { + let query!: ReturnType>; + const view = render(() => { + query = createNonSuspendingQuery({ + source: () => key, + cacheKey: (key) => key, + fetcher, + initialValue: '', + }); + return {query.value()}; + }); + return { query, ...view }; +} + +describe('retained query access boundary', () => { + it.each([401, 403])( + 'withdraws the denied value and all remount entries after %s', + async (status) => { + const oldRange = mount('pbs:1h', async () => 'former reading'); + await settle(); + oldRange.unmount(); + let rejectRead = false; + const active = mount('pbs:24h', async () => { + if (rejectRead) throw denied(status); + return 'current reading'; + }); + await settle(); + expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(2); + rejectRead = true; + await active.query.refetch({ background: true }); + expect(active.query.value()).toBe(''); + expect(active.query.error()).toMatchObject({ status }); + expect(active.query.loading()).toBe(false); + expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(0); + active.unmount(); + const remount = mount('pbs:1h', () => new Promise(() => {})); + expect(remount.query.value()).toBe(''); + expect(remount.query.resolvedOnce()).toBe(false); + }, + ); + + it('does not repopulate the remount cache from a pre-denial read', async () => { + let complete!: (value: string) => void; + const pending = mount( + 'pbs:1h', + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const active = mount('pbs:24h', async () => { + throw denied(403); + }); + await settle(); + expect(active.query.value()).toBe(''); + complete('before-access-change'); + await settle(); + expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(0); + pending.unmount(); + const remount = mount('pbs:1h', () => new Promise(() => {})); + expect(remount.query.value()).toBe(''); + }); + + it.each([500, 503, 429, undefined])( + 'retains useful readings on transient status %s', + async (status) => { + let rejectRead = false; + const active = mount('pbs:24h', async () => { + if (rejectRead) throw denied(status as number); + return 'stored reading'; + }); + await settle(); + rejectRead = true; + await active.query.refetch(); + expect(active.query.value()).toBe('stored reading'); + expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(1); + }, + ); + + it('restores only freshly authorised data after denial', async () => { + let result: string | Error = 'before denial'; + const active = mount('pbs:24h', async () => { + if (result instanceof Error) throw result; + return result; + }); + await settle(); + result = denied(403); + await active.query.refetch(); + expect(active.query.value()).toBe(''); + result = 'newly authorised reading'; + await active.query.refetch(); + expect(active.query.value()).toBe(result); + expect(active.query.error()).toBeNull(); + expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().size).toBe(1); + }); + it('ignores a superseded denial rather than clearing the new target and its cache', async () => { + let rejectOld!: (error: Error) => void; + let query!: ReturnType>; + const [source, setSource] = createSignal('old'); + render(() => { + query = createNonSuspendingQuery({ + source, + cacheKey: (key) => key, + initialValue: '', + fetcher: (key) => + key === 'old' + ? new Promise((_, reject) => { + rejectOld = reject; + }) + : Promise.resolve('new reading'), + }); + return {query.value()}; + }); + setSource('new'); + await settle(); + rejectOld(denied(403)); + await settle(); + expect(query.value()).toBe('new reading'); + expect(query.error()).toBeNull(); + expect(getCreateNonSuspendingQueryCacheDiagnosticsForTest().keys).toEqual(['new']); + }); +}); diff --git a/frontend-modern/src/hooks/createNonSuspendingQuery.ts b/frontend-modern/src/hooks/createNonSuspendingQuery.ts index 39c88f8a79..920e0de316 100644 --- a/frontend-modern/src/hooks/createNonSuspendingQuery.ts +++ b/frontend-modern/src/hooks/createNonSuspendingQuery.ts @@ -1,5 +1,6 @@ -import { type Accessor, createEffect, createSignal, onCleanup } from 'solid-js'; +import { type Accessor, batch, createEffect, createSignal, onCleanup } from 'solid-js'; import { eventBus } from '@/stores/events'; +import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError'; interface CreateNonSuspendingQueryOptions { source: Accessor; @@ -229,7 +230,17 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery return nextValue; } catch (nextError) { if (requestId === latestRequestId) { - setError(nextError); + batch(() => { + if (getAPIReadAccessErrorMessage(nextError)) { + // A permission/session/plan failure is not a transient outage. + // Withdraw this value and all remount entries: the same access + // context may own other resource/range keys. The generation also + // prevents pre-denial reads from repopulating the shared cache. + clearRetainedQueryCache(); + setValue(() => options.initialValue); + } + setError(nextError); + }); } return value(); } finally { diff --git a/frontend-modern/src/utils/__tests__/apiAccessError.test.ts b/frontend-modern/src/utils/__tests__/apiAccessError.test.ts new file mode 100644 index 0000000000..40eb4e1882 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/apiAccessError.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { getAPIReadAccessErrorMessage } from '../apiAccessError'; + +describe('bounded API read access messages', () => { + it.each([ + [401, 'Sign in again or check your API token.'], + [403, 'Access denied. Check your permissions and license plan.'], + ])('recognises final HTTP status %s without exposing diagnostics', (status, message) => { + expect( + getAPIReadAccessErrorMessage(Object.assign(new Error('private detail'), { status })), + ).toBe(message); + expect( + getAPIReadAccessErrorMessage({ + status, + message: '', + code: 'arbitrary', + }), + ).toBe(message); + }); + it.each([ + null, + undefined, + '403 Forbidden', + new Error('401 unauthorised'), + {}, + { status: '403' }, + { status: 400 }, + { status: 402 }, + { status: 404 }, + { status: 429 }, + { status: 500 }, + ])('does not infer permission failure from %j', (error) => { + expect(getAPIReadAccessErrorMessage(error)).toBeNull(); + }); +}); diff --git a/frontend-modern/src/utils/apiAccessError.ts b/frontend-modern/src/utils/apiAccessError.ts new file mode 100644 index 0000000000..a414a00118 --- /dev/null +++ b/frontend-modern/src/utils/apiAccessError.ts @@ -0,0 +1,12 @@ +/** Only final HTTP status establishes an access failure, never transport text. */ +export function getAPIReadAccessErrorMessage(error: unknown): string | null { + if (!error || typeof error !== 'object' || !('status' in error)) return null; + switch (error.status) { + case 401: + return 'Sign in again or check your API token.'; + case 403: + return 'Access denied. Check your permissions and license plan.'; + default: + return null; + } +} From a2fc29b70b202dda36f0a9ee1f4d66cb3f4dcdcf Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:01:05 +0100 Subject: [PATCH 21/86] Limit guarded demo updates to owned signed runtime files Retain the rejected VERSION rollback result. Replace general installer execution with pinned-signature archive selection and atomic binary/VERSION swaps, preserve unrelated distribution assets, and fail closed on missing matching native acceptance before production mutation. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 112 +++++++++++-- .github/scripts/dispatch-demo-runtime.py | 108 ++++++++++-- .github/scripts/tests/demo_runtime_native.py | 41 ++--- .../tests/test_demo_runtime_transaction.py | 154 ++++++++++++++++-- .github/workflows/deploy-demo-server.yml | 1 + .github/workflows/recover-demo-server.yml | 2 + .github/workflows/release-convergence.yml | 1 + .github/workflows/release-dry-run.yml | 1 + .github/workflows/update-demo-server.yml | 20 ++- .../v6/internal/DEMO_RUNTIME_TRANSACTION.md | 51 +++++- .../subsystems/deployment-installability.md | 19 ++- .../installtests/build_release_assets_test.go | 4 +- scripts/installtests/demo_transaction_test.go | 2 +- 13 files changed, 440 insertions(+), 76 deletions(-) diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 6f12dfebf2..ca3984c65b 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -14,6 +14,7 @@ import socket import stat import subprocess import sys +import tarfile import time from urllib.parse import urlsplit @@ -24,6 +25,7 @@ SERVICES = ("pulse", "pulse-relay", "caddy") ROOT = Path("/var/lib/pulse-deploy/demo") LOCK = Path("/var/lib/pulse-deploy/relay/deploy.lock") PATHS = {"binary": Path("/opt/pulse/bin/pulse"), + "version": Path("/opt/pulse/VERSION"), "unit": Path("/etc/systemd/system/pulse.service"), "dropins": Path("/etc/systemd/system/pulse.service.d"), "data": Path("/etc/pulse")} @@ -40,6 +42,7 @@ DEMO_HISTORY = ("ai_incidents.json", "ai_incidents.json.tmp", "alerts/events.db", "alerts/events.db-shm", "alerts/events.db-wal") CRASH = re.compile(r"\bpanic\b|\bfatal error\b|segmentation fault|core dumped|watchdog timeout", re.I) TERMINAL = {"committed", "healthy_noop", "rolled_back", "refused"} +MAX_BINARY = 128 * 1024 * 1024 class Failure(Exception): @@ -62,7 +65,7 @@ def atomic_json(path, value): def validate(request): - if set(request) != {"mode", "hostname", "local_url", "public_url", "version", "profile", "control_sha", "run_id", "run_attempt", "installer_sha256"}: + if set(request) != {"mode", "hostname", "local_url", "public_url", "version", "profile", "control_sha", "run_id", "run_attempt", "binary_sha256", "version_sha256"}: raise Failure("request-shape") if request["mode"] not in {"update", "recover"}: raise Failure("request-mode") @@ -92,9 +95,47 @@ def validate(request): pattern = r"[1-9][0-9]{0,3}" if name in COUNT_KEYS else r"[1-9][0-9]{0,3}[smhd]" if not isinstance(value, str) or not re.fullmatch(pattern, value): raise Failure("request-profile") - digest = request["installer_sha256"] - if (request["mode"] == "update" and not re.fullmatch(r"[0-9a-f]{64}", digest)) or (request["mode"] == "recover" and digest != ""): - raise Failure("request-installer") + for name in ("binary_sha256", "version_sha256"): + digest = request[name] + if (not isinstance(digest, str) + or (request["mode"] == "update" and not re.fullmatch(r"[0-9a-f]{64}", digest)) + or (request["mode"] == "recover" and digest != "")): + raise Failure("request-runtime") + + +def runtime_members(archive, version): + """Select two regular files from an already signature-verified archive. + + Never extract archive paths or execute its installer. This demo-only route + does not update bundled agents, scripts, helpers, units or timer assets. + """ + files = {} + count = 0 + with tarfile.open(archive, "r:gz") as stream: + for member in stream: + count += 1 + if count > 4096: + raise Failure("archive-member-bound") + name = member.name.removeprefix("./") + if name not in {"pulse", "bin/pulse", "VERSION"}: + continue + label = "version" if name == "VERSION" else "binary" + if (label in files or not member.isfile() or member.issparse() + or not 0 < member.size <= (128 if label == "version" else MAX_BINARY)): + raise Failure("archive-runtime-shape") + with stream.extractfile(member) as source: + files[label] = source.read(member.size + 1) + if len(files[label]) != member.size: + raise Failure("archive-runtime-size") + if set(files) != {"binary", "version"}: + raise Failure("archive-runtime-missing") + if files["version"].strip() not in {version.encode(), version.removeprefix("v").encode()}: + raise Failure("archive-runtime-version") + # The demo estate is the existing Linux/amd64 server, not a new platform. + data = files["binary"] + if len(data) < 64 or data[:6] != b"\x7fELF\x02\x01" or data[18:20] != b"\x3e\x00": + raise Failure("archive-runtime-architecture") + return files def copy_path(src, dst): @@ -175,6 +216,8 @@ class Host: raise Failure("estate-identity") if PATHS["dropins"].is_symlink(): raise Failure("dropin-identity") + if PATHS["version"].is_symlink() or any(p.is_symlink() for p in PATHS["binary"].parents): + raise Failure("distribution-identity") for path in fields.get("DropInPaths", "").split(): if Path(path).parent != PATHS["dropins"] or Path(path).is_symlink(): raise Failure("unsupported-dropin") @@ -244,11 +287,44 @@ class Host: self.command(["systemctl", "start", "pulse"], timeout=90) def install(self, attempt, request): - installer = attempt / "installer.sh" - if hashlib.sha256(installer.read_bytes()).hexdigest() != request["installer_sha256"]: - raise Failure("installer-identity") - self.command(["bash", str(installer), "--version", request["version"]], timeout=900, - env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/root", "PULSE_SERVICE_NAME": "pulse"}) + # Trust is checked against the pinned release key in CI, before these + # exact bytes enter the authenticated SSH envelope. Check the retained + # byte identities again before any swap; no arbitrary shell is run. + staged = {label: attempt / label for label in ("binary", "version")} + for label, path in staged.items(): + if path.is_symlink() or not path.is_file(): + raise Failure("runtime-payload-shape") + if not 0 < path.stat().st_size <= (128 if label == "version" else MAX_BINARY): + raise Failure("runtime-payload-bound") + if hashlib.sha256(path.read_bytes()).hexdigest() != request[label + "_sha256"]: + raise Failure("runtime-payload-identity") + if staged["version"].read_bytes().strip() not in { + request["version"].encode(), request["version"].removeprefix("v").encode()}: + raise Failure("runtime-payload-version") + for label, source in staged.items(): + destination = PATHS[label] + # Sibling staging keeps replacement atomic; owned recovery removes + # the sibling if a failure interrupts either of the two swaps. + temporary = destination.with_name(destination.name + ".demo-new") + if temporary.exists() or temporary.is_symlink(): + raise Failure("runtime-staging-exists") + try: + with open(source, "rb") as src, open(temporary, "xb") as dst: + shutil.copyfileobj(src, dst) + owner = PATHS["binary"].stat() + os.fchown(dst.fileno(), owner.st_uid, owner.st_gid) + os.fchmod(dst.fileno(), 0o755 if label == "binary" else 0o644) + dst.flush() + os.fsync(dst.fileno()) + os.replace(temporary, destination) + fd = os.open(destination.parent, os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + finally: + if temporary.is_file() and not temporary.is_symlink(): + temporary.unlink() def profile_values(self, request): values = {"DEMO_MODE": "true", "PULSE_MOCK_MODE": "true", "PULSE_MOCK_RANDOM_METRICS": "true", @@ -308,7 +384,9 @@ class Transaction: self.receipt = {"schema_version": 2, "status": "intent", "mutated": False, "control_sha": request["control_sha"], "run_id": request["run_id"], "run_attempt": request["run_attempt"], "mode": request["mode"], - "expected_version": request["version"], "installer_sha256": request["installer_sha256"], + "expected_version": request["version"], + "footprint": "server-binary-version-and-demo-data", + "binary_sha256": request["binary_sha256"], "version_sha256": request["version_sha256"], "forward": {}, "recovery": {}, "recovery_required": False} def save(self, status): @@ -346,6 +424,11 @@ class Transaction: evidence["observed_version"] = self.host.version(self.request["local_url"]) if evidence["observed_version"] != expected_version: raise Failure("runtime-version") + runtime = estate_hash({name: PATHS[name] for name in ("binary", "version")}) + expected = self.receipt.get("runtime_snapshot_sha256" if phase == "recovery" else "installed_runtime_sha256") + if expected and runtime != expected: + raise Failure("distribution-state-changed") + evidence["runtime_sha256"] = runtime evidence["samples"] += 1 elapsed = self.host.now() - started evidence["elapsed_seconds"] = elapsed @@ -358,8 +441,8 @@ class Transaction: size = sum(path.lstat().st_size for root in PATHS.values() if root.exists() for path in ([root] + (list(root.rglob("*")) if root.is_dir() else [])) if path.is_file() and not path.is_symlink()) - # Both our independent estate and the signed installer's own backup need - # space. Refuse shortage; never delete backups or databases for headroom. + # Keep room for the independent estate and the two atomic runtime swaps. + # Never run the installer's backup rotation or delete data for headroom. if any(shutil.disk_usage(parent).free < 2 * size + 64 * 1024 * 1024 for parent in (self.attempt, PATHS["data"].parent)): raise Failure("snapshot-headroom") @@ -372,6 +455,7 @@ class Transaction: atomic_json(snapshot / "paths.json", manifest) digest = estate_hash(PATHS) self.receipt["snapshot_sha256"] = digest + self.receipt["runtime_snapshot_sha256"] = estate_hash({name: PATHS[name] for name in ("binary", "version")}) self.receipt["snapshot_retained"] = True return digest @@ -433,8 +517,8 @@ class Transaction: cursors = self.host.cursors() if self.request["mode"] == "update": self.host.install(self.attempt, self.request) - # The installer may start the unit; finish profile work quiescent. - self.host.stop() + self.receipt["installed_runtime_sha256"] = estate_hash({ + name: PATHS[name] for name in ("binary", "version")}) self.host.profile(self.request, not healthy_baseline) self.host.start() self.ready(cursors) diff --git a/.github/scripts/dispatch-demo-runtime.py b/.github/scripts/dispatch-demo-runtime.py index a56e018ff0..815735075e 100644 --- a/.github/scripts/dispatch-demo-runtime.py +++ b/.github/scripts/dispatch-demo-runtime.py @@ -4,7 +4,10 @@ from __future__ import annotations import base64 import hashlib +import importlib.util import json +import datetime +import re import os from pathlib import Path import shlex @@ -14,12 +17,14 @@ import sys BOOTSTRAP = r''' import base64, fcntl, hashlib, json, os, pathlib, subprocess, sys, time root = pathlib.Path('/var/lib/pulse-deploy/demo') -payload = json.loads(sys.stdin.read(512 * 1024)) -assert os.geteuid() == 0 and set(payload) == {'request', 'source', 'installer'} +payload = json.loads(sys.stdin.read(192 * 1024 * 1024 + 1)) +assert os.geteuid() == 0 and set(payload) == {'request', 'source', 'binary', 'version_file'} source = base64.b64decode(payload['source'], validate=True) -installer = base64.b64decode(payload['installer'], validate=True) -assert len(source) < 128 * 1024 and len(installer) < 256 * 1024 -assert (hashlib.sha256(installer).hexdigest() == payload['request']['installer_sha256']) if installer else (payload['request']['installer_sha256'] == '') +binary = base64.b64decode(payload['binary'], validate=True) +version_file = base64.b64decode(payload['version_file'], validate=True) +assert len(source) < 128 * 1024 and len(binary) <= 128 * 1024 * 1024 and len(version_file) <= 128 +for name, data in [('binary', binary), ('version', version_file)]: + assert (hashlib.sha256(data).hexdigest() == payload['request'][name + '_sha256']) if data else (payload['request'][name + '_sha256'] == '') identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest() root.mkdir(mode=0o700, parents=True, exist_ok=True) assert not root.is_symlink() and root.stat().st_uid == 0 @@ -31,7 +36,7 @@ fcntl.flock(fd, fcntl.LOCK_EX) attempt = root / 'attempts' / identity if not attempt.exists(): attempt.mkdir(mode=0o700) - for name, data in [('transaction.py', source), ('installer.sh', installer), + for name, data in [('transaction.py', source), ('binary', binary), ('version', version_file), ('request.json', json.dumps(payload['request']).encode()), ('intent.json', json.dumps({'schema_version': 1, 'identity': identity, 'state': 'intent'}).encode())]: with open(attempt / name, 'xb') as stream: @@ -83,20 +88,103 @@ def payload(mode, environ=os.environ): ('nodes', 'vms_per_node', 'lxcs_per_node', 'docker_hosts', 'docker_containers', 'generic_hosts', 'k8s_clusters', 'k8s_nodes', 'k8s_pods', 'k8s_deployments', 'seed_duration', 'sample_interval', 'update_interval')} - installer = Path('/tmp/pulse-install.sh').read_bytes() if mode == 'update' else b'' + binary = Path('/tmp/pulse-demo-binary').read_bytes() if mode == 'update' else b'' + version_file = Path('/tmp/pulse-demo-VERSION').read_bytes() if mode == 'update' else b'' request = {'mode': mode, 'hostname': environ['DEMO_EXPECTED_HOSTNAME'], 'local_url': environ['DEMO_LOCAL_BASE_URL'], 'public_url': environ['DEMO_PUBLIC_HEALTH_URL'], 'version': environ['EXPECTED_VERSION'], 'profile': profile, 'control_sha': environ['GITHUB_SHA'], 'run_id': environ['GITHUB_RUN_ID'], 'run_attempt': environ['GITHUB_RUN_ATTEMPT'], - 'installer_sha256': hashlib.sha256(installer).hexdigest() if installer else ''} + 'binary_sha256': hashlib.sha256(binary).hexdigest() if binary else '', + 'version_sha256': hashlib.sha256(version_file).hexdigest() if version_file else ''} return {'request': request, 'source': base64.b64encode(Path(__file__).with_name('demo-runtime-transaction.py').read_bytes()).decode(), - 'installer': base64.b64encode(installer).decode()} + 'binary': base64.b64encode(binary).decode(), 'version_file': base64.b64encode(version_file).decode()} + + +NATIVE_PATHS = ('.github/scripts/demo-runtime-transaction.py', + '.github/scripts/dispatch-demo-runtime.py', + '.github/scripts/tests/demo_runtime_native.py', + '.github/workflows/demo-runtime-native.yml') + + +def require_native(environ=os.environ): + """Fail closed before SSH; a metadata/component pass is not native proof.""" + sha, repository = environ['GITHUB_SHA'], environ['GITHUB_REPOSITORY'] + if not re.fullmatch(r'[0-9a-f]{40}', sha) or repository != 'rcourtman/Pulse': + raise ValueError('native-source-identity') + since = (datetime.datetime.now(datetime.timezone.utc).date() - datetime.timedelta(days=6)).isoformat() + base = f'repos/{repository}/actions/workflows/demo-runtime-native.yml/runs?branch=main&event=push&per_page=5' + + def read(query): + result = subprocess.run(['gh', 'api', query], capture_output=True, timeout=30, check=False) + if result.returncode: + raise ValueError('native-evidence-unavailable') + rows = json.loads(result.stdout)['workflow_runs'] + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError('native-evidence-malformed') + for row in rows: + if (type(row.get('id')) is not int or not re.fullmatch(r'[0-9a-f]{40}', str(row.get('head_sha'))) + or type(row.get('run_attempt')) is not int or not isinstance(row.get('updated_at'), str) + or not isinstance(row.get('created_at'), str)): + raise ValueError('native-evidence-malformed') + return rows + + rows = read(base + '&created=' + since + '..*') + if len(rows) < 5: + rows += read(base) + merged = {} + for row in rows: + old = merged.get(row['id']) + if old is None or (row['run_attempt'], row['updated_at']) > (old['run_attempt'], old['updated_at']): + merged[row['id']] = row + for row in sorted(merged.values(), key=lambda r: (r['created_at'], r['id']), reverse=True): + head = row['head_sha'] + if (row.get('event') != 'push' or row.get('head_branch') != 'main' + or row.get('path') != '.github/workflows/demo-runtime-native.yml'): + raise ValueError('native-run-identity') + ancestor = subprocess.run(['git', 'merge-base', '--is-ancestor', head, sha], capture_output=True) + if ancestor.returncode != 0: + continue + matching = subprocess.run(['git', 'diff', '--quiet', head, sha, '--', *NATIVE_PATHS], capture_output=True) + if matching.returncode == 1: + continue + if matching.returncode != 0: + raise ValueError('native-source-unavailable') + # Never select an older green sample over a newer matching failure or + # pending run. The native driver exits success only with full cleanup. + if row.get('status') != 'completed' or row.get('conclusion') != 'success': + raise ValueError('native-acceptance-not-passed') + return {'run_id': row['id'], 'run_attempt': row['run_attempt'], 'source_sha': head} + raise ValueError('native-acceptance-missing') + + +def prepare_runtime(): + version = os.environ['EXPECTED_VERSION'] + if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', version): + raise ValueError('release-version') + spec = importlib.util.spec_from_file_location('demo_runtime', Path(__file__).with_name('demo-runtime-transaction.py')) + engine = importlib.util.module_from_spec(spec); spec.loader.exec_module(engine) + # Called only after mandatory pinned-key SSH signature verification in CI. + files = engine.runtime_members('/tmp/pulse-demo-release.tgz', version) + for label, name in (('binary', '/tmp/pulse-demo-binary'), ('version', '/tmp/pulse-demo-VERSION')): + with open(name, 'xb') as stream: + os.chmod(name, 0o600) + stream.write(files[label]) def main(): - if len(sys.argv) != 2 or sys.argv[1] not in {'update', 'recover'}: + if len(sys.argv) != 2 or sys.argv[1] not in {'prepare', 'update', 'recover'}: return 2 + if sys.argv[1] == 'prepare': + prepare_runtime() + return 0 + # Check exact reviewed native source before opening the production SSH path. + try: + require_native() + except (ValueError, KeyError, OSError, subprocess.TimeoutExpired): + print(json.dumps({'schema_version': 2, 'status': 'refused', 'mutated': False, + 'failure': 'native-acceptance-unavailable'})) + return 1 # Same project CI SSH identity and pinned host verification as before. This # is not an installed worker dispatcher or a new project credential. command = ['ssh', '-i', str(Path.home() / '.ssh/id_ed25519'), '-o', 'IdentitiesOnly=yes', diff --git a/.github/scripts/tests/demo_runtime_native.py b/.github/scripts/tests/demo_runtime_native.py index 0b7fc6a73b..e381e2e435 100644 --- a/.github/scripts/tests/demo_runtime_native.py +++ b/.github/scripts/tests/demo_runtime_native.py @@ -31,6 +31,9 @@ def binary(version, fail_after=0, port=17655): return ('''#!/usr/bin/python3 import http.server,json,os,threading,time VERSION = %r +if %r == 17655: + from pathlib import Path + Path('/etc/pulse/persistent-marker').write_text('candidate data ' + VERSION) def fail(): time.sleep(%r) print('panic: synthetic disposable service fault',flush=True) @@ -45,29 +48,13 @@ class Handler(http.server.BaseHTTPRequestHandler): self.send_response(200); self.end_headers(); self.wfile.write(body) def log_message(self,*args): pass http.server.HTTPServer(('127.0.0.1',%r),Handler).serve_forever() -''' % (version, fail_after, fail_after, port, port)).encode() +''' % (version, port, fail_after, fail_after, port, port)).encode() def unit(executable, user="pulse"): return f"[Unit]\nDescription=Disposable demo native fixture\n[Service]\nUser={user}\nGroup={user}\nExecStart={executable}\nRestart=no\n[Install]\nWantedBy=multi-user.target\n" -def installer(version, fail_after=0): - source = binary(version, fail_after) - return ("#!/usr/bin/python3\nfrom pathlib import Path\n" - "import base64\n" - f"p=Path('/opt/pulse/bin/pulse');p.write_bytes(base64.b64decode({base64.b64encode(source).decode()!r}));p.chmod(0o755)\n" - "p=Path('/etc/systemd/system/pulse.service');p.write_text(p.read_text()+'# candidate fixture\\n')\n" - f"Path('/etc/pulse/persistent-marker').write_text({'candidate data ' + version!r})\n").encode() - - -def fixture_installer(version, fail_after=0): - # The real engine invokes bash like the published installer. This fixture - # deliberately changes executable, unit and persistent data without download. - script = installer(version, fail_after) - return ("#!/bin/bash\nset -eu\npython3 - <<'PY'\n" + script.decode().split("\n", 1)[1] + "\nPY\n").encode() - - def wait(predicate, limit): deadline = time.monotonic() + limit while time.monotonic() < deadline: @@ -110,6 +97,13 @@ def main(): for path in Path("/etc/pulse").iterdir(): os.chown(path, owner.pw_uid, owner.pw_gid) Path("/opt/pulse/bin/pulse").write_bytes(binary("1.0.0")); Path("/opt/pulse/bin/pulse").chmod(0o755) + Path("/opt/pulse/VERSION").write_text("1.0.0\n") + Path("/opt/pulse/scripts").mkdir() + untouched = {"agent": Path("/opt/pulse/bin/pulse-agent-fixture"), + "script": Path("/opt/pulse/scripts/install-fixture.sh")} + for path in untouched.values(): + path.write_bytes(b"unchanged bundled distribution asset") + untouched_before = {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} Path("/opt/pulse/bin/relay").write_bytes(binary("1.0.0", port=17656)); Path("/opt/pulse/bin/relay").chmod(0o755) for name, executable in (("pulse", "/opt/pulse/bin/pulse"), ("pulse-relay", "/opt/pulse/bin/relay")): Path(f"/etc/systemd/system/{name}.service").write_text(unit(executable)) @@ -144,13 +138,16 @@ def main(): profile.update(seed_duration="2h", sample_interval="5m", update_interval="15s") def submit(version, fault=0): - install = fixture_installer(version, fault) + candidate = binary(version, fault) + metadata = (version + "\n").encode() request = {"mode": "update", "hostname": socket.gethostname(), "local_url": "http://127.0.0.1:17655", "public_url": "https://127.0.0.1:18443/api/health", "version": "v" + version, "profile": profile, "control_sha": os.environ["GITHUB_SHA"], "run_id": os.environ["GITHUB_RUN_ID"], "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], - "installer_sha256": hashlib.sha256(install).hexdigest()} - payload = {"request": request, "source": base64.b64encode(source).decode(), "installer": base64.b64encode(install).decode()} + "binary_sha256": hashlib.sha256(candidate).hexdigest(), + "version_sha256": hashlib.sha256(metadata).hexdigest()} + payload = {"request": request, "source": base64.b64encode(source).decode(), + "binary": base64.b64encode(candidate).decode(), "version_file": base64.b64encode(metadata).decode()} identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() proc = subprocess.Popen(["python3", "-c", dispatcher.BOOTSTRAP], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) processes.append(proc); proc.stdin.write(json.dumps(payload).encode()); proc.stdin.close() @@ -165,6 +162,8 @@ def main(): receipt = json.loads((attempt / "receipt.json").read_text()); result["cases"].append(receipt) assert proc.returncode == 0 and receipt["status"] == "committed" and receipt["forward"]["elapsed_seconds"] >= 300 assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1" + assert Path("/opt/pulse/VERSION").read_text() == "1.0.1\n" + assert {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} == untouched_before # A later candidate fails at 55s; lose the observer while the owned # systemd child continues, then TERM during actual full recovery. proc, attempt, payload = submit("1.0.2", 55) @@ -180,6 +179,8 @@ def main(): assert replay.returncode == 1 and receipt["status"] == "rolled_back" and receipt["recovery"]["elapsed_seconds"] >= 300 assert receipt["failure"] in {"new-service-crash", "sustained-health"} assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1" + assert Path("/opt/pulse/VERSION").read_text() == "1.0.1\n" + assert {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} == untouched_before version = json.loads(command(["curl", "--disable", "--silent", "--fail", "http://127.0.0.1:17655/api/version"]))["version"] assert version == "1.0.1" result["stage"] = "complete" diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 86bc90e60c..7215be25a6 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -1,5 +1,7 @@ import hashlib import importlib.util +import io +import tarfile import json import os from pathlib import Path @@ -29,7 +31,8 @@ def request(mode="recover"): return {"mode": mode, "hostname": "fixture", "local_url": "http://127.0.0.1:7655", "public_url": "https://demo.example/api/health", "version": "v6.4.5", "control_sha": "a" * 40, "run_id": "123", "run_attempt": "1", - "installer_sha256": hashlib.sha256(b"signed-installer-fixture").hexdigest() if mode == "update" else "", + "binary_sha256": hashlib.sha256(b"changed binary").hexdigest() if mode == "update" else "", + "version_sha256": hashlib.sha256(b"6.4.6\n").hexdigest() if mode == "update" else "", "profile": {**{name: "2" for name in engine.COUNT_KEYS}, "seed_duration": "2h", "sample_interval": "5m", "update_interval": "15s"}} @@ -123,13 +126,10 @@ class FixtureHost(engine.Host): self.unhealthy = False def install(self, attempt, req): - if hashlib.sha256((attempt / "installer.sh").read_bytes()).hexdigest() != req["installer_sha256"]: - raise engine.Failure("installer-identity") + # Keep the clock/services virtual, but use the real narrow file swaps. + super().install(attempt, req) self.install_count += 1 self.runtime_version = req["version"].lstrip("v") - engine.PATHS["binary"].write_bytes(b"new executable") - engine.PATHS["unit"].write_bytes(b"new unit") - (engine.PATHS["data"] / "persisted-history").write_bytes(b"forward history") def profile(self, req, unhealthy): super().profile(req, unhealthy) @@ -159,7 +159,15 @@ class TransactionTest(unittest.TestCase): self.root = root / "control" self.attempt = self.root / "attempts" / ("a" * 64) self.attempt.mkdir(parents=True) - (self.attempt / "installer.sh").write_bytes(b"signed-installer-fixture") + self.paths["version"].write_text("6.4.5\n") + (self.attempt / "binary").write_bytes(b"changed binary") + (self.attempt / "version").write_bytes(b"6.4.6\n") + # Untouched distribution/service assets are outside the chosen operation. + self.untouched = {name: root / "unowned" / name for name in + ("agent", "scripts", "helper", "auto-update", "timer", "symlink", "marker", "backup")} + for name, path in self.untouched.items(): + path.parent.mkdir(parents=True, exist_ok=True); path.write_text("original " + name) + self.untouched_before = engine.estate_hash(self.untouched) self.addCleanup(patch.stopall) patch.object(engine, "ROOT", self.root).start() patch.object(engine, "PATHS", self.paths).start() @@ -222,6 +230,34 @@ class TransactionTest(unittest.TestCase): self.assertEqual(engine.estate_hash(self.paths), self.before) self.assertEqual(self.host.runtime_version, "6.4.5") + def test_success_changes_only_runtime_and_profile_leaving_distribution_assets(self): + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (0, "committed")) + self.assertEqual(self.paths["binary"].read_bytes(), b"changed binary") + self.assertEqual(self.paths["version"].read_text(), "6.4.6\n") + for name in ("unit", "dropins"): + self.assertEqual(engine.estate_hash({name: self.paths[name]}), + engine.estate_hash({name: self.attempt / "snapshot" / name})) + self.assertEqual(engine.estate_hash(self.untouched), self.untouched_before) + self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) + + def test_late_failure_restores_VERSION_and_runtime_with_untouched_helpers(self): + self.host.fail_at = 55 + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (1, "rolled_back")) + self.assertEqual(self.paths["version"].read_text(), "6.4.5\n") + self.assertEqual(engine.estate_hash(self.paths), self.before) + self.assertEqual(engine.estate_hash(self.untouched), self.untouched_before) + self.assertEqual(receipt["recovery"]["runtime_sha256"], receipt["runtime_snapshot_sha256"]) + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + + def test_payload_version_mismatch_restores_without_installing(self): + (self.attempt / "version").write_text("6.4.7\n") + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (1, "rolled_back")) + self.assertEqual(receipt["failure"], "runtime-payload-identity") + self.assertEqual(engine.estate_hash(self.paths), self.before) + def test_failure_at_299_seconds_still_collects_entire_recovery_and_cancellation(self): self.host.fail_at = 299 self.host.cancel_in_recovery = True @@ -359,7 +395,7 @@ class InputAndCommandTest(unittest.TestCase): ("local_url", "http://other-host:7655"), ("public_url", "http://demo.example/api/health"), ("public_url", "https://user:secret@demo.example/api/health"), ("public_url", "https://demo.example/api/health?token=secret"), - ("installer_sha256", "arbitrary")] + ("binary_sha256", "arbitrary"), ("version_sha256", "arbitrary")] for name, value in cases: with self.subTest(name=name, value=value): req = request(); req[name] = value @@ -435,7 +471,7 @@ class DispatchLifecycleTest(unittest.TestCase): source = source.replace("root.stat().st_uid == 0", "root.stat().st_uid == os.getuid()") clock = [0] calls = [] - payload = {"request": request(), "source": "c291cmNl", "installer": ""} + payload = {"request": request(), "source": "c291cmNl", "binary": "", "version_file": ""} def launch(argv, **kwargs): calls.append(argv) @@ -490,13 +526,103 @@ class NativeAdmissionTest(unittest.TestCase): self.assertEqual(result["failure_code"], "not-an-empty-disposable-public-ci-runner") self.assertTrue(existing.is_dir()) - def test_synthetic_changed_executable_and_data_are_distinct_between_steps(self): + def test_synthetic_changed_executables_have_actual_persistent_effects(self): compile(native.binary("1.0.1"), "fixture-runtime", "exec") - compile(native.installer("1.0.1"), "fixture-installer", "exec") self.assertNotEqual(native.binary("1.0.1"), native.binary("1.0.2", 55)) - self.assertIn(b"candidate data 1.0.1", native.installer("1.0.1")) - self.assertIn(b"candidate data 1.0.2", native.installer("1.0.2", 55)) - self.assertNotIn(b"curl", native.fixture_installer("1.0.1")) + self.assertIn(b"persistent-marker", native.binary("1.0.1")) + self.assertNotIn(b"curl", native.binary("1.0.1")) + + + +class RuntimeSelectionTest(unittest.TestCase): + def archive(self, directory, entries): + path = Path(directory) / 'release.tgz' + with tarfile.open(path, 'w:gz') as stream: + for name, content, kind in entries: + member = tarfile.TarInfo(name); member.size = len(content); member.type = kind + if kind == tarfile.SYMTYPE: + member.linkname = '/outside'; member.size = 0 + stream.addfile(member) + else: + stream.addfile(member, io.BytesIO(content)) + return path + + def elf(self): + body = bytearray(64); body[:6] = b'\x7fELF\x02\x01'; body[18:20] = b'\x3e\x00' + return bytes(body) + + def test_selects_only_signed_archive_runtime_without_extracting_paths(self): + with tempfile.TemporaryDirectory() as directory: + entries = [('bin/pulse', self.elf(), tarfile.REGTYPE), ('./VERSION', b'6.4.6\n', tarfile.REGTYPE), + ('../outside', b'untrusted path', tarfile.REGTYPE), + ('scripts/install.sh', b'never executed', tarfile.REGTYPE), + ('bin/pulse-agent', b'not installed', tarfile.REGTYPE)] + files = engine.runtime_members(self.archive(directory, entries), 'v6.4.6') + self.assertEqual(set(files), {'binary', 'version'}) + self.assertEqual(files['binary'], self.elf()) + self.assertEqual([p.name for p in Path(directory).iterdir()], ['release.tgz']) + + def test_duplicate_link_wrong_version_or_architecture_is_rejected(self): + valid = [('bin/pulse', self.elf(), tarfile.REGTYPE), ('VERSION', b'6.4.6\n', tarfile.REGTYPE)] + cases = [valid + [('pulse', self.elf(), tarfile.REGTYPE)], + [valid[0], ('VERSION', b'', tarfile.SYMTYPE)], + [valid[0], ('VERSION', b'6.4.7\n', tarfile.REGTYPE)], + [('bin/pulse', b'#!/bin/sh\n', tarfile.REGTYPE), valid[1]], [valid[0]]] + for entries in cases: + with self.subTest(entries=[x[0] for x in entries]), tempfile.TemporaryDirectory() as directory: + with self.assertRaises(engine.Failure): + engine.runtime_members(self.archive(directory, entries), 'v6.4.6') + + +class NativeGateTest(unittest.TestCase): + def row(self, run_id=10, **fields): + return {'id': run_id, 'head_sha': 'a' * 40, 'run_attempt': 1, + 'created_at': '2026-10-03T01:00:00Z', 'updated_at': '2026-10-03T01:10:00Z', + 'path': '.github/workflows/demo-runtime-native.yml', 'event': 'push', 'head_branch': 'main', + 'status': 'completed', 'conclusion': 'success', **fields} + + def check(self, rows, history=None, read_exit=0, same=True): + calls = [] + def command(argv, **kwargs): + calls.append(argv) + if argv[0] == 'gh': + payload = rows if 'created=' in argv[-1] else (history if history is not None else rows) + return subprocess.CompletedProcess(argv, read_exit, json.dumps({'workflow_runs': payload}).encode()) + return subprocess.CompletedProcess(argv, 0 if argv[1] == 'merge-base' or same else 1) + with patch.object(dispatcher.subprocess, 'run', side_effect=command): + result = dispatcher.require_native({'GITHUB_SHA': 'b' * 40, 'GITHUB_REPOSITORY': 'rcourtman/Pulse'}) + return result, calls + + def test_matching_reviewed_native_source_passes_with_current_first_inventory(self): + result, calls = self.check([self.row()]) + self.assertEqual(result['run_id'], 10) + reads = [c for c in calls if c[0] == 'gh'] + self.assertEqual(len(reads), 2) + self.assertIn('created=', reads[0][-1]); self.assertNotIn('created=', reads[1][-1]) + self.assertEqual(set(calls[-1][6:]), set(dispatcher.NATIVE_PATHS)) + + def test_newer_matching_failure_or_pending_run_cannot_use_older_green(self): + for fields in ({'conclusion': 'failure'}, {'status': 'in_progress', 'conclusion': None}): + with self.subTest(fields=fields), self.assertRaisesRegex(ValueError, 'not-passed'): + self.check([self.row(11, **fields), self.row(10)]) + + def test_latest_duplicate_attempt_retains_failure(self): + with self.assertRaisesRegex(ValueError, 'not-passed'): + self.check([self.row()], [self.row(run_attempt=2, conclusion='failure')]) + + def test_missing_changed_or_misidentified_proof_cannot_enable_mutation(self): + for rows, same in (([], True), ([self.row()], False), ([self.row(event='workflow_dispatch')], True)): + with self.subTest(rows=rows, same=same), self.assertRaises(ValueError): + self.check(rows, same=same) + + def test_refused_read_has_no_history_fallback_or_ssh(self): + calls = [] + def denied(argv, **kwargs): + calls.append(argv); return subprocess.CompletedProcess(argv, 1, b'') + with patch.object(dispatcher.subprocess, 'run', side_effect=denied), self.assertRaises(ValueError): + dispatcher.require_native({'GITHUB_SHA': 'b' * 40, 'GITHUB_REPOSITORY': 'rcourtman/Pulse'}) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0:2], ['gh', 'api']) if __name__ == "__main__": diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml index bd6a2e4b63..3ca779668f 100644 --- a/.github/workflows/deploy-demo-server.yml +++ b/.github/workflows/deploy-demo-server.yml @@ -14,6 +14,7 @@ jobs: name: Verify Current Committed Stable Demo (No Mutation) permissions: contents: read + actions: read uses: ./.github/workflows/update-demo-server.yml secrets: inherit with: diff --git a/.github/workflows/recover-demo-server.yml b/.github/workflows/recover-demo-server.yml index b0dc92f2c0..5e6b0f37b8 100644 --- a/.github/workflows/recover-demo-server.yml +++ b/.github/workflows/recover-demo-server.yml @@ -5,6 +5,7 @@ on: permissions: contents: read + actions: read concurrency: # This is the same physical target lock used by update-demo-server.yml. @@ -99,6 +100,7 @@ jobs: - name: Recover current installed runtime id: recovery env: + GH_TOKEN: ${{ github.token }} DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} EXPECTED_VERSION: ${{ steps.stable.outputs.tag }} diff --git a/.github/workflows/release-convergence.yml b/.github/workflows/release-convergence.yml index 19aa282df1..736580dfee 100644 --- a/.github/workflows/release-convergence.yml +++ b/.github/workflows/release-convergence.yml @@ -340,6 +340,7 @@ jobs: if: ${{ needs.acquire_customer_promotion_lease.result == 'success' && needs.acquire_customer_promotion_lease.outputs.superseded != 'true' && inputs.prerelease != true && startsWith(inputs.version, '6.') }} permissions: contents: read + actions: read uses: ./.github/workflows/update-demo-server.yml secrets: inherit with: diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 3ea8d6fd08..0789d377a3 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -639,6 +639,7 @@ jobs: needs: dry-run permissions: contents: read + actions: read uses: ./.github/workflows/update-demo-server.yml secrets: inherit with: diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 214115ebbe..f39ac182d0 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -40,6 +40,7 @@ on: permissions: contents: read + actions: read # GitHub environments gate secret access and deployment admission, but do not # serialize jobs. Share one resource lock with the recovery workflow so two @@ -343,23 +344,27 @@ jobs: echo "::error::Timeout waiting for release assets" exit 1 - - name: Materialize tagged installer + - name: Verify signed release and select only demo server runtime if: inputs.verify_only != true env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} + EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - TAG="${WORKFLOW_OUTPUT_1}" TRUSTED_SSH_PUBLIC_KEY="$( go run ./scripts/release_update_key.go public-key-ssh \ --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ --comment pulse-installer )" - git show "refs/tags/${TAG}:install.sh" > /tmp/pulse-install.sh - sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh - grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" /tmp/pulse-install.sh - chmod +x /tmp/pulse-install.sh + asset="pulse-${EXPECTED_VERSION}-linux-amd64.tar.gz" + curl --disable --fail --location --silent --show-error --max-time 300 \ + -o /tmp/pulse-demo-release.tgz "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}" + curl --disable --fail --location --silent --show-error --max-time 30 \ + -o /tmp/pulse-demo-release.sshsig "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}.sshsig" + printf 'pulse-installer %s\n' "$TRUSTED_SSH_PUBLIC_KEY" > /tmp/pulse-demo-signers + ssh-keygen -Y verify -f /tmp/pulse-demo-signers -I pulse-installer \ + -n pulse-install -s /tmp/pulse-demo-release.sshsig < /tmp/pulse-demo-release.tgz + python3 .github/scripts/dispatch-demo-runtime.py prepare - name: Tailscale id: tailscale @@ -453,6 +458,7 @@ jobs: - name: Apply guarded demo transaction if: inputs.verify_only != true env: + GH_TOKEN: ${{ github.token }} DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }} diff --git a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md index 106e939ffc..0202d8e0c0 100644 --- a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md +++ b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md @@ -9,13 +9,17 @@ workflow queue does not exclude another repository's host deployment. - Both existing mutating workflows submit the same reviewed Python engine. Update still requires the immutable stable activation and customer-promotion - lease, exact tagged installer and trusted release signing key. Verification + lease and archive verification against the trusted release signing key. Only + the signed Linux/amd64 server binary and VERSION are selected; no installer is + executed. The demo does not serve enrolled agents, so updating its bundled + agents/scripts or updater/service assets is not part of this runtime operation. Verification only never submits it. Recovery never installs a different executable. - The independently owned systemd child holds the existing Relay deployment - file lock through capture, installation/profile work, forward observation, + file lock through capture, atomic runtime/profile work, forward observation, complete restoration and recovery observation. It does not restart Relay or Caddy, upgrade a package, lift the Caddy hold or activate frozen commercial copy. -- The complete quiescent Pulse executable/unit/drop-in/data estate is retained +- The complete quiescent **chosen footprint**: Pulse executable, VERSION, + unit/drop-in identity and demo data estate is retained privately before mutation. No backup or database is deleted for space. The unhealthy demo-only route retains the original generated operational history before its existing bounded-profile reset. No snapshot is automatically erased. @@ -39,7 +43,7 @@ workflow queue does not exclude another repository's host deployment. `TestDemoTransactionConnectedRecoveryControls` executes the real engine and bootstrap using a real private filesystem estate and virtual-clock command, service, journal and HTTP adapters. Tests retain delayed 55/299-second failure, -changed executable/unit/data rollback, cancellation, failed and unhealthy +changed executable/VERSION/data rollback, cancellation, failed and unhealthy recovery, headroom refusal, shared-lock contention, source mismatch, journal failure, cohost restart, source-bound repeated/lost submission and no-op proof. The production window is not shortened. This is **not native acceptance**. @@ -54,13 +58,13 @@ sent during restoration, and the old executable/data must return through a full real-clock recovery window. Sanitised artifacts bind control/driver/ engine/bootstrap identity, service PID/restart observations and cleanup. The fixture rebinds only Relay's fixed health URL to its local Caddy endpoint; the -original and fixture hashes remain distinct. This is a synthetic installer, -not signed published-installer or customer acceptance. Its source exists; +original and fixture hashes remain distinct. This exercises the actual narrow atomic runtime swaps with synthetic executables, +not a signed published archive or customer acceptance. Its source exists; **no native pass is asserted by this document**. Read the exact-head terminal job and artifact before judging that result. Before treating this route as restored, the exact reviewed source must return -native disposable systemd/SSH-loss receipts and signed published-installer +native disposable systemd/SSH-loss receipts and signed published-runtime forward/reverse data/identity results. Then reconcile actual demo workflow and public browser readback through the release owner; source tests do not establish an installed recovery. Delivery owns that continuation and the remaining ingress @@ -73,3 +77,36 @@ do not remove a lock, intent or snapshot merely to permit a fresh sample. A later frontend/network failure is not authority for a second SSH session to stop a committed or unrelated service. Keep the signed packet, failure and native/browser acceptance distinct in the release judgment. + +## Returned footprint defect and chosen correction (3 October 2026) + +Review of ee451f8c reproduced late failure followed by `rolled_back/verified` +with the candidate `/opt/pulse/VERSION` left installed. The old tests restricted +the synthetic installer to PATHS and missed that distribution mutation. + +The actual v6.4.5 installer at 99c8740785dcb6d66f7078112ae673ab45bf1b81 +changes the server, VERSION, bundled agents and scripts, binary symlink, +`/usr/local/bin/update-pulse`, `pulse-auto-update.sh`, service/auto-update units, +existing timer assets/activation, config/marker state and sibling configuration +backups (including rotation). Missing-install paths can create a service account +and acquire OS dependencies. Snapshotting only a binary and /etc/pulse cannot +roll all that back; adding more partial snapshots would repeat the defect. + +The existing installer and in-process updater were compared. Both include agent +distribution/helper provisioning unnecessary for this synthetic demo runtime; +the in-process updater also lacks this route's cohost exclusion and full-window +recovery. We keep existing signature verification and atomic sibling-swap +semantics but apply **only the two runtime files** and demo profile. Agents, +scripts, helpers, symlinks, backup rotation, service account, packages, unit and +timer provisioning/activation remain untouched. VERSION joins the private +snapshot and both runtime file identities are checked through forward/recovery +watches. Data rollback remains quiescent and retained; startup may naturally +write new data. This is not a claim to restore every general-installer effect. + +The CI dispatcher refuses production SSH mutation until the newest matching +main native run passes. It checks ancestry and final content of the engine, +dispatcher, native driver and workflow; an unrelated later main commit does not +invalidate unchanged proof. A matching failure/pending run cannot select an +older favourable sample. Refused/malformed reads have no fallback. Only the +normal recent-first/history-fill inventory rule is used for successful reads. +Native and signed installed acceptance are still separate; absence fails closed. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c24547706f..06b0edcb4f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -29,7 +29,7 @@ new route; disposable acceptance also rejects an unknown route rather than responding healthy to every URL. Before installation or runtime-profile mutation, stop only Pulse and durably -capture its executable, unit/drop-ins and entire persistent data estate. Refuse +capture its executable, VERSION, unit/drop-ins and entire persistent data estate. Refuse insufficient space rather than prune backups/databases. A failed healthy change restores that complete estate and observes another 300 seconds; the original operation still fails. An unhealthy recovery baseline is never claimed as a @@ -50,7 +50,7 @@ recovery. See [demo transaction acceptance](../DEMO_RUNTIME_TRANSACTION.md). The secret-free public `demo-runtime-native.yml` checks the exact PR/push source on a fresh hosted runner, using actual systemd and Caddy with disposable TLS and -synthetic executable/data fixtures. Its two real-window cases exercise a +synthetic executable/data fixtures through the real narrow atomic swaps. Its two real-window cases exercise a changed executable, a 55-second delayed synthetic failure, observer loss and TERM during full restoration. It records original/fixture source identities, actual bounded service observations and cleanup; a missing or failed record is @@ -2634,6 +2634,21 @@ artifact-selection behaviour. ## Current State +### Demo runtime footprint qualification + +The demo transaction executes no general installer. It verifies the activated +stable archive with the pinned SSH key/namespace, selects only regular bounded +Linux/amd64 server and VERSION members, and rechecks their exact hashes before +atomic sibling swaps. VERSION is in the owned rollback snapshot; runtime hashes +remain fixed through forward and restored 300-second observations. No agent, +script, helper, unit/timer, service-account, package or backup-rotation effect is +claimed or applied. CI production mutation requires a successful main native +run whose engine/dispatcher/driver/workflow final content matches current main, +and refuses newer matching failures, pending runs and unavailable evidence. +The v6.4.5 full-installer VERSION rollback defect and this route's signed/native +limits are retained in DEMO_RUNTIME_TRANSACTION.md. + + ### Existing-install auto-update consent (1 October 2026) A manual update, version-pinned rollback or reinstall is not consent to turn diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index fff9fd54fd..efc7f3a7f6 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2326,7 +2326,9 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `Refuse mutation during verification-only checks`, `uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0`, `go run ./scripts/release_update_key.go public-key-ssh`, - `sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh`, + `ssh-keygen -Y verify -f /tmp/pulse-demo-signers -I pulse-installer`, + `-n pulse-install -s /tmp/pulse-demo-release.sshsig`, + `python3 .github/scripts/dispatch-demo-runtime.py prepare`, `Verify target host identity`, `bash .github/scripts/setup-demo-ssh.sh`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, diff --git a/scripts/installtests/demo_transaction_test.go b/scripts/installtests/demo_transaction_test.go index d2d2be0683..dbc8bafe0d 100644 --- a/scripts/installtests/demo_transaction_test.go +++ b/scripts/installtests/demo_transaction_test.go @@ -41,7 +41,7 @@ func TestDemoTransactionKeepsSignedAdmissionAndVerificationOnlyReadOnly(t *testi t.Fatalf("unguarded demo mutation remains: %s", forbidden) } } - for _, required := range []string{"Require exact committed activation marker for mutation", "release-activation.json", "Stable demo mutation refuses mutable, inactive, or prerelease tag", "PINNED_RELEASE_SSH_PUBLIC_KEY", "git show \"refs/tags/${TAG}:install.sh\"", "Verify public browser smoke"} { + for _, required := range []string{"Require exact committed activation marker for mutation", "release-activation.json", "Stable demo mutation refuses mutable, inactive, or prerelease tag", "ssh-keygen -Y verify", "-n pulse-install", "${asset}.sshsig", "Verify public browser smoke"} { if !strings.Contains(text, required) { t.Fatalf("lost signed/admission/customer verification boundary: %s", required) } From ac995ef565eddeab517a5d787daf1d28313bf60d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:10:14 +0100 Subject: [PATCH 22/86] Keep pending notification groups occurrence-qualified Coalesce repeated firing callbacks before delivery into the newest complete occurrence snapshot, retaining distinct and unknown identities and the original grouping deadline. Discard the transient index on cancellation, flush, disable and stop; cancel abandoned firing timers before queue shutdown. Verify ordering, concurrency, routing, persistent restart and actual loopback firing/recovery delivery without changing escalation or retry policy. Change-source: pulse-maintainer --- .../v6/internal/subsystems/notifications.md | 27 ++ .../firing_grouping_occurrence_test.go | 359 ++++++++++++++++++ internal/notifications/notifications.go | 71 +++- 3 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 internal/notifications/firing_grouping_occurrence_test.go diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index 2903cb9f5a..ae96dfcbce 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -15,6 +15,33 @@ ## Purpose +### Pending firing groups count occurrences, not callbacks + +Before a grouping window expires, repeated firing callbacks with the same full +alert ID and start instant occupy one slot. The newest observed snapshot owns +the whole payload and operational linkage, including a genuine later severity +downgrade. Equal observation times prefer higher severity; an unknown time +cannot replace a known later observation. Missing ID/start identity remains +separate, as do different occurrences of a reusable ID. The index is scoped to +the pending window and discarded on flush, disable, cancellation or stop; callbacks +do not extend the original timer or establish delivery. + +`firing_grouping_occurrence_test.go` verifies timestamp/severity ordering, +instant equality across time-zone/monotonic representations, cloned payloads, +concurrent callbacks, occurrence-qualified cancellation, all-destination and +webhook-only disable/zero-window flushes, and notification disable/re-enable. +Stopping the manager cancels its volatile firing timer and snapshots, rather +than letting an abandoned timer send directly after its queue is closed. +Committed queue work retains its existing restart semantics. +`TestPendingFiringGroupStopCancelsTimer` checks actual timer/HTTP silence after stop. +The connected `TestPendingFiringGroupHTTPAcrossRestart` uses actual timer expiry, +persistent queue reopen, the autonomous sender and two loopback receivers: one +snapshot per occurrence/destination, truthful counts/latest content, ordinary +cooldown and independent firing/recovery receipts. Ungrouped admissions, +scheduled escalation, retry budgets, queue schema and delivery policy are +unchanged. This is source acceptance, not a native destination, natural schedule +cycle or a reproduced cause of the existing flood reports. + ### Completion order cannot replace a newer occurrence's cooldown Firing completions still record an independent delivery receipt for their own diff --git a/internal/notifications/firing_grouping_occurrence_test.go b/internal/notifications/firing_grouping_occurrence_test.go new file mode 100644 index 0000000000..6544adce92 --- /dev/null +++ b/internal/notifications/firing_grouping_occurrence_test.go @@ -0,0 +1,359 @@ +package notifications + +import ( + "encoding/json" + "net/http" + "strconv" + "sync" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/stretchr/testify/require" +) + +func pendingGroupSnapshot(id string, start, seen time.Time, level alerts.AlertLevel, message string) *alerts.Alert { + a := quietReplayAlert(id, level, start) + a.LastSeen = seen + a.Message = message + a.Metadata = map[string]interface{}{"snapshot": message} + a.LatestTransition.ID = "transition-" + message + return a +} + +func TestPendingFiringGroupSnapshotOrdering(t *testing.T) { + start := time.Now().UTC().Add(-time.Minute) + first := pendingGroupSnapshot("cpu", start, start, alerts.AlertLevelWarning, "first") + for _, tc := range []struct { + name string + before func(*alerts.Alert) + update func(*alerts.Alert) + wantNew bool + wantTwo bool + }{ + {name: "repeat", wantNew: true}, + {name: "newer_critical", update: func(a *alerts.Alert) { a.LastSeen = start.Add(time.Second); a.Level = alerts.AlertLevelCritical }, wantNew: true}, + {name: "newer_downgrade", before: func(a *alerts.Alert) { a.Level = alerts.AlertLevelCritical }, update: func(a *alerts.Alert) { a.LastSeen = start.Add(time.Second) }, wantNew: true}, + {name: "older_critical", update: func(a *alerts.Alert) { a.LastSeen = start.Add(-time.Second); a.Level = alerts.AlertLevelCritical }}, + {name: "equal_time_critical", update: func(a *alerts.Alert) { a.Level = alerts.AlertLevelCritical }, wantNew: true}, + {name: "equal_time_downgrade", before: func(a *alerts.Alert) { a.Level = alerts.AlertLevelCritical }}, + {name: "unknown_observation", update: func(a *alerts.Alert) { a.LastSeen = time.Time{}; a.Level = alerts.AlertLevelCritical }}, + {name: "known_observation", before: func(a *alerts.Alert) { a.LastSeen = time.Time{} }, wantNew: true}, + {name: "same_instant_different_location", update: func(a *alerts.Alert) { a.StartTime = start.In(time.FixedZone("other", 3600)) }, wantNew: true}, + {name: "same_instant_without_monotonic", before: func(a *alerts.Alert) { a.StartTime = time.Now() }, update: func(a *alerts.Alert) { a.StartTime = a.StartTime.Round(0) }, wantNew: true}, + {name: "different_occurrence", update: func(a *alerts.Alert) { a.StartTime = start.Add(time.Nanosecond) }, wantTwo: true}, + {name: "different_identifier", update: func(a *alerts.Alert) { a.ID = "other-cpu" }, wantTwo: true}, + {name: "unknown_start", before: func(a *alerts.Alert) { a.StartTime = time.Time{} }, wantTwo: true}, + {name: "unknown_identifier", before: func(a *alerts.Alert) { a.ID = "" }, wantTwo: true}, + } { + t.Run(tc.name, func(t *testing.T) { + m := NewNotificationManagerWithDeferredQueue("", t.TempDir()) + t.Cleanup(m.Stop) + m.SetGroupingConfig(true, 3600, false, false) + a := first.Clone() + if tc.before != nil { + tc.before(a) + } + m.SendAlert(a) + m.mu.RLock() + timer := m.groupTimer + m.mu.RUnlock() + incoming := pendingGroupSnapshot(a.ID, a.StartTime, first.LastSeen, alerts.AlertLevelWarning, "incoming") + if tc.update != nil { + tc.update(incoming) + } + m.SendAlert(nil) + m.SendAlert(incoming) + m.mu.RLock() + defer m.mu.RUnlock() + require.Same(t, timer, m.groupTimer, "an update must not restart the grouping deadline") + require.Empty(t, m.lastNotified, "group admission is not delivery") + if tc.wantTwo { + require.Len(t, m.pendingAlerts, 2, "incomplete or distinct identities must not merge") + require.Equal(t, a, m.pendingAlerts[0]) + require.Equal(t, incoming, m.pendingAlerts[1]) + return + } + require.Len(t, m.pendingAlerts, 1, "one occurrence must occupy one group slot") + want := a + if tc.wantNew { + want = incoming + } + require.Equal(t, want, m.pendingAlerts[0], "payload and operational linkage must come from the same selected snapshot") + incoming.Metadata["snapshot"] = "caller-mutated" + require.NotEqual(t, incoming.Metadata, m.pendingAlerts[0].Metadata, "pending snapshot must not alias its caller") + }) + } +} + +func TestPendingFiringGroupConcurrentSnapshots(t *testing.T) { + m := NewNotificationManagerWithDeferredQueue("", t.TempDir()) + t.Cleanup(m.Stop) + m.SetGroupingConfig(true, 3600, false, false) + start := time.Now().UTC().Add(-time.Minute) + var wg sync.WaitGroup + for i := range 64 { + wg.Add(1) + go func(i int) { + defer wg.Done() + m.SendAlert(pendingGroupSnapshot("cpu", start, start.Add(time.Duration(i)*time.Second), alerts.AlertLevelWarning, strconv.Itoa(i))) + }(i) + } + wg.Wait() + m.mu.RLock() + defer m.mu.RUnlock() + require.Len(t, m.pendingAlerts, 1) + require.Equal(t, "63", m.pendingAlerts[0].Message) + require.Equal(t, "transition-63", m.pendingAlerts[0].LatestTransition.ID) + require.Empty(t, m.lastNotified) +} + +func TestPendingFiringGroupCancellationAndFlush(t *testing.T) { + for _, target := range []string{"all", "webhook"} { + for _, flush := range []string{"disable", "zero_window", "disable_notifications"} { + t.Run(target+"/"+flush, func(t *testing.T) { + m := NewNotificationManagerWithDeferredQueue("", t.TempDir()) + t.Cleanup(m.Stop) + m.SetGroupingConfig(true, 3600, false, false) + m.SetInitialNotifyTarget(target) + m.SetEmailConfig(EmailConfig{Enabled: true, SMTPHost: "smtp.example.test", SMTPPort: 587, From: "pulse@example.test", To: []string{"ops@example.test"}}) + m.SetAppriseConfig(AppriseConfig{Enabled: true, Targets: []string{"ntfy://example.test/pulse"}}) + m.AddWebhook(WebhookConfig{ID: "ops", URL: "https://ops.example.test", Enabled: true}) + start := time.Now().UTC().Add(-time.Minute) + old := pendingGroupSnapshot("cpu", start, start, alerts.AlertLevelCritical, "old") + current := pendingGroupSnapshot("cpu", start.Add(time.Nanosecond), start, alerts.AlertLevelWarning, "current") + current.OperationalRecord.ID += "-new" + peer := pendingGroupSnapshot("memory", start, start, alerts.AlertLevelWarning, "peer") + for _, a := range []*alerts.Alert{old, current, old, peer, current} { + m.SendAlert(a) + } + require.True(t, m.CancelResolvedAlert(old)) + // Rebuild after cancellation; a stale index must neither overwrite + // the peer nor append the current occurrence again. + current.LastSeen = start.Add(time.Second) + current.Message = "updated-current" + current.LatestTransition.ID = "updated-transition" + m.SendAlert(current) + m.mu.RLock() + pending := append([]*alerts.Alert(nil), m.pendingAlerts...) + m.mu.RUnlock() + require.Equal(t, []*alerts.Alert{current, peer}, pending) + switch flush { + case "disable": + m.SetGroupingConfig(false, 30, false, false) + case "zero_window": + m.SetGroupingConfig(true, 0, false, false) + case "disable_notifications": + m.SetEnabled(false) + m.SetEnabled(true) + m.SendAlert(peer) + m.SetGroupingConfig(false, 0, false, false) + pending = []*alerts.Alert{peer} + } + rows := quietReplayRows(t, m.GetQueue()) + destinations := 3 + if target == "webhook" { + destinations = 1 + } + require.Len(t, rows, destinations*len(pending)) + counts := map[string]int{} + for _, row := range rows { + require.Equal(t, QueueStatusPending, row.Status) + require.Zero(t, row.Attempts) + require.Len(t, row.Alerts, 1) + require.Len(t, row.Links, 1) + a := row.Alerts[0] + require.Equal(t, a.LatestTransition.ID, row.Links[0].TransitionID) + require.False(t, old.ID == a.ID && old.StartTime.Equal(a.StartTime), "cancelled occurrence must not reach the queue") + counts[row.Type+"/"+a.ID]++ + } + for _, count := range counts { + require.Equal(t, 1, count, "flush duplicated an occurrence/destination") + } + m.mu.RLock() + require.Empty(t, m.pendingAlerts) + require.Nil(t, m.groupTimer) + require.Empty(t, m.lastNotified, "persisting pending work is not a successful delivery") + m.mu.RUnlock() + }) + } + } +} + +// The real timer admits one snapshot per occurrence into the persistent queue. +// Bootstrap restart, autonomous processing and recovery use normal entry points; +// no synthetic successful receipt, direct dispatcher or edited queue clock. +func TestPendingFiringGroupHTTPAcrossRestart(t *testing.T) { + type message struct { + destination string + Event string `json:"event"` + Grouped bool `json:"grouped"` + Alerts []*alerts.Alert `json:"alerts"` + Count int `json:"count"` + } + received := make(chan message, 16) + server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var msg message + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + t.Errorf("decode grouped delivery: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + msg.destination = r.URL.Path + select { + case received <- msg: + default: + t.Error("unexpected excess grouped deliveries") + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + dir := t.TempDir() + hooks := []WebhookConfig{ + {ID: "left", URL: server.URL + "/left", Enabled: true, Service: "generic"}, + {ID: "right", URL: server.URL + "/right", Enabled: true, Service: "generic"}, + } + open := func() *NotificationManager { + m := NewNotificationManagerWithDeferredQueue("", dir) + t.Cleanup(m.Stop) + m.webhookClient = server.Client() + require.NoError(t, m.UpdateAllowedPrivateCIDRs("127.0.0.1/32")) + m.SetCooldown(0) + m.SetGroupingConfig(true, 1, false, false) + for _, hook := range hooks { + m.AddWebhook(hook) + } + return m + } + wait := func(check func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !check() { + if time.Now().After(deadline) { + t.Fatal("grouped delivery did not complete") + } + time.Sleep(time.Millisecond) + } + } + m := open() + start := time.Now().UTC().Add(-time.Minute) + current := pendingGroupSnapshot("cpu", start, start, alerts.AlertLevelWarning, "warning") + peer := pendingGroupSnapshot("memory", start, start, alerts.AlertLevelWarning, "peer") + for range 32 { + m.SendAlert(current) + } + m.SendAlert(peer) + current.LastSeen = time.Now().UTC() + current.Level = alerts.AlertLevelCritical + current.Message = "current critical" + current.LatestTransition.ID = "current-critical-transition" + m.SendAlert(current) + // Actual timer expiry, not a call to the private flush method. + wait(func() bool { return len(quietReplayRows(t, m.GetQueue())) == 2 }) + for _, row := range quietReplayRows(t, m.GetQueue()) { + require.Len(t, row.Alerts, 2, "callback count leaked into persisted payload") + require.Equal(t, current, row.Alerts[0]) + require.Equal(t, peer, row.Alerts[1]) + require.Len(t, row.Links, 2) + require.Equal(t, current.LatestTransition.ID, row.Links[0].TransitionID) + require.Zero(t, row.Attempts) + } + m.Stop() + m = open() + m.StartQueueProcessing() + wait(func() bool { + stats, err := m.GetQueue().GetQueueStats() + require.NoError(t, err) + return stats["sent"] == 2 + }) + firingDestinations := map[string]bool{} + for range 2 { + select { + case msg := <-received: + require.True(t, msg.Grouped) + require.Equal(t, 2, msg.Count) + require.Len(t, msg.Alerts, 2) + require.Equal(t, current.Message, msg.Alerts[0].Message) + require.Equal(t, current.Level, msg.Alerts[0].Level) + require.Equal(t, peer.ID, msg.Alerts[1].ID) + require.Contains(t, []string{"/left", "/right"}, msg.destination) + require.False(t, firingDestinations[msg.destination], "one destination cannot stand in for another") + firingDestinations[msg.destination] = true + default: + t.Fatal("successful queued firing has no receiver evidence") + } + } + m.SendAlert(current) + m.mu.RLock() + require.Empty(t, m.pendingAlerts, "delivered occurrence must keep its ordinary cooldown") + m.mu.RUnlock() + require.False(t, m.CancelResolvedAlert(current)) + m.SetGroupingConfig(false, 0, false, false) + m.SendResolvedAlert(&alerts.ResolvedAlert{Alert: current, ResolvedTime: time.Now()}) + wait(func() bool { + stats, err := m.GetQueue().GetQueueStats() + require.NoError(t, err) + return stats["sent"] == 4 + }) + recoveryDestinations := map[string]bool{} + for range 2 { + select { + case msg := <-received: + require.Equal(t, "resolved", msg.Event) + require.Len(t, msg.Alerts, 1) + require.Equal(t, current.ID, msg.Alerts[0].ID) + require.True(t, firingDestinations[msg.destination]) + require.False(t, recoveryDestinations[msg.destination]) + recoveryDestinations[msg.destination] = true + default: + t.Fatal("successful queued recovery has no receiver evidence") + } + } + m.Stop() + m = open() + for _, hook := range hooks { + for _, tc := range []struct { + a *alerts.Alert + want int + }{{current, 0}, {peer, 1}} { + jobs := buildNotificationDeliveryJobs(EmailConfig{}, []WebhookConfig{hook}, AppriseConfig{}, []*alerts.Alert{tc.a}, eventResolved, time.Now()) + require.Len(t, m.filterResolvedJobsByDeliveryReceipt(jobs), tc.want, "reopen must preserve each destination/occurrence receipt independently") + } + } + select { + case extra := <-received: + t.Fatalf("duplicate provider message: %+v", extra) + default: + } + t.Log("34 callbacks become two occurrence snapshots per destination; actual timer, persisted restart, two HTTP 200 firing deliveries and two matching recoveries pass, without duplicate receiver messages.") +} + +func TestPendingFiringGroupStopCancelsTimer(t *testing.T) { + received := make(chan struct{}, 4) + server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received <- struct{}{} + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + m := NewNotificationManagerWithDeferredQueue("", t.TempDir()) + t.Cleanup(m.Stop) + m.webhookClient = server.Client() + require.NoError(t, m.UpdateAllowedPrivateCIDRs("127.0.0.1/32")) + m.AddWebhook(WebhookConfig{ID: "ops", URL: server.URL, Enabled: true}) + m.SetGroupingConfig(true, 1, false, false) + start := time.Now().UTC() + m.SendAlert(pendingGroupSnapshot("cpu", start, start, alerts.AlertLevelWarning, "pending at stop")) + m.Stop() + // An abandoned timer must not dispatch directly after Stop clears the queue. + select { + case <-received: + t.Fatal("stopped manager delivered its abandoned firing group") + case <-time.After(1500 * time.Millisecond): + } + m.mu.RLock() + defer m.mu.RUnlock() + require.Empty(t, m.pendingAlerts) + require.Nil(t, m.groupTimer) + require.Empty(t, m.lastNotified) +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index d508f53bbb..1741712f9d 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -248,6 +248,7 @@ type NotificationManager struct { groupWindow time.Duration groupingEnabled bool pendingAlerts []*alerts.Alert + pendingAlertIndex map[pendingFiringOccurrence]int groupTimer *time.Timer pendingResolved []*alerts.Alert resolvedGroupTimer *time.Timer @@ -1007,7 +1008,9 @@ func (n *NotificationManager) SetGroupingConfig(enabled bool, seconds int, byNod if !enabled || seconds == 0 { resolved = n.takePendingResolvedLocked() pending = append(pending, n.pendingAlerts...) + clear(n.pendingAlerts) n.pendingAlerts = n.pendingAlerts[:0] + n.pendingAlertIndex = nil if n.groupTimer != nil { n.groupTimer.Stop() n.groupTimer = nil @@ -1118,10 +1121,9 @@ func (n *NotificationManager) SetEnabled(enabled bool) { n.enabled = enabled if !enabled { n.takePendingResolvedLocked() - for i := range n.pendingAlerts { - n.pendingAlerts[i] = nil - } + clear(n.pendingAlerts) n.pendingAlerts = n.pendingAlerts[:0] + n.pendingAlertIndex = nil if n.groupTimer != nil { n.groupTimer.Stop() n.groupTimer = nil @@ -1251,8 +1253,9 @@ func (n *NotificationManager) sendAlert(alert *alerts.Alert, options alertSendOp Bool("inCooldown", exists). Msg("alert passed cooldown check - adding to pending notifications") - // Add to pending alerts for grouping - n.pendingAlerts = append(n.pendingAlerts, alert) + // Group occurrences, not callback invocations. A repeat before delivery has + // no cooldown receipt yet, but must not inflate the batch's count or payload. + n.addPendingFiringAlertLocked(alert) // If this is the first alert in the group, start the timer if n.groupTimer == nil { @@ -1267,6 +1270,52 @@ func (n *NotificationManager) sendAlert(alert *alerts.Alert, options alertSendOp n.mu.Unlock() } +type pendingFiringOccurrence struct { + id string + start time.Time +} + +func pendingFiringKey(alert *alerts.Alert) (pendingFiringOccurrence, bool) { + if alert == nil || alert.ID == "" || alert.StartTime.IsZero() { + // Incomplete legacy identity cannot prove that two callbacks refer to + // the same occurrence. Preserve both rather than silently lose one. + return pendingFiringOccurrence{}, false + } + // time.Time's map equality includes location and monotonic clock data; + // occurrence identity must use instant equality, as cancellation does. + return pendingFiringOccurrence{alert.ID, alert.StartTime.UTC().Round(0)}, true +} + +// addPendingFiringAlertLocked takes an already-cloned snapshot under n.mu. +// The index belongs only to the current grouping window; cancellation rebuilds +// it lazily, and every flush/disable discards it. No delivery is recorded here. +func (n *NotificationManager) addPendingFiringAlertLocked(alert *alerts.Alert) { + if key, known := pendingFiringKey(alert); known { + if n.pendingAlertIndex == nil { + n.pendingAlertIndex = make(map[pendingFiringOccurrence]int, len(n.pendingAlerts)+1) + for index, pending := range n.pendingAlerts { + if key, known := pendingFiringKey(pending); known { + n.pendingAlertIndex[key] = index + } + } + } + if index, exists := n.pendingAlertIndex[key]; exists { + previous := n.pendingAlerts[index] + // Keep the newest observation's complete snapshot, including its + // operational links. Equal-time callbacks prefer higher severity; + // unknown observation time cannot evict a known newer observation. + if previous.LastSeen.After(alert.LastSeen) || + (previous.LastSeen.Equal(alert.LastSeen) && notificationSeverityRank(previous.Level) > notificationSeverityRank(alert.Level)) { + return + } + n.pendingAlerts[index] = alert + return + } + n.pendingAlertIndex[key] = len(n.pendingAlerts) + } + n.pendingAlerts = append(n.pendingAlerts, alert) +} + // Unknown levels cannot opt into the severity cooldown exception. func notificationSeverityRank(level alerts.AlertLevel) int { switch level { @@ -1633,6 +1682,9 @@ func (n *NotificationManager) cancelAlert(alertID string, start *time.Time) bool } n.pendingAlerts = filtered + if removed > 0 { + n.pendingAlertIndex = nil + } if len(n.pendingAlerts) == 0 && n.groupTimer != nil { if n.groupTimer.Stop() { @@ -1687,7 +1739,9 @@ func (n *NotificationManager) sendGroupedAlerts() { copy(alertsToSend, n.pendingAlerts) // Clear pending alerts + clear(n.pendingAlerts) n.pendingAlerts = n.pendingAlerts[:0] + n.pendingAlertIndex = nil if n.groupTimer != nil { n.groupTimer.Stop() } @@ -4214,6 +4268,13 @@ func (n *NotificationManager) Stop() { n.mu.Lock() n.enabled = false n.takePendingResolvedLocked() + clear(n.pendingAlerts) + n.pendingAlerts = n.pendingAlerts[:0] + n.pendingAlertIndex = nil + if n.groupTimer != nil { + n.groupTimer.Stop() + n.groupTimer = nil + } queue := n.queue cleanupDone := n.cleanupDone client := n.webhookClient From a23029a80c61d9bbee5c226a85f8a9e9e6729d18 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:15:20 +0100 Subject: [PATCH 23/86] Keep demo installation proof aligned with signed runtime operation The complete installation suite detected the obsolete installer-identity assertion. Require the actual binary/VERSION payload identity check instead and retain the connected Python verdict in verbose and JSON source proof output. Contract-Neutral: Test-only alignment and proof visibility for the already implemented signed runtime footprint; no runtime, authority or contract delta. Change-source: pulse-maintainer --- scripts/installtests/build_release_assets_test.go | 2 +- scripts/installtests/demo_transaction_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index efc7f3a7f6..95bc3ddeae 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2367,7 +2367,7 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `restored-estate-identity`, `demo_fixtures`, `PULSE_MOCK_SEED_METRICS_STORE`, - `installer-identity`, + `runtime-payload-identity`, `/api/license/runtime-capabilities`, `Mock mode enabled`, `Demo server mock mode did not enable after entitlement sync`, diff --git a/scripts/installtests/demo_transaction_test.go b/scripts/installtests/demo_transaction_test.go index dbc8bafe0d..c0135f7b33 100644 --- a/scripts/installtests/demo_transaction_test.go +++ b/scripts/installtests/demo_transaction_test.go @@ -16,6 +16,7 @@ func TestDemoTransactionConnectedRecoveryControls(t *testing.T) { if !strings.Contains(string(output), "\nOK\n") { t.Fatalf("demo controls did not return a complete unittest verdict: %s", output) } + t.Logf("Connected Python controls:\n%s", output) } func TestDemoTransactionKeepsSignedAdmissionAndVerificationOnlyReadOnly(t *testing.T) { From 4fbb69a7dd5a439dd94ae036417979523732061e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:50:08 +0100 Subject: [PATCH 24/86] Make server update and rollback help evidence-preserving Replace the destructive hardcoded restore recipe with path and backup-scope checks, the existing history control and reversible recovery guidance. Keep Compose image selection persistent and recreate only Pulse after a successful pull. Verify shipped mirrors, source/UI scope, synthetic command failures and desktop/phone Docs rendering. Change-source: pulse-maintainer --- docs/AUTO_UPDATE.md | 122 +++++++++--- docs/DOCKER.md | 51 ++++-- .../browser-tests/update-recovery-help.cjs | 99 ++++++++++ frontend-modern/public/docs/AUTO_UPDATE.md | 122 +++++++++--- frontend-modern/public/docs/DOCKER.md | 51 ++++-- scripts/tests/test_update_recovery_docs.py | 173 ++++++++++++++++++ 6 files changed, 534 insertions(+), 84 deletions(-) create mode 100644 frontend-modern/browser-tests/update-recovery-help.cjs create mode 100644 scripts/tests/test_update_recovery_docs.py diff --git a/docs/AUTO_UPDATE.md b/docs/AUTO_UPDATE.md index 6d0fe0de29..6f366f4757 100644 --- a/docs/AUTO_UPDATE.md +++ b/docs/AUTO_UPDATE.md @@ -83,19 +83,16 @@ Auto-update preferences are stored in `system.json` and edited via the UI. ### Docker -```bash -# Pull latest image -docker pull rcourtman/pulse:vX.Y.Z +Use [Docker server updates](DOCKER.md#-updates). Change the configured image to +the exact release first, then pull and recreate only the Pulse service with the +same mounted data. Pulling another tag alone does not change the image in your +Compose file. Do not run `docker compose down` for a Pulse-only update or remove +its data volume. -# Restart container -docker compose down && docker compose up -d -``` - -This command uses the public Community image. Private Pro runtime installs must -use the private image and credentials supplied by the private download/update -path; replacing a Pro image with `rcourtman/pulse` changes the runtime edition. - -If you use the legacy `docker-compose` binary, replace `docker compose` with `docker-compose`. +Private Pro installs must keep the private image supplied by the +[download page](https://pulserelay.pro/download.html); replacing it with +`rcourtman/pulse` changes the runtime edition. Server updates and monitored +container updates are separate operations. ### ProxmoxVE LXC (Manual) @@ -124,30 +121,99 @@ sudo systemctl restart pulse ## Rollback -If an update causes issues: +An update error or a stuck progress modal does not prove that the old version +is still running. Check the running version and service health before retrying +or restoring anything; preserve the update error, logs and history. Do not run +another update just to reproduce a failure. + +Before any rollback, take a consistent private backup of the current state and +check the proposed backup's scope. Keep the failed installation and update +snapshots until recovery is verified. A rollback can revert settings, alert +changes and history written since the selected backup. ### Automatic Rollback -Pulse creates a backup before updating. If the update fails: -1. The previous version is automatically restored -2. Service restarts with the old version -3. Error details are logged + +The in-app updater creates an installation snapshot before applying files. If +applying files fails, it attempts to restore that snapshot and logs any restore +error. This is not a guarantee that every update failure restores all state or +restarts successfully. Confirm the running version, connections and service +health afterwards; a progress message alone is not recovery evidence. + +### Roll back from Update History + +If your installed version offers it, open **Settings โ†’ System โ†’ Updates โ†’ +Update History**. **Roll back** is offered for a successful in-app update with +a retained backup. The confirmation names the version before that update and +warns that later settings and alert changes will be reverted. Check that version +and backup before confirming; Pulse restarts to use the restored binary. + +Older versions may not have this control. An absent button or backup does not +justify deleting data or creating an empty configuration. Docker server installs +use their previous image and matching data backup, not this in-app rollback. + +### What an update snapshot contains + +In-app snapshots normally live at `backup-/` under the runtime data +directory (usually `/etc/pulse`), with `/tmp/pulse-backup-` as a +low-space fallback. Use the `backup_path` recorded for the actual update in +`update-history.jsonl`, rather than guessing a timestamp. The updater retains +the most recent three snapshots; a `/tmp` snapshot may also be lost on reboot. + +The in-app snapshot copies the running server binary, available `VERSION` and +`.env` files, and available `data/` and `config/` directories under +`PULSE_INSTALL_DIR` (default `/opt/pulse`). It is not necessarily a complete +backup of the active `PULSE_DATA_DIR`, external metrics/audit stores, service +units, bundled agents or update helpers. Copy errors can leave a partial +snapshot: a recorded path is not proof that every file was saved. + +Verify the backup against your effective service paths and update logs. Preserve +the active data directory, matching `.encryption.key`, audit history and its +signing key together, including custom paths and organisation directories. Use +a stopped, consistent filesystem/volume backup; copying a live `.db` file alone +can omit SQLite sidecar files. Keep these backups private, not in an issue or +diagnostic attachment. See [configuration paths](CONFIGURATION.md) and +[audit storage and safe recovery](AUDIT_LOGGING.md#storage). ### Manual Rollback -Update backups created by in-app updates are stored as `backup-/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory does not have enough free space, Pulse falls back to `/tmp/pulse-backup-`. Pulse keeps the most recent three in-app rollback snapshots and prunes older ones from retention. -There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart. -Example (systemd/LXC): +If Pulse cannot start, work inside the Pulse host or LXC, not on the Proxmox host +itself. Start with read-only service discovery: + ```bash -sudo systemctl stop pulse -sudo cp -a /etc/pulse/backup-/pulse /opt/pulse/pulse -sudo cp -a /etc/pulse/backup-/VERSION /opt/pulse/VERSION -sudo rm -rf /opt/pulse/data /opt/pulse/config -sudo cp -a /etc/pulse/backup-/data /opt/pulse/data -sudo cp -a /etc/pulse/backup-/config /opt/pulse/config -sudo cp -a /etc/pulse/backup-/.env /opt/pulse/.env -sudo systemctl start pulse +systemctl is-active pulse +systemctl show pulse --property=FragmentPath --property=DropInPaths ``` +An inactive service makes `is-active` return non-zero; that is useful evidence, +not a reason to reset it. Inspect the named unit and drop-ins locally for the +effective executable, `PULSE_INSTALL_DIR` and `PULSE_DATA_DIR`. Do not post full +service environments or `.env` contents; they may contain credentials. + +1. Stop Pulse before taking the failed-state backup or replacing files. Preserve + the actual executable, distribution files, unit/drop-ins and every active + data path, not just a guessed `/opt/pulse/data` directory. +2. Match the retained backup to the intended pre-update version. Check its + files and the update log for missing/copy-failed entries. If the backup is + missing, partial or has the wrong scope, stop here; do not delete live + directories to make a restore fit. +3. Prefer restoring a verified pre-update filesystem/volume snapshot with your + host backup tooling. Check it in an isolated recovery instance first, with + no access to monitored systems or notification destinations. Do not start a + second connected copy with the same agent identities. +4. For a file-level repair, stage verified replacements and keep reversible + copies of the failed state. Restore only the confirmed paths, ownership and + permissions for this deployment. Do not merge unrelated snapshots, delete + data/configuration directories, or regenerate encryption or signing keys. +5. Start Pulse and verify the running version and edition, service health, + saved connections, agent identities and retained history. If any check fails, + preserve the error and backups rather than retrying the update blindly. + +For Docker, change the configured image back to the recorded previous tag or +digest and recreate only Pulse with its existing volume. An older image may not +understand data migrated by the newer version; use a verified matching data +backup when required. Never remove the volume or initialise a fresh one as a +rollback shortcut. An image change alone is not a data rollback. + ## Update History History entries are stored in `update-history.jsonl` under the Pulse data directory (`/etc/pulse` or `/data`), and exposed via `GET /api/updates/history` (admin auth required). diff --git a/docs/DOCKER.md b/docs/DOCKER.md index ed97be315d..9d919d9197 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -118,21 +118,46 @@ services: ## ๐Ÿ”„ Updates -To update Pulse to a specific release tag: +These steps update the Pulse server, not the containers it monitors. Record the +current image and take a consistent private backup of the mounted Pulse data +before changing it. Keep the previous image and data backup for recovery; do +not delete volumes. ```bash -docker pull rcourtman/pulse:vX.Y.Z -docker stop pulse -docker rm pulse -# Re-run your docker run command +docker inspect pulse --format '{{.Config.Image}} {{.Image}}' ``` -If using Compose: +Run commands from the original Compose project directory. First set the exact +target in your Compose `image:` line, or persist `PULSE_IMAGE` in the project's +`.env` if the file uses `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}`. +Setting `PULSE_IMAGE` has no effect on a hardcoded image line. Community uses +`rcourtman/pulse:vX.Y.Z`; paid Pro installs must keep the private image and +existing registry login from . Do not +replace Pro with the Community image or post registry credentials. + +For the `pulse` service in the examples above: + ```bash -docker compose pull -docker compose up -d +( +set -e +docker compose pull pulse +docker compose up -d --no-deps pulse +) ``` +If pulling fails, stop rather than recreating with an unverified or old image. +Pulling a different tag alone does not update your configured image. There is +no need to bring the whole Compose project down. Use your actual service and +container names if they differ; legacy `docker-compose` users can substitute +that command name. + +For `docker run` or an app UI, change the image in the existing saved deployment +and recreate it with the same data mount, ports and settings; do not start a +second Pulse against that data. Check the running image again, Pulse's displayed +server version and service health after recreation. See +[rollback and backup scope](AUTO_UPDATE.md#rollback) before reverting a version: +an image change alone does not undo data migrations. + --- ## ๐Ÿ”„ Docker / Podman Updates @@ -188,12 +213,10 @@ To keep update detection anonymous-only (no credential store reads, no credentia Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than the public `rcourtman/pulse` image. Open , -paste your activation key, run the Docker login command shown there, then run -the shown `PULSE_IMAGE=license.pulserelay.pro/pulse-pro: docker compose pull` -and `docker compose up -d` commands from the host that already runs Pulse. If -your compose file has a hardcoded `image: rcourtman/pulse:...` line, change it -to `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}` or directly to the private -image shown on the download page before running those commands. +enter your activation key in the page and use its registry login instructions +on the host that already runs Pulse. Persist the private image for both pull +and recreation as described in [server updates](#-updates); an image override +used for only the pull does not select it for the later recreation. ### Disabling Update Features diff --git a/frontend-modern/browser-tests/update-recovery-help.cjs b/frontend-modern/browser-tests/update-recovery-help.cjs new file mode 100644 index 0000000000..0b377be7c9 --- /dev/null +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -0,0 +1,99 @@ +// Actual shipped Markdown in the production Docs/router/styles; no backend. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules', 'update-recovery-help-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-update-recovery-help'), + server: { host: '127.0.0.1', port: 5248, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + await page.goto('http://127.0.0.1:5248/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + await page.getByRole('link', { name: 'Automatic updates', exact: true }).click(); + await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor(); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + const article = await page.locator('article').innerText(); + for (const text of ['Roll back from Update History', 'successful in-app update', + 'not necessarily a complete backup', 'Copy errors can leave a partial snapshot', + 'matching .encryption.key', 'SQLite sidecar files', 'keep reversible copies', + 'An image change alone is not a data rollback']) assert.ok(article.includes(text), text); + assert.ok(!article.includes('There is no rollback UI')); + assert.ok(!article.includes('sudo rm -rf')); + const scope = page.getByRole('heading', { name: 'What an update snapshot contains', exact: true }); + await scope.scrollIntoViewIfNeeded(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-scope.png`) }); + const manual = page.getByRole('heading', { name: 'Manual Rollback', exact: true }); + await manual.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) }); + + await page.getByRole('link', { name: 'audit storage and safe recovery', exact: true }).click(); + await page.waitForFunction(() => { + const heading = document.getElementById('storage'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/AUDIT_LOGGING#storage')); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + await page.getByRole('link', { name: 'Automatic updates', exact: true }).click(); + await page.getByRole('link', { name: 'Docker server updates', exact: true }).click(); + const updates = page.getByRole('heading', { name: '๐Ÿ”„ Updates', exact: true }); + await updates.waitFor(); + await page.waitForFunction(() => { + const heading = document.getElementById('-updates'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/DOCKER#-updates')); + const dockerText = await page.locator('article').innerText(); + for (const text of ['persist PULSE_IMAGE', 'has no effect on a hardcoded image line', + 'docker compose pull pulse', 'docker compose up -d --no-deps pulse', + 'paid Pro installs must keep the private image', 'no need to bring the whole Compose project down']) + assert.ok(dockerText.includes(text), text); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-docker.png`) }); + await page.getByRole('link', { name: 'rollback and backup scope', exact: true }).click(); + await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor(); + assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#rollback')); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, + snapshotScope: true, noDestructiveRecipe: true, scopedCompose: true, + auditLink: true, dockerFragment: true, rollbackLink: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'shipped help rendering and links, not installation or recovery', results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/AUTO_UPDATE.md b/frontend-modern/public/docs/AUTO_UPDATE.md index 6d0fe0de29..6f366f4757 100644 --- a/frontend-modern/public/docs/AUTO_UPDATE.md +++ b/frontend-modern/public/docs/AUTO_UPDATE.md @@ -83,19 +83,16 @@ Auto-update preferences are stored in `system.json` and edited via the UI. ### Docker -```bash -# Pull latest image -docker pull rcourtman/pulse:vX.Y.Z +Use [Docker server updates](DOCKER.md#-updates). Change the configured image to +the exact release first, then pull and recreate only the Pulse service with the +same mounted data. Pulling another tag alone does not change the image in your +Compose file. Do not run `docker compose down` for a Pulse-only update or remove +its data volume. -# Restart container -docker compose down && docker compose up -d -``` - -This command uses the public Community image. Private Pro runtime installs must -use the private image and credentials supplied by the private download/update -path; replacing a Pro image with `rcourtman/pulse` changes the runtime edition. - -If you use the legacy `docker-compose` binary, replace `docker compose` with `docker-compose`. +Private Pro installs must keep the private image supplied by the +[download page](https://pulserelay.pro/download.html); replacing it with +`rcourtman/pulse` changes the runtime edition. Server updates and monitored +container updates are separate operations. ### ProxmoxVE LXC (Manual) @@ -124,30 +121,99 @@ sudo systemctl restart pulse ## Rollback -If an update causes issues: +An update error or a stuck progress modal does not prove that the old version +is still running. Check the running version and service health before retrying +or restoring anything; preserve the update error, logs and history. Do not run +another update just to reproduce a failure. + +Before any rollback, take a consistent private backup of the current state and +check the proposed backup's scope. Keep the failed installation and update +snapshots until recovery is verified. A rollback can revert settings, alert +changes and history written since the selected backup. ### Automatic Rollback -Pulse creates a backup before updating. If the update fails: -1. The previous version is automatically restored -2. Service restarts with the old version -3. Error details are logged + +The in-app updater creates an installation snapshot before applying files. If +applying files fails, it attempts to restore that snapshot and logs any restore +error. This is not a guarantee that every update failure restores all state or +restarts successfully. Confirm the running version, connections and service +health afterwards; a progress message alone is not recovery evidence. + +### Roll back from Update History + +If your installed version offers it, open **Settings โ†’ System โ†’ Updates โ†’ +Update History**. **Roll back** is offered for a successful in-app update with +a retained backup. The confirmation names the version before that update and +warns that later settings and alert changes will be reverted. Check that version +and backup before confirming; Pulse restarts to use the restored binary. + +Older versions may not have this control. An absent button or backup does not +justify deleting data or creating an empty configuration. Docker server installs +use their previous image and matching data backup, not this in-app rollback. + +### What an update snapshot contains + +In-app snapshots normally live at `backup-/` under the runtime data +directory (usually `/etc/pulse`), with `/tmp/pulse-backup-` as a +low-space fallback. Use the `backup_path` recorded for the actual update in +`update-history.jsonl`, rather than guessing a timestamp. The updater retains +the most recent three snapshots; a `/tmp` snapshot may also be lost on reboot. + +The in-app snapshot copies the running server binary, available `VERSION` and +`.env` files, and available `data/` and `config/` directories under +`PULSE_INSTALL_DIR` (default `/opt/pulse`). It is not necessarily a complete +backup of the active `PULSE_DATA_DIR`, external metrics/audit stores, service +units, bundled agents or update helpers. Copy errors can leave a partial +snapshot: a recorded path is not proof that every file was saved. + +Verify the backup against your effective service paths and update logs. Preserve +the active data directory, matching `.encryption.key`, audit history and its +signing key together, including custom paths and organisation directories. Use +a stopped, consistent filesystem/volume backup; copying a live `.db` file alone +can omit SQLite sidecar files. Keep these backups private, not in an issue or +diagnostic attachment. See [configuration paths](CONFIGURATION.md) and +[audit storage and safe recovery](AUDIT_LOGGING.md#storage). ### Manual Rollback -Update backups created by in-app updates are stored as `backup-/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory does not have enough free space, Pulse falls back to `/tmp/pulse-backup-`. Pulse keeps the most recent three in-app rollback snapshots and prunes older ones from retention. -There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart. -Example (systemd/LXC): +If Pulse cannot start, work inside the Pulse host or LXC, not on the Proxmox host +itself. Start with read-only service discovery: + ```bash -sudo systemctl stop pulse -sudo cp -a /etc/pulse/backup-/pulse /opt/pulse/pulse -sudo cp -a /etc/pulse/backup-/VERSION /opt/pulse/VERSION -sudo rm -rf /opt/pulse/data /opt/pulse/config -sudo cp -a /etc/pulse/backup-/data /opt/pulse/data -sudo cp -a /etc/pulse/backup-/config /opt/pulse/config -sudo cp -a /etc/pulse/backup-/.env /opt/pulse/.env -sudo systemctl start pulse +systemctl is-active pulse +systemctl show pulse --property=FragmentPath --property=DropInPaths ``` +An inactive service makes `is-active` return non-zero; that is useful evidence, +not a reason to reset it. Inspect the named unit and drop-ins locally for the +effective executable, `PULSE_INSTALL_DIR` and `PULSE_DATA_DIR`. Do not post full +service environments or `.env` contents; they may contain credentials. + +1. Stop Pulse before taking the failed-state backup or replacing files. Preserve + the actual executable, distribution files, unit/drop-ins and every active + data path, not just a guessed `/opt/pulse/data` directory. +2. Match the retained backup to the intended pre-update version. Check its + files and the update log for missing/copy-failed entries. If the backup is + missing, partial or has the wrong scope, stop here; do not delete live + directories to make a restore fit. +3. Prefer restoring a verified pre-update filesystem/volume snapshot with your + host backup tooling. Check it in an isolated recovery instance first, with + no access to monitored systems or notification destinations. Do not start a + second connected copy with the same agent identities. +4. For a file-level repair, stage verified replacements and keep reversible + copies of the failed state. Restore only the confirmed paths, ownership and + permissions for this deployment. Do not merge unrelated snapshots, delete + data/configuration directories, or regenerate encryption or signing keys. +5. Start Pulse and verify the running version and edition, service health, + saved connections, agent identities and retained history. If any check fails, + preserve the error and backups rather than retrying the update blindly. + +For Docker, change the configured image back to the recorded previous tag or +digest and recreate only Pulse with its existing volume. An older image may not +understand data migrated by the newer version; use a verified matching data +backup when required. Never remove the volume or initialise a fresh one as a +rollback shortcut. An image change alone is not a data rollback. + ## Update History History entries are stored in `update-history.jsonl` under the Pulse data directory (`/etc/pulse` or `/data`), and exposed via `GET /api/updates/history` (admin auth required). diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index ed97be315d..9d919d9197 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -118,21 +118,46 @@ services: ## ๐Ÿ”„ Updates -To update Pulse to a specific release tag: +These steps update the Pulse server, not the containers it monitors. Record the +current image and take a consistent private backup of the mounted Pulse data +before changing it. Keep the previous image and data backup for recovery; do +not delete volumes. ```bash -docker pull rcourtman/pulse:vX.Y.Z -docker stop pulse -docker rm pulse -# Re-run your docker run command +docker inspect pulse --format '{{.Config.Image}} {{.Image}}' ``` -If using Compose: +Run commands from the original Compose project directory. First set the exact +target in your Compose `image:` line, or persist `PULSE_IMAGE` in the project's +`.env` if the file uses `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}`. +Setting `PULSE_IMAGE` has no effect on a hardcoded image line. Community uses +`rcourtman/pulse:vX.Y.Z`; paid Pro installs must keep the private image and +existing registry login from . Do not +replace Pro with the Community image or post registry credentials. + +For the `pulse` service in the examples above: + ```bash -docker compose pull -docker compose up -d +( +set -e +docker compose pull pulse +docker compose up -d --no-deps pulse +) ``` +If pulling fails, stop rather than recreating with an unverified or old image. +Pulling a different tag alone does not update your configured image. There is +no need to bring the whole Compose project down. Use your actual service and +container names if they differ; legacy `docker-compose` users can substitute +that command name. + +For `docker run` or an app UI, change the image in the existing saved deployment +and recreate it with the same data mount, ports and settings; do not start a +second Pulse against that data. Check the running image again, Pulse's displayed +server version and service health after recreation. See +[rollback and backup scope](AUTO_UPDATE.md#rollback) before reverting a version: +an image change alone does not undo data migrations. + --- ## ๐Ÿ”„ Docker / Podman Updates @@ -188,12 +213,10 @@ To keep update detection anonymous-only (no credential store reads, no credentia Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than the public `rcourtman/pulse` image. Open , -paste your activation key, run the Docker login command shown there, then run -the shown `PULSE_IMAGE=license.pulserelay.pro/pulse-pro: docker compose pull` -and `docker compose up -d` commands from the host that already runs Pulse. If -your compose file has a hardcoded `image: rcourtman/pulse:...` line, change it -to `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}` or directly to the private -image shown on the download page before running those commands. +enter your activation key in the page and use its registry login instructions +on the host that already runs Pulse. Persist the private image for both pull +and recreation as described in [server updates](#-updates); an image override +used for only the pull does not select it for the later recreation. ### Disabling Update Features diff --git a/scripts/tests/test_update_recovery_docs.py b/scripts/tests/test_update_recovery_docs.py new file mode 100644 index 0000000000..5a9c993716 --- /dev/null +++ b/scripts/tests/test_update_recovery_docs.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Exercise update help without touching a service, volume or Docker socket. + +The synthetic commands prove scope and failure handling, not installed recovery. +The remaining checks bind the guide to the existing UI and snapshot writer. +""" + +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +def guide(name): + return (ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8") + + +def section(name, heading, level="##"): + return guide(name).split(f"{level} {heading}\n", 1)[1].split(f"\n{level} ", 1)[0] + + +READER = r'''#!/usr/bin/python3 +import json, os, sys +from pathlib import Path +name = Path(sys.argv[0]).name +args = sys.argv[1:] +with Path(os.environ['CALLS']).open('a') as output: + output.write(json.dumps({'name': name, 'args': args}) + '\n') +if name == 'systemctl': + if args == ['is-active', 'pulse']: + print(os.environ['SERVICE_STATE']) + sys.exit(0 if os.environ['SERVICE_STATE'] == 'active' else 3) + assert args == ['show', 'pulse', '--property=FragmentPath', '--property=DropInPaths'] + print('FragmentPath=/etc/systemd/system/pulse.service\nDropInPaths=/etc/systemd/system/pulse.service.d/local.conf') +elif name == 'docker': + if args == ['inspect', 'pulse', '--format', '{{.Config.Image}} {{.Image}}']: + print('private.example/pulse:old sha256:previous') + elif args == ['compose', 'pull', 'pulse']: + sys.exit(int(os.environ.get('PULL_EXIT', '0'))) + elif args == ['compose', 'up', '-d', '--no-deps', 'pulse']: + sys.exit(int(os.environ.get('UP_EXIT', '0'))) + else: + raise AssertionError('unexpected Docker mutation or unbounded inspection') +else: + raise AssertionError('unexpected command') +''' + + +class UpdateRecoveryDocsTest(unittest.TestCase): + def exercise(self, recipe, **settings): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name in ("systemctl", "docker"): + executable = root / name + executable.write_text(READER, encoding="utf-8") + executable.chmod(0o700) + calls_path = root / "calls.jsonl" + env = dict(os.environ, PATH=f"{root}:{os.environ['PATH']}", + CALLS=str(calls_path), SERVICE_STATE="active", + SYNTHETIC_CREDENTIAL="never-print-this-private-value") + env.update(settings) + result = subprocess.run(["bash", "-c", recipe], env=env, + text=True, capture_output=True, timeout=10) + calls = [json.loads(line) for line in calls_path.read_text().splitlines()] + self.assertNotIn(env["SYNTHETIC_CREDENTIAL"], result.stdout + result.stderr) + return result, calls + + def compose_recipe(self): + recipes = re.findall(r"```bash\n(.*?)```", section("DOCKER", "๐Ÿ”„ Updates"), re.S) + return next(recipe for recipe in recipes if "compose" in recipe) + + def test_mirrors_match_the_exercised_guides(self): + for name in ("AUTO_UPDATE", "DOCKER"): + with self.subTest(guide=name): + self.assertEqual(guide(name), + (ROOT / "frontend-modern/public/docs" / f"{name}.md").read_text()) + + def test_rollback_matches_the_existing_history_control(self): + help_text = " ".join(section("AUTO_UPDATE", "Rollback").split()) + ui = (ROOT / "frontend-modern/src/components/Settings/UpdateHistorySection.tsx").read_text() + for label in ("Update History", "Roll back"): + self.assertIn(label, ui) + self.assertIn(label, help_text) + self.assertIn("entry.action === 'update' && entry.status === 'success' && Boolean(entry.backup_path)", ui) + for phrase in ("successful in-app update", "retained backup", "version before that update", + "Older versions may not have this control", "later settings and alert changes"): + self.assertIn(phrase, help_text) + self.assertNotIn("There is no rollback UI", help_text) + + def test_snapshot_scope_is_not_claimed_to_be_a_full_data_backup(self): + help_text = " ".join(section("AUTO_UPDATE", "What an update snapshot contains", "###").split()) + manager = (ROOT / "internal/updates/manager.go").read_text() + writer = manager.split("func (m *Manager) createBackup(", 1)[1].split("// ensureApplyTargetIsNewer", 1)[0] + self.assertIn('dirsToBackup := []string{"data", "config"}', writer) + self.assertIn('os.Getenv("PULSE_INSTALL_DIR")', writer) + self.assertIn('os.Executable()', writer) + for phrase in ("running server binary", "`VERSION`", "`.env`", "`data/`", "`config/`", + "`PULSE_INSTALL_DIR`", "not necessarily a complete", "active `PULSE_DATA_DIR`", + "Copy errors can leave a partial", "`backup_path`", "lost on reboot"): + self.assertIn(phrase, help_text) + # The current writer logs some errors and continues; existence alone + # must not be promoted to completeness in operator guidance. + self.assertIn('Msg("Failed to backup directory")', writer) + self.assertIn('return backupDir, nil', writer) + + def test_recovery_preserves_failed_state_keys_and_sidecars(self): + help_text = " ".join(section("AUTO_UPDATE", "Rollback").split()) + for phrase in ("consistent private backup", "matching `.encryption.key`", "signing key together", + "SQLite sidecar files", "Keep these backups private", "missing, partial", + "isolated recovery instance", "no access to monitored systems or notification destinations", + "Do not start a second connected copy", "keep reversible copies", + "regenerate encryption or signing keys", "running version and edition", + "An image change alone is not a data rollback"): + self.assertIn(phrase, help_text) + # No executable deletion, overwrite, restore, start or stop recipe is + # safe without knowing this deployment's effective paths and backup. + blocks = re.findall(r"```bash\n(.*?)```", section("AUTO_UPDATE", "Rollback"), re.S) + self.assertEqual(len(blocks), 1) + self.assertNotRegex(blocks[0], r"\b(?:rm|cp|mv|install|tee|start|stop|restart)\b") + + def test_read_only_discovery_handles_active_and_inactive_services(self): + recipe = re.findall(r"```bash\n(.*?)```", section("AUTO_UPDATE", "Manual Rollback", "###"), re.S)[0] + for state in ("active", "inactive"): + with self.subTest(state=state): + result, calls = self.exercise(recipe, SERVICE_STATE=state) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, [ + {"name": "systemctl", "args": ["is-active", "pulse"]}, + {"name": "systemctl", "args": ["show", "pulse", "--property=FragmentPath", "--property=DropInPaths"]}, + ]) + self.assertIn(state, result.stdout) + self.assertIn("FragmentPath=", result.stdout) + self.assertNotIn("Environment=", result.stdout) + + def test_compose_updates_only_pulse_after_a_successful_pull(self): + result, calls = self.exercise(self.compose_recipe()) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, [ + {"name": "docker", "args": ["compose", "pull", "pulse"]}, + {"name": "docker", "args": ["compose", "up", "-d", "--no-deps", "pulse"]}, + ]) + + def test_failed_pull_never_recreates_and_failed_recreation_is_not_success(self): + for settings, exit_code, count in (({"PULL_EXIT": "7"}, 7, 1), ({"UP_EXIT": "8"}, 8, 2)): + with self.subTest(settings=settings): + result, calls = self.exercise(self.compose_recipe(), **settings) + self.assertEqual(result.returncode, exit_code, result.stderr) + self.assertEqual(len(calls), count) + + def test_image_selection_is_durable_and_private_edition_is_preserved(self): + help_text = " ".join(section("DOCKER", "๐Ÿ”„ Updates").split()) + for phrase in ("exact target", "persist `PULSE_IMAGE`", "has no effect on a hardcoded image line", + "paid Pro installs must keep the private image", "same data mount, ports and settings", + "running image again", "an image change alone does not undo data migrations"): + self.assertIn(phrase, help_text) + recipe = re.findall(r"```bash\n(.*?)```", section("DOCKER", "๐Ÿ”„ Updates"), re.S)[0] + result, calls = self.exercise(recipe) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls[0]["args"], ["inspect", "pulse", "--format", "{{.Config.Image}} {{.Image}}"]) + self.assertIn("private.example/pulse:old sha256:previous", result.stdout) + short = section("AUTO_UPDATE", "Docker", "###") + self.assertIn("DOCKER.md#-updates", short) + self.assertNotIn("docker pull", short) + + +if __name__ == "__main__": + unittest.main() From c71bdbbda718435307c2c59a7fac5fb42056b901 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:04:52 +0100 Subject: [PATCH 25/86] Capture the recovery procedure after theme and scroll settle Visual inspection caught the desktop manual capture still showing the preceding snapshot section and phone theme transition. Put each checked section at the viewport start and wait for the actual theme transition before retaining the screenshot; product source and guidance are unchanged. Change-source: pulse-maintainer --- .../browser-tests/update-recovery-help.cjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend-modern/browser-tests/update-recovery-help.cjs b/frontend-modern/browser-tests/update-recovery-help.cjs index 0b377be7c9..0ebdcce9a2 100644 --- a/frontend-modern/browser-tests/update-recovery-help.cjs +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -39,7 +39,11 @@ const { chromium, webkit } = require('playwright'); await page.getByRole('link', { name: 'โ† All documentation' }).click(); await page.getByRole('link', { name: 'Automatic updates', exact: true }).click(); await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor(); - if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + if (tone === 'dark') await page.evaluate(async () => { + document.documentElement.classList.add('dark'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {}))); + }); const article = await page.locator('article').innerText(); for (const text of ['Roll back from Update History', 'successful in-app update', 'not necessarily a complete backup', 'Copy errors can leave a partial snapshot', @@ -48,11 +52,15 @@ const { chromium, webkit } = require('playwright'); assert.ok(!article.includes('There is no rollback UI')); assert.ok(!article.includes('sudo rm -rf')); const scope = page.getByRole('heading', { name: 'What an update snapshot contains', exact: true }); - await scope.scrollIntoViewIfNeeded(); + await scope.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); await page.screenshot({ path: path.join(artifacts, `${engine}-scope.png`) }); const manual = page.getByRole('heading', { name: 'Manual Rollback', exact: true }); - await manual.scrollIntoViewIfNeeded(); + await manual.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); + await page.waitForFunction(() => { + const heading = document.getElementById('manual-rollback'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 10; + }); await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) }); await page.getByRole('link', { name: 'audit storage and safe recovery', exact: true }).click(); From ae427e6afe1a317301f2ccf802a22940039ae248 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:07:05 +0100 Subject: [PATCH 26/86] Respect the Docs heading inset in recovery visual checks The capture wait incorrectly required a zero-pixel heading offset despite the production scroll-margin. Keep the section visible near the viewport start without changing product styles, then rerun the same visual acceptance. Change-source: pulse-maintainer --- frontend-modern/browser-tests/update-recovery-help.cjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend-modern/browser-tests/update-recovery-help.cjs b/frontend-modern/browser-tests/update-recovery-help.cjs index 0ebdcce9a2..fbc4daf6f3 100644 --- a/frontend-modern/browser-tests/update-recovery-help.cjs +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -59,7 +59,9 @@ const { chromium, webkit } = require('playwright'); await manual.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); await page.waitForFunction(() => { const heading = document.getElementById('manual-rollback'); - return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 10; + // Respect the product's heading scroll-margin rather than demanding + // alignment at zero pixels above its normal reading inset. + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 200; }); await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) }); From f827f7150f9e313117066a90be460713b5f2605e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:08:57 +0100 Subject: [PATCH 27/86] Restore owned demo runtime despite receipt I/O failure Keep evidence loss failed and unverified, preserve snapshots and complete recovery watches, and bind terminal collection and subsequent mutations to the original writer exit. Retain the exact returned candidate and its failing control without expanding production privileges. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 91 ++++++-- .github/scripts/dispatch-demo-runtime.py | 42 +++- .../tests/test_demo_runtime_transaction.py | 218 ++++++++++++++++++ .../v6/internal/DEMO_RUNTIME_TRANSACTION.md | 21 ++ .../subsystems/deployment-installability.md | 22 ++ 5 files changed, 372 insertions(+), 22 deletions(-) diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index ca3984c65b..04c95f4490 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -42,6 +42,7 @@ DEMO_HISTORY = ("ai_incidents.json", "ai_incidents.json.tmp", "alerts/events.db", "alerts/events.db-shm", "alerts/events.db-wal") CRASH = re.compile(r"\bpanic\b|\bfatal error\b|segmentation fault|core dumped|watchdog timeout", re.I) TERMINAL = {"committed", "healthy_noop", "rolled_back", "refused"} +TERMINAL_EXIT = {"committed": 0, "healthy_noop": 0, "rolled_back": 1, "refused": 1} MAX_BINARY = 128 * 1024 * 1024 @@ -202,6 +203,20 @@ class Host: raise Failure("command-failed") return result.stdout.decode("utf-8", errors="strict") + def prior_closed(self, attempt, status): + """Do not unblock on a stale JSON terminal from a failed writer.""" + if not re.fullmatch(r"[0-9a-f]{64}", attempt.name): + return False + try: + text = self.command(["systemctl", "show", "pulse-demo-" + attempt.name[:32], + "--property=ActiveState,SubState,MainPID,ExecMainCode,ExecMainStatus", "--no-pager"]) + fields = dict(line.split("=", 1) for line in text.splitlines() if "=" in line) + expected = TERMINAL_EXIT[status] + return (fields["ActiveState"], fields["SubState"], fields["MainPID"], fields["ExecMainCode"], fields["ExecMainStatus"]) == ( + "active" if expected == 0 else "failed", "exited" if expected == 0 else "failed", "0", "1", str(expected)) + except (Failure, KeyError, ValueError): + return False + def identity(self, hostname): if socket.gethostname() != hostname or os.geteuid() != 0: raise Failure("host-identity") @@ -387,11 +402,47 @@ class Transaction: "expected_version": request["version"], "footprint": "server-binary-version-and-demo-data", "binary_sha256": request["binary_sha256"], "version_sha256": request["version_sha256"], - "forward": {}, "recovery": {}, "recovery_required": False} + "forward": {}, "recovery": {}, "recovery_required": False, + "observation_failures": [], "child_result_required": True} def save(self, status): self.receipt["status"] = status - atomic_json(self.attempt / "receipt.json", self.receipt) + try: + atomic_json(self.attempt / "receipt.json", self.receipt) + except OSError as error: + # Retain only the fixed phase, never an OS message/private path. + if status not in self.receipt["observation_failures"]: + self.receipt["observation_failures"].append(status) + raise Failure("receipt-observation") from error + + def recovery_save(self, status): + """Receipt storage must not gate the already-owned restoration.""" + try: + self.save(status) + except Failure as error: + if str(error) != "receipt-observation": + raise + + def finish(self, status): + """Best-effort failed evidence is not a verified terminal outcome.""" + self.receipt["observed_outcome"] = status + + def unverified(): + self.receipt["status"] = "observation_failed" + self.receipt["recovery_required"] = True + if self.receipt.get("rollback") == "verified": + self.receipt["rollback"] = "unverified" + + if self.receipt["observation_failures"]: + unverified() + try: + self.save(self.receipt["status"] if self.receipt["observation_failures"] else status) + except Failure as error: + if str(error) != "receipt-observation": + raise + unverified() + # One bounded attempt to retain the failure, not to obtain a pass. + self.recovery_save("observation_failed") def urls(self): return (self.request["local_url"].rstrip("/") + "/api/health", self.request["public_url"], RELAY_HEALTH) @@ -485,7 +536,9 @@ class Transaction: if entry == self.attempt: continue receipt = entry / "receipt.json" - if not receipt.exists() or json.loads(receipt.read_text()).get("status") not in TERMINAL: + prior = json.loads(receipt.read_text()) if receipt.exists() else {} + if (prior.get("status") not in TERMINAL + or (prior.get("child_result_required") and not self.host.prior_closed(entry, prior["status"]))): raise Failure("prior-recovery-required") states = self.host.state() if not all(active(states[name]) for name in ("pulse-relay", "caddy")) or self.host.status(RELAY_HEALTH) != "200": @@ -537,14 +590,14 @@ class Transaction: signal.signal(sig, signal.SIG_IGN) if stopped: try: - self.save("recovering") + self.recovery_save("recovering") self.host.stop() if "snapshot_sha256" in self.receipt: self.restore() if not healthy_baseline: self.receipt["recovery_required"] = True self.receipt["rollback"] = "unavailable-unhealthy-baseline" - self.save("recovery_required") + outcome = "recovery_required" else: cursors = self.host.cursors() self.host.start() @@ -554,15 +607,20 @@ class Transaction: if any(final[name] != states[name] for name in ("pulse-relay", "caddy")): raise Failure("cohost-identity-changed") self.receipt["rollback"] = "verified" - self.save("rolled_back") + outcome = "rolled_back" except Exception as recovery: self.receipt["rollback"] = "failed" self.receipt["recovery_failure"] = str(recovery) if isinstance(recovery, Failure) else "recovery-observation" self.receipt["recovery_required"] = True - self.save("rollback_failed") + outcome = "rollback_failed" + # A terminal write failure cannot undo or interrupt recovery, + # nor relabel a restored estate as a failed file restoration. + self.finish(outcome) else: - self.save("refused") - return 1 + self.finish("refused") + # Distinguish evidence loss from an ordinary failed-but-observed + # operation, even if the last visible JSON still says rolled_back. + return 2 if self.receipt["observation_failures"] else 1 finally: for sig, handler in old_handlers.items(): signal.signal(sig, handler) @@ -579,14 +637,15 @@ def main(): transaction = Transaction(Host(), request, attempt) fd = None try: - LOCK.parent.mkdir(mode=0o700, parents=True, exist_ok=True) - fd = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + LOCK.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (OSError, ValueError): + transaction.receipt["failure"] = "host-lock-unavailable" + transaction.finish("refused") + return 2 if transaction.receipt["observation_failures"] else 1 return transaction.run() - except (OSError, ValueError): - transaction.receipt["failure"] = "host-lock-unavailable" - transaction.save("refused") - return 1 finally: if fd is not None: os.close(fd) diff --git a/.github/scripts/dispatch-demo-runtime.py b/.github/scripts/dispatch-demo-runtime.py index 815735075e..11448b0796 100644 --- a/.github/scripts/dispatch-demo-runtime.py +++ b/.github/scripts/dispatch-demo-runtime.py @@ -46,8 +46,9 @@ if not attempt.exists(): for directory in (attempt, root / 'attempts', root, root.parent): dfd = os.open(directory, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd) try: - child = subprocess.run(['systemd-run', '--quiet', '--collect', + child = subprocess.run(['systemd-run', '--quiet', '--unit=pulse-demo-' + identity[:32], + '--property=RemainAfterExit=yes', '--property=RuntimeMaxSec=45min', '--property=TimeoutStopSec=20min', '--property=KillMode=mixed', '/usr/bin/python3', str(attempt / 'transaction.py'), str(attempt)], @@ -63,16 +64,45 @@ if not attempt.exists(): os.close(fd) # Closing or losing this observer never stops the independently owned unit. deadline = time.monotonic() + 4000 -terminal = {'committed', 'healthy_noop', 'rolled_back', 'rollback_failed', 'recovery_required', 'refused'} +terminal = {'committed', 'healthy_noop', 'rolled_back', 'rollback_failed', 'recovery_required', 'refused', 'observation_failed'} while time.monotonic() < deadline: try: receipt = json.loads((attempt / 'receipt.json').read_text()) - except (FileNotFoundError, ValueError): + except (OSError, ValueError): receipt = None if receipt and receipt.get('status') in terminal: - receipt['request_identity'] = identity - print(json.dumps(receipt)) - sys.exit(0 if receipt['status'] in {'committed', 'healthy_noop'} else 1) + # A replace can become visible before directory fsync fails. Never + # accept that provisional favourable receipt while its writer is still + # running, or after the writer exits failed. Retain the unit's result; + # reading it neither launches nor stops a child. + try: + child = subprocess.run(['systemctl', 'show', 'pulse-demo-' + identity[:32], + '--property=ActiveState,SubState,MainPID,ExecMainCode,ExecMainStatus', + '--no-pager'], capture_output=True, timeout=20) + if child.returncode: + break + fields = dict(line.split('=', 1) for line in child.stdout.decode().splitlines() if '=' in line) + pid = int(fields['MainPID']) + code = int(fields['ExecMainCode']) + status = int(fields['ExecMainStatus']) + pending = (pid > 0 and fields['ActiveState'] in {'activating', 'active', 'deactivating'}) + complete = (pid == 0 and code in {1, 2, 3} + and (fields['ActiveState'], fields['SubState']) in {('active', 'exited'), ('failed', 'failed')}) + if complete: + succeeded = (fields['ActiveState'], fields['SubState'], code, status) == ('active', 'exited', 1, 0) + receipt['request_identity'] = identity + receipt['child_result'] = {'exit_code': status if code == 1 else None, + 'signal': status if code in {2, 3} else None} + favourable = receipt['status'] in {'committed', 'healthy_noop'} + expected = 0 if favourable else (2 if receipt['status'] == 'observation_failed' else 1) + if code != 1 or status != expected or favourable != succeeded: + receipt.update(status='uncertain', failure='terminal-child-mismatch', recovery_required=True) + print(json.dumps(receipt)) + sys.exit(0 if favourable and succeeded else 1) + if not pending: + break + except (OSError, ValueError, KeyError, subprocess.TimeoutExpired): + break launch = attempt / 'launch.json' if launch.exists() and json.loads(launch.read_text()).get('state') == 'uncertain': break diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 7215be25a6..39452b1ece 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -61,6 +61,7 @@ class FixtureHost(engine.Host): self.co_host_change = False self.co_pid = 200 self.transaction = None + self.prior_exit = 0 def now(self): return self.clock @@ -72,6 +73,9 @@ class FixtureHost(engine.Host): if hostname != "fixture": raise engine.Failure("host-identity") + def prior_closed(self, attempt, status): + return self.prior_exit == engine.TERMINAL_EXIT[status] + def state(self): state = {"ActiveState": "active", "SubState": "running", "MainPID": str(self.pid), "NRestarts": "0"} result = {"pulse": state, "pulse-relay": {**state, "MainPID": "200"}, "caddy": {**state, "MainPID": str(self.co_pid)}} @@ -251,6 +255,132 @@ class TransactionTest(unittest.TestCase): self.assertEqual(receipt["recovery"]["runtime_sha256"], receipt["runtime_snapshot_sha256"]) self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + def receipt_fault(self, status, *, persistent=False, after_replace=False): + """Fault only receipt I/O, leaving the real capture/restore usable.""" + original = engine.atomic_json + fired = [] + + def write(path, value): + if path == self.attempt / "receipt.json" and ( + value["status"] == status or (persistent and fired)): + fired.append(value["status"]) + if after_replace: + original(path, value) + raise OSError("private fixture path must not appear in receipt") + return original(path, value) + + return patch.object(engine, "atomic_json", side_effect=write), fired + + def assert_observation_failure_restored(self, tx, result, status): + receipt = tx.receipt + self.assertEqual((result, receipt["status"], receipt["rollback"]), + (2, "observation_failed", "unverified")) + self.assertTrue(receipt["recovery_required"]) + self.assertIn(status, receipt["observation_failures"]) + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + self.assertEqual(receipt["recovery"]["samples"], 61) + self.assertEqual(engine.estate_hash(self.paths), self.before) + self.assertEqual(engine.estate_hash(self.untouched), self.untouched_before) + self.assertEqual(self.paths["version"].read_text(), "6.4.5\n") + self.assertEqual(self.host.runtime_version, "6.4.5") + self.assertEqual((self.host.stops, self.host.starts), + (2, 1 if status == "applying" else 2)) + self.assertTrue(receipt["snapshot_retained"]) + self.assertTrue((self.attempt / "snapshot/data/persisted-history").exists()) + self.assertNotIn("private fixture", json.dumps(receipt)) + + def run_fault(self, status, **fault): + req = request("update"); req["version"] = "v6.4.6" + tx = engine.Transaction(self.host, req, self.attempt) + self.host.transaction = tx + self.host.fail_at = 55 if status in {"recovering", "rolled_back"} else None + writer, fired = self.receipt_fault(status, **fault) + with writer: + result = tx.run() + self.assertTrue(fired) + self.assert_observation_failure_restored(tx, result, status) + return tx + + def test_receipt_failure_after_stop_does_not_abandon_original_service(self): + tx = self.run_fault("applying") + self.assertEqual(self.host.install_count, 0) + self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + + def test_terminal_forward_receipt_failure_restores_changed_runtime(self): + tx = self.run_fault("committed") + self.assertEqual(tx.receipt["forward"]["elapsed_seconds"], 300) + self.assertEqual(self.host.install_count, 1) + self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + + def test_recovering_receipt_failure_cannot_prevent_restoration(self): + tx = self.run_fault("recovering") + self.assertEqual(tx.receipt["failure"], "new-service-crash") + self.assertEqual(tx.receipt["forward"]["elapsed_seconds"], 50) + self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + + def test_recovery_terminal_receipt_failure_retains_full_restoration(self): + tx = self.run_fault("rolled_back") + self.assertEqual(tx.receipt["observed_outcome"], "rolled_back") + self.assertEqual(tx.receipt["failure"], "new-service-crash") + self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + + def test_persistent_receipt_failure_keeps_capture_and_full_cancel_safe_recovery(self): + self.host.cancel_in_recovery = True + previous = signal.getsignal(signal.SIGTERM) + tx = self.run_fault("recovering", persistent=True) + self.assertEqual(tx.receipt["failure"], "new-service-crash") + self.assertEqual(tx.receipt["observation_failures"], ["recovering", "observation_failed"]) + retained = json.loads((self.attempt / "receipt.json").read_text()) + self.assertEqual(retained["status"], "applying") + self.assertNotIn(retained["status"], engine.TERMINAL) + self.assertFalse(self.host.cancel_in_recovery) + self.assertEqual(signal.getsignal(signal.SIGTERM), previous) + # Lost terminal evidence blocks a different attempt without touching it. + self.attempt = self.root / "attempts" / ("b" * 64); self.attempt.mkdir() + stops = self.host.stops + code, receipt = self.run_transaction() + self.assertEqual((code, receipt["failure"]), (1, "prior-recovery-required")) + self.assertEqual(self.host.stops, stops) + + def test_visible_forward_receipt_does_not_make_failed_fsync_an_acceptance(self): + tx = self.run_fault("committed", persistent=True, after_replace=True) + self.assertEqual(tx.receipt["failure"], "receipt-observation") + self.assertEqual(json.loads((self.attempt / "receipt.json").read_text())["status"], "observation_failed") + + def test_stale_visible_terminal_cannot_unblock_new_mutation_after_writer_failure(self): + for status in ("committed", "rolled_back", "refused"): + with self.subTest(status=status): + (self.attempt / "receipt.json").write_text(json.dumps({"status": status, "child_result_required": True})) + original = self.attempt + self.attempt = self.root / "attempts" / ("b" * 64); self.attempt.mkdir() + self.host.prior_exit = 2 + code, receipt = self.run_transaction() + self.assertEqual((code, receipt["failure"]), (1, "prior-recovery-required")) + self.assertEqual(self.host.stops, 0) + self.assertEqual(engine.estate_hash(self.paths), self.before) + (self.attempt / "receipt.json").unlink(); self.attempt.rmdir(); self.attempt = original + + def test_noop_terminal_failure_never_restarts_or_reports_healthy_acceptance(self): + self.host.profile(request(), False) + writer, _ = self.receipt_fault("healthy_noop") + with writer: + code, receipt = self.run_transaction() + self.assertEqual((code, receipt["status"]), (2, "observation_failed")) + self.assertEqual(receipt["failure"], "receipt-observation") + self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) + self.assertEqual((self.host.stops, self.host.starts), (0, 0)) + + def test_lock_refusal_receipt_error_stays_failed_without_touching_estate(self): + lock = self.root / "host.lock" + fd = os.open(lock, os.O_CREAT | os.O_RDWR, 0o600); self.addCleanup(os.close, fd) + engine.fcntl.flock(fd, engine.fcntl.LOCK_EX | engine.fcntl.LOCK_NB) + (self.attempt / "request.json").write_text(json.dumps(request())) + writer, _ = self.receipt_fault("refused", persistent=True) + with writer, patch.object(engine, "LOCK", lock), patch.object(engine.sys, "argv", ["transaction", str(self.attempt)]): + self.assertEqual(engine.main(), 2) + self.assertEqual(self.host.stops, 0) + self.assertEqual(engine.estate_hash(self.paths), self.before) + def test_payload_version_mismatch_restores_without_installing(self): (self.attempt / "version").write_text("6.4.7\n") code, receipt = self.run_transaction("update", "v6.4.6") @@ -383,6 +513,21 @@ class TransactionTest(unittest.TestCase): class InputAndCommandTest(unittest.TestCase): + def test_prior_terminal_result_rejects_missing_and_failed_writer_evidence(self): + host = engine.Host(); attempt = Path("/fixed/attempts") / ("a" * 64) + for status, expected in engine.TERMINAL_EXIT.items(): + body = f"ActiveState={'active' if expected == 0 else 'failed'}\nSubState={'exited' if expected == 0 else 'failed'}\nMainPID=0\nExecMainCode=1\nExecMainStatus={expected}\n" + with self.subTest(status=status), patch.object(host, "command", return_value=body) as command: + self.assertTrue(host.prior_closed(attempt, status)) + self.assertEqual(command.call_args.args[0][2], "pulse-demo-" + "a" * 32) + with patch.object(host, "command", return_value=body.replace(f"ExecMainStatus={expected}", "ExecMainStatus=2")): + self.assertFalse(host.prior_closed(attempt, status)) + for body in ("", "MainPID=bad", "ActiveState=inactive\nSubState=dead"): + with patch.object(host, "command", return_value=body): + self.assertFalse(host.prior_closed(attempt, "committed")) + with patch.object(host, "command", side_effect=engine.Failure("command-unavailable")): + self.assertFalse(host.prior_closed(attempt, "committed")) + def test_cohost_check_uses_the_existing_relay_health_route(self): self.assertEqual(engine.RELAY_HEALTH, "https://relay.pulserelay.pro/healthz") @@ -460,6 +605,8 @@ class DispatchLifecycleTest(unittest.TestCase): self.assertGreater(4000, 45 * 60 + 20 * 60) self.assertNotIn("'--wait'", source) self.assertNotIn("systemctl', 'stop'", source) + self.assertIn("RemainAfterExit=yes", source) + self.assertNotIn("'--collect'", source) def test_bootstrap_retains_intent_and_repeated_or_uncertain_launch_does_not_replay(self): # Execute the real bootstrap with only its filesystem/root/systemd/clock @@ -474,6 +621,8 @@ class DispatchLifecycleTest(unittest.TestCase): payload = {"request": request(), "source": "c291cmNl", "binary": "", "version_file": ""} def launch(argv, **kwargs): + if argv[0] == "systemctl": + return subprocess.CompletedProcess(argv, 0, b"ActiveState=active\nSubState=exited\nMainPID=0\nExecMainCode=1\nExecMainStatus=0\n") calls.append(argv) attempt = next((root / "attempts").iterdir()) if uncertain: @@ -499,6 +648,75 @@ class DispatchLifecycleTest(unittest.TestCase): if uncertain: self.assertEqual(json.loads((attempt / "launch.json").read_text())["state"], "uncertain") + def test_terminal_observer_requires_writer_closure_without_replay_or_stale_success(self): + cases = [ + ("committed", "success", 0, "committed"), + ("committed", "failure", 1, "uncertain"), + ("healthy_noop", "signal", 1, "uncertain"), + ("observation_failed", "evidence_failure", 1, "observation_failed"), + ("rolled_back", "failure", 1, "rolled_back"), + ("rolled_back", "evidence_failure", 1, "uncertain"), + ("committed", "malformed", 1, "uncertain"), + ("committed", "unavailable", 1, "uncertain"), + ("committed", "missing", 1, "uncertain"), + ("committed", "timeout", 1, "uncertain"), + ("committed", "running", 1, "uncertain"), + ] + for receipt_status, child_status, expected_code, expected_status in cases: + with self.subTest(receipt=receipt_status, child=child_status), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "state" + source = dispatcher.BOOTSTRAP.replace("'/var/lib/pulse-deploy/demo'", repr(str(root))) + source = source.replace("root.stat().st_uid == 0", "root.stat().st_uid == os.getuid()") + clock, launches, reads = [0], [], [] + payload = {"request": request(), "source": "c291cmNl", "binary": "", "version_file": ""} + + class Input: + def read(self, _limit): + return json.dumps(payload) + + def command(argv, **kwargs): + if argv[0] == "systemd-run": + launches.append(argv) + attempt = next((root / "attempts").iterdir()) + (attempt / "receipt.json").write_text(json.dumps({"status": receipt_status})) + return subprocess.CompletedProcess(argv, 0) + self.assertEqual(argv[:2], ["systemctl", "show"]) + reads.append(argv) + # Even a visible terminal must wait for its writing child. + if len(reads) == 1 or child_status == "running": + body = b"ActiveState=active\nSubState=running\nMainPID=123\nExecMainCode=0\nExecMainStatus=0\n" + elif child_status == "timeout": + raise subprocess.TimeoutExpired("systemctl", 20) + else: + body = { + "success": b"ActiveState=active\nSubState=exited\nMainPID=0\nExecMainCode=1\nExecMainStatus=0\n", + "failure": b"ActiveState=failed\nSubState=failed\nMainPID=0\nExecMainCode=1\nExecMainStatus=1\n", + "evidence_failure": b"ActiveState=failed\nSubState=failed\nMainPID=0\nExecMainCode=1\nExecMainStatus=2\n", + "signal": b"ActiveState=failed\nSubState=failed\nMainPID=0\nExecMainCode=2\nExecMainStatus=9\n", + "malformed": b"MainPID=not-a-pid\n", + "unavailable": b"", + "missing": b"ActiveState=inactive\nSubState=dead\nMainPID=0\nExecMainCode=0\nExecMainStatus=0\n", + }[child_status] + return subprocess.CompletedProcess(argv, 1 if child_status == "unavailable" and len(reads) > 1 else 0, body) + + with patch.object(os, "geteuid", return_value=0), patch.object(subprocess, "run", side_effect=command), \ + patch.object(dispatcher.sys, "stdin", Input()), patch("time.monotonic", side_effect=lambda: clock[0]), \ + patch("time.sleep", side_effect=lambda seconds: clock.__setitem__(0, clock[0] + seconds)), patch("builtins.print") as output: + for _ in range(2): + reads.clear(); clock[0] = 0 + with self.assertRaises(SystemExit) as exit_result: + exec(compile(source, "fixed-bootstrap", "exec"), {}) + self.assertEqual(exit_result.exception.code, expected_code) + result = json.loads(output.call_args.args[0]) + self.assertEqual(result["status"], expected_status) + self.assertGreaterEqual(len(reads), 2) + if expected_status == "uncertain": + self.assertTrue(result["recovery_required"]) + self.assertEqual(len(launches), 1) + # Collection must not edit or fabricate the host receipt. + attempt = next((root / "attempts").iterdir()) + self.assertEqual(json.loads((attempt / "receipt.json").read_text())["status"], receipt_status) + class NativeAdmissionTest(unittest.TestCase): def test_native_driver_refuses_wrong_context_before_any_service_command(self): diff --git a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md index 0202d8e0c0..cf638ea766 100644 --- a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md +++ b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md @@ -32,6 +32,22 @@ workflow queue does not exclude another repository's host deployment. mutation. A prior failed baseline is not a healthy rollback. No crash dump is induced. Receipt fields contain fixed diagnostics and identities, never raw journal/HTTP bodies, environment values or snapshot contents. +- Phase/terminal receipt I/O failure cannot gate owned stop, restoration or the + full recovery watch. It retains captures and the original failure. Even an + observed full restored window is **unverified** after evidence loss: + `observation_failed`/`recovery_required` blocks a new mutation. A persistently + unwritable receipt may leave only a nonterminal record; missing evidence is + not converted to acceptance or retried as a new request. +- Terminal collection also observes the original systemd child's exit, without + restarting or stopping it. `RemainAfterExit=yes` retains the closed result; + failed units are not collected automatically. A favourable JSON replacement + can become visible before its directory fsync fails, so it is provisional + until the writer exits successfully. Missing, malformed or contradictory + child evidence returns uncertainty, never a successful receipt or replay. + The engine exits **2** on receipt loss, distinct from **1** for an observed + failed operation; a stale `rolled_back` JSON cannot hide that loss. New + attempts also reconcile the retained child of a prior source-bound terminal + that requires this binding. Older receipts gain no missing exit evidence. - Source-bound intent precedes systemd submission. An identical request reads its receipt and cannot start another child after a lost response. SSH loss does not terminate the child. TERM/INT/HUP cannot abort owned recovery. The @@ -46,6 +62,11 @@ service, journal and HTTP adapters. Tests retain delayed 55/299-second failure, changed executable/VERSION/data rollback, cancellation, failed and unhealthy recovery, headroom refusal, shared-lock contention, source mismatch, journal failure, cohost restart, source-bound repeated/lost submission and no-op proof. +Receipt faults after stop, after runtime replacement and during restoration +exercise the real filesystem engine, including persistent write loss, +post-replacement fsync loss, cancellation and failed-terminal non-replay. The +unchanged-parent control retains the adverse skipped restoration. Collection +controls wait for writer closure and reject favourable stale terminals. The production window is not shortened. This is **not native acceptance**. `demo-runtime-native.yml` now carries the next executable acceptance rather diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 06b0edcb4f..c7189987c1 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -38,6 +38,28 @@ failed/uncertain terminal evidence privately. An unresolved estate blocks later mutation. No source-worker production capability, package upgrade, Relay copy activation or credential is added. +Receipt I/O failure must not skip the owned stop/restore or shorten either +recovery observation window. Physical restoration and complete observations +remain distinct from retained evidence: any phase/terminal write loss yields +failed, unverified `observation_failed` evidence and blocks later mutation, +even when the old runtime/data are restored. The capture remains private and +retained. No OS error text or private path is returned. A missing terminal may +remain nonterminal on disk; it cannot be fabricated as verified. + +The bootstrap observes the exact child's closed systemd result before accepting +a terminal, including favourable JSON made visible by replacement before a +failed directory fsync. The child retains `RemainAfterExit=yes` and failed +units are not automatically collected. Running, missing, malformed or +contradictory child/receipt evidence is not acceptance. This is terminal +observation of the existing transaction, not another dispatch/admission route. +Receipt loss exits 2 rather than an ordinary failed-operation exit 1; new +attempts cannot clear a prior child-bound terminal whose exit disagrees. Older +unbound receipts gain no retrospective result. +Connected verification is in `test_demo_runtime_transaction.py`: receipt I/O +faults after stop/replacement and through recovery preserve complete watches, +capture/runtime/data and cancellation ownership; collector controls cover +writer closure, contradictory exits, unavailable state and repeated non-replay. + SSH observes, but never owns or cancels, the systemd child. Durable intent precedes submission; the exact request identity is not replayed after a lost response. Forward cancellation initiates restoration; recovery ignores From 5cf724b4e7606c02f509f261c4b0fd022b3d68a3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:15:43 +0100 Subject: [PATCH 28/86] Keep recovery independent of its phase writer interface Exercise direct recovering and terminal OSError injection as well as atomic receipt faults. Preserve the real restored estate and complete watches while evidence loss remains failed and unverified. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 13 ++++++--- .../tests/test_demo_runtime_transaction.py | 29 +++++++++++++++++++ .../subsystems/deployment-installability.md | 3 +- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 04c95f4490..1bec825f82 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -417,11 +417,14 @@ class Transaction: def recovery_save(self, status): """Receipt storage must not gate the already-owned restoration.""" + self.receipt["status"] = status try: self.save(status) - except Failure as error: - if str(error) != "receipt-observation": + except (OSError, Failure) as error: + if isinstance(error, Failure) and str(error) != "receipt-observation": raise + if status not in self.receipt["observation_failures"]: + self.receipt["observation_failures"].append(status) def finish(self, status): """Best-effort failed evidence is not a verified terminal outcome.""" @@ -437,9 +440,11 @@ class Transaction: unverified() try: self.save(self.receipt["status"] if self.receipt["observation_failures"] else status) - except Failure as error: - if str(error) != "receipt-observation": + except (OSError, Failure) as error: + if isinstance(error, Failure) and str(error) != "receipt-observation": raise + if self.receipt["status"] not in self.receipt["observation_failures"]: + self.receipt["observation_failures"].append(self.receipt["status"]) unverified() # One bounded attempt to retain the failure, not to obtain a pass. self.recovery_save("observation_failed") diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 39452b1ece..e249f17e0b 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -318,6 +318,35 @@ class TransactionTest(unittest.TestCase): self.assertEqual(tx.receipt["forward"]["elapsed_seconds"], 50) self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + def test_recovery_survives_direct_phase_and_terminal_writer_oserror(self): + for phase in ("recovering", "rolled_back"): + with self.subTest(phase=phase): + req = request("update"); req["version"] = "v6.4.6" + tx = engine.Transaction(self.host, req, self.attempt) + self.host.transaction = tx; self.host.fail_at = self.host.clock + 55 + self.host.failed = False + original = tx.save + + def write(status): + if status == phase: + raise OSError("private writer error") + original(status) + + with patch.object(tx, "save", side_effect=write): + code = tx.run() + self.assertEqual((code, tx.receipt["status"], tx.receipt["rollback"]), + (2, "observation_failed", "unverified")) + self.assertEqual(engine.estate_hash(self.paths), self.before) + self.assertEqual(tx.receipt["recovery"]["elapsed_seconds"], 300) + self.assertEqual(tx.receipt["failure"], "new-service-crash") + self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) + # Keep each independent snapshot/intent; never reuse its path. + self.attempt = self.root / "attempts" / ("b" * 64) + if phase == "recovering": + # The prior unverified estate correctly blocks a new + # request; use an independent fixture for the next fault. + self.doCleanups(); self.setUp() + def test_recovery_terminal_receipt_failure_retains_full_restoration(self): tx = self.run_fault("rolled_back") self.assertEqual(tx.receipt["observed_outcome"], "rolled_back") diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c7189987c1..932168a995 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -39,7 +39,8 @@ mutation. No source-worker production capability, package upgrade, Relay copy activation or credential is added. Receipt I/O failure must not skip the owned stop/restore or shorten either -recovery observation window. Physical restoration and complete observations +recovery observation window, including an OSError at the phase/terminal writer +interface itself. Physical restoration and complete observations remain distinct from retained evidence: any phase/terminal write loss yields failed, unverified `observation_failed` evidence and blocks later mutation, even when the old runtime/data are restored. The capture remains private and From 227657b1b0d6924e98da9382f812bb0cb1874f57 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:17:11 +0100 Subject: [PATCH 29/86] Retain the attempted terminal phase on writer failure The direct writer-interface control also checks the fixed failed phase. Report the attempted terminal, not the preceding in-memory recovery phase. Contract-Neutral: Correct the fixed diagnostic label and its existing contract control; restoration, evidence-failure exit and authority remain unchanged. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 7 ++++--- .github/scripts/tests/test_demo_runtime_transaction.py | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 1bec825f82..3cd8824742 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -438,13 +438,14 @@ class Transaction: if self.receipt["observation_failures"]: unverified() + terminal = self.receipt["status"] if self.receipt["observation_failures"] else status try: - self.save(self.receipt["status"] if self.receipt["observation_failures"] else status) + self.save(terminal) except (OSError, Failure) as error: if isinstance(error, Failure) and str(error) != "receipt-observation": raise - if self.receipt["status"] not in self.receipt["observation_failures"]: - self.receipt["observation_failures"].append(self.receipt["status"]) + if terminal not in self.receipt["observation_failures"]: + self.receipt["observation_failures"].append(terminal) unverified() # One bounded attempt to retain the failure, not to obtain a pass. self.recovery_save("observation_failed") diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index e249f17e0b..3900e32a01 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -339,6 +339,7 @@ class TransactionTest(unittest.TestCase): self.assertEqual(engine.estate_hash(self.paths), self.before) self.assertEqual(tx.receipt["recovery"]["elapsed_seconds"], 300) self.assertEqual(tx.receipt["failure"], "new-service-crash") + self.assertIn(phase, tx.receipt["observation_failures"]) self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) # Keep each independent snapshot/intent; never reuse its path. self.attempt = self.root / "attempts" / ("b" * 64) From 2dda2e66531eed4f812aaf6b514c90223cadadb3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:18:12 +0100 Subject: [PATCH 30/86] Scope fetched History to the active organisation Replace HistoryChart request and polling ownership on org changes, even when resource IDs match. Withdraw old readings and inspection without changing same-org outage retention or caller-supplied sample ownership. Preserve touch compatibility-focus suppression. Add original-source controls, production browser verification and the primitive contract. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 21 + frontend-modern/browser-tests/history-org.cjs | 388 ++++++++++++++++++ .../browser-tests/history-org.html | 12 + frontend-modern/browser-tests/history-org.tsx | 76 ++++ frontend-modern/browser-verification.json | 51 +-- .../shared/__tests__/HistoryChart.test.tsx | 78 ++++ .../__tests__/useHistoryChartState.test.tsx | 117 ++++++ .../components/shared/useHistoryChartState.ts | 12 + 8 files changed, 725 insertions(+), 30 deletions(-) create mode 100644 frontend-modern/browser-tests/history-org.cjs create mode 100644 frontend-modern/browser-tests/history-org.html create mode 100644 frontend-modern/browser-tests/history-org.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f99fcd1271..7f1fbe6186 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,27 @@ ## Purpose +### Organisation-scoped single-metric History + +Fetched HistoryChart observations belong to the active organisation as well as +the resource type/ID, metric and range. `org_switched` replaces the request owner +even when those chart props are unchanged: withdraw old samples, provenance, +errors and touch/keyboard/group inspection, abort superseded reads and replace +the polling timer before immediately requesting the same selection in the new +API context. Late success or failure from an earlier organisation cannot update +or settle that request. Disposal removes both timer and org subscription. + +Same-organisation transport/5xx failures still retain their own successful +observations with explicit refresh warnings. A failed first read in a new org +must not fall back to the previous org. Locked or missing targets do not fetch; +explicit supplied samples remain caller-owned, not silently converted to fetched +history. Matching same-org supplied-sample refreshes retain inspection. +Runtime ownership and keyboard/touch rendering regressions plus the production +`browser-tests/history-org.cjs` HTTP fixture verify these boundaries with the +real API org header and the existing org-safe batch reader as a control. This is +client presentation/isolation proof, not native tenancy, collector or release +acceptance. + ### Stable physical-disk History composition Disk detail composes shared HistoryChart owners from stable disk-family catalog diff --git a/frontend-modern/browser-tests/history-org.cjs b/frontend-modern/browser-tests/history-org.cjs new file mode 100644 index 0000000000..7955ed65e7 --- /dev/null +++ b/frontend-modern/browser-tests/history-org.cjs @@ -0,0 +1,388 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +const parent = false; +(async () => { + const root = parent + ? '/workspace/tmp/history-org-parent/frontend-modern' + : '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = `/workspace/tmp/history-org-${parent ? 'parent' : 'final'}-proof`; + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), + server: { host: '127.0.0.1', port: 5264, strictPort: true }, + }); + const playwright = require('playwright/package.json').version; + assert.equal( + playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const results = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + }); + page.setDefaultTimeout(30_000); + await page.clock.install({ time: new Date('2026-10-03T12:00:00Z') }); + const modes = { + 'org-a': { mode: 'success', value: 42, source: 'memory' }, + 'org-b': { mode: 'hold', value: 80, source: 'store' }, + 'org-c': { mode: '503' }, + 'org-d': { mode: 'success', value: 61, source: 'store' }, + }; + const requests = [], + held = [], + checks = [], + states = [], + errors = [], + offOrigin = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5264') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + const query = Object.fromEntries(url.searchParams); + const org = (await route.request().allHeaders())['x-pulse-org-id']; + let response = { ...modes[org] }; + const record = { ...query, org, responseMode: response.mode }; + requests.push(record); + if (response.mode === 'hold') { + response = await new Promise((resolve) => held.push({ org, resolve })); + } + const points = [0, 1].map((index) => ({ + timestamp: 1791028740000 + index * 60_000, + value: response.value - 1 + index, + min: response.value - 1 + index, + max: response.value - 1 + index, + })); + try { + await route.fulfill({ + status: response.mode === '503' ? 503 : 200, + json: + response.mode === '503' + ? { error: 'synthetic outage' } + : { + resourceType: query.resourceType, + resourceId: query.resourceId, + range: query.range, + metric: query.metric, + source: response.source, + start: 1791028740000, + end: 1791028800000, + ...(query.metric ? { points } : { metrics: { cpu: points, memory: points } }), + }, + }); + record.fulfilled = true; + } catch { + // Explicitly aborted/superseded browser requests can no longer be fulfilled. + record.fulfilled = false; + } + return; + } + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { enabled: false, data: [] }, + }); + }); + const check = async (label, fn) => { + try { + await fn(); + checks.push({ label, passed: true }); + } catch (error) { + checks.push({ label, passed: false, error: error.message }); + } + }; + const screenshot = async (state) => { + const file = `${name}-${state}.png`; + await page.screenshot({ + path: path.join(artifacts, file), + fullPage: true, + animations: 'disabled', + }); + states.push({ + state, + screenshot: file, + body: await page.locator('main').innerText(), + overflow: await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), + }); + }; + const release = (org, response) => { + const matches = held.filter((entry) => entry.org === org); + for (const entry of matches) { + held.splice(held.indexOf(entry), 1); + entry.resolve(response); + } + }; + const bootRequests = [], + failedRequests = []; + page.on('request', (r) => { + if (bootRequests.length < 300) bootRequests.push(r.url()); + }); + page.on('requestfailed', (r) => failedRequests.push({ url: r.url(), failure: r.failure() })); + try { + await page.goto('http://127.0.0.1:5264/browser-tests/history-org.html', { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }); + } catch (error) { + fs.writeFileSync( + path.join(artifacts, 'boot-error.json'), + JSON.stringify( + { + parent, + playwright, + name, + error: error.message, + bootRequests, + failedRequests, + errors, + requests, + body: await page + .locator('body') + .innerText() + .catch(() => ''), + }, + null, + 2, + ), + ); + throw error; + } + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + const disk = page.getByTestId('disk'), + pool = page.getByTestId('pool'), + service = page.getByTestId('service'); + const thermal = disk.getByRole('img', { name: 'Temperature chart' }); + const description = () => + thermal.evaluate( + (el) => document.getElementById(el.getAttribute('aria-describedby')).textContent, + ); + const waitThermal = async (value) => { + await page.waitForFunction((reading) => { + const canvas = document.querySelector('[data-testid="disk"] canvas'); + return document + .getElementById(canvas.getAttribute('aria-describedby')) + .textContent.includes(reading); + }, `${value}ยฐC`); + }; + const inspect = async () => { + if (phone) await thermal.tap({ position: { x: 100, y: 80 } }); + else { + await thermal.focus(); + await thermal.press('End'); + } + }; + await waitThermal(42); + await service.getByRole('slider', { name: 'Inspect Utilization history' }).waitFor(); + await inspect(); + await check( + 'Initial org-a observations and provenance are real rendered reader results', + async () => { + assert.match(await description(), /42ยฐC/); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1); + assert.match(await disk.innerText(), /Buffer/i); + assert(requests.some((r) => r.org === 'org-a' && r.resourceType === 'disk')); + }, + ); + await screenshot('org-a-loaded'); + modes['org-a'] = { mode: 'hold', value: 99, source: 'memory' }; + await page.clock.fastForward(30_000); + await page.waitForTimeout(100); + await check( + 'Ordinary same-org pending polling retains inspection and stored readings', + async () => { + assert(held.some((entry) => entry.org === 'org-a')); + assert.match(await description(), /42ยฐC/); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1); + }, + ); + const beforeSwitch = requests.length; + await page.getByRole('button', { name: 'Switch to org-b' }).click(); + await page.waitForTimeout(150); + await check('All exact targets are immediately reread with the new org header', async () => { + const next = requests.slice(beforeSwitch); + for (const id of ['disk:nas:sda', 'storage:nas:pool', 'pbs-service']) { + assert(next.some((r) => r.org === 'org-b' && r.resourceId === id)); + } + assert(next.every((r) => r.org === 'org-b')); + }); + await check( + 'Pending org-b removes org-a values, min/max, provenance and grouped inspection', + async () => { + assert.doesNotMatch(await description(), /42ยฐC/); + assert.doesNotMatch(await disk.innerText(), /Buffer|42ยฐC/i); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await pool.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await service.getByRole('slider').count(), 0); + assert.equal((await disk.locator('[aria-live="polite"]').innerText()).trim(), ''); + }, + ); + await screenshot('org-b-pending'); + release('org-a', { mode: 'success', value: 99, source: 'memory' }); + await page.waitForTimeout(150); + await check( + 'Late pre-switch completion cannot resurrect readings or settle the new org', + async () => { + assert.doesNotMatch(await description(), /42ยฐC|99ยฐC/); + assert.doesNotMatch(await disk.innerText(), /Buffer|99ยฐC/i); + assert.equal(await service.getByRole('slider').count(), 0); + }, + ); + await screenshot('late-org-a'); + modes['org-b'] = { mode: 'success', value: 80, source: 'store' }; + release('org-b', modes['org-b']); + await page.waitForTimeout(150); + await check( + 'Only successful org-b observations restore History without old inspection', + async () => { + assert.match(await description(), /80ยฐC/); + assert.doesNotMatch(await description(), /99ยฐC|42ยฐC/); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await pool.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await service.getByRole('slider').count(), 1); + }, + ); + // Allow the unchanged parent's next poll to catch up before testing the + // independent transient-outage control, instead of relying on its stale state. + await page.clock.fastForward(30_000); + await waitThermal(80); + await inspect(); + await screenshot('org-b-loaded'); + modes['org-b'] = { mode: '503' }; + await page.clock.fastForward(30_000); + await page.waitForTimeout(300); + await check( + 'A same-org 503 still retains its own successful readings with a warning', + async () => { + assert.match(await description(), /80ยฐC/); + assert.match( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + /last successful result/, + ); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 1); + }, + ); + await screenshot('org-b-outage'); + await page.getByRole('button', { name: 'Switch to org-c' }).click(); + await page.clock.fastForward(30_000); + await page.waitForTimeout(300); + await check( + 'Failure in org-c cannot fall back to org-b values or its refresh warning', + async () => { + assert.doesNotMatch(await description(), /80ยฐC|99ยฐC|42ยฐC/); + assert.match(await disk.getByRole('alert').innerText(), /Failed to load history data/); + assert.equal( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + '', + ); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 0); + assert.equal(await service.getByRole('slider').count(), 0); + }, + ); + await screenshot('org-c-failed'); + await page.getByRole('button', { name: 'Switch to org-d' }).click(); + await page.clock.fastForward(30_000); + await waitThermal(61); + await check( + 'New successful org-d data recovers; all requests keep their target/range/metric scope', + async () => { + assert.equal(await disk.getByRole('alert').count(), 0); + assert.equal( + await disk.getByRole('status', { name: 'History refresh status' }).innerText(), + '', + ); + assert(requests.every((r) => r.range === '1h')); + assert( + requests + .filter((r) => r.resourceId === 'disk:nas:sda') + .every((r) => r.metric === 'smart_temp' && r.resourceType === 'disk'), + ); + assert( + requests + .filter((r) => r.resourceId === 'storage:nas:pool') + .every((r) => r.metric === 'disk' && r.resourceType === 'storage'), + ); + assert( + requests + .filter((r) => r.resourceId === 'pbs-service') + .every((r) => !r.metric && r.maxPoints === '240'), + ); + }, + ); + await screenshot('org-d-recovered'); + await check( + 'Light desktop/dark touch phone states fit, with no page errors or off-origin data', + async () => { + assert(states.every((state) => !state.overflow)); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + }, + ); + results.push({ + name, + engineVersion: browser.version(), + checks, + states, + requests, + errors, + offOrigin, + }); + for (const entry of held) entry.resolve({ mode: '503' }); + await browser.close(); + browser = undefined; + } + fs.writeFileSync( + path.join(artifacts, 'result.json'), + JSON.stringify({ parent, playwright, results }, null, 2), + ); + const failed = results.flatMap((result) => result.checks.filter((check) => !check.passed)); + console.log( + JSON.stringify({ + parent, + checks: results.flatMap((r) => r.checks).length, + failed: failed.length, + artifacts, + }), + ); + if (failed.length) process.exitCode = 1; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/history-org.html b/frontend-modern/browser-tests/history-org.html new file mode 100644 index 0000000000..f972f7451c --- /dev/null +++ b/frontend-modern/browser-tests/history-org.html @@ -0,0 +1,12 @@ + + + + + + History organisation boundary verification + + +
+ + + diff --git a/frontend-modern/browser-tests/history-org.tsx b/frontend-modern/browser-tests/history-org.tsx new file mode 100644 index 0000000000..135b338be7 --- /dev/null +++ b/frontend-modern/browser-tests/history-org.tsx @@ -0,0 +1,76 @@ +// Production readers, org event/client and styles; all observations are synthetic. +// A stable resource ID across organisations must not preserve fetched History. +import { For } from 'solid-js'; +import { render } from 'solid-js/web'; +import { GuestDrawerHistory } from '../src/components/Workloads/GuestDrawerHistory'; +import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart'; +import { HOST_METRICS_HISTORY_GROUPS } from '../src/components/shared/hostMetricsHistoryModel'; +import { eventBus } from '../src/stores/events'; +import { setOrgID } from '../src/utils/apiClient'; +import '../src/index.css'; + +setOrgID('org-a'); + +function Fixture() { + const switchOrg = (org: string) => { + // Same ordering as useAppRuntimeState.handleOrgSwitch, with no authentication bypass. + setOrgID(org); + eventBus.emit('org_switched', org); + }; + return ( +
+

History organisation boundary verification

+
+ + {(org) => ( + + )} + +
+ +
+

Disk History

+ +
+
+

Pool History

+ +
+
+
+

PBS service History (existing org-safe batch control)

+ +
+
+ ); +} +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 353d9f0ce2..03eafe7e2a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,25 +1,17 @@ { "version": 1, "result": "passed", - "base_sha": "a9533eb82a29700ede2d4ad609812ddd01e5f287", - "verified_at": "2026-10-03T01:44:27.336168Z", + "base_sha": "e862d622dd236824b42565c3e0c90182140be245", + "verified_at": "2026-10-03T03:18:08.489528Z", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/shared/HistoryChartOverlay.tsx", - "frontend-modern/src/components/shared/useHistoryChartState.ts", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts", - "frontend-modern/src/utils/apiAccessError.ts" + "frontend-modern/src/components/shared/useHistoryChartState.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "c4370a69495c8ccea01a0d92076e4a051e4d06abec0e675e2fb3547d46e87bcb", - "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "14c0699b827ec6eb4d85cf9d8fc76d61a0af3dd6565d2396a3f22a03429af104", - "frontend-modern/src/components/shared/useHistoryChartState.ts": "5a9b022656ac7316710675a0742e9b2908c9114ffbed67d6f6962d361bb65bbf", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "df293d0484612a6d31dd9f3fa6af2a1160e1e98d66965730a45829b7048a4ba7", - "frontend-modern/src/utils/apiAccessError.ts": "3b34a498959a77014af3c842677fc6646b5e7e6a8b9b332ca8acf91254c01730" + "frontend-modern/src/components/shared/useHistoryChartState.ts": "4f71e1b3856773530b9789b71d4fc1638c2bbf3f8d9272c6218da1e8952bb442" }, "routes": [ - "Existing PBS/Machine/resource drawer History batch reader", - "Existing Storage physical disk shared History reader" + "Existing Storage physical-disk and pool History shared reader", + "Existing PBS/Machine drawer History batch reader as unchanged org-safe control" ], "viewports": [ { @@ -32,25 +24,24 @@ } ], "states": [ - "Real same-origin 503 responses retain stored samples with explicit warnings in both production readers", - "Real final HTTP 403 responses withdraw samples, tooltip, keyboard announcement, sliders and formerly displayed current-only legends", - "Access-denied guidance fits light desktop Chromium and dark touch WebKit without horizontal overflow", - "The existing focusable scoped retry remains mounted and ignores duplicate activation while busy", - "Earlier cached ranges and remounts cannot resurrect denied stored readings while a replacement is pending", - "Only a new successful read restores History and clears denial; old values do not return", - "Every final screenshot inspected; fixed Playwright 1.56.1 equals integration lock, no page errors or off-origin data requests" + "Initial org-a fetched observations, provenance and keyboard/touch/grouped inspection", + "Same-org pending polling retains its readings and inspection", + "Org-b switch with identical resource IDs removes old samples, min/max, provenance, tooltip and announced inspection before the new response", + "Late org-a completion cannot repopulate or settle the new selection; exact targets use X-Pulse-Org-ID org-b immediately", + "Only successful org-b results restore History without old inspection; ordinary same-org 503 still retains observations with a visible warning", + "A failed org-c first read cannot fall back to org-b observations; fresh successful org-d read recovers", + "20 checks pass; all 14 final screenshots inspected in light desktop Chromium and dark touch phone WebKit; no page errors, off-origin data requests or horizontal overflow" ], "interactions": [ - "Inspect by desktop keyboard and phone tap", - "Poll through 503 then 403", - "Retry twice while busy", - "Change 24h/1h range", - "Unmount and remount batch History", - "Resume with newly successful observations" + "Inspect by End key and phone tap across shared hover group", + "Keep background reads pending, switch org with the same resource IDs", + "Release pre-switch responses while new-org reads remain pending", + "Release fresh new-org observations", + "Poll through same-org 503, switch into failed first read, then recover in another org" ], "limitations": [ - "Direct production readers/client with synthetic HTTP responses, not a complete native PBS/NAS drawer, session revocation, appliance collection or release acceptance.", - "401 is covered by runtime regressions; the real API client redirects on 401, not bypassed for browser proof.", - "Independently supplied current readings remain distinctly current during a new pending query; they are not stored History." + "Production readers, styles, API client and org event with synthetic same-origin HTTP responses; not full-application, native tenancy/collector, installed or release acceptance.", + "Supplied-data ownership, locking, disposal, bounded polling and transport-ignore-cancellation controls are runtime-tested, not browser-proven.", + "Parent browser corrected request had an unavailable capability result; only the unchanged-parent unit controls establish the original defect." ] } diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index ab9ba9d221..ea56a052df 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createSignal } from 'solid-js'; import { ChartsAPI } from '@/api/charts'; +import { eventBus } from '@/stores/events'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import historyChartHeaderSource from '@/components/shared/HistoryChartHeader.tsx?raw'; import historyChartHoverGroupSource from '@/components/shared/HistoryChartHoverGroup.tsx?raw'; @@ -572,6 +573,83 @@ describe('HistoryChart', () => { }); }); +describe('History organisation boundary rendering', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + it.each(['keyboard', 'touch'])( + 'clears %s inspection and accessible values on org switch until a new read succeeds', + async (input) => { + vi.useFakeTimers(); + const request = vi.mocked(ChartsAPI.getMetricsHistory); + request.mockReset(); + request.mockResolvedValueOnce({ + points: [{ timestamp: 1000, value: 42, min: 42, max: 42 }], + source: 'memory', + } as never); + let complete!: (response: Awaited>) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { container } = render(() => ( + + )); + await vi.advanceTimersByTimeAsync(0); + const canvas = screen.getByRole('img', { name: 'Temperature chart' }); + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + toJSON: () => ({}), + }); + if (input === 'keyboard') fireEvent.focus(canvas); + else { + touchPointer(canvas, 'pointerdown'); + touchPointer(canvas, 'pointerup'); + } + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42ยฐC'); + eventBus.emit('org_switched', 'org-b'); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + expect(container.querySelector('[aria-live="polite"]')?.textContent).toBe(''); + expect(container).not.toHaveTextContent('42ยฐC'); + expect(container).not.toHaveTextContent('Buffer'); + expect(canvas).toHaveAccessibleDescription(/Loading 1-hour history data/); + complete({ + points: [{ timestamp: 1000, value: 80, min: 80, max: 80 }], + source: 'store', + } as never); + await vi.advanceTimersByTimeAsync(0); + expect(canvas).toHaveAccessibleDescription(/80ยฐC/); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + if (input === 'touch') { + // Touch compatibility focus from the old chart must not reactivate its pinned reading. + fireEvent.focus(canvas); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + touchPointer(canvas, 'pointerdown'); + touchPointer(canvas, 'pointerup'); + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('80ยฐC'); + } + }, + ); +}); + describe('History access boundary rendering', () => { afterEach(() => { cleanup(); diff --git a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx index 571dcd49ac..3364d3f6af 100644 --- a/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/useHistoryChartState.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createSignal } from 'solid-js'; import { cleanup, render } from '@solidjs/testing-library'; import { ChartsAPI } from '@/api/charts'; +import { eventBus } from '@/stores/events'; import type { HistoryChartProps } from '../historyChartModel'; import { useHistoryChartState, type HistoryChartState } from '../useHistoryChartState'; @@ -218,6 +219,122 @@ describe('History request ownership', () => { }); }); +describe('History organisation ownership', () => { + beforeEach(() => { + vi.useFakeTimers(); + request.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('withdraws loaded readings, provenance and inspection before reading the same target in a new org', async () => { + request.mockResolvedValueOnce({ points: points(42), source: 'memory' } as never); + const { state } = mount(); + await settle(); + state.handleFocus(); + expect(state.data()).toEqual(points(42)); + expect(state.keyboardInspecting()).toBe(true); + const next = deferred(); + request.mockReturnValueOnce(next.promise); + eventBus.emit('org_switched', 'org-b'); + expect(state.data()).toEqual([]); + expect(state.source()).toBeNull(); + expect(state.keyboardInspecting()).toBe(false); + expect(state.hoveredPoint()).toBeNull(); + expect(state.loading()).toBe(true); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1][0]).toMatchObject({ + resourceType: 'agent', + resourceId: 'a', + metric: 'cpu', + range: '1h', + }); + next.resolve({ points: points(80), source: 'store' } as never); + await settle(); + expect(state.data()).toEqual(points(80)); + expect(state.source()).toBe('store'); + expect(state.keyboardInspecting()).toBe(false); + }); + + it.each(['success', 'failure'])( + 'aborts a pre-switch request and ignores its late %s without settling the new request', + async (completion) => { + const old = deferred(), + current = deferred(); + request.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise); + const { state } = mount(); + const oldSignal = request.mock.calls[0][0].signal!; + eventBus.emit('org_switched', 'org-b'); + expect(oldSignal.aborted).toBe(true); + expect(request).toHaveBeenCalledTimes(2); + if (completion === 'success') old.resolve({ points: points(42), source: 'memory' } as never); + else old.reject(Object.assign(new Error('old org denied'), { status: 403 })); + await settle(); + expect(state.loading()).toBe(true); + expect(state.data()).toEqual([]); + expect(state.error()).toBeNull(); + current.resolve({ points: points(80), source: 'store' } as never); + await settle(); + expect(state.loading()).toBe(false); + expect(state.data()).toEqual(points(80)); + expect(state.error()).toBeNull(); + }, + ); + + it("does not retain another org's successful result when the new read fails", async () => { + request.mockResolvedValueOnce({ points: points(42), source: 'store' } as never); + const { state } = mount(); + await settle(); + request.mockRejectedValueOnce(new Error('new org unavailable')); + eventBus.emit('org_switched', 'org-b'); + await settle(); + expect(state.data()).toEqual([]); + expect(state.source()).toBeNull(); + expect(state.error()).toBe('Failed to load history data'); + expect(state.refreshFailed()).toBe(false); + }); + + it('replaces polling ownership on repeated switches and removes the subscription on disposal', async () => { + request.mockResolvedValue({ points: points(80), source: 'store' } as never); + const { state, unmount } = mount(); + await settle(); + eventBus.emit('org_switched', 'org-b'); + await settle(); + eventBus.emit('org_switched', 'org-c'); + await settle(); + expect(request).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(10_000); + expect(request).toHaveBeenCalledTimes(4); + expect(state.data()).toEqual(points(80)); + unmount(); + eventBus.emit('org_switched', 'org-d'); + await vi.advanceTimersByTimeAsync(30_000); + expect(request).toHaveBeenCalledTimes(4); + }); + + it('preserves caller-owned supplied samples and does not fetch locked or missing targets on a switch', async () => { + request.mockReturnValueOnce(deferred().promise); + const view = mount(); + view.change({ data: points(42) }); + const initialCalls = request.mock.calls.length; + eventBus.emit('org_switched', 'org-b'); + expect(view.state.data()).toEqual(points(42)); + expect(view.state.source()).toBe('live'); + view.change({ data: undefined, range: '90d' }); + eventBus.emit('org_switched', 'org-c'); + expect(view.state.isLocked()).toBe(true); + expect(view.state.data()).toEqual([]); + view.change({ range: '1h', resourceId: '' }); + eventBus.emit('org_switched', 'org-d'); + await vi.advanceTimersByTimeAsync(30_000); + expect(request).toHaveBeenCalledTimes(initialCalls); + }); +}); + describe('History access errors', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index fb02e328d9..b5aac2231e 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -1,6 +1,7 @@ import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { ChartsAPI, type HistoryTimeRange } from '@/api/charts'; import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError'; +import { eventBus } from '@/stores/events'; import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license'; import { calculateOptimalPoints } from '@/utils/downsample'; import { setupCanvasDPR } from '@/utils/canvasRenderQueue'; @@ -46,6 +47,7 @@ export function useHistoryChartState( const [localHoveredTimestamp, setLocalHoveredTimestamp] = createSignal(null); const [hoveredPoint, setHoveredPoint] = createSignal(null); const [chartWidth, setChartWidth] = createSignal(300); + const [orgVersion, setOrgVersion] = createSignal(0); const chartHeight = createMemo(() => props.height || 200); let chartLeftInset = HISTORY_CHART_MIN_LEFT_INSET; let chartRightInset = 0; @@ -55,6 +57,13 @@ export function useHistoryChartState( const hoveredTimestamp = hoverGroup?.hoveredTimestamp ?? localHoveredTimestamp; const setHoveredTimestamp = hoverGroup?.setHoveredTimestamp ?? setLocalHoveredTimestamp; + // Resource IDs can be identical in different organisations. A context switch + // replaces the request owner even when none of the chart props have changed. + const unsubscribeOrgSwitch = eventBus.on('org_switched', () => { + setOrgVersion((version) => version + 1); + }); + onCleanup(unsubscribeOrgSwitch); + const refreshIntervalMs = createMemo(() => getHistoryChartRefreshIntervalMs(range())); onMount(() => { @@ -109,6 +118,7 @@ export function useHistoryChartState( const pointsCap = maxPoints(); const locked = isLocked(); const interval = refreshIntervalMs(); + const orgSelection = orgVersion(); let active = true; let pending = false; let hasLoaded = false; @@ -127,6 +137,7 @@ export function useHistoryChartState( setRefreshFailed(false); setLoading(false); const selection = JSON.stringify([ + orgSelection, resourceType, resourceId, metric, @@ -140,6 +151,7 @@ export function useHistoryChartState( touchTimestamp = null; setHoveredPoint(null); setHoveredTimestamp(null); + setKeyboardInspecting(false); } previousSelection = selection; if (suppliedData !== undefined || locked || !resourceId || !resourceType) return; From d16e665e6c319ddcaee86082e1969477f1ce7641 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:56:56 +0100 Subject: [PATCH 31/86] Route deployment updates through evidence-preserving procedures Remove unconfigured Docker and Helm shorthand from the deployment overview. Preserve target edition, existing service and storage scope, and the limits of snapshots and rollback; exercise the maintained help links and regression guards. Change-source: pulse-maintainer --- docs/DEPLOYMENT_MODELS.md | 61 +++++++--- .../browser-tests/deployment-update-help.cjs | 105 ++++++++++++++++++ .../public/docs/DEPLOYMENT_MODELS.md | 61 +++++++--- scripts/tests/test_deployment_update_docs.py | 77 +++++++++++++ 4 files changed, 272 insertions(+), 32 deletions(-) create mode 100644 frontend-modern/browser-tests/deployment-update-help.cjs create mode 100644 scripts/tests/test_deployment_update_docs.py diff --git a/docs/DEPLOYMENT_MODELS.md b/docs/DEPLOYMENT_MODELS.md index 5542939316..d150b7ad99 100644 --- a/docs/DEPLOYMENT_MODELS.md +++ b/docs/DEPLOYMENT_MODELS.md @@ -4,7 +4,7 @@ Pulse supports multiple deployment models. This page clarifies what differs betw ## Summary -| Model | Recommended for | Data/config path | Updates | +| Model | Recommended for | Default data/config path | Updates | | --- | --- | --- | --- | | Proxmox VE LXC (installer) | Proxmox-first deployments | `/etc/pulse` | In-app updates supported | | systemd (bare metal / VM) | Traditional Linux hosts | `/etc/pulse` | In-app updates supported | @@ -59,11 +59,16 @@ Pulse uses a split config model: - **Relay config**: `relay.enc` (encrypted, Relay and above) - **RBAC roles**: `rbac_roles.json` (Pro/legacy Pro+/Cloud) -Path mapping: +Default path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +These are defaults, not a backup inventory. Check the active deployment's +`PULSE_DATA_DIR`, mounts and any external stores before copying or restoring +files. Metrics can use a separate `PULSE_METRICS_DB_PATH`; see +[Metrics storage](METRICS_HISTORY.md#storage-location). + The audit paths above are the default store, not a licence-dependent guarantee that storage initialised successfully. Runtime-specific storage can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring @@ -84,30 +89,54 @@ Provider-hosted MSP layout: ## Updates by Model +Before changing the server binary or image, record its running version and +edition, keep the saved deployment configuration, and take a consistent private +backup of the actual data and external stores. Preserve encrypted files with +their matching `.encryption.key` and audit signing key. Use a stopped, consistent +filesystem/volume backup or supported backup tooling; copying a live `.db` file +alone can omit SQLite sidecar files. Do not post configuration files, environment +dumps or Helm secret values in an issue. + +An update snapshot is not necessarily a complete data backup, and reverting a +binary or image does not undo data migrations. Check +[rollback and backup scope](AUTO_UPDATE.md#rollback) before updating or reverting; +keep the previous binary/image and matching data backup until recovery is checked. + ### systemd and Proxmox LXC -Use the UI: +Use **Settings โ†’ System โ†’ Updates**. Follow +[server update guidance](AUTO_UPDATE.md) for the deployment's update controls and +Update History. Pulse attempts snapshot and rollback operations; a recorded +backup path alone does not establish that all active data was copied or that +recovery succeeded. -- **Settings โ†’ System โ†’ Updates** - -These deployments can apply updates by downloading a release and swapping binaries/config safely with backups and history. +For LXC, Pulse's service and data are inside the container, not on the Proxmox +host. Keep the failed state and check the running version and service health +before retrying a failed update. ### Docker -Pull a new image and restart: +Follow [Docker server updates](DOCKER.md#-updates) from the original Compose +project or saved container deployment. Persist the exact target in its `image:` +line, or in `PULSE_IMAGE` when that line uses the variable; pulling a different +tag alone does not change the configured image. Paid Pro installs must keep +their private image, not replace it with the Community image. + +Pull and recreate only the Pulse service, stopping if the pull fails. Preserve +its data volume, ports and settings, and check the running image and server +version afterwards. This updates the Pulse server, not its agents or the +containers it monitors; there is no need to restart unrelated Compose services. -```bash -docker pull rcourtman/pulse:latest -docker compose up -d -``` ### Kubernetes (Helm) -Upgrade the chart: +Use the existing Helm release and namespace, the chosen chart version and your +saved deployment values. Review changes to persistence, Secret references and +image edition before rollout; keep the current PVC, keys and settings. Do not +let a default Community image replace paid Pro. -```bash -helm repo update -helm upgrade pulse pulse/pulse -n pulse -``` +See [Kubernetes deployment settings](KUBERNETES.md) and use your cluster's +rollout and recovery procedure. A Helm rollback changes release resources; it +does not guarantee restoration of Pulse data changed by a newer binary. ### Provider-hosted MSP diff --git a/frontend-modern/browser-tests/deployment-update-help.cjs b/frontend-modern/browser-tests/deployment-update-help.cjs new file mode 100644 index 0000000000..3c897e5731 --- /dev/null +++ b/frontend-modern/browser-tests/deployment-update-help.cjs @@ -0,0 +1,105 @@ +// Production Docs/router and real shipped Markdown; no backend or deployment. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules', 'deployment-update-help-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-deployment-update-help'), + server: { host: '127.0.0.1', port: 5250, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + await page.goto('http://127.0.0.1:5250/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + await page.getByRole('link', { name: 'Deployment models', exact: true }).click(); + await page.getByRole('heading', { name: 'Updates by Model', exact: true }).waitFor(); + if (tone === 'dark') await page.evaluate(async () => { + document.documentElement.classList.add('dark'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {}))); + }); + const text = await page.locator('article').innerText(); + for (const phrase of ['defaults, not a backup inventory', 'SQLite sidecar files', + 'snapshot is not necessarily a complete data backup', 'Persist the exact target', + 'Paid Pro installs must keep their private image', 'only the Pulse service', + 'stopping if the pull fails', 'existing Helm release and namespace', + 'current PVC, keys and settings']) assert.ok(text.includes(phrase), phrase); + for (const unsafe of ['docker pull rcourtman/pulse:latest', 'docker compose up -d', + 'helm upgrade pulse pulse/pulse', 'swapping binaries/config safely']) + assert.ok(!text.includes(unsafe), unsafe); + await page.getByRole('heading', { name: 'Updates by Model', exact: true }) + .evaluate((heading) => heading.scrollIntoView({ block: 'start' })); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-overview.png`) }); + + // Keyboard activation must reach the actual maintained procedure/anchor. + const docker = page.getByRole('link', { name: 'Docker server updates', exact: true }); + await docker.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('heading', { name: '๐Ÿ”„ Updates', exact: true }).waitFor(); + await page.waitForFunction(() => { + const heading = document.getElementById('-updates'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/DOCKER#-updates')); + const dockerText = await page.locator('article').innerText(); + for (const phrase of ['persist PULSE_IMAGE', 'docker compose pull pulse', + 'docker compose up -d --no-deps pulse', 'paid Pro installs must keep the private image']) + assert.ok(dockerText.includes(phrase), phrase); + await page.screenshot({ path: path.join(artifacts, `${engine}-docker.png`) }); + await page.getByRole('link', { name: 'rollback and backup scope', exact: true }).click(); + await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor(); + assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#rollback')); + assert.ok((await page.locator('article').innerText()).includes('Copy errors can leave a partial snapshot')); + + await page.goBack(); + await page.goBack(); + await page.getByRole('heading', { name: 'Updates by Model', exact: true }).waitFor(); + await page.getByRole('link', { name: 'Kubernetes deployment settings', exact: true }).click(); + await page.waitForFunction(() => location.pathname === '/docs/KUBERNETES' && + document.querySelector('article')?.textContent.includes('Kubernetes cluster')); + await page.goBack(); + await page.getByRole('link', { name: 'Metrics storage', exact: true }).click(); + await page.getByRole('heading', { name: 'Storage Location', exact: true }).waitFor(); + assert.ok(page.url().endsWith('/docs/METRICS_HISTORY#storage-location')); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, + overviewLimits: true, keyboardDockerLink: true, scopedDockerProcedure: true, + rollbackLink: true, kubernetesLink: true, metricsLink: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { playwright: require('playwright/package.json').version, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(result)}\n`); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { process.stderr.write(`${error.stack}\n`); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md index 5542939316..d150b7ad99 100644 --- a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md +++ b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md @@ -4,7 +4,7 @@ Pulse supports multiple deployment models. This page clarifies what differs betw ## Summary -| Model | Recommended for | Data/config path | Updates | +| Model | Recommended for | Default data/config path | Updates | | --- | --- | --- | --- | | Proxmox VE LXC (installer) | Proxmox-first deployments | `/etc/pulse` | In-app updates supported | | systemd (bare metal / VM) | Traditional Linux hosts | `/etc/pulse` | In-app updates supported | @@ -59,11 +59,16 @@ Pulse uses a split config model: - **Relay config**: `relay.enc` (encrypted, Relay and above) - **RBAC roles**: `rbac_roles.json` (Pro/legacy Pro+/Cloud) -Path mapping: +Default path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +These are defaults, not a backup inventory. Check the active deployment's +`PULSE_DATA_DIR`, mounts and any external stores before copying or restoring +files. Metrics can use a separate `PULSE_METRICS_DB_PATH`; see +[Metrics storage](METRICS_HISTORY.md#storage-location). + The audit paths above are the default store, not a licence-dependent guarantee that storage initialised successfully. Runtime-specific storage can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring @@ -84,30 +89,54 @@ Provider-hosted MSP layout: ## Updates by Model +Before changing the server binary or image, record its running version and +edition, keep the saved deployment configuration, and take a consistent private +backup of the actual data and external stores. Preserve encrypted files with +their matching `.encryption.key` and audit signing key. Use a stopped, consistent +filesystem/volume backup or supported backup tooling; copying a live `.db` file +alone can omit SQLite sidecar files. Do not post configuration files, environment +dumps or Helm secret values in an issue. + +An update snapshot is not necessarily a complete data backup, and reverting a +binary or image does not undo data migrations. Check +[rollback and backup scope](AUTO_UPDATE.md#rollback) before updating or reverting; +keep the previous binary/image and matching data backup until recovery is checked. + ### systemd and Proxmox LXC -Use the UI: +Use **Settings โ†’ System โ†’ Updates**. Follow +[server update guidance](AUTO_UPDATE.md) for the deployment's update controls and +Update History. Pulse attempts snapshot and rollback operations; a recorded +backup path alone does not establish that all active data was copied or that +recovery succeeded. -- **Settings โ†’ System โ†’ Updates** - -These deployments can apply updates by downloading a release and swapping binaries/config safely with backups and history. +For LXC, Pulse's service and data are inside the container, not on the Proxmox +host. Keep the failed state and check the running version and service health +before retrying a failed update. ### Docker -Pull a new image and restart: +Follow [Docker server updates](DOCKER.md#-updates) from the original Compose +project or saved container deployment. Persist the exact target in its `image:` +line, or in `PULSE_IMAGE` when that line uses the variable; pulling a different +tag alone does not change the configured image. Paid Pro installs must keep +their private image, not replace it with the Community image. + +Pull and recreate only the Pulse service, stopping if the pull fails. Preserve +its data volume, ports and settings, and check the running image and server +version afterwards. This updates the Pulse server, not its agents or the +containers it monitors; there is no need to restart unrelated Compose services. -```bash -docker pull rcourtman/pulse:latest -docker compose up -d -``` ### Kubernetes (Helm) -Upgrade the chart: +Use the existing Helm release and namespace, the chosen chart version and your +saved deployment values. Review changes to persistence, Secret references and +image edition before rollout; keep the current PVC, keys and settings. Do not +let a default Community image replace paid Pro. -```bash -helm repo update -helm upgrade pulse pulse/pulse -n pulse -``` +See [Kubernetes deployment settings](KUBERNETES.md) and use your cluster's +rollout and recovery procedure. A Helm rollback changes release resources; it +does not guarantee restoration of Pulse data changed by a newer binary. ### Provider-hosted MSP diff --git a/scripts/tests/test_deployment_update_docs.py b/scripts/tests/test_deployment_update_docs.py new file mode 100644 index 0000000000..38093525bf --- /dev/null +++ b/scripts/tests/test_deployment_update_docs.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Keep the deployment entry point from bypassing the safe update procedures. + +These are documentation regression checks, not installed update/restore proof. +The linked Compose recipe has executable controls in test_update_recovery_docs; +the production Docs navigation is exercised by deployment-update-help.cjs. +""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +def guide(name): + return (ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8") + + +def overview(): + return guide("DEPLOYMENT_MODELS") + + +class DeploymentUpdateDocsTest(unittest.TestCase): + def test_shipped_overview_matches_the_reviewed_guide(self): + self.assertEqual(overview(), (ROOT / "frontend-modern/public/docs/DEPLOYMENT_MODELS.md") + .read_text(encoding="utf-8")) + + def test_default_paths_are_not_a_complete_backup_inventory(self): + text = " ".join(overview().split()) + for phrase in ("Default data/config path", "defaults, not a backup inventory", + "`PULSE_DATA_DIR`", "external stores", "`PULSE_METRICS_DB_PATH`"): + self.assertIn(phrase, text) + self.assertIn("METRICS_HISTORY.md#storage-location", text) + self.assertIn("AUDIT_LOGGING.md#storage", text) + + def test_backup_and_rollback_claims_preserve_state_and_limits(self): + text = " ".join(overview().split()) + for phrase in ("running version and edition", "consistent private backup", + "matching `.encryption.key` and audit signing key", + "stopped, consistent filesystem/volume backup", "SQLite sidecar files", + "Do not post configuration files, environment dumps or Helm secret values", + "snapshot is not necessarily a complete data backup", + "does not undo data migrations", "backup path alone does not establish", + "Keep the failed state", "before retrying a failed update"): + self.assertIn(phrase, text) + self.assertNotIn("swapping binaries/config safely", text) + + def test_overview_routes_to_the_maintained_procedures_not_a_shortcut(self): + updates = overview().split("## Updates by Model", 1)[1] + # A second shorthand can skip durable image selection, backup checks, + # fail-fast pulling or the actual release/namespace and saved values. + self.assertNotRegex(updates, r"```(?:bash|sh|shell)\b") + for link in ("AUTO_UPDATE.md#rollback", "AUTO_UPDATE.md", "DOCKER.md#-updates", + "KUBERNETES.md"): + self.assertIn(f"]({link})", updates) + self.assertTrue((ROOT / "frontend-modern/public/docs" / link.split("#")[0]).is_file()) + self.assertIn("## ๐Ÿ”„ Updates", guide("DOCKER")) + self.assertIn("## Rollback", guide("AUTO_UPDATE")) + + def test_deployment_specific_context_survives_the_handoff(self): + updates = " ".join(overview().split("## Updates by Model", 1)[1].split()) + for phrase in ("inside the container, not on the Proxmox host", + "original Compose project", "Persist the exact target", + "pulling a different tag alone does not change the configured image", + "Paid Pro installs must keep their private image", + "only the Pulse service, stopping if the pull fails", + "no need to restart unrelated Compose services", + "existing Helm release and namespace", "chosen chart version", + "saved deployment values", "current PVC, keys and settings", + "does not guarantee restoration of Pulse data"): + self.assertIn(phrase, updates) + + +if __name__ == "__main__": + unittest.main() From 6e5acd9c021ee7b7dc8651da4ff7ea746b0460d9 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:13:32 +0100 Subject: [PATCH 32/86] Keep denied resource evidence out of drawer fallbacks Final facet access failures must not re-display embedded or unfiltered history. Retain transient snapshots, and show failed action reads with an available retry rather than false empty/loaded states. Preserve the existing unavailable-plan client boundary. Add rejecting parent controls and desktop/touch browser proof. Change-source: pulse-maintainer --- .../internal/subsystems/unified-resources.md | 20 +++ .../resource-evidence-access.cjs | 100 +++++++++++ .../resource-evidence-access.html | 2 + .../resource-evidence-access.tsx | 62 +++++++ frontend-modern/browser-verification.json | 56 +++--- .../Infrastructure/ResourceActionHistory.tsx | 13 +- .../Infrastructure/ResourceDetailDrawer.tsx | 2 +- .../ResourceDetailDrawerOverviewTab.tsx | 13 +- .../ResourceDetailDrawer.history.test.tsx | 65 ++++++- ...ceDetailDrawerHistoryState.access.test.tsx | 166 ++++++++++++++++++ .../useResourceDetailDrawerHistoryState.ts | 73 ++++++-- 11 files changed, 514 insertions(+), 58 deletions(-) create mode 100644 frontend-modern/browser-tests/resource-evidence-access.cjs create mode 100644 frontend-modern/browser-tests/resource-evidence-access.html create mode 100644 frontend-modern/browser-tests/resource-evidence-access.tsx create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/useResourceDetailDrawerHistoryState.access.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 83a8a073de..975c62f408 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,26 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Resource evidence fallback access boundaries + +When the existing resource-facet reader receives a final HTTP 401/403, its +drawer must not substitute embedded snapshot events, counts, capabilities or +relationships for the withdrawn response. A denied filtered read must not +substitute the unfiltered bundle. Denial remains visible during that request's +retry, and fresh success restores its evidence. Independently successful +unfiltered reads retain their own ownership; this is not a global revocation +broadcast. Transient failures still retain applicable snapshots, and compact +snapshot-only rows still make no remote history reads. + +Failed change/action reads are unavailable, not โ€œloadedโ€ or empty. The Manage +tab keeps a failed action read's retry visible even without retained rows, +without changing the action client's existing unavailable-plan responses. +The hook access tests and mounted ResourceDetailDrawer.history tests protect +these boundaries; browser-tests/resource-evidence-access.cjs exercises the +production drawer/client/CSS on desktop Chromium and dark touch WebKit. Its +synthetic HTTP responses do not establish native authorisation, a reporter's +cause, installed recovery or release availability. + ### Detail snapshot and selection ownership ResourceDetailDrawer reads the current resource through a reactive getter in diff --git a/frontend-modern/browser-tests/resource-evidence-access.cjs b/frontend-modern/browser-tests/resource-evidence-access.cjs new file mode 100644 index 0000000000..55a01c1fdc --- /dev/null +++ b/frontend-modern/browser-tests/resource-evidence-access.cjs @@ -0,0 +1,100 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); +const parent = false; +(async () => { + const root = parent ? '/workspace/tmp/resource-access-parent/frontend-modern' : '/workspace/frontend-modern'; + const artifacts = `/workspace/tmp/resource-evidence-${parent ? 'parent' : 'final'}-proof`; + fs.mkdirSync(artifacts, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts'), cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), server: { host: '127.0.0.1', port: 5267, strictPort: true, fs: { allow: ['/workspace'] } } }); + const playwright = require('playwright/package.json').version; + assert.equal(playwright, JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages['node_modules/@playwright/test'].version); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [['chromium-desktop', chromium, false], ['webkit-phone', webkit, true]]) { + browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true }); + const page = await browser.newPage({ viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, hasTouch: phone, isMobile: phone }); + page.setDefaultTimeout(60000); + let facetMode = 'success', actionMode = 'success', actor = 'Authorised response actor'; + const requests = [], errors = [], offOrigin = [], checks = [], held = []; + page.on('pageerror', error => errors.push(error.message)); + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5267') { offOrigin.push(url.origin); return route.abort(); } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname.endsWith('/facets')) { + const mode = facetMode; + requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), mode }); + if (mode === 'hold') await new Promise(resolve => held.push(resolve)); + if (mode === '403' || mode === '503') return route.fulfill({ status: Number(mode), json: { error: 'Fixture service unavailable' } }); + return route.fulfill({ json: { capabilities: [], relationships: [], counts: { recentChanges: 1 }, recentChanges: [{ id: 'remote-event', resourceId: 'pbs-a', observedAt: '2026-10-03T03:00:00Z', kind: 'restart', sourceType: 'platform_event', confidence: 'high', actor }] } }); + } + if (url.pathname === '/api/audit/actions') { + requests.push({ path: url.pathname, mode: actionMode }); + return actionMode === '503' ? route.fulfill({ status: 503, json: { error: 'Fixture actions unavailable' } }) : route.fulfill({ json: { audits: [], count: 0 } }); + } + return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } : { capabilities: [], relationships: [], recentChanges: [], audits: [], count: 0, data: [], enabled: false } }); + }); + const record = (name, pass) => { checks.push({ name, pass }); }; + const screenshot = state => page.screenshot({ path: path.join(artifacts, `${name}-${state}.png`), fullPage: true }); + const changes = page.getByTestId('resource-change-history-section'); + const actions = page.getByTestId('resource-action-history-section'); + await page.goto('http://127.0.0.1:5267/browser-tests/resource-evidence-access.html', { waitUntil: 'domcontentloaded', timeout: 120000 }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + await changes.getByText('Authorised response actor', { exact: true }).waitFor(); + record('successful remote evidence replaces snapshot', !(await changes.getByText('Embedded snapshot actor').count())); + await screenshot('loaded'); + facetMode = '403'; actionMode = '503'; + await page.getByRole('button', { name: 'Remount details' }).click(); + await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor(); + await page.waitForTimeout(500); + record('denial withdraws embedded evidence', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count())); + record('bounded denial guidance, not empty history', (await changes.getByText('Access denied. Check your permissions and license plan.', { exact: true }).count()) === 1 && !(await changes.getByText('No events yet.', { exact: true }).count())); + record('denial is labelled unavailable', (await changes.getByText('Changes unavailable', { exact: true }).count()) === 1); + await page.getByRole('tab', { name: 'Manage', exact: true }).click(); + record('failed first action read has visible retry, not empty history', (await actions.count()) === 1 && (await actions.getByRole('button', { name: 'Retry', exact: true }).count()) === 1 && !(await actions.getByText('No actions yet.', { exact: true }).count())); + await screenshot('action-failed'); + await page.getByRole('tab', { name: 'Overview', exact: true }).click(); + await screenshot('denied'); + facetMode = 'hold'; + await changes.getByRole('button', { name: 'Retry', exact: true }).focus(); + await changes.getByRole('button', { name: 'Retry', exact: true }).press('Enter'); + await changes.getByText('Refreshing changes...', { exact: true }).waitFor(); + record('retry keeps denied evidence withdrawn', !(await changes.getByText('Embedded snapshot actor').count()) && !(await changes.getByText('Authorised response actor').count())); + await screenshot('retry-pending'); + actor = 'Fresh response actor'; facetMode = 'success'; + held.splice(0).forEach(resolve => resolve()); + await changes.getByText('Fresh response actor', { exact: true }).waitFor(); + record('fresh successful retry restores evidence and clears guidance', !(await changes.getByText('Access denied. Check your permissions and license plan.').count())); + await screenshot('recovered'); + await changes.getByRole('button', { name: 'Filter history', exact: true }).click(); + facetMode = '403'; + await changes.getByLabel('Change kind', { exact: true }).selectOption('restart'); + await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor(); + record('filtered denial cannot substitute authorised unfiltered events', !(await changes.getByText('Fresh response actor').count()) && (await changes.getByText('Filtered changes unavailable', { exact: true }).count()) === 1); + await screenshot('filtered-denied'); + await changes.getByRole('button', { name: 'Clear filters', exact: true }).click(); + await changes.getByText('Fresh response actor', { exact: true }).waitFor(); + record('clearing filter restores independently authorised base request', true); + facetMode = '503'; + await page.getByRole('button', { name: 'Remount details' }).click(); + await changes.getByRole('button', { name: 'Retry', exact: true }).waitFor(); + record('transient failure preserves embedded snapshot evidence', (await changes.getByText('Embedded snapshot actor', { exact: true }).count()) === 1); + await screenshot('transient'); + record('no page errors or off-origin requests', errors.length === 0 && offOrigin.length === 0); + observations.push({ name, browser: browser.version(), viewport: page.viewportSize(), requests, errors, offOrigin, checks }); + await browser.close(); browser = null; + } + } finally { + if (browser) await browser.close(); + await server.close(); + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify({ parent, playwright, observations }, null, 2)); + } + console.log(JSON.stringify({ parent, checks: observations.flatMap(o => o.checks).length, failures: observations.flatMap(o => o.checks).filter(c => !c.pass) })); + if (!parent) assert.ok(observations.flatMap(o => o.checks).every(c => c.pass)); +})().catch(error => { console.error(error.stack); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/resource-evidence-access.html b/frontend-modern/browser-tests/resource-evidence-access.html new file mode 100644 index 0000000000..6198edc8f8 --- /dev/null +++ b/frontend-modern/browser-tests/resource-evidence-access.html @@ -0,0 +1,2 @@ + +Resource evidence access verification
diff --git a/frontend-modern/browser-tests/resource-evidence-access.tsx b/frontend-modern/browser-tests/resource-evidence-access.tsx new file mode 100644 index 0000000000..e0f39cb4bd --- /dev/null +++ b/frontend-modern/browser-tests/resource-evidence-access.tsx @@ -0,0 +1,62 @@ +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer'; +import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +// Synthetic snapshots and HTTP responses; production drawer, query/client and CSS. +syncAIRuntimeSettings({ discovery_enabled: false }); +const resource: Resource = { + id: 'pbs-a', + name: 'PBS A', + displayName: 'PBS A', + type: 'pbs', + platformId: 'pbs-a', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: 'online', + lastSeen: Date.now(), + cpu: { current: 12 }, + recentChanges: [ + { + id: 'embedded-event', + resourceId: 'pbs-a', + observedAt: '2026-10-03T03:00:00Z', + kind: 'restart', + sourceType: 'platform_event', + confidence: 'high', + actor: 'Embedded snapshot actor', + }, + ], + facetCounts: { recentChanges: 7 }, +}; +const [mounted, setMounted] = createSignal(true); +render( + () => ( + true } as any}> + document.documentElement.classList.contains('dark')}> +
+

Resource evidence access verification

+

+ Synthetic PBS evidence; no appliance, release or installed authorisation acceptance. +

+ + + + +
+
+
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 353d9f0ce2..bd2176689c 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,25 +1,23 @@ { "version": 1, "result": "passed", - "base_sha": "a9533eb82a29700ede2d4ad609812ddd01e5f287", - "verified_at": "2026-10-03T01:44:27.336168Z", + "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57", + "verified_at": "2026-10-03T04:13:25.499572Z", "changed_paths": [ - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/shared/HistoryChartOverlay.tsx", - "frontend-modern/src/components/shared/useHistoryChartState.ts", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts", - "frontend-modern/src/utils/apiAccessError.ts" + "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "c4370a69495c8ccea01a0d92076e4a051e4d06abec0e675e2fb3547d46e87bcb", - "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "14c0699b827ec6eb4d85cf9d8fc76d61a0af3dd6565d2396a3f22a03429af104", - "frontend-modern/src/components/shared/useHistoryChartState.ts": "5a9b022656ac7316710675a0742e9b2908c9114ffbed67d6f6962d361bb65bbf", - "frontend-modern/src/hooks/createNonSuspendingQuery.ts": "df293d0484612a6d31dd9f3fa6af2a1160e1e98d66965730a45829b7048a4ba7", - "frontend-modern/src/utils/apiAccessError.ts": "3b34a498959a77014af3c842677fc6646b5e7e6a8b9b332ca8acf91254c01730" + "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6" }, "routes": [ - "Existing PBS/Machine/resource drawer History batch reader", - "Existing Storage physical disk shared History reader" + "Existing PBS/resource detail Overview change history", + "Existing resource detail Manage action history" ], "viewports": [ { @@ -32,25 +30,23 @@ } ], "states": [ - "Real same-origin 503 responses retain stored samples with explicit warnings in both production readers", - "Real final HTTP 403 responses withdraw samples, tooltip, keyboard announcement, sliders and formerly displayed current-only legends", - "Access-denied guidance fits light desktop Chromium and dark touch WebKit without horizontal overflow", - "The existing focusable scoped retry remains mounted and ignores duplicate activation while busy", - "Earlier cached ranges and remounts cannot resurrect denied stored readings while a replacement is pending", - "Only a new successful read restores History and clears denial; old values do not return", - "Every final screenshot inspected; fixed Playwright 1.56.1 equals integration lock, no page errors or off-origin data requests" + "Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence", + "Denied filtered facets do not fall back to the unfiltered bundle", + "Retry retains denial while pending; fresh successful response restores events and clears guidance", + "First action-history HTTP 503 stays visible in Manage with retry and no false empty state", + "Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads", + "All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock" ], "interactions": [ - "Inspect by desktop keyboard and phone tap", - "Poll through 503 then 403", - "Retry twice while busy", - "Change 24h/1h range", - "Unmount and remount batch History", - "Resume with newly successful observations" + "Remount full production resource drawer with denied/transient reads", + "Activate scoped Retry with keyboard Enter", + "Apply and clear Change kind filter", + "Switch between Overview and Manage" ], "limitations": [ - "Direct production readers/client with synthetic HTTP responses, not a complete native PBS/NAS drawer, session revocation, appliance collection or release acceptance.", - "401 is covered by runtime regressions; the real API client redirects on 401, not bypassed for browser proof.", - "Independently supplied current readings remain distinctly current during a new pending query; they are not stored History." + "Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.", + "401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.", + "Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.", + "Independent reader authorisation remains independent; this is not a global session revocation broadcast." ] } diff --git a/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx b/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx index a14fc47b69..f69839590b 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx @@ -215,7 +215,10 @@ export const ResourceActionHistory: Component = (pro
-
+
-

- This restores the binary, configuration, and data from the backup taken before the - update to {formatVersionLabel(entry().version_to)} on{' '} - {formatTimestamp(entry().timestamp)}. +

+ Running now: Pulse {formatVersionLabel(updateStore.versionInfo()?.version || '')}.

- Settings and alert changes made since that backup will be reverted, and Pulse will - restart to complete the rollback. + This requests an installation rollback using the backup taken before the update to{' '} + {formatVersionLabel(entry().version_to)} on {formatTimestamp(entry().timestamp)}. + Pulse will restart if the rollback succeeds. +

+

+ Restore scope is not reported for this backup. A scope-aware server keeps active + runtime data in place and restores only the binary and independent installation + files. Older recovery can also replace install-local data and configuration, + reverting later changes. +

+

+ Do not assume this will undo settings or alerts. Full-state recovery requires + Pulse to be stopped and a consistent backup of every active store.{' '} + + Read the recovery instructions + + .

diff --git a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx index 4f6ab77192..20a5b255e5 100644 --- a/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/UpdateHistorySection.test.tsx @@ -4,6 +4,7 @@ import type { UpdateHistoryEntry } from '@/api/updates'; const mockListUpdateHistory = vi.fn(); const mockStoreRollbackUpdate = vi.fn(); +let runningVersion: string | undefined = '6.0.5'; vi.mock('@/api/updates', () => ({ UpdatesAPI: { @@ -13,7 +14,7 @@ vi.mock('@/api/updates', () => ({ vi.mock('@/stores/updates', () => ({ updateStore: { - versionInfo: () => ({ version: '6.0.5' }), + versionInfo: () => (runningVersion ? { version: runningVersion } : null), rollbackUpdate: (...args: unknown[]) => mockStoreRollbackUpdate(...args), }, })); @@ -39,6 +40,7 @@ describe('UpdateHistorySection', () => { beforeEach(() => { mockListUpdateHistory.mockReset(); mockStoreRollbackUpdate.mockReset(); + runningVersion = '6.0.5'; }); afterEach(() => { @@ -102,6 +104,24 @@ describe('UpdateHistorySection', () => { fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); expect(await screen.findByText('Roll back to Pulse v6.0.4?')).toBeInTheDocument(); + expect(screen.getByText('Running now: Pulse v6.0.5.')).toBeInTheDocument(); + expect(screen.getByText(/Restore scope is not reported for this backup/)).toHaveTextContent( + 'A scope-aware server keeps active runtime data in place', + ); + expect(screen.getByText(/Older recovery can also/)).toHaveTextContent( + 'reverting later changes', + ); + expect(screen.getByText(/Do not assume this will undo settings or alerts/)).toHaveTextContent( + 'Full-state recovery requires Pulse to be stopped', + ); + expect(screen.getByRole('link', { name: 'Read the recovery instructions' })).toHaveAttribute( + 'href', + '/docs/AUTO_UPDATE#manual-rollback', + ); + expect( + screen.queryByText(/Settings and alert changes made since that backup will be reverted/), + ).not.toBeInTheDocument(); + expect(mockStoreRollbackUpdate).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' })); @@ -116,4 +136,62 @@ describe('UpdateHistorySection', () => { expect(screen.queryByText('Roll back to Pulse v6.0.4?')).not.toBeInTheDocument(), ); }); + + it('distinguishes the running version from an older history entry without guessing restore scope', async () => { + runningVersion = 'v6.6.0-rc.1'; + mockListUpdateHistory.mockResolvedValue([{ ...baseEntry, notes: 'restore everything' }]); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + expect(screen.getByText('Running now: Pulse v6.6.0-rc.1.')).toBeInTheDocument(); + expect(screen.getByText(/This requests an installation rollback/)).toHaveTextContent( + 'before the update to v6.0.5', + ); + expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument(); + expect(screen.queryByText('restore everything')).not.toBeInTheDocument(); + expect(screen.queryByText(baseEntry.backup_path!)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mockStoreRollbackUpdate).not.toHaveBeenCalled(); + }); + + it('does not infer the running version from the selected update when version information is missing', async () => { + runningVersion = undefined; + mockListUpdateHistory.mockResolvedValue([baseEntry]); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + expect(screen.getByText('Running now: Pulse unknown.')).toBeInTheDocument(); + expect(screen.getByText(/Restore scope is not reported/)).toBeInTheDocument(); + }); + + it('retains the consent and refreshes history after a rejected rollback', async () => { + mockListUpdateHistory.mockResolvedValue([baseEntry]); + mockStoreRollbackUpdate.mockResolvedValue(false); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' })); + await waitFor(() => expect(mockListUpdateHistory).toHaveBeenCalledTimes(2)); + expect(screen.getByRole('dialog')).toHaveTextContent('Restore scope is not reported'); + expect(screen.getByRole('button', { name: 'Roll back to v6.0.4' })).not.toBeDisabled(); + }); + + it('prevents duplicate requests and cancellation while the rollback request is pending', async () => { + mockListUpdateHistory.mockResolvedValue([baseEntry]); + let finish!: (accepted: boolean) => void; + mockStoreRollbackUpdate.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + render(() => ); + fireEvent.click(await screen.findByRole('button', { name: 'Roll back' })); + fireEvent.click(screen.getByRole('button', { name: 'Roll back to v6.0.4' })); + expect(screen.getByRole('button', { name: 'Starting rollback...' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Starting rollback...' })); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockStoreRollbackUpdate).toHaveBeenCalledTimes(1); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + finish(true); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); }); diff --git a/frontend-modern/src/components/Storage/DiskDetail.tsx b/frontend-modern/src/components/Storage/DiskDetail.tsx index 71c6cedb3a..3588836cc0 100644 --- a/frontend-modern/src/components/Storage/DiskDetail.tsx +++ b/frontend-modern/src/components/Storage/DiskDetail.tsx @@ -11,6 +11,10 @@ import { resolveHistoryRangeWithinLimit, } from '@/components/Storage/historyRangeAccess'; import { + DISK_DETAIL_HEADER_STACK_CLASS, + DISK_DETAIL_HEALTH_LABEL_CLASS, + DISK_DETAIL_HEALTH_ROW_CLASS, + DISK_DETAIL_HEALTH_SUMMARY_CLASS, DISK_DETAIL_HISTORY_RANGE_OPTIONS, DISK_DETAIL_LIVE_CHARTS, getDiskAttributeValueTextClass, @@ -55,6 +59,7 @@ export const DiskDetail: Component = (props) => { historyResourceId, attributeCards, historyCharts, + healthPresentation, metricResourceId, collectionMessages, liveIOAvailable, @@ -91,19 +96,32 @@ export const DiskDetail: Component = (props) => { return (
-
- {diskData().model || 'Unknown Disk'} - {diskData().devPath} - {diskData().node} - - {getPhysicalDiskSourceBadgePresentation(props.disk).label} - - - S/N: {diskData().serial} - +
+
+ + {diskData().model || 'Unknown Disk'} + + {diskData().devPath} + {diskData().node} + + {getPhysicalDiskSourceBadgePresentation(props.disk).label} + + + S/N: {diskData().serial} + +
+ {/* The verdict the row shows as a single word, with its reason in full. */} +
+ + {healthPresentation().label} + + + {healthPresentation().summary} + +
diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index 5715390316..fdff3b3d36 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -55,7 +55,6 @@ import { PHYSICAL_DISK_HEADER_SIZE_CLASS, PHYSICAL_DISK_HEADER_TEMP_CLASS, PHYSICAL_DISK_HEALTH_LABEL_CLASS, - PHYSICAL_DISK_HEALTH_SUMMARY_CLASS, PHYSICAL_DISK_HEALTH_WRAP_CLASS, PHYSICAL_DISK_DEVICE_TEXT_CLASS, PHYSICAL_DISK_LIFE_CLASS, @@ -72,8 +71,10 @@ import { PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS, PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS, PHYSICAL_DISK_TABLE_ROW_STYLE, - getPhysicalDiskColumnWidthPercent, + getPhysicalDiskCellPaddingClass, + getPhysicalDiskColumnWidthStyle, getPhysicalDiskEmptyStatePresentation, + getPhysicalDiskHealthCompactLabel, getPhysicalDiskHealthStatus, getPhysicalDiskHealthSummary, getPhysicalDiskHostLabel, @@ -117,11 +118,18 @@ export const DiskList: Component = (props) => { baseClass: string, columnId: PhysicalDiskTableColumnId, visibleClass: 'table-column' | 'table-cell', - ) => - `${baseClass} ${isPhysicalDiskColumnVisible(layoutMode(), columnId) ? visibleClass : 'hidden'}`.trim(); - const columnStyle = (columnId: PhysicalDiskTableColumnId) => ({ - width: `${getPhysicalDiskColumnWidthPercent(layoutMode(), columnId)}%`, - }); + ) => { + const visibility = isPhysicalDiskColumnVisible(layoutMode(), columnId) + ? visibleClass + : 'hidden'; + // Rendered cells and headers shed the shared primitives' desktop padding + // on the phone layouts; `` elements carry no padding. + const padding = + visibleClass === 'table-cell' ? getPhysicalDiskCellPaddingClass(layoutMode()) : ''; + return `${baseClass} ${padding} ${visibility}`.replace(/\s+/g, ' ').trim(); + }; + const columnStyle = (columnId: PhysicalDiskTableColumnId) => + getPhysicalDiskColumnWidthStyle(layoutMode(), columnId); const { getDiskTemperatureThresholds } = useAlertsActivation(); const model = useDiskListModel({ disks: () => props.disks, @@ -327,6 +335,11 @@ export const DiskList: Component = (props) => { const status = createMemo(() => getPhysicalDiskHealthStatus(data())); const hostLabel = createMemo(() => getPhysicalDiskHostLabel(data(), disk)); const healthSummary = createMemo(() => getPhysicalDiskHealthSummary(status())); + const healthCompactLabel = createMemo(() => + getPhysicalDiskHealthCompactLabel(status().label), + ); + const healthLabelClass = () => + `${PHYSICAL_DISK_HEALTH_LABEL_CLASS} ${status().tone}`; const isSelected = () => model.selectedDisk()?.id === disk.id; const summarySeriesId = createMemo(() => resolvePhysicalDiskMetricResourceId(disk)); const isSummaryHighlighted = () => @@ -446,15 +459,30 @@ export const DiskList: Component = (props) => { data-storage-column="health" >
- - {status().label} - - + {/* The row shows the verdict word only; the reason is the + cell's title here and is spelled out in the disk drawer. + The phone projection of the table container shows the + shorter word; the tone stays on each span so the rendered + label is the styled element in either projection. */} + + {status().label} + + } + > - {healthSummary()} + {healthCompactLabel()} + + + {status().label}
diff --git a/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx b/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx index 71ea29ccef..02a5af5eca 100644 --- a/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx +++ b/frontend-modern/src/components/Storage/EnhancedStorageBar.tsx @@ -25,6 +25,7 @@ import { getStorageBarTooltipTitle, getStorageBarZfsHeadingLabel, } from '@/features/storageBackups/storageBarPresentation'; +import { PlatformResponsiveTableLabel } from '@/features/platformPage/sharedPlatformPage'; import { useTooltip } from '@/hooks/useTooltip'; import { ProgressBar } from '@/components/shared/ProgressBar'; import { TooltipPortal } from '@/components/shared/TooltipPortal'; @@ -42,13 +43,14 @@ interface EnhancedStorageBarProps { export function EnhancedStorageBar(props: EnhancedStorageBarProps) { const tip = useTooltip(); const alertsActivation = useAlertsActivation(); - const { usagePercent, barColor, label, tooltipRows, zfsSummary } = useEnhancedStorageBarModel({ - used: () => props.used, - total: () => props.total, - free: () => props.free, - zfsPool: () => props.zfsPool, - thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'), - }); + const { usagePercent, barColor, label, compactLabel, tooltipRows, zfsSummary } = + useEnhancedStorageBarModel({ + used: () => props.used, + total: () => props.total, + free: () => props.free, + zfsPool: () => props.zfsPool, + thresholds: () => alertsActivation.getMetricThresholds('storage', 'disk'), + }); return (
@@ -71,7 +73,12 @@ export function EnhancedStorageBar(props: EnhancedStorageBarProps) { } label={ - {label()} + + {/* The phone projection of the table container keeps the + percentage only; the used/total sublabel returns with the + wider container and always lives in the tooltip. */} + + } /> diff --git a/frontend-modern/src/components/Storage/StoragePoolRow.tsx b/frontend-modern/src/components/Storage/StoragePoolRow.tsx index 58e8e6d2a8..1d29643412 100644 --- a/frontend-modern/src/components/Storage/StoragePoolRow.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolRow.tsx @@ -26,6 +26,7 @@ import { import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation'; import type { Resource } from '@/types/resource'; import { + getStoragePoolCellPaddingClass, isStoragePoolColumnVisible, type StoragePoolTableColumnId, type StoragePoolTableLayoutMode, @@ -62,7 +63,11 @@ interface StoragePoolRowProps { export const StoragePoolRow: Component = (props) => { const columnClass = (baseClass: string, columnId: StoragePoolTableColumnId) => - `${baseClass} ${isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden'}`.trim(); + `${baseClass} ${getStoragePoolCellPaddingClass(props.layoutMode)} ${ + isStoragePoolColumnVisible(props.layoutMode, columnId) ? 'table-cell' : 'hidden' + }` + .replace(/\s+/g, ' ') + .trim(); const row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null)); const detailControlsId = createMemo(() => buildSummaryDisclosureControlsId(props.summarySeriesId), diff --git a/frontend-modern/src/components/Storage/StoragePoolsTable.tsx b/frontend-modern/src/components/Storage/StoragePoolsTable.tsx index c6217f0273..47dea2524a 100644 --- a/frontend-modern/src/components/Storage/StoragePoolsTable.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolsTable.tsx @@ -9,7 +9,7 @@ import { } from '@/components/shared/Table'; import { getPlatformTableHeadClassForKind } from '@/features/platformPage/sharedPlatformPage'; import { - getStoragePoolColumnWidthPercent, + getStoragePoolColumnWidthStyle, getStoragePoolTableColumns, getStoragePoolTableLayoutModeForContainer, getStorageEmptyStateMessage, @@ -106,9 +106,7 @@ export const StoragePoolsTable: Component = (props) => { {(column) => ( )} diff --git a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx index cfa8a0f5d0..babc7242a7 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskDetail.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@solidjs/testing-library'; +import { fireEvent, render, screen, within } from '@solidjs/testing-library'; import { createSignal, type JSX } from 'solid-js'; import { describe, expect, it, vi } from 'vitest'; import { DiskDetail } from '@/components/Storage/DiskDetail'; @@ -135,6 +135,33 @@ describe('DiskDetail', () => { expect(screen.getByText('Reallocated Sectors')).toBeInTheDocument(); }); + it('shows the health verdict and its reason in the drawer header', () => { + const disk = buildDisk(); + disk.physicalDisk!.health = 'FAILED'; + + render(() => ); + + const health = screen.getByTestId('disk-detail-health'); + expect(within(health).getByText('Replace Now')).toHaveClass('text-red-700'); + expect(within(health).getByText('Disk health has degraded to a critical state.')).toHaveClass( + 'min-w-0', + 'max-w-full', + 'whitespace-normal', + 'break-words', + ); + }); + + it('keeps the drawer header to the verdict alone when the disk is healthy', () => { + const disk = buildDisk(); + disk.physicalDisk!.health = 'PASSED'; + + render(() => ); + + const health = screen.getByTestId('disk-detail-health'); + expect(within(health).getByText('Healthy')).toBeInTheDocument(); + expect(within(health).queryByText('No active disk-health issues.')).not.toBeInTheDocument(); + }); + it('shows an explicit overview fallback when no detail readings are available', () => { const disk = buildDisk(); delete disk.physicalDisk!.smart; diff --git a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx index c8b0ad9432..ae75eb0693 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx @@ -134,12 +134,18 @@ describe('DiskList', () => { 'archive', 'Cache Pool', 'Archive Pool', - 'SMART failed.', '4%', '63ยฐC', ]) { expect(within(row as HTMLElement).getByText(text)).toBeInTheDocument(); } + // The reason is not a second truncating string in the 32px row: it rides + // on the verdict as its title and is spelled out in the disk drawer. + expect(within(row as HTMLElement).queryByText('SMART failed.')).not.toBeInTheDocument(); + expect(within(row as HTMLElement).getByText('Replace Now')).toHaveAttribute( + 'title', + 'SMART failed.', + ); expect(within(row as HTMLElement).getByText('3.64 TB')).toBeInTheDocument(); expect(screen.queryByText('Healthy')).not.toBeInTheDocument(); expect(screen.queryByText('96%')).not.toBeInTheDocument(); @@ -275,7 +281,13 @@ describe('DiskList', () => { expect(screen.getByText('Parity')).toBeInTheDocument(); expect(screen.getByText('Tower Array')).toBeInTheDocument(); expect(screen.getByText('Needs Attention')).toBeInTheDocument(); - expect(screen.getByText('Pending sectors detected.')).toBeInTheDocument(); + // The reason stays one hover away on the verdict rather than as a second + // truncating string in the row; the drawer spells it out. + expect(screen.queryByText('Pending sectors detected.')).not.toBeInTheDocument(); + expect(screen.getByText('Needs Attention')).toHaveAttribute( + 'title', + 'Pending sectors detected.', + ); }); it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => { diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index e77acd0a3d..7c917328be 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -1154,10 +1154,11 @@ describe('Storage', () => { render(() => ); + // zpool's upper-case state is presented titleized, like every other state. await waitFor(() => { - expect(screen.getAllByText('DEGRADED').length).toBeGreaterThan(0); + expect(screen.getAllByText('Degraded').length).toBeGreaterThan(0); }); - expect(screen.getAllByText('DEGRADED')[0]).toHaveAttribute( + expect(screen.getAllByText('Degraded')[0]).toHaveAttribute( 'title', '1 read, 2 checksum errors', ); diff --git a/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts b/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts index b3fc3721a0..18d67ca221 100644 --- a/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts +++ b/frontend-modern/src/components/Storage/__tests__/useEnhancedStorageBarModel.test.ts @@ -28,6 +28,7 @@ describe('useEnhancedStorageBarModel', () => { expect(result.usagePercent()).toBe(40); expect(result.label()).toBe('40% (400 B/1000 B)'); + expect(result.compactLabel()).toBe('40%'); expect(result.tooltipRows()).toEqual([ { label: 'Used', value: '400 B' }, { label: 'Free', value: '600 B' }, diff --git a/frontend-modern/src/components/Storage/useDiskDetailModel.ts b/frontend-modern/src/components/Storage/useDiskDetailModel.ts index 7ea36e2a61..2038b38433 100644 --- a/frontend-modern/src/components/Storage/useDiskDetailModel.ts +++ b/frontend-modern/src/components/Storage/useDiskDetailModel.ts @@ -7,6 +7,7 @@ import { } from '@/features/storageBackups/diskPresentation'; import { getDiskDetailAttributeCards, + getDiskDetailHealthPresentation, getDiskDetailHistoryCharts, } from '@/features/storageBackups/diskDetailPresentation'; import { useAlertsActivation } from '@/stores/alertsActivation'; @@ -29,6 +30,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => { getDiskDetailAttributeCards(diskData(), getDiskTemperatureThresholds(diskData().type)), ); const historyCharts = createMemo(() => getDiskDetailHistoryCharts(diskData())); + const healthPresentation = createMemo(() => getDiskDetailHealthPresentation(diskData())); const metricResourceId = createMemo(() => historyResourceId()); const collectionMessages = createMemo(() => getPhysicalDiskCollectionMessages(diskData())); const liveIOAvailable = createMemo(() => { @@ -43,6 +45,7 @@ export const useDiskDetailModel = (options: UseDiskDetailModelOptions) => { historyResourceId, attributeCards, historyCharts, + healthPresentation, metricResourceId, collectionMessages, liveIOAvailable, diff --git a/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts b/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts index ddb6f141e7..06cf0aa3d7 100644 --- a/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts +++ b/frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts @@ -3,6 +3,7 @@ import type { ZFSPool } from '@/types/api'; import { getMetricColorClass } from '@/utils/metricThresholds'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; import { + getStorageBarCompactLabel, getStorageBarLabel, getStorageBarTooltipRows, getStorageBarUsagePercent, @@ -23,6 +24,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp getMetricColorClass(usagePercent(), 'disk', options.thresholds?.()), ); const label = createMemo(() => getStorageBarLabel(options.used(), options.total())); + const compactLabel = createMemo(() => getStorageBarCompactLabel(options.used(), options.total())); const tooltipRows = createMemo(() => getStorageBarTooltipRows(options.used(), options.free(), options.total()), ); @@ -32,6 +34,7 @@ export const useEnhancedStorageBarModel = (options: UseEnhancedStorageBarModelOp usagePercent, barColor, label, + compactLabel, tooltipRows, zfsSummary, }; diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 6e72824233..d6fb813c6b 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -1308,23 +1308,26 @@ describe('GUEST_COLUMNS', () => { }); it('derives mobile overrides from the canonical guest column model', () => { + // The phone name cell carries the toggle, status dot, and backup badge, so + // it takes a wider anchor than the shared 30% and the metric chips share + // the rest. expect(getGuestColumnStyle('name', true)).toEqual({ - width: '30%', - 'max-width': '30%', + width: '38%', + 'max-width': '38%', }); expect(getGuestColumnStyle('cpu', true)).toEqual({ - width: '11.3235%', - 'max-width': '11.3235%', + width: '10.0294%', + 'max-width': '10.0294%', }); expect(getGuestColumnStyle('availability', true)).toEqual({ - width: '7.2059%', - 'max-width': '7.2059%', + width: '6.3824%', + 'max-width': '6.3824%', }); expect(getGuestColumnStyle('type', true)).toEqual({ - width: '9.2647%', - 'max-width': '9.2647%', + width: '8.2059%', + 'max-width': '8.2059%', }); - expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '30%' }); + expect(getGuestColumnWidthStyle('name', true)).toEqual({ width: '38%' }); expect(getGuestColumnWidthStyle('diskIo', true)).toEqual({ width: '170px' }); }); diff --git a/frontend-modern/src/components/Workloads/guestRowModel.tsx b/frontend-modern/src/components/Workloads/guestRowModel.tsx index 40b81bf47e..0c7c67ea17 100644 --- a/frontend-modern/src/components/Workloads/guestRowModel.tsx +++ b/frontend-modern/src/components/Workloads/guestRowModel.tsx @@ -469,6 +469,13 @@ const percentageColumn = (width: string): GuestColumnWidthOverride => ({ maxWidth: width, }); +// The phone name cell carries the expansion toggle, status dot, and backup +// badge before the name, so the shared 30% identity anchor left a 390px phone +// 66px for the name and clipped "artifact-cache-310" to "artifact-caโ€ฆ". The +// four metric chips beside it need far less than an even split, so the name +// takes the same wider phone anchor the Proxmox host table uses. +const GUEST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + // Responsive weights are normalized against the currently visible column set. // That keeps each workload view mode full-width without assuming one fixed set. const GUEST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -548,9 +555,11 @@ const getResponsiveColumnOverride = ( activeIds, layoutMode === 'narrow' ? { columnId: 'name', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'name', widthPercent: GUEST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'name', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); return typeof style.width === 'string' ? percentageColumn(style.width) : undefined; }; diff --git a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx index 8d7c18e9d3..3b64504700 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx @@ -16,6 +16,7 @@ import { formatPlatformTableDateTimeValue, formatPlatformTableRelativeTimeValue, formatPlatformTableTextValue, + getPlatformResourceCountNoun, getPlatformTableCellClassForKind, PlatformTableShell, type PlatformTableSortValue, @@ -43,15 +44,20 @@ const controllerName = (resource: Resource): string => const controllerKind = (resource: Resource): string => resource.kubernetes?.resourceKind || getResourceTypeLabel(resource.type) || resource.type; +const countedTarget = (count: number | undefined, noun: string): string => { + const value = count ?? 0; + return `${value} ${getPlatformResourceCountNoun(noun, value)}`; +}; + const targetValue = (resource: Resource): string => { switch (resource.type) { case 'k8s-replicaset': case 'k8s-statefulset': - return `${resource.kubernetes?.desiredReplicas ?? 0} pods`; + return countedTarget(resource.kubernetes?.desiredReplicas, 'pods'); case 'k8s-daemonset': - return `${resource.kubernetes?.desiredNumberScheduled ?? 0} nodes`; + return countedTarget(resource.kubernetes?.desiredNumberScheduled, 'nodes'); case 'k8s-job': - return `${resource.kubernetes?.desiredReplicas ?? 0} completions`; + return countedTarget(resource.kubernetes?.desiredReplicas, 'completions'); case 'k8s-cronjob': return formatPlatformTableTextValue(resource.kubernetes?.schedule); default: @@ -317,7 +323,7 @@ export const KubernetesControllersTable: Component<{ kind="text" sort={sort} sortKey="kind" - class="platform-table-mobile-w-15 md:w-[8%]" + class="platform-table-mobile-w-25 md:w-[8%]" > Kind @@ -329,11 +335,16 @@ export const KubernetesControllersTable: Component<{ > Scope + {/* The phone row keeps controller, kind, ready and issues. A + 15% kind track clipped "DaemonSet" and "StatefulSet" and the + target track clipped "1 completions", so target moves to the + row expansion, which also carries the schedule and job + timestamps the narrower layouts drop. */} Target @@ -365,7 +376,7 @@ export const KubernetesControllersTable: Component<{ kind="text" sort={sort} sortKey="exceptions" - class="platform-table-mobile-w-20 md:w-[11%]" + class="platform-table-mobile-w-30 md:w-[11%]" > @@ -431,7 +442,7 @@ export const KubernetesControllersTable: Component<{ {target()} diff --git a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx index cf201f66f5..1823b37427 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx +++ b/frontend-modern/src/features/kubernetes/__tests__/KubernetesControllersTable.test.tsx @@ -1,6 +1,16 @@ -import { cleanup, render, screen } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; +vi.mock('@/api/resources', () => ({ + ResourceAPI: { + getFacetBundle: vi.fn().mockResolvedValue({ + capabilities: [], + relationships: [], + recentChanges: [], + }), + }, +})); + import { formatPlatformTableDateTimeValue } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; import { KubernetesControllersTable } from '../KubernetesControllersTable'; @@ -160,6 +170,117 @@ describe('KubernetesControllersTable', () => { expect(screen.queryByText(/2026-05-24T1[23]/)).not.toBeInTheDocument(); }); + it('keeps controller, kind, ready and issues on the phone row and moves target to the expansion', () => { + render(() => ( + } + emptyTitle="No controllers" + emptyDescription="No controllers" + showToolbar={false} + /> + )); + + // "DaemonSet" and "StatefulSet" clipped in a 15% kind track at 390px, and + // "1 completions" clipped in the target track, so the phone row widens kind + // and issues and demotes target to the row expansion. + expect(screen.getByText('Kind').closest('th')).toHaveClass('platform-table-mobile-w-25'); + expect(screen.getByText('Target').closest('th')).toHaveClass('platform-table-phone-hidden'); + expect(screen.getByText('6 nodes').closest('td')).toHaveClass('platform-table-phone-hidden'); + expect(screen.getByText('Exceptions').closest('th')).toHaveClass('platform-table-mobile-w-30'); + expect(screen.getByText('Controller').closest('th')).toHaveClass('platform-table-mobile-w-30'); + expect(screen.getByText('Ready/Done').closest('th')).toHaveClass('platform-table-mobile-w-15'); + expect(screen.getByText('Kind').closest('th')).not.toHaveClass('platform-table-phone-hidden'); + }); + + it('shows the absolute Job and CronJob times in the row expansion', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); + + render(() => ( + } + emptyTitle="No controllers" + emptyDescription="No controllers" + showToolbar={false} + /> + )); + + // Tapping the row opens the expansion; the Detail column is hidden below a + // large container, so this is where a phone or half-width pane reads the + // completion time. + fireEvent.click(screen.getByText('nightly-import')); + const job = screen.getByTestId('resource-kubernetes-controller-section'); + expect(within(job).getByText('Started')).toBeInTheDocument(); + expect( + within(job).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`), + ).toBeInTheDocument(); + expect(within(job).getByText('Completed')).toBeInTheDocument(); + expect( + within(job).getByText(`${absoluteTime('2026-05-24T13:00:00Z')} (31m ago)`), + ).toBeInTheDocument(); + expect(within(job).getByText('Duration')).toBeInTheDocument(); + expect(within(job).getByText('5m')).toBeInTheDocument(); + expect(within(job).getByText('Target')).toBeInTheDocument(); + expect(within(job).getByText('10 completions')).toBeInTheDocument(); + expect(within(job).getByText('Failed').closest('tr')).toHaveTextContent('2'); + + fireEvent.click(screen.getByText('billing-rollup')); + const cron = screen.getByTestId('resource-kubernetes-controller-section'); + expect(within(cron).getByText('Schedule')).toBeInTheDocument(); + expect(within(cron).getByText('*/5 * * * *')).toBeInTheDocument(); + expect(within(cron).getByText('Last run')).toBeInTheDocument(); + expect( + within(cron).getByText(`${absoluteTime('2026-05-24T13:25:00Z')} (6m ago)`), + ).toBeInTheDocument(); + expect(within(cron).getByText('Last success')).toBeInTheDocument(); + expect( + within(cron).getByText(`${absoluteTime('2026-05-24T12:55:00Z')} (36m ago)`), + ).toBeInTheDocument(); + expect(within(cron).getByText('Namespace').closest('tr')).toHaveTextContent('batch'); + }); + it('falls back to start and last-run times when a Job or CronJob has not completed', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-24T13:31:00Z')); @@ -198,6 +319,9 @@ describe('KubernetesControllersTable', () => { /> )); + // A single desired completion reads as "1 completion", matching the + // expansion's Target row. + expect(screen.getByText('1 completion')).toBeInTheDocument(); expect(screen.getByText('Started 1m ago')).toHaveAttribute( 'title', `Started: ${absoluteTime('2026-05-24T13:29:30Z')}`, diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index 313270eca0..79c59775f0 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -422,14 +422,10 @@ export function ProxmoxBackupServersTable(props: { } header={ <> - + - + @@ -449,20 +445,16 @@ export function ProxmoxBackupServersTable(props: { - + - + Used @@ -597,12 +589,14 @@ export function ProxmoxBackupServersTable(props: { > {(datastore) => (
- + + + diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index dfdf028f97..530547b651 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -43,6 +43,7 @@ import { import { buildProxmoxBackupRecoveryModel, coverageRowMatchesSearch, + isBackupArtifact, recoverableArtifactMatchesSearch, type RecoverableArtifact, type WorkloadCoverageRow, @@ -347,17 +348,20 @@ export const ProxmoxBackupsTable: Component<{ return recoveryModel().coverageRows.flatMap((row) => { const artifacts = row.artifacts.filter(locationMatches); if (artifacts.length === 0) return []; + // Same rule as the model: running or failed artifacts stay listed but + // never become a "latest" pointer, and snapshots never become the backup. + const completed = artifacts.filter((artifact) => !artifact.running && !artifact.failed); return [ { ...row, artifacts, - latestRecovery: newestArtifact(artifacts), - latestPBS: newestArtifact(artifacts.filter((artifact) => artifact.sourceKind === 'pbs')), + latestBackup: newestArtifact(completed.filter(isBackupArtifact)), + latestPBS: newestArtifact(completed.filter((artifact) => artifact.sourceKind === 'pbs')), latestArchive: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'archive'), + completed.filter((artifact) => artifact.sourceKind === 'archive'), ), latestSnapshot: newestArtifact( - artifacts.filter((artifact) => artifact.sourceKind === 'snapshot'), + completed.filter((artifact) => artifact.sourceKind === 'snapshot'), ), pbsCount: artifacts.filter((artifact) => artifact.sourceKind === 'pbs').length, archiveCount: artifacts.filter((artifact) => artifact.sourceKind === 'archive').length, @@ -385,7 +389,7 @@ export const ProxmoxBackupsTable: Component<{ case 'workload': return cmpString(a.workload.label, b.workload.label, direction); case 'latest': - return cmpNumber(a.latestRecovery?.createdMs, b.latestRecovery?.createdMs, direction); + return cmpNumber(a.latestBackup?.createdMs, b.latestBackup?.createdMs, direction); case 'pbs': return cmpNumber(a.latestPBS?.createdMs, b.latestPBS?.createdMs, direction); case 'archive': diff --git a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx index c963a6e98d..5c58e4c8f7 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx @@ -50,6 +50,10 @@ import { useProxmoxBackupTableWindowing } from './useProxmoxBackupTableWindowing // "None" instead of restating the source ("No PVE backup file"). The long form // truncated at most widths and stays available as the hover title. const COVERAGE_EMPTY_LABEL = 'None'; +const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file'; +// Matches the posture engine's own wording for a snapshot-only subject. +const COVERAGE_SNAPSHOT_ONLY_TITLE = + 'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.'; const coveragePostureVariant = ( posture: WorkloadCoverageRow['posture'], @@ -240,7 +244,13 @@ export function ProxmoxCoverageTable(props: { headClass={getPlatformTableHeadClassForKind('text')} /> + {COVERAGE_EMPTY_LABEL} } > - {(artifact) => } + {(artifact) => ( + + )} @@ -429,7 +451,12 @@ export function ProxmoxCoverageTable(props: { } > - {(artifact) => } + {(artifact) => ( + + )} @@ -703,7 +730,7 @@ export function ProxmoxCoverageTable(props: { > {artifact.detail || 'โ€”'} diff --git a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx index 6da1dd7948..4c29fd190c 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx @@ -123,6 +123,9 @@ export function ProxmoxRecoverableTable(props: { artifact.workload.name || artifact.workload.label, artifact.workload.typeLabel, artifact.workload.vmid, + // The phone projection has no location column, so the row's + // hover title carries the repository instead. + columnVisible('location') ? undefined : artifact.location, ] .filter(Boolean) .join(' ยท ')} @@ -163,7 +166,7 @@ export function ProxmoxRecoverableTable(props: { - + @@ -189,7 +192,10 @@ export function ProxmoxRecoverableTable(props: { - + {artifact.detail || 'โ€”'} diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx index 50adeb3dd8..b9b7e75eaf 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx @@ -80,7 +80,7 @@ describe('ProxmoxCoverageTable column visibility', () => { expect(headers).toContain('Target ID'); expect(headers).toContain('Node'); expect(headers).toContain('Postureโ–ฒ'); - expect(headers).toContain('Restore'); + expect(headers).toContain('Last backup'); expect(headers).toContain('PBS snapshot'); expect(headers).toContain('Guest snapshot'); expect(headers).not.toContain('PVE file'); @@ -239,7 +239,7 @@ describe('ProxmoxCoverageTable column visibility', () => { // cannot truncate to "No PVE backup fโ€ฆ". const empty = (title: string) => document.querySelector(`td span[title="${title}"]`); for (const title of [ - 'No restore point', + 'No PBS snapshot or PVE backup file', 'No PBS snapshot', 'No PVE backup file', 'No guest snapshot', diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx index 0dea167612..a8c0248ff5 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxRecoverableTable.test.tsx @@ -55,12 +55,13 @@ describe('ProxmoxRecoverableTable responsive columns', () => { expect([...document.querySelectorAll('thead th')].map((th) => th.textContent?.trim())).toEqual([ 'Workload', 'Via', - 'Loc', 'Ageโ–ผ', 'State', ]); expect(document.body.textContent).toContain('VM 100'); - expect(document.body.textContent).toContain('main / pve1'); + // The phone projection has no location column; the row title carries it. + expect(document.body.textContent).not.toContain('main / pve1'); + expect(document.querySelector('td [title*="main / pve1"]')).not.toBeNull(); expect(document.body.textContent).not.toContain('5 PBS files'); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts index 8ae3acda04..6ab7884a78 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.branchcov.test.ts @@ -522,7 +522,7 @@ describe('buildProxmoxBackupRecoveryModel summary, sort, and empty edges', () => expect(model.coverageSummary.attention).toBe(0); }); - it('sorts rows by postureRank asc, then latestRecovery createdMs desc', () => { + it('sorts rows by postureRank asc, then latestBackup createdMs desc', () => { const postures = new Map([ ['vm-830', posture('vm-830', 'unprotected')], ['vm-840', posture('vm-840', 'protected')], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts index a7e222ac29..43fc7e5a41 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupRecoveryModel.test.ts @@ -162,6 +162,35 @@ describe('proxmoxBackupRecoveryModel', () => { ); }); + it('shows the archive format as its detail and keeps the volid on hover and in search', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: 'tar.zst' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const [artifact] = model.recoverableArtifacts; + expect(artifact.detail).toBe('tar.zst'); + expect(artifact.detailTitle).toBe('local:backup/vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + expect(recoverableArtifactMatchesSearch(artifact, 'vzdump-lxc-112')).toBe(true); + }); + + it('falls back to the archive file name when the provider reports no format', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [archive({ format: '' })], + snapshots: [], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + expect(model.recoverableArtifacts[0].detail).toBe('vzdump-lxc-112-2026_05_24-02_00_00.tar.zst'); + }); + it('keeps a terminally incomplete PBS artifact out of latest recovery', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], @@ -186,10 +215,49 @@ describe('proxmoxBackupRecoveryModel', () => { const failed = model.recoverableArtifacts.find((artifact) => artifact.id.includes('failed')); expect(failed).toMatchObject({ failed: true, running: false, verified: undefined }); - expect(model.coverageRows[0].latestRecovery?.createdAt).toBe('2026-05-25T01:34:25Z'); + expect(model.coverageRows[0].latestBackup?.createdAt).toBe('2026-05-25T01:34:25Z'); expect(recoverableArtifactMatchesSearch(failed!, 'failed incomplete')).toBe(true); }); + it('keeps a fresh guest snapshot from standing in for the last backup', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [pbsBackup({ backupTime: '2026-05-05T01:34:25Z' })], + archives: [], + snapshots: [snapshot({ time: '2026-05-26T03:00:00Z' })], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestSnapshot?.createdAt).toBe('2026-05-26T03:00:00Z'); + expect(row.latestBackup?.sourceKind).toBe('pbs'); + expect(row.latestBackup?.createdAt).toBe('2026-05-05T01:34:25Z'); + expect( + getRecoveryAgeBand( + row.latestBackup?.createdMs, + model.coverageRows.length && Date.parse('2026-05-26T08:00:00Z'), + ), + ).toBe('aging'); + }); + + it('reports no last backup for a snapshot-only workload', () => { + const model = buildProxmoxBackupRecoveryModel({ + workloads: [workload({})], + pbsBackups: [], + archives: [], + snapshots: [snapshot()], + tasks: [], + nowMs: Date.parse('2026-05-26T08:00:00Z'), + }); + + const row = model.coverageRows[0]; + expect(row.latestBackup).toBeUndefined(); + expect(row.latestSnapshot?.sourceKind).toBe('snapshot'); + expect(row.artifacts).toHaveLength(1); + expect(model.coverageSummary.recoverableArtifacts).toBe(1); + }); + it('uses canonical workload attention while retaining the failed task as evidence', () => { const model = buildProxmoxBackupRecoveryModel({ workloads: [workload({})], diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts index 8817787792..a52bd7c075 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTablePresentation.test.ts @@ -44,10 +44,10 @@ describe('Proxmox backups responsive table presentation', () => { 'status', 'datastore', 'used', - 'backups', ]); expect(getBackupServerColumnWidthStyle('server', 'compact')).toEqual({ width: '40%' }); - expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '20%' }); + expect(getBackupServerColumnWidthStyle('status', 'compact')).toEqual({ width: '23%' }); + expect(getBackupServerColumnWidthStyle('used', 'compact')).toEqual({ width: '15%' }); expect(ids(getBackupServerColumns('basic'))).toEqual([ 'server', 'status', @@ -124,12 +124,11 @@ describe('Proxmox backups responsive table presentation', () => { expect(ids(getRecoverableColumns('compact'))).toEqual([ 'workload', 'source', - 'location', 'created', 'state', ]); expect(getRecoverableColumnWidthStyle('workload', 'compact')).toEqual({ width: '40%' }); - expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '16%' }); + expect(getRecoverableColumnWidthStyle('state', 'compact')).toEqual({ width: '30%' }); expect(ids(getRecoverableColumns('basic'))).toEqual([ 'workload', 'source', diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx index cb34f14569..3694f78460 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxBackupsTableShared.test.tsx @@ -8,8 +8,10 @@ import proxmoxBackupsTableSharedSource from '../proxmoxBackupsTableShared.tsx?ra import { ArtifactSourceBadge, ArtifactStateBadge, + ProxmoxBackupAgeText, ProxmoxBackupWorkloadTypeBadge, SortableHead, + formatCompactBackupAge, } from '../proxmoxBackupsTableShared'; import type { RecoverableArtifact } from '../proxmoxBackupRecoveryModel'; @@ -86,6 +88,28 @@ describe('proxmoxBackupsTableShared', () => { } }); + it('drops the age suffix in the phone projection and keeps the timestamp on hover', () => { + const createdAt = new Date(Date.now() - 18 * 60 * 60 * 1000).toISOString(); + expect(formatCompactBackupAge(createdAt)).toBe('18h'); + expect(formatCompactBackupAge(new Date().toISOString())).toBe('now'); + + const artifact = { + id: 'pbs:1', + nativeId: '1', + sourceKind: 'pbs', + sourceLabel: 'PBS', + workload: { key: 'w', type: 'vm', typeLabel: 'VM', vmid: '100', label: 'VM 100' }, + createdAt, + createdMs: Date.parse(createdAt), + location: 'main', + detail: '', + protected: false, + } as RecoverableArtifact; + render(() => ); + const age = screen.getByText('18h'); + expect(age.closest('[title]')?.getAttribute('title')).toContain(createdAt); + }); + it('keeps backup age text on the shared relative-time primitive', () => { expect(proxmoxBackupsTableSharedSource).toContain('PlatformTableRelativeTimeValue'); expect(proxmoxBackupsTableSharedSource).not.toContain('formatRelativeTime('); diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts index 1e23674fb7..305232fb26 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxHostTableModel.test.ts @@ -19,16 +19,16 @@ describe('proxmoxHostTableModel', () => { expect(getProxmoxHostColumnWidthStyle('uptime', 'narrow', ids)).toEqual({ width: '15%' }); }); - it('shows the full operational metric track at phone width', () => { + it('matches the guest table columns and keeps node names whole at phone width', () => { const columns = getProxmoxHostVisibleColumnsForLayout('phone'); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'temp', 'uptime']); + expect(ids).toEqual(['node', 'cpu', 'memory', 'disk', 'uptime']); expect(getProxmoxHostTableMinWidthClass('phone')).toBe('min-w-[0px]'); - expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '30%' }); - expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '13.3824%' }); - expect(getProxmoxHostColumnWidthStyle('temp', 'phone', ids)).toEqual({ width: '15.4412%' }); - expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '14.4118%' }); + expect(getProxmoxHostColumnWidthStyle('node', 'phone', ids)).toEqual({ width: '38%' }); + expect(getProxmoxHostColumnWidthStyle('cpu', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('disk', 'phone', ids)).toEqual({ width: '15.5%' }); + expect(getProxmoxHostColumnWidthStyle('uptime', 'phone', ids)).toEqual({ width: '15.5%' }); }); it('prioritizes live utilization columns in the mobile host table', () => { diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts index c71846a6ae..b4288cf9f6 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts @@ -47,6 +47,8 @@ export interface RecoverableArtifact { /** Operator-facing source/repository label used by the backup-location filter. */ locationLabel?: string; detail: string; + /** Full provider identifier behind a shortened `detail`, shown on hover. */ + detailTitle?: string; protected: boolean; verified?: boolean; /** A still-running backup is writing this artifact; it is not recoverable yet. */ @@ -70,7 +72,13 @@ export interface WorkloadCoverageRow { key: string; workload: WorkloadReference; artifacts: RecoverableArtifact[]; - latestRecovery?: RecoverableArtifact; + /** + * Newest completed PBS snapshot or PVE backup file. Guest snapshots never + * fill this slot: they live on the guest's own storage and the protection + * posture engine does not count them as independent recovery, so a fresh + * snapshot must not read as a fresh backup beside a stale posture. + */ + latestBackup?: RecoverableArtifact; latestPBS?: RecoverableArtifact; latestArchive?: RecoverableArtifact; latestSnapshot?: RecoverableArtifact; @@ -138,12 +146,25 @@ export function getRecoveryAgeBand( return 'stale'; } +// A guest snapshot shares the guest's storage, so it is listed as restore +// evidence but never counted as a backup. Mirrors the server posture rule that +// "snapshots alone do not prove independent recovery". +export function isBackupArtifact(artifact: RecoverableArtifact): boolean { + return artifact.sourceKind !== 'snapshot'; +} + function parseTimestampMs(value: string | undefined): number | undefined { if (!value) return undefined; const ms = Date.parse(value); return Number.isFinite(ms) ? ms : undefined; } +function archiveFileName(volid: string | undefined): string { + const trimmed = volid?.trim() ?? ''; + if (!trimmed) return ''; + return trimmed.slice(trimmed.lastIndexOf('/') + 1); +} + function pbsBackupFileDetailLabel(fileCount: number): string { if (fileCount <= 0) return 'PBS files not listed'; if (fileCount === 1) return '1 PBS file'; @@ -572,7 +593,12 @@ export function buildProxmoxBackupRecoveryModel( size: archive.size, location: archive.storage || archive.node || 'โ€”', ...artifactLocationScope('archive', archive.instance, archive.storage || archive.node), - detail: archive.volid || archive.format || archiveSource.detailFallbackLabel, + // The volid repeats the storage, guest type, VMID, and timestamp the row + // already shows in their own columns and clips at every width; the + // archive format is the one fact it adds, so that is the cell and the + // volid stays on hover and in search. + detail: archive.format || archiveFileName(archive.volid) || archiveSource.detailFallbackLabel, + detailTitle: archive.volid || undefined, protected: archive.protected, verified: archive.inProgress ? undefined : archive.isPBS ? archive.verified : undefined, running: archive.inProgress === true, @@ -626,6 +652,7 @@ export function buildProxmoxBackupRecoveryModel( // the "latest" pointers (which answer "what can I recover to?") skip them. // The artifacts themselves stay listed with an honest state. const completed = row.artifacts.filter((artifact) => !artifact.running && !artifact.failed); + row.latestBackup = newest(completed.filter(isBackupArtifact), (artifact) => artifact.createdMs); row.latestPBS = newest( completed.filter((artifact) => artifact.sourceKind === 'pbs'), (artifact) => artifact.createdMs, @@ -638,7 +665,6 @@ export function buildProxmoxBackupRecoveryModel( completed.filter((artifact) => artifact.sourceKind === 'snapshot'), (artifact) => artifact.createdMs, ); - row.latestRecovery = newest(completed, (artifact) => artifact.createdMs); } const coverageRows = Array.from(rows.values()).map((row) => { @@ -665,7 +691,7 @@ export function buildProxmoxBackupRecoveryModel( coverageRows.sort((left, right) => { if (left.postureRank !== right.postureRank) return left.postureRank - right.postureRank; - return (right.latestRecovery?.createdMs ?? 0) - (left.latestRecovery?.createdMs ?? 0); + return (right.latestBackup?.createdMs ?? 0) - (left.latestBackup?.createdMs ?? 0); }); artifacts.sort((left, right) => (right.createdMs ?? 0) - (left.createdMs ?? 0)); @@ -730,6 +756,7 @@ export function recoverableArtifactMatchesSearch( artifact.sourceTitle, artifact.location, artifact.detail, + artifact.detailTitle, artifact.verified === true ? 'verified' : artifact.verified === false diff --git a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts index b1342d3396..92e3e2f3a0 100644 --- a/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts +++ b/frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts @@ -93,8 +93,10 @@ const BACKUP_SERVER_VISIBLE: Record< > = { // Reachability and datastore exhaustion are the two risks that can stop all // future backups. Host telemetry and space-efficiency context return only as - // the table gains enough room to keep those headline answers readable. - compact: ['server', 'status', 'datastore', 'used', 'backups'], + // the table gains enough room to keep those headline answers readable. The + // phone projection keeps those two answers whole ("Healthy", "33.6%") and + // lets the backup count return with the first wider layout. + compact: ['server', 'status', 'datastore', 'used'], basic: ['server', 'status', 'datastore', 'used', 'backups'], operational: ['server', 'status', 'cpu', 'memory', 'datastore', 'used', 'backups'], expanded: ['server', 'status', 'cpu', 'memory', 'uptime', 'datastore', 'used', 'backups'], @@ -105,8 +107,10 @@ const BACKUP_SERVER_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { server: 40, status: 15, datastore: 15, used: 20, backups: 10 }, - basic: { server: 24, status: 14, datastore: 20, used: 28, backups: 14 }, + compact: { server: 40, status: 23, datastore: 22, used: 15 }, + // Just above the phone projection the used cell carries its used/total + // pair again, the longest value in the row, so it takes the most room. + basic: { server: 24, status: 13.6, datastore: 18.2, used: 28.4, backups: 9.8 }, operational: { server: 20, status: 12, @@ -175,7 +179,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] = { id: 'targetId', label: 'Target ID', kind: 'text' }, { id: 'node', label: 'Node', kind: 'text' }, { id: 'posture', label: 'Posture', kind: 'text' }, - { id: 'latest', label: 'Restore', kind: 'numeric-value' }, + { id: 'latest', label: 'Last backup', kind: 'numeric-value' }, { id: 'pbs', label: 'PBS snapshot', kind: 'text' }, { id: 'archive', label: 'PVE file', kind: 'text' }, { id: 'snapshot', label: 'Guest snapshot', kind: 'text' }, @@ -184,7 +188,7 @@ export const COVERAGE_COLUMNS: readonly BackupTableColumn[] = const COVERAGE_VISIBLE: Record = { // On narrow surfaces answer: which workload, what posture, how recent is the - // newest restore point, and did the latest task succeed? Provider-by-provider + // newest independent backup, and did the latest task succeed? Provider-by-provider // evidence is progressive detail in the expansion row; target identity folds // beneath the name so the scan stays legible at 320px. compact: ['workload', 'posture', 'latest', 'pbs', 'task'], @@ -198,11 +202,14 @@ const COVERAGE_WEIGHTS: Record< ProxmoxBackupsTableLayoutMode, Partial> > = { - compact: { workload: 40, posture: 18, latest: 16, pbs: 16, task: 10 }, + // Posture keeps its whole compact word ("Unknown"), the two age cells hold + // the suffix-free compact age, and the job dot takes what a "Job" header + // needs. + compact: { workload: 40, posture: 21, latest: 14.5, pbs: 14.5, task: 10 }, // Above the phone projection the identity columns (workload, node) take the // slack that the short type badge, target id, and age cells cannot use, so // names stay whole instead of truncating beside empty space. - basic: { workload: 31, node: 20, posture: 20, latest: 14, task: 15 }, + basic: { workload: 31, node: 18, posture: 22, latest: 15, task: 14 }, operational: { workload: 27, type: 8, node: 17, posture: 17, latest: 15, task: 16 }, expanded: { workload: 19, @@ -287,7 +294,10 @@ const RECOVERABLE_VISIBLE: Record getRecoveryAgeBand(props.artifact.createdMs); const title = () => { const parts = [recoveryAgeTitleByBand[band()], props.artifact.createdAt].filter(Boolean); @@ -228,7 +239,12 @@ export function ProxmoxBackupAgeText(props: { artifact: RecoverableArtifact }) { return ( - + } + > + {formatCompactBackupAge(props.artifact.createdAt)} + ); } diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index b053bdb00f..1a9d7db7cd 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -31,7 +31,7 @@ const HOST_COLUMN_MIN_LAYOUT: Record = { - node: 18, - version: 7, + node: 20, + version: 6, uptime: 7, cpu: 13, memory: 13, @@ -55,7 +55,7 @@ const HOST_COLUMN_DESKTOP_WIDTHS: Record = { temp: 6, vms: 5, cts: 5, - cluster: 13, + cluster: 12, }; const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< @@ -69,13 +69,15 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< disk: 15, uptime: 15, }, + // Phones carry the same five columns as the guest table below them. A sixth + // (temperature) left the node name with four characters, which made a + // cluster of "Analytics A".."F" rows unreadable. phone: { node: 32, - cpu: 13, - memory: 13, - disk: 13, - temp: 15, - uptime: 14, + cpu: 15, + memory: 15, + disk: 15, + uptime: 15, }, mobile: { node: 32, @@ -146,6 +148,11 @@ export const getProxmoxHostVisibleColumnsForLayout = ( ); }; +// The node cell carries the expansion toggle and status dot before the name, +// so it needs more than the shared 30% phone identity anchor to show a +// typical node name whole. +const PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT = 38; + export const getProxmoxHostColumnWidthStyle = ( columnId: ProxmoxHostTableColumnId, layoutMode: WorkloadTableLayoutMode, @@ -159,9 +166,11 @@ export const getProxmoxHostColumnWidthStyle = ( visibleColumnIds, layoutMode === 'narrow' ? { columnId: 'node', widthPercent: PLATFORM_TABLE_NARROW_IDENTITY_WIDTH_PERCENT } - : layoutMode === 'phone' || layoutMode === 'mobile' - ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } - : undefined, + : layoutMode === 'phone' + ? { columnId: 'node', widthPercent: PROXMOX_HOST_PHONE_IDENTITY_WIDTH_PERCENT } + : layoutMode === 'mobile' + ? { columnId: 'node', widthPercent: PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT } + : undefined, ); }; diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts index 8edf3ca711..89a8caacb3 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskDetailPresentation.test.ts @@ -4,6 +4,7 @@ import { DISK_DETAIL_LIVE_CHARTS, getDiskAttributeValueTextClass, getDiskDetailAttributeCards, + getDiskDetailHealthPresentation, getDiskDetailHistoryCharts, getDiskDetailHistoryFallbackMessage, getDiskDetailLiveBadgeLabel, @@ -34,6 +35,41 @@ describe('diskDetailPresentation', () => { ); }); + it('carries the row verdict and its reason into the drawer header', () => { + const disk = { + node: 'tower', + instance: 'cluster-main', + devPath: '/dev/sda', + model: 'Archive HDD', + serial: 'SERIAL-1', + wwn: '', + size: 1, + health: 'PASSED', + wearout: -1, + type: 'hdd', + temperature: 42, + rpm: 7200, + used: '', + riskReasons: [] as string[], + }; + expect(getDiskDetailHealthPresentation(disk)).toEqual({ + label: 'Healthy', + summary: '', + tone: 'text-emerald-700 dark:text-emerald-300', + }); + expect( + getDiskDetailHealthPresentation({ + ...disk, + health: 'FAILED', + riskReasons: ['SMART failed.'], + }), + ).toEqual({ + label: 'Replace Now', + summary: 'SMART failed.', + tone: 'text-red-700 dark:text-red-300', + }); + }); + it('builds canonical SATA and NVMe attribute cards', () => { expect( getDiskDetailAttributeCards({ diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts index 4d177523c1..b6823bd0f7 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts @@ -19,9 +19,11 @@ import { PHYSICAL_DISK_TABLE_CLASS, PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS, getPhysicalDiskEmptyStatePresentation, - getPhysicalDiskColumnWidthPercent, + getPhysicalDiskCellPaddingClass, + getPhysicalDiskColumnWidthStyle, getPhysicalDiskCollectionMessages, getPhysicalDiskFieldStatusMessage, + getPhysicalDiskHealthCompactLabel, getPhysicalDiskHealthStatus, getPhysicalDiskHealthSummary, getPhysicalDiskHostLabel, @@ -158,14 +160,55 @@ describe('diskPresentation', () => { ), ), ).toBe(true); - expect(getPhysicalDiskColumnWidthPercent('compact', 'disk')).toBe(33); - expect(getPhysicalDiskColumnWidthPercent('compact', 'size')).toBe(14); - expect(getPhysicalDiskColumnWidthPercent('operational', 'life')).toBe(9); - expect(getPhysicalDiskColumnWidthPercent('compact', 'device')).toBe(0); + expect(getPhysicalDiskColumnWidthStyle('compact', 'disk')).toEqual({ width: '34%' }); + expect(getPhysicalDiskColumnWidthStyle('compact', 'temp')).toEqual({ width: '12%' }); + expect(getPhysicalDiskColumnWidthStyle('compact', 'size')).toEqual({ width: '15%' }); + expect(getPhysicalDiskColumnWidthStyle('operational', 'life')).toEqual({ width: '9%' }); + expect(getPhysicalDiskColumnWidthStyle('compact', 'device')).toEqual({ width: '0%' }); expect(isPhysicalDiskColumnVisible('narrow', 'life')).toBe(false); expect(isPhysicalDiskColumnVisible('narrow', 'temp')).toBe(true); - expect(getPhysicalDiskColumnWidthPercent('narrow', 'disk')).toBe(40); - expect(getPhysicalDiskColumnWidthPercent('narrow', 'size')).toBe(15); + expect(getPhysicalDiskColumnWidthStyle('narrow', 'disk')).toEqual({ width: '41%' }); + expect(getPhysicalDiskColumnWidthStyle('narrow', 'temp')).toEqual({ width: '12%' }); + expect(getPhysicalDiskColumnWidthStyle('narrow', 'size')).toEqual({ width: '16%' }); + }); + + it('keeps the desktop disk column widths on the canonical weighted helper', () => { + const columns = [ + 'disk', + 'device', + 'host', + 'role', + 'parent', + 'health', + 'life', + 'temp', + 'size', + ] as const; + expect(columns.map((column) => getPhysicalDiskColumnWidthStyle('full', column).width)).toEqual([ + '19%', + '9%', + '10%', + '8%', + '12%', + '17%', + '6%', + '7%', + '12%', + ]); + }); + + it('sheds the shared cell padding only on the phone disk layouts', () => { + expect(getPhysicalDiskCellPaddingClass('narrow')).toBe('!px-1'); + expect(getPhysicalDiskCellPaddingClass('compact')).toBe('!px-1'); + expect(getPhysicalDiskCellPaddingClass('basic')).toBe(''); + expect(getPhysicalDiskCellPaddingClass('full')).toBe(''); + }); + + it('shortens only the health words that cannot fit a phone health column', () => { + expect(getPhysicalDiskHealthCompactLabel('Needs Attention')).toBe('Attention'); + expect(getPhysicalDiskHealthCompactLabel('Replace Now')).toBe('Replace'); + expect(getPhysicalDiskHealthCompactLabel('Healthy')).toBe('Healthy'); + expect(getPhysicalDiskHealthCompactLabel('Unknown')).toBe('Unknown'); }); it('detects SMART warnings from counters', () => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts index 99ee1f17b7..a23aa645a0 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov0712b.test.ts @@ -219,12 +219,12 @@ describe('getStoragePoolStateLabel branch coverage', () => { expect(getStoragePoolStateLabel(record)).toBe('Online'); }); - it('returns the raw pool state when it is anything other than "ONLINE"', () => { + it('titleizes a non-ONLINE zfs pool state like every other state path', () => { const record = { ...baseRecord(), details: { zfsPool: { state: 'FAULTED', devices: [] } }, } as unknown as StorageRecord; - expect(getStoragePoolStateLabel(record)).toBe('FAULTED'); + expect(getStoragePoolStateLabel(record)).toBe('Faulted'); }); it('skips a zfs pool whose state is the empty string (pool?.state truthiness guard false)', () => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts index 0c2c8f0b59..27d8fff8e6 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.branchcov2.test.ts @@ -239,7 +239,7 @@ describe('getStoragePoolStateLabel branch coverage', () => { expect(getStoragePoolStateLabel(record)).toBe('Online'); }); - it('returns the raw pool state when it is anything other than "ONLINE"', () => { + it('titleizes a non-ONLINE zfs pool state like every other state path', () => { const record = { ...baseRecord(), details: { @@ -249,7 +249,7 @@ describe('getStoragePoolStateLabel branch coverage', () => { }, }, } as unknown as StorageRecord; - expect(getStoragePoolStateLabel(record)).toBe('FAULTED'); + expect(getStoragePoolStateLabel(record)).toBe('Faulted'); }); it('titleizes the derived status when arrayState and pool are both absent', () => { diff --git a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts index 1afe8dff92..8787f8637d 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/rowPresentation.test.ts @@ -101,6 +101,47 @@ describe('storage row presentation', () => { expect(getCompactStoragePoolProtectionTitle(record)).not.toContain('without parity'); }); + it('shows a zfs rebuild as its activity word and keeps the sentence as the title', () => { + const resilvering = { + ...baseRecord(), + rebuildInProgress: true, + protectionLabel: 'ZFS pool tank is resilvering', + details: { + zfsPool: { state: 'DEGRADED', scan: 'resilver in progress', devices: [] }, + }, + } as unknown as StorageRecord; + expect(getCompactStoragePoolProtectionLabel(resilvering)).toBe('Resilvering'); + expect(getCompactStoragePoolProtectionTitle(resilvering)).toBe('ZFS pool tank is resilvering'); + + const scrubbing = { + ...baseRecord(), + rebuildInProgress: true, + protectionLabel: 'ZFS pool tank scrub is running (45.2%)', + protectionSummary: 'ZFS pool tank scrub is running (45.2%)', + details: { + zfsPool: { + state: 'ONLINE', + scan: '', + scanDetails: { function: 'SCRUB', state: 'SCANNING', percentage: 45.2 }, + devices: [], + }, + }, + } as unknown as StorageRecord; + expect(getCompactStoragePoolProtectionLabel(scrubbing)).toBe('Scrubbing 45%'); + expect(getCompactStoragePoolProtectionTitle(scrubbing)).toBe( + 'ZFS pool tank scrub is running (45.2%)', + ); + + // A rebuild without zfs scan data keeps the label its platform supplied. + const parity = { + ...baseRecord(), + rebuildInProgress: true, + protectionLabel: 'Parity sync 12%', + } as unknown as StorageRecord; + expect(getCompactStoragePoolProtectionLabel(parity)).toBe('Parity sync 12%'); + expect(getCompactStoragePoolProtectionTitle(parity)).toBe('Parity sync 12%'); + }); + it('derives zfs issue fallback from pool state and errors', () => { const record = { ...baseRecord(), diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts index a2b1c04bed..86ec88080b 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageBarPresentation.test.ts @@ -13,6 +13,7 @@ import { STORAGE_BAR_ZFS_SECTION_CLASS, STORAGE_BAR_ZFS_STATE_LABEL_CLASS, STORAGE_BAR_ZFS_STATE_ROW_CLASS, + getStorageBarCompactLabel, getStorageBarLabel, getStorageBarTooltipRowClass, getStorageBarTooltipRows, @@ -39,6 +40,7 @@ describe('storageBarPresentation', () => { expect(STORAGE_BAR_ZFS_STATE_LABEL_CLASS).toBe('text-slate-400'); expect(getStorageBarUsagePercent(40, 100)).toBe(40); expect(getStorageBarLabel(40, 100)).toBe('40% (40.0 B/100 B)'); + expect(getStorageBarCompactLabel(40, 100)).toBe('40%'); expect(getStorageBarTooltipTitle()).toBe('Storage Details'); expect(getStorageBarTooltipRowClass()).toBe('flex justify-between gap-3 py-0.5 '); expect(getStorageBarTooltipRowClass(true)).toBe( diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts index a8971d42b9..b909d2e8f5 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - getStoragePoolColumnWidthPercent, + getStoragePoolCellPaddingClass, + getStoragePoolColumnWidthStyle, getStoragePoolTableColumns, getStoragePoolTableLayoutModeForContainer, getStorageTableHeading, @@ -46,22 +47,44 @@ describe('storagePagePresentation', () => { expect(getStoragePoolTableLayoutModeForContainer(0)).toBe('compact'); expect(getStoragePoolTableLayoutModeForContainer(359)).toBe('narrow'); expect(getStoragePoolTableLayoutModeForContainer(360)).toBe('compact'); - expect(getStoragePoolTableLayoutModeForContainer(559)).toBe('compact'); - expect(getStoragePoolTableLayoutModeForContainer(560)).toBe('operational'); + // The compact/operational boundary is the shared phone container query + // (34rem), so the usage bar label and the column set change together. + expect(getStoragePoolTableLayoutModeForContainer(543)).toBe('compact'); + expect(getStoragePoolTableLayoutModeForContainer(544)).toBe('operational'); expect(getStoragePoolTableLayoutModeForContainer(1_039)).toBe('operational'); expect(getStoragePoolTableLayoutModeForContainer(1_040)).toBe('full'); expect(isStoragePoolColumnVisible('operational', 'host')).toBe(true); expect(isStoragePoolColumnVisible('operational', 'growth')).toBe(false); - expect(getStoragePoolColumnWidthPercent('operational', 'usage')).toBe(21); + expect(getStoragePoolColumnWidthStyle('operational', 'usage')).toEqual({ width: '25%' }); + expect(getStoragePoolColumnWidthStyle('operational', 'name')).toEqual({ width: '27%' }); expect(isStoragePoolColumnVisible('compact', 'host')).toBe(true); expect(isStoragePoolColumnVisible('compact', 'type')).toBe(false); expect(isStoragePoolColumnVisible('compact', 'protection')).toBe(false); - expect(getStoragePoolColumnWidthPercent('compact', 'name')).toBe(35); - expect(getStoragePoolColumnWidthPercent('compact', 'usage')).toBe(25); - expect(getStoragePoolColumnWidthPercent('compact', 'growth')).toBe(0); + expect(getStoragePoolColumnWidthStyle('compact', 'name')).toEqual({ width: '37%' }); + expect(getStoragePoolColumnWidthStyle('compact', 'host')).toEqual({ width: '31.5%' }); + expect(getStoragePoolColumnWidthStyle('compact', 'usage')).toEqual({ width: '13%' }); + expect(getStoragePoolColumnWidthStyle('compact', 'growth')).toEqual({ width: '0%' }); expect(isStoragePoolColumnVisible('narrow', 'type')).toBe(false); expect(isStoragePoolColumnVisible('narrow', 'protection')).toBe(false); - expect(getStoragePoolColumnWidthPercent('narrow', 'name')).toBe(40); - expect(getStoragePoolColumnWidthPercent('narrow', 'usage')).toBe(20); + expect(getStoragePoolColumnWidthStyle('narrow', 'name')).toEqual({ width: '35%' }); + expect(getStoragePoolColumnWidthStyle('narrow', 'usage')).toEqual({ width: '13%' }); + }); + + it('sheds the desktop cell gutter only on the phone pool layouts', () => { + expect(getStoragePoolCellPaddingClass('narrow')).toBe('!px-1'); + expect(getStoragePoolCellPaddingClass('compact')).toBe('!px-1'); + expect(getStoragePoolCellPaddingClass('operational')).toBe(''); + expect(getStoragePoolCellPaddingClass('full')).toBe(''); + }); + + it('keeps the desktop pool column widths on the canonical weighted helper', () => { + const fullWidths = ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'].map( + (column) => + getStoragePoolColumnWidthStyle( + 'full', + column as 'name' | 'state' | 'type' | 'host' | 'protection' | 'usage' | 'growth', + ).width, + ); + expect(fullWidths).toEqual(['20%', '14%', '10%', '12%', '13%', '20%', '11%']); }); }); diff --git a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts index 12cc6a8f12..449020cb8f 100644 --- a/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskDetailPresentation.ts @@ -1,4 +1,8 @@ -import type { PhysicalDiskPresentationData } from '@/features/storageBackups/diskPresentation'; +import { + getPhysicalDiskHealthStatus, + getPhysicalDiskHealthSummary, + type PhysicalDiskPresentationData, +} from '@/features/storageBackups/diskPresentation'; import type { HistoryTimeRange } from '@/api/charts'; import { formatPowerOnHours } from '@/utils/format'; import { getMetricSeverity, type MetricDisplayThresholds } from '@/utils/metricThresholds'; @@ -78,6 +82,32 @@ export const getDiskDetailLiveBadgeLabel = (): string => 'Real-time'; export const getDiskDetailHistoryFallbackMessage = (): string => 'Historical disk charts are unavailable until Pulse can resolve a stable identity for this disk.'; +export type DiskDetailHealthPresentation = { + label: string; + summary: string; + tone: string; +}; + +// The drawer header carries the disk's verdict and its reason. The table row +// shows only the verdict word, so this is where the reason is read in full. +export const DISK_DETAIL_HEADER_STACK_CLASS = 'min-w-0 space-y-1'; +export const DISK_DETAIL_HEALTH_ROW_CLASS = + 'flex flex-wrap items-baseline gap-x-2 gap-y-0.5 text-[11px]'; +export const DISK_DETAIL_HEALTH_LABEL_CLASS = 'font-semibold'; +export const DISK_DETAIL_HEALTH_SUMMARY_CLASS = + 'min-w-0 max-w-full whitespace-normal break-words text-muted'; + +export function getDiskDetailHealthPresentation( + disk: PhysicalDiskPresentationData, +): DiskDetailHealthPresentation { + const status = getPhysicalDiskHealthStatus(disk); + return { + label: status.label, + summary: getPhysicalDiskHealthSummary(status), + tone: status.tone, + }; +} + export function getDiskDetailAttributeCards( disk: PhysicalDiskPresentationData, diskTempThresholds?: MetricDisplayThresholds | null, diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts index 9cf9707e53..5d4aee37ca 100644 --- a/frontend-modern/src/features/storageBackups/diskPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts @@ -1,8 +1,10 @@ +import type { JSX } from 'solid-js'; import type { PhysicalDiskCollectionStatus, PhysicalDiskFieldStatus, Resource, } from '@/types/resource'; +import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage'; import { getSourcePlatformLabel, getSourcePlatformPresentation, @@ -141,12 +143,16 @@ const PHYSICAL_DISK_VISIBLE_COLUMNS: Record< full: ['disk', 'device', 'host', 'role', 'parent', 'health', 'life', 'temp', 'size'], }; -const PHYSICAL_DISK_COLUMN_WIDTHS: Record< +// Relative weights per layout, resolved through the canonical weighted-width +// helper. The phone layouts are sized from the measured values they show: +// a temperature ("100ยฐC"), a capacity ("5.46 TB"), a compact health word and +// a short node name must each fit whole, and the disk model takes the rest. +const PHYSICAL_DISK_COLUMN_WEIGHTS: Record< PhysicalDiskTableLayoutMode, Partial> > = { - narrow: { disk: 40, host: 15, health: 20, temp: 10, size: 15 }, - compact: { disk: 33, host: 15, health: 18, life: 10, temp: 10, size: 14 }, + narrow: { disk: 41, host: 12, health: 19, temp: 12, size: 16 }, + compact: { disk: 34, host: 11, health: 17, life: 11, temp: 12, size: 15 }, basic: { disk: 33, host: 17, health: 22, temp: 12, size: 16 }, operational: { disk: 25, host: 12, parent: 15, health: 17, life: 9, temp: 9, size: 13 }, expanded: { disk: 22, host: 10, role: 9, parent: 14, health: 15, life: 8, temp: 8, size: 14 }, @@ -168,10 +174,31 @@ export const isPhysicalDiskColumnVisible = ( columnId: PhysicalDiskTableColumnId, ): boolean => PHYSICAL_DISK_VISIBLE_COLUMNS[layout].includes(columnId); -export const getPhysicalDiskColumnWidthPercent = ( +export const getPhysicalDiskColumnWidthStyle = ( layout: PhysicalDiskTableLayoutMode, columnId: PhysicalDiskTableColumnId, -): number => PHYSICAL_DISK_COLUMN_WIDTHS[layout][columnId] ?? 0; +): JSX.CSSProperties => + getPlatformTableWeightedColumnWidthStyle( + columnId, + PHYSICAL_DISK_COLUMN_WEIGHTS[layout], + PHYSICAL_DISK_VISIBLE_COLUMNS[layout], + ); + +// The shared table cell primitives pad every cell for desktop density. On the +// phone layouts that padding alone is a fifth of the row, so rendered cells +// and headers shed it the same way the Proxmox replication table does. +export const PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS = '!px-1'; + +export const getPhysicalDiskCellPaddingClass = (layout: PhysicalDiskTableLayoutMode): string => + layout === 'narrow' || layout === 'compact' ? PHYSICAL_DISK_PHONE_CELL_PADDING_CLASS : ''; + +// Health words that do not fit a phone-width health column fall back to a +// shorter form; the full label stays in the wider projections and the detail. +export const getPhysicalDiskHealthCompactLabel = (label: string): string => { + if (label === 'Needs Attention') return 'Attention'; + if (label === 'Replace Now') return 'Replace'; + return label; +}; export const PHYSICAL_DISK_COL_DISK_CLASS = ''; export const PHYSICAL_DISK_COL_DEVICE_CLASS = ''; @@ -221,8 +248,10 @@ export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted'; export const PHYSICAL_DISK_LIFE_CLASS = 'text-[11px] font-medium'; export const PHYSICAL_DISK_HEALTH_WRAP_CLASS = 'flex min-w-0 items-center gap-1.5 whitespace-nowrap'; -export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'min-w-0 truncate text-[11px] font-semibold'; -export const PHYSICAL_DISK_HEALTH_SUMMARY_CLASS = 'hidden xl:block truncate text-[11px] text-muted'; +// The health word is the row's verdict and always renders whole. Its reason +// lives in the cell title and in the disk drawer, not in a truncating sibling +// that would compete with it for the track. +export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'shrink-0 text-[11px] font-semibold'; export const PHYSICAL_DISK_TEMPERATURE_CLASS = 'text-[11px] font-medium'; export const PHYSICAL_DISK_SIZE_VALUE_CLASS = 'text-[11px] text-base-content'; diff --git a/frontend-modern/src/features/storageBackups/rowPresentation.ts b/frontend-modern/src/features/storageBackups/rowPresentation.ts index 10e4c68f7a..f9bacbab41 100644 --- a/frontend-modern/src/features/storageBackups/rowPresentation.ts +++ b/frontend-modern/src/features/storageBackups/rowPresentation.ts @@ -66,9 +66,32 @@ export function getStoragePoolStateTextClass(record: StorageRecord): string { return 'text-base-content'; } +// The rebuild summary the backend promotes into the protection label is a +// sentence ("ZFS pool tank is resilvering (45.2%)"). The protection column is +// a badge, so a ZFS scan reads as the activity word, derived from the pool's +// own scan state (the same source the backend keys its risk reason on); the +// sentence stays available as the cell title. Non-ZFS rebuilds keep the label +// their platform supplies. +export function getStoragePoolRebuildLabel(record: StorageRecord): string { + const pool = getStorageRecordZfsPool(record); + if (!pool) return ''; + const scan = `${pool.scanDetails?.function || ''} ${pool.scan || ''}`.toLowerCase(); + const percentage = pool.scanDetails?.percentage; + const progress = + typeof percentage === 'number' && Number.isFinite(percentage) && percentage > 0 + ? ` ${Math.round(percentage)}%` + : ''; + if (scan.includes('resilver')) return `Resilvering${progress}`; + if (scan.includes('scrub')) return `Scrubbing${progress}`; + return ''; +} + export function getCompactStoragePoolProtectionLabel(record: StorageRecord): string { const label = (record.protectionLabel || '').trim(); - if (record.rebuildInProgress || record.protectionReduced) { + if (record.rebuildInProgress) { + return getStoragePoolRebuildLabel(record) || label || 'โ€”'; + } + if (record.protectionReduced) { return label || 'โ€”'; } if (label && label.toLowerCase() !== 'healthy') { @@ -87,7 +110,9 @@ export function getStoragePoolStateLabel(record: StorageRecord): string { } const pool = getStorageRecordZfsPool(record); if (pool?.state) { - return pool.state === 'ONLINE' ? 'Online' : pool.state; + // zpool reports upper-case states; present them like every other state + // path so a DEGRADED pool reads the same as a degraded array. + return titleize(pool.state); } const status = getStorageRecordStatus(record); return status ? titleize(status) : 'โ€”'; @@ -122,7 +147,9 @@ export function getCompactStoragePoolProtectionTitle(record: StorageRecord): str return issueSummary; } } - return label; + // When the badge shows a derived word, the platform's full label is the title. + const fullLabel = (record.protectionLabel || '').trim(); + return fullLabel && fullLabel.toLowerCase() !== label.toLowerCase() ? fullLabel : label; } export function getCompactStoragePoolImpactLabel(record: StorageRecord): string { diff --git a/frontend-modern/src/features/storageBackups/storageBarPresentation.ts b/frontend-modern/src/features/storageBackups/storageBarPresentation.ts index 02b7fda274..533641e90a 100644 --- a/frontend-modern/src/features/storageBackups/storageBarPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storageBarPresentation.ts @@ -38,8 +38,13 @@ export const getStorageBarUsagePercent = (used: number, total: number): number = return (used / total) * 100; }; +// Phone-width tables only have room for the percentage inside the bar; the +// used/total sublabel stays in the tooltip and in the wider projections. +export const getStorageBarCompactLabel = (used: number, total: number): string => + formatPercent(getStorageBarUsagePercent(used, total)); + export const getStorageBarLabel = (used: number, total: number): string => - `${formatPercent(getStorageBarUsagePercent(used, total))} (${formatBytes(used)}/${formatBytes(total)})`; + `${getStorageBarCompactLabel(used, total)} (${formatBytes(used)}/${formatBytes(total)})`; export const getStorageBarTooltipTitle = (): string => 'Storage Details'; diff --git a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts index 6d1d641452..26c156fd0e 100644 --- a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts @@ -1,5 +1,7 @@ +import type { JSX } from 'solid-js'; import type { StorageSortKey } from './storageModelCore'; import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment'; +import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage'; export type StorageViewOption = { value: 'pools' | 'disks'; @@ -21,11 +23,14 @@ export type StoragePoolTableColumn = { export type StoragePoolTableLayoutMode = 'narrow' | 'compact' | 'operational' | 'full'; +// The compact/operational boundary matches the shared phone projection of the +// table container (`@container (max-width: 33.999rem)` = 544px), so the +// columns and the in-cell labels always change projection together. export const getStoragePoolTableLayoutModeForContainer = ( containerWidth: number, ): StoragePoolTableLayoutMode => { if (containerWidth >= 1_040) return 'full'; - if (containerWidth >= 560) return 'operational'; + if (containerWidth >= 544) return 'operational'; if (containerWidth > 0 && containerWidth < 360) return 'narrow'; return 'compact'; }; @@ -43,13 +48,20 @@ const STORAGE_POOL_VISIBLE_COLUMNS: Record< full: ['name', 'state', 'type', 'host', 'protection', 'usage', 'growth'], }; -const STORAGE_POOL_COLUMN_WIDTHS: Record< +// Relative weights per layout, resolved through the canonical weighted-width +// helper. On phones the usage bar carries only its percentage, so most of the +// row goes to the two identity strings (pool name and host); a state word such +// as "Degraded" must still fit whole. The shared 30% identity anchor is not +// used here because a four-column row cannot keep the host readable with it. +const STORAGE_POOL_COLUMN_WEIGHTS: Record< StoragePoolTableLayoutMode, Partial> > = { - narrow: { name: 40, state: 20, host: 20, usage: 20 }, - compact: { name: 35, state: 20, host: 20, usage: 25 }, - operational: { name: 29, state: 20, host: 15, protection: 15, usage: 21 }, + narrow: { name: 35, state: 21, host: 31, usage: 13 }, + compact: { name: 37, state: 18.5, host: 31.5, usage: 13 }, + // The full "59% (1.18 TB/2.00 TB)" bar label returns at 544px, so the usage + // track must hold ~120px of text from the first operational width. + operational: { name: 27, state: 18, host: 15, protection: 15, usage: 25 }, full: { name: 20, state: 14, type: 10, host: 12, protection: 13, usage: 20, growth: 11 }, }; @@ -58,10 +70,24 @@ export const isStoragePoolColumnVisible = ( columnId: StoragePoolTableColumnId, ): boolean => STORAGE_POOL_VISIBLE_COLUMNS[layout].includes(columnId); -export const getStoragePoolColumnWidthPercent = ( +// On phones the pool row sheds the desktop cell gutter the same way its +// headers (via the shared container query) and the physical-disk rows do: +// four cells at 12px each were 48px of a 361px row, and the measured values +// fit the tracks with only a pixel or two to spare. +export const STORAGE_POOL_PHONE_CELL_PADDING_CLASS = '!px-1'; + +export const getStoragePoolCellPaddingClass = (layout: StoragePoolTableLayoutMode): string => + layout === 'narrow' || layout === 'compact' ? STORAGE_POOL_PHONE_CELL_PADDING_CLASS : ''; + +export const getStoragePoolColumnWidthStyle = ( layout: StoragePoolTableLayoutMode, columnId: StoragePoolTableColumnId, -): number => STORAGE_POOL_COLUMN_WIDTHS[layout][columnId] ?? 0; +): JSX.CSSProperties => + getPlatformTableWeightedColumnWidthStyle( + columnId, + STORAGE_POOL_COLUMN_WEIGHTS[layout], + STORAGE_POOL_VISIBLE_COLUMNS[layout], + ); const STORAGE_POOL_TABLE_HEADER_CLASS = 'overflow-hidden text-ellipsis whitespace-nowrap text-[10px] sm:text-[11px] lg:text-xs uppercase tracking-wider'; From f273e861892b4b62551713a5b746f8ecffb0e7e2 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:41:56 +0100 Subject: [PATCH 35/86] Keep Docker setup credentials private across help languages Remove the shared-password override from German and Spanish Compose setup and the password-on-argv example from configuration help. Default to bootstrap setup; exercise private-file permissions, argument boundaries and failed preparation with synthetic credentials. Contract-Neutral: Help and regression fixtures only; authentication, container and API contracts are unchanged. Change-source: pulse-maintainer --- docs/CONFIGURATION.md | 95 +++++++-- docs/DOCKER.md | 15 +- docs/i18n/de/README.md | 12 +- docs/i18n/es/README.md | 12 +- .../browser-tests/private-auth-help.cjs | 124 +++++++++++ frontend-modern/public/docs/CONFIGURATION.md | 95 +++++++-- frontend-modern/public/docs/DOCKER.md | 15 +- frontend-modern/public/docs/i18n/de/README.md | 12 +- frontend-modern/public/docs/i18n/es/README.md | 12 +- scripts/tests/test_configuration_auth_docs.py | 192 ++++++++++++++++++ scripts/tests/test_localized_public_docs.py | 12 +- 11 files changed, 542 insertions(+), 54 deletions(-) create mode 100644 frontend-modern/browser-tests/private-auth-help.cjs create mode 100644 scripts/tests/test_configuration_auth_docs.py diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e1a12bbe6d..f95747ce41 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,7 +4,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | File | Purpose | Security Level | | ------ | --------- | ---------------- | -| `.env` | Authentication & Secrets | ๐Ÿ”’ **Critical** (Read-only by owner) | +| `.env` | Authentication & Secrets | ๐Ÿ”’ **Critical** (Owner access only) | | `.encryption.key` | Encryption key for `.enc` files | ๐Ÿ”’ **Critical** | | `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | ๐Ÿ”’ **Sensitive** | | `system.json` | General Settings | ๐Ÿ“ Standard | @@ -66,30 +66,93 @@ Multi-tenant layout: ## ๐Ÿ” Authentication (`.env`) -This file controls access to Pulse. It is **never** exposed to the UI. +For a new install, leave `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` unset and +complete the bootstrap-token setup in your browser. See +[First login](INSTALL.md#step-1-get-the-token). Do not deploy a shared example password. -```bash -# /etc/pulse/.env +The Pulse-generated `.env` controls local authentication and is not exposed +to the UI. Preserve its owner-only permissions. A bcrypt hash is sensitive too: +someone who obtains it can attempt offline password cracking. -# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup) +```dotenv +# Pulse-generated file, not a command to paste into a shell PULSE_AUTH_USER='admin' -PULSE_AUTH_PASS='$2a$12$...' +PULSE_AUTH_PASS='' ``` -
-Advanced: Automated Setup (Skip UI) +Deployment-supplied environment values take precedence over this file. Changing +the generated file or using Pulse's password-change UI does not replace a +password supplied by your deployment manager; update that managed source too. -You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup. +### Private Docker authentication file + +If automation must skip browser setup, keep the credentials in a private file, +not a `docker run -e PULSE_AUTH_PASS=...` command, shell history or a shared +Compose file. On the Docker host, prepare the file below. These commands do not +erase an existing file; edit it locally, without printing its contents: ```bash -# Docker Example -docker run -d \ - -e PULSE_DEPLOYMENT_METHOD=docker_run \ - -e PULSE_AUTH_USER=admin \ - -e PULSE_AUTH_PASS=secret123 \ - rcourtman/pulse:latest +( + set -e + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/docker-auth.env" + if [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` -
+ +In that editor, enter these two settings and replace the placeholder with a +**complete bcrypt hash** generated by a trusted local tool with an interactive +password prompt. Do not put the password in that tool's command arguments. +Unlike Pulse's own quoted `.env`, Docker's `--env-file` input uses unquoted +values; keep the hash's `$` characters literal, with no `$$` substitution: + +```dotenv +PULSE_AUTH_USER=admin +PULSE_AUTH_PASS= +``` + +For a **new Community container**, replace `vX.Y.Z` with the exact published +tag you intend to install, then run: + +```bash +( + set -e + if [ ! -s "$HOME/.config/pulse/docker-auth.env" ]; then + printf 'Prepare the private authentication file first.\n' >&2 + exit 1 + fi +docker run -d \ + --name pulse \ + -p 7655:7655 \ + -v pulse_data:/data \ + --env-file "$HOME/.config/pulse/docker-auth.env" \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ + --restart unless-stopped \ + rcourtman/pulse:vX.Y.Z +) +``` + +This keeps credential values out of the command arguments, **not out of Docker's +container environment**: administrators with Docker access can still read them. +Pulse hashes a plaintext `PULSE_AUTH_PASS` for authentication at startup, but +does not scrub its original value from Docker or your deployment file. Keep the +file private, outside the repository, and do not share `docker inspect` or +resolved Compose configuration. Do not source the file into your shell. + +For an existing or paid Pro deployment, retain its image, mounts and managed +configuration; this new-container example is not an upgrade procedure. Compose +interpolation and Docker `--env-file` are different formats. Prefer bootstrap +setup for Compose unless your deployment manager already has a private +credential mechanism. See the [Docker guide](DOCKER.md) for setup and updates.
Advanced: OIDC / SSO diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 9d919d9197..c87e2e9d0f 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -45,9 +45,6 @@ services: environment: - TZ=Europe/London - PULSE_DEPLOYMENT_METHOD=docker_compose - # Optional: Pre-configure auth (skips setup wizard) - # - PULSE_AUTH_USER=admin - # - PULSE_AUTH_PASS=secret123 volumes: pulse_data: @@ -55,6 +52,13 @@ volumes: Run with: `docker compose up -d` +Leave authentication overrides unset for a new install and complete +[bootstrap-token setup](INSTALL.md#step-1-get-the-token) in your browser. Do not add a +shared example password to the Compose file. If automation must skip setup, +use a private deployment-managed credential source; the +[private Docker authentication file](CONFIGURATION.md#private-docker-authentication-file) +example is for `docker run`, not Compose interpolation. + The `PULSE_IMAGE` variable lets the same compose file run either the public community image or, for eligible paid customers, the private Pulse Pro image shown on . @@ -79,7 +83,10 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid > **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events. > **Note**: API tokens are managed in the UI and stored in `api_tokens.json`. -> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup. +> **Note**: Plain text values in `PULSE_AUTH_PASS` are hashed for authentication +> at startup, but remain in the container environment and deployment file. +> Docker administrators can read those values. Prefer bootstrap setup; never +> share full `docker inspect` or resolved Compose output. For SSD-sensitive installs, keep `/data` persistent and put only metrics history on tmpfs: diff --git a/docs/i18n/de/README.md b/docs/i18n/de/README.md index 12d0d1110f..b1750aaf22 100644 --- a/docs/i18n/de/README.md +++ b/docs/i18n/de/README.md @@ -90,14 +90,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Lass `PULSE_AUTH_USER` und `PULSE_AUTH_PASS` bei einer neuen Installation +ungesetzt und richte den Zugang beim ersten Anmelden mit dem Bootstrap-Token +ein. Verwende kein gemeinsames Beispielpasswort. Fรผr automatisierte Einrichtung +nutze eine private, vom Deployment verwaltete Zugangsdatenquelle; siehe +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +Passwรถrter gehรถren nicht in Befehlsargumente oder eine geteilte Compose-Datei. +Ein Docker-Administrator kann Zugangsdaten in der Container-Umgebung weiterhin +lesen, auch wenn Pulse sie fรผr die Anmeldung hasht. + ## Erste Anmeldung Beim ersten Start musst du ein **Bootstrap Token** abrufen und damit den diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index 922051c1f6..106b98cd2e 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -89,14 +89,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Deja `PULSE_AUTH_USER` y `PULSE_AUTH_PASS` sin configurar en una instalaciรณn +nueva y completa el primer acceso con el token de arranque. No uses una +contraseรฑa de ejemplo compartida. Para una configuraciรณn automatizada, usa +una fuente privada de credenciales gestionada por el despliegue; consulta +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +No pongas contraseรฑas en argumentos de comandos ni en un archivo Compose +compartido. Un administrador de Docker aรบn puede leer las credenciales en el +entorno del contenedor, aunque Pulse las convierta en hash para la autenticaciรณn. + ## Primer inicio de sesiรณn En el primer arranque debes recuperar un **Bootstrap Token** y usarlo para diff --git a/frontend-modern/browser-tests/private-auth-help.cjs b/frontend-modern/browser-tests/private-auth-help.cjs new file mode 100644 index 0000000000..4c69dd9550 --- /dev/null +++ b/frontend-modern/browser-tests/private-auth-help.cjs @@ -0,0 +1,124 @@ +// Verify shipped setup guidance in the production Docs/router/Markdown/CSS. +// No backend, Docker daemon, credentials or container startup is exercised. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'private-auth-help-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-private-auth-help'), + server: { host: '127.0.0.1', port: 5249, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5249/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(async () => { + document.documentElement.classList.add('dark'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + await page.getByRole('link', { name: 'Configure Pulse', exact: true }).click(); + const privateHeading = page.getByRole('heading', { name: 'Private Docker authentication file', exact: true }); + await privateHeading.waitFor(); + let text = await page.locator('article').innerText(); + for (const phrase of ['complete bcrypt hash', 'no $$ substitution', 'Do not source the file', + 'Deployment-supplied environment values take precedence', 'not out of Docker\'s', + 'does not scrub its original value', 'this new-container example is not an upgrade procedure']) + assert.ok(text.includes(phrase), phrase); + assert.ok(!text.includes('secret123')); + const shell = await page.locator('pre code.language-bash').allTextContents(); + assert.ok(shell.some((block) => block.includes('--env-file "$HOME/.config/pulse/docker-auth.env"'))); + assert.ok(shell.every((block) => !/-e\s+PULSE_AUTH_(PASS|USER)=/.test(block))); + const firstLogin = page.getByRole('link', { name: 'First login', exact: true }); + await firstLogin.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('step-1-get-the-token'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/INSTALL#step-1-get-the-token')); + await page.goBack(); + await privateHeading.waitFor(); + await privateHeading.evaluate((h) => h.scrollIntoView({ block: 'start' })); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-private-file.png`) }); + const launchCode = page.locator('pre').filter({ hasText: 'docker run -d' }); + await launchCode.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${engine}-docker-credentials.png`) }); + + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + await page.getByRole('link', { name: 'Docker and Podman', exact: true }).click(); + await page.getByRole('heading', { name: '๐Ÿ“ฆ Docker Compose', exact: true }).waitFor(); + text = await page.locator('article').innerText(); + assert.ok(!text.includes('secret123')); + assert.ok(text.includes('Leave authentication overrides unset')); + assert.ok(text.includes('remain in the container environment and deployment file')); + const privateLink = page.getByRole('link', { name: 'private Docker authentication file', exact: true }); + await privateLink.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('private-docker-authentication-file'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file')); + + for (const [locale, label, heading, safeguard] of [ + ['de', 'Deutsch', 'Erste Anmeldung', 'Verwende kein gemeinsames Beispielpasswort'], + ['es', 'Espaรฑol', 'Primer inicio de sesiรณn', 'No uses una contraseรฑa de ejemplo compartida'], + ]) { + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + const link = page.getByRole('link', { name: label, exact: true }); + await link.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('heading', { name: heading, exact: true }).waitFor(); + text = await page.locator('article').innerText(); + assert.ok(!text.includes('secret123')); + assert.ok(!text.includes('PULSE_AUTH_PASS=')); + assert.ok(text.includes('docker exec pulse /app/pulse bootstrap-token')); + assert.ok(text.includes(safeguard)); + const compose = page.locator('pre').filter({ hasText: 'services:' }); + await compose.scrollIntoViewIfNeeded(); + assert.ok(!(await compose.innerText()).includes('PULSE_AUTH_')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-${locale}-bootstrap.png`) }); + const target = page.locator('a[href="/docs/CONFIGURATION#private-docker-authentication-file"]'); + await target.focus(); + await page.keyboard.press('Enter'); + await privateHeading.waitFor(); + assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file')); + } + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, firstLoginKeyboard: true, + privateFileRendering: true, noCommandCredential: true, envVisibilityWarning: true, + dockerFragmentKeyboard: true, translatedBootstrap: ['de', 'es'], errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'shipped setup help and links, not native Docker or password changes', results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index e1a12bbe6d..f95747ce41 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -4,7 +4,7 @@ Pulse uses a split-configuration model to ensure security and flexibility. | File | Purpose | Security Level | | ------ | --------- | ---------------- | -| `.env` | Authentication & Secrets | ๐Ÿ”’ **Critical** (Read-only by owner) | +| `.env` | Authentication & Secrets | ๐Ÿ”’ **Critical** (Owner access only) | | `.encryption.key` | Encryption key for `.enc` files | ๐Ÿ”’ **Critical** | | `audit/.audit-signing.key` | Encrypted signing key for the default audit store; preserve with its history and matching encryption key | ๐Ÿ”’ **Sensitive** | | `system.json` | General Settings | ๐Ÿ“ Standard | @@ -66,30 +66,93 @@ Multi-tenant layout: ## ๐Ÿ” Authentication (`.env`) -This file controls access to Pulse. It is **never** exposed to the UI. +For a new install, leave `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` unset and +complete the bootstrap-token setup in your browser. See +[First login](INSTALL.md#step-1-get-the-token). Do not deploy a shared example password. -```bash -# /etc/pulse/.env +The Pulse-generated `.env` controls local authentication and is not exposed +to the UI. Preserve its owner-only permissions. A bcrypt hash is sensitive too: +someone who obtains it can attempt offline password cracking. -# Admin Credentials (bcrypt hashed; plain text auto-hashes on startup) +```dotenv +# Pulse-generated file, not a command to paste into a shell PULSE_AUTH_USER='admin' -PULSE_AUTH_PASS='$2a$12$...' +PULSE_AUTH_PASS='' ``` -
-Advanced: Automated Setup (Skip UI) +Deployment-supplied environment values take precedence over this file. Changing +the generated file or using Pulse's password-change UI does not replace a +password supplied by your deployment manager; update that managed source too. -You can pre-configure Pulse by setting environment variables. Plain text credentials are automatically hashed on startup. +### Private Docker authentication file + +If automation must skip browser setup, keep the credentials in a private file, +not a `docker run -e PULSE_AUTH_PASS=...` command, shell history or a shared +Compose file. On the Docker host, prepare the file below. These commands do not +erase an existing file; edit it locally, without printing its contents: ```bash -# Docker Example -docker run -d \ - -e PULSE_DEPLOYMENT_METHOD=docker_run \ - -e PULSE_AUTH_USER=admin \ - -e PULSE_AUTH_PASS=secret123 \ - rcourtman/pulse:latest +( + set -e + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/docker-auth.env" + if [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` -
+ +In that editor, enter these two settings and replace the placeholder with a +**complete bcrypt hash** generated by a trusted local tool with an interactive +password prompt. Do not put the password in that tool's command arguments. +Unlike Pulse's own quoted `.env`, Docker's `--env-file` input uses unquoted +values; keep the hash's `$` characters literal, with no `$$` substitution: + +```dotenv +PULSE_AUTH_USER=admin +PULSE_AUTH_PASS= +``` + +For a **new Community container**, replace `vX.Y.Z` with the exact published +tag you intend to install, then run: + +```bash +( + set -e + if [ ! -s "$HOME/.config/pulse/docker-auth.env" ]; then + printf 'Prepare the private authentication file first.\n' >&2 + exit 1 + fi +docker run -d \ + --name pulse \ + -p 7655:7655 \ + -v pulse_data:/data \ + --env-file "$HOME/.config/pulse/docker-auth.env" \ + -e PULSE_DEPLOYMENT_METHOD=docker_run \ + --restart unless-stopped \ + rcourtman/pulse:vX.Y.Z +) +``` + +This keeps credential values out of the command arguments, **not out of Docker's +container environment**: administrators with Docker access can still read them. +Pulse hashes a plaintext `PULSE_AUTH_PASS` for authentication at startup, but +does not scrub its original value from Docker or your deployment file. Keep the +file private, outside the repository, and do not share `docker inspect` or +resolved Compose configuration. Do not source the file into your shell. + +For an existing or paid Pro deployment, retain its image, mounts and managed +configuration; this new-container example is not an upgrade procedure. Compose +interpolation and Docker `--env-file` are different formats. Prefer bootstrap +setup for Compose unless your deployment manager already has a private +credential mechanism. See the [Docker guide](DOCKER.md) for setup and updates.
Advanced: OIDC / SSO diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index 9d919d9197..c87e2e9d0f 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -45,9 +45,6 @@ services: environment: - TZ=Europe/London - PULSE_DEPLOYMENT_METHOD=docker_compose - # Optional: Pre-configure auth (skips setup wizard) - # - PULSE_AUTH_USER=admin - # - PULSE_AUTH_PASS=secret123 volumes: pulse_data: @@ -55,6 +52,13 @@ volumes: Run with: `docker compose up -d` +Leave authentication overrides unset for a new install and complete +[bootstrap-token setup](INSTALL.md#step-1-get-the-token) in your browser. Do not add a +shared example password to the Compose file. If automation must skip setup, +use a private deployment-managed credential source; the +[private Docker authentication file](CONFIGURATION.md#private-docker-authentication-file) +example is for `docker run`, not Compose interpolation. + The `PULSE_IMAGE` variable lets the same compose file run either the public community image or, for eligible paid customers, the private Pulse Pro image shown on . @@ -79,7 +83,10 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid > **Tip**: Set `LOG_LEVEL=warn` to reduce log volume while still capturing important events. > **Note**: API tokens are managed in the UI and stored in `api_tokens.json`. -> **Note**: Plain text values in `PULSE_AUTH_PASS` are auto-hashed on startup. +> **Note**: Plain text values in `PULSE_AUTH_PASS` are hashed for authentication +> at startup, but remain in the container environment and deployment file. +> Docker administrators can read those values. Prefer bootstrap setup; never +> share full `docker inspect` or resolved Compose output. For SSD-sensitive installs, keep `/data` persistent and put only metrics history on tmpfs: diff --git a/frontend-modern/public/docs/i18n/de/README.md b/frontend-modern/public/docs/i18n/de/README.md index 12d0d1110f..b1750aaf22 100644 --- a/frontend-modern/public/docs/i18n/de/README.md +++ b/frontend-modern/public/docs/i18n/de/README.md @@ -90,14 +90,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Lass `PULSE_AUTH_USER` und `PULSE_AUTH_PASS` bei einer neuen Installation +ungesetzt und richte den Zugang beim ersten Anmelden mit dem Bootstrap-Token +ein. Verwende kein gemeinsames Beispielpasswort. Fรผr automatisierte Einrichtung +nutze eine private, vom Deployment verwaltete Zugangsdatenquelle; siehe +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +Passwรถrter gehรถren nicht in Befehlsargumente oder eine geteilte Compose-Datei. +Ein Docker-Administrator kann Zugangsdaten in der Container-Umgebung weiterhin +lesen, auch wenn Pulse sie fรผr die Anmeldung hasht. + ## Erste Anmeldung Beim ersten Start musst du ein **Bootstrap Token** abrufen und damit den diff --git a/frontend-modern/public/docs/i18n/es/README.md b/frontend-modern/public/docs/i18n/es/README.md index 922051c1f6..106b98cd2e 100644 --- a/frontend-modern/public/docs/i18n/es/README.md +++ b/frontend-modern/public/docs/i18n/es/README.md @@ -89,14 +89,20 @@ services: - "7655:7655" volumes: - pulse_data:/data - environment: - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: ``` +Deja `PULSE_AUTH_USER` y `PULSE_AUTH_PASS` sin configurar en una instalaciรณn +nueva y completa el primer acceso con el token de arranque. No uses una +contraseรฑa de ejemplo compartida. Para una configuraciรณn automatizada, usa +una fuente privada de credenciales gestionada por el despliegue; consulta +[Configuration](../../CONFIGURATION.md#private-docker-authentication-file). +No pongas contraseรฑas en argumentos de comandos ni en un archivo Compose +compartido. Un administrador de Docker aรบn puede leer las credenciales en el +entorno del contenedor, aunque Pulse las convierta en hash para la autenticaciรณn. + ## Primer inicio de sesiรณn En el primer arranque debes recuperar un **Bootstrap Token** y usarlo para diff --git a/scripts/tests/test_configuration_auth_docs.py b/scripts/tests/test_configuration_auth_docs.py new file mode 100644 index 0000000000..ab4972bd72 --- /dev/null +++ b/scripts/tests/test_configuration_auth_docs.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Exercise private-file auth recipes with synthetic credentials, not Docker. + +The recorders verify file permissions, argument boundaries and failure stops. +They do not establish native container startup or a password-change outcome. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDES = ("CONFIGURATION.md", "DOCKER.md", "i18n/de/README.md", "i18n/es/README.md") +HASH = "$2a$12$" + "a" * 53 # Synthetic file/argv sentinel, not a real password. +CONTENT = f"PULSE_AUTH_USER=admin\nPULSE_AUTH_PASS={HASH}\n" + + +def recipe(needle: str) -> str: + text = (ROOT / "docs/CONFIGURATION.md").read_text(encoding="utf-8") + matches = [block for block in re.findall(r"```bash\n(.*?)```", text, re.S) + if needle in block] + if len(matches) != 1: + raise AssertionError(f"expected one configuration recipe containing {needle!r}") + return matches[0] + + +EDITOR = '''#!/usr/bin/env python3 +import json, os, stat, sys +from pathlib import Path +p = Path(sys.argv[1]) +assert p == Path.home() / '.config/pulse/docker-auth.env' +assert stat.S_IMODE(p.stat().st_mode) == 0o600 +assert stat.S_IMODE(p.parent.stat().st_mode) == 0o700 +(Path.home() / 'editor.json').write_text(json.dumps(sys.argv[1:])) +sys.exit(int(os.environ.get('EDITOR_EXIT', '0'))) +''' + +DOCKER = '''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +args = sys.argv[1:] +p = Path(args[args.index('--env-file') + 1]) +assert p == Path.home() / '.config/pulse/docker-auth.env' +assert p.read_text() == os.environ['EXPECTED_FILE'] +assert os.environ['FIXTURE_HASH'] not in ' '.join(args) +assert not any(a.startswith('PULSE_AUTH_PASS=') for a in args) +(Path.home() / 'docker.json').write_text(json.dumps(args)) +sys.exit(int(os.environ.get('DOCKER_EXIT', '0'))) +''' + + +def environment(home: Path, **overrides: str) -> dict[str, str]: + tools = home / "tools" + tools.mkdir(exist_ok=True) + for name, body in (("vi", EDITOR), ("docker", DOCKER)): + script = tools / name + script.write_text(body, encoding="utf-8") + script.chmod(0o700) + return {**os.environ, "HOME": str(home), "PATH": f"{tools}:{os.environ['PATH']}", + "FIXTURE_HASH": HASH, "EXPECTED_FILE": CONTENT, **overrides} + + +def private_file(home: Path, content: str = CONTENT) -> Path: + path = home / ".config/pulse/docker-auth.env" + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + path.write_text(content, encoding="utf-8") + path.chmod(0o600) + return path + + +class ConfigurationAuthDocsTest(unittest.TestCase): + def test_no_shared_password_or_secret_in_command_arguments(self): + for name in GUIDES: + with self.subTest(guide=name): + text = (ROOT / "docs" / name).read_text(encoding="utf-8") + self.assertNotIn("secret123", text) + shell = "\n".join(re.findall(r"```bash\n(.*?)```", text, re.S)) + self.assertNotRegex(shell, r"(?:-e|--env)\s+PULSE_AUTH_(?:PASS|USER)=") + self.assertNotRegex(shell, r"\b(?:export\s+)?PULSE_AUTH_PASS=") + if name != "CONFIGURATION.md": + self.assertNotRegex(text, r"PULSE_AUTH_PASS\s*[:=]") + + def test_mirrors_and_sensitive_runtime_limit(self): + for name in GUIDES: + self.assertEqual((ROOT / "docs" / name).read_bytes(), + (ROOT / "frontend-modern/public/docs" / name).read_bytes()) + text = (ROOT / "docs/CONFIGURATION.md").read_text(encoding="utf-8") + for expected in ("not out of Docker's", "does not scrub its original value", + "Do not source the file", "Deployment-supplied environment values take precedence", + "complete bcrypt hash", "unquoted", "no `$$` substitution", + "this new-container example is not an upgrade procedure"): + self.assertIn(expected, text) + + def test_preparation_preserves_existing_file_and_protects_both_paths(self): + for existing in (False, True): + with self.subTest(existing=existing), tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = home / ".config/pulse/docker-auth.env" + if existing: + private_file(home) + path.chmod(0o644) + path.parent.chmod(0o755) + result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')], + env=environment(home), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(path.parent.stat().st_mode), 0o700) + self.assertEqual(path.read_text(), CONTENT if existing else "") + self.assertEqual(json.loads((home / "editor.json").read_text()), [str(path)]) + self.assertNotIn(HASH.encode(), result.stdout + result.stderr) + + def test_preparation_refuses_symlink_without_touching_target(self): + for linked in ("file", "directory"): + with self.subTest(linked=linked), tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + target = home / "retained" + target.mkdir() + credential = target / "docker-auth.env" + credential.write_text(CONTENT) + credential.chmod(0o644) + directory = home / ".config/pulse" + directory.parent.mkdir() + if linked == "directory": + directory.symlink_to(target, target_is_directory=True) + else: + directory.mkdir() + (directory / "docker-auth.env").symlink_to(credential) + result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')], + env=environment(home), capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"Refusing a symlinked credential path", result.stderr) + self.assertEqual(credential.read_text(), CONTENT) + self.assertEqual(stat.S_IMODE(credential.stat().st_mode), 0o644) + self.assertFalse((home / "editor.json").exists()) + + def test_editor_failure_is_not_reported_as_preparation_success(self): + with tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = private_file(home) + result = subprocess.run(["bash", "-c", recipe('vi "$auth_file"')], + env=environment(home, EDITOR_EXIT="23"), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 23) + self.assertEqual(path.read_text(), CONTENT) + + def test_launch_passes_only_private_path_and_preserves_hash_bytes(self): + with tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = private_file(home) + result = subprocess.run(["bash", "-c", recipe("docker run -d")], + env=environment(home), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + argv = json.loads((home / "docker.json").read_text()) + self.assertEqual(argv, ["run", "-d", "--name", "pulse", "-p", "7655:7655", + "-v", "pulse_data:/data", "--env-file", str(path), "-e", + "PULSE_DEPLOYMENT_METHOD=docker_run", "--restart", "unless-stopped", + "rcourtman/pulse:vX.Y.Z"]) + self.assertNotIn(HASH, json.dumps(argv)) + self.assertNotIn(HASH.encode(), result.stdout + result.stderr) + self.assertEqual(path.read_text(), CONTENT) + + def test_absent_or_empty_file_prevents_docker_start(self): + for empty in (False, True): + with self.subTest(empty=empty), tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + if empty: + private_file(home, "") + result = subprocess.run(["bash", "-c", recipe("docker run -d")], + env=environment(home), capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"Prepare the private authentication file first", result.stderr) + self.assertFalse((home / "docker.json").exists()) + + def test_docker_failure_remains_failed(self): + with tempfile.TemporaryDirectory(prefix="auth docs ") as temp: + home = Path(temp) + path = private_file(home) + result = subprocess.run(["bash", "-c", recipe("docker run -d")], + env=environment(home, DOCKER_EXIT="31"), capture_output=True, timeout=10) + self.assertEqual(result.returncode, 31) + self.assertEqual(path.read_text(), CONTENT) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_localized_public_docs.py b/scripts/tests/test_localized_public_docs.py index ae09887c67..7012652368 100644 --- a/scripts/tests/test_localized_public_docs.py +++ b/scripts/tests/test_localized_public_docs.py @@ -28,8 +28,6 @@ MACHINE_FACING_STRINGS = ( "docker run -d", "rcourtman/pulse:vX.Y.Z", "image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}", - "PULSE_AUTH_USER=admin", - "PULSE_AUTH_PASS=secret123", "docker exec pulse /app/pulse bootstrap-token", "kubectl exec -it -- /app/pulse bootstrap-token", "sudo pulse bootstrap-token", @@ -70,6 +68,16 @@ class LocalizedPublicDocsTest(unittest.TestCase): self.assertIn("[Agent Security](../../AGENT_SECURITY.md)", content) self.assertIn("[Plans and entitlements](../../PULSE_PRO.md)", content) + def test_localized_compose_uses_bootstrap_not_shared_credentials(self) -> None: + for rel in LOCALIZED_DOCS: + content = read(rel) + with self.subTest(path=rel): + self.assertNotIn("secret123", content) + self.assertNotIn("PULSE_AUTH_USER=", content) + self.assertNotIn("PULSE_AUTH_PASS=", content) + self.assertIn("../../CONFIGURATION.md#private-docker-authentication-file", content) + self.assertIn("docker exec pulse /app/pulse bootstrap-token", content) + def test_localized_docs_do_not_reintroduce_paid_capacity_claims(self) -> None: forbidden = ( "unlimited", From aefbb2b6c4e29b8fdabc15de621897e5b5b449c9 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:46:22 +0100 Subject: [PATCH 36/86] Verify private-auth help through production rendering Match the Docs sanitizer's class-free code blocks and the frontend build's Tailwind working directory. The first browser attempt stopped at the obsolete language-class selector; retain it rather than treating it as a page pass. Contract-Neutral: Browser fixture correction only; shipped guide bytes and runtime contracts are unchanged. Change-source: pulse-maintainer --- frontend-modern/browser-tests/private-auth-help.cjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend-modern/browser-tests/private-auth-help.cjs b/frontend-modern/browser-tests/private-auth-help.cjs index 4c69dd9550..0f41b06d22 100644 --- a/frontend-modern/browser-tests/private-auth-help.cjs +++ b/frontend-modern/browser-tests/private-auth-help.cjs @@ -9,6 +9,9 @@ const { chromium, webkit } = require('playwright'); const root = path.resolve('frontend-modern'); const artifacts = path.join(root, 'node_modules', 'private-auth-help-browser'); fs.mkdirSync(artifacts, { recursive: true }); + // Tailwind resolves its config/content from cwd, as the normal npm build + // does. The tool is still invoked from the assigned repository root. + process.chdir(root); const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); const server = await createServer({ root, configFile: path.join(root, 'vite.config.ts'), @@ -45,7 +48,8 @@ const { chromium, webkit } = require('playwright'); 'does not scrub its original value', 'this new-container example is not an upgrade procedure']) assert.ok(text.includes(phrase), phrase); assert.ok(!text.includes('secret123')); - const shell = await page.locator('pre code.language-bash').allTextContents(); + // The production Docs sanitizer removes Markdown language classes. + const shell = await page.locator('pre code').allTextContents(); assert.ok(shell.some((block) => block.includes('--env-file "$HOME/.config/pulse/docker-auth.env"'))); assert.ok(shell.every((block) => !/-e\s+PULSE_AUTH_(PASS|USER)=/.test(block))); const firstLogin = page.getByRole('link', { name: 'First login', exact: true }); From 200ce831a85953bef1c91aceadaa6fe992538010 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:43:25 +0100 Subject: [PATCH 37/86] Keep installation quick starts on bootstrap authentication Remove the shared Compose password from the main installation entry point, preserve image and data settings, and explain existing-deployment credential overrides. Extend the auth guide controls and rendered browser navigation checks to this page. Contract-Neutral: Documentation and its controls now reflect the existing bootstrap and environment-override behaviour; no runtime or API contract changes. Change-source: pulse-maintainer --- docs/INSTALL.md | 22 ++++- .../installation-bootstrap-help.cjs | 94 +++++++++++++++++++ frontend-modern/public/docs/INSTALL.md | 22 ++++- scripts/tests/test_configuration_auth_docs.py | 17 +++- 4 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 frontend-modern/browser-tests/installation-bootstrap-help.cjs diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7b0d724b83..70e326c93e 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -107,6 +107,9 @@ docker run -d \ rcourtman/pulse:vX.Y.Z ``` +For a new container, continue with [bootstrap-token setup](#step-1-get-the-token) +in your browser; do not add an example password to the command. + ### Docker Compose Create a `docker-compose.yml` file: @@ -122,8 +125,6 @@ services: - pulse_data:/data environment: - PULSE_DEPLOYMENT_METHOD=docker_compose - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: @@ -134,7 +135,22 @@ to the private Pulse Pro image shown on without rebuilding the file around a second deployment path. -> **Note**: Plain text passwords set via `PULSE_AUTH_PASS` are auto-hashed on startup. For production, prefer Quick Security Setup or a pre-hashed bcrypt value. +Leave authentication overrides unset for a new install and complete +[bootstrap-token setup](#step-1-get-the-token) in your browser. Do not deploy a +shared example password. If automation must skip setup, use a private +deployment-managed credential source. The +[authentication guide](CONFIGURATION.md#private-docker-authentication-file) +explains the visibility and override limits; its `docker run --env-file` +example is not a Compose interpolation recipe. + +For an existing installation, preserve its image, data mounts and managed +configuration; do not reset authentication to repeat first-time setup. If you +used a shared example password, replace it in your deployment's credential +source. A deployment-supplied password takes precedence over changes made in +Pulse's password-change UI. Hashing it inside Pulse does not remove the +original value from Docker's environment or your deployment file. Never share +full `docker inspect` or resolved Compose output. + > **Note**: Docker monitoring requires the unified agent on the Docker host with socket access; the Pulse server container does not need `/var/run/docker.sock`. See [UNIFIED_AGENT.md](UNIFIED_AGENT.md). --- diff --git a/frontend-modern/browser-tests/installation-bootstrap-help.cjs b/frontend-modern/browser-tests/installation-bootstrap-help.cjs new file mode 100644 index 0000000000..436985c69a --- /dev/null +++ b/frontend-modern/browser-tests/installation-bootstrap-help.cjs @@ -0,0 +1,94 @@ +// Exercise the Installation Guide in the production Docs/router/Markdown/CSS. +// This checks rendered instructions and navigation, not native Docker startup. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'installation-bootstrap-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-installation-bootstrap'), + server: { host: '127.0.0.1', port: 5250, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5250/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + const installLink = page.getByRole('link', { name: 'Install Pulse', exact: true }); + await installLink.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('heading', { name: 'Docker Compose', exact: true }).waitFor(); + const text = await page.locator('article').innerText(); + for (const phrase of ['Leave authentication overrides unset for a new install', + 'do not reset authentication', 'deployment-supplied password takes precedence', + 'not a Compose interpolation recipe', 'Never share full docker inspect']) + assert.ok(text.includes(phrase), phrase); + assert.ok(!text.includes('secret123')); + const compose = page.locator('pre').filter({ hasText: 'services:' }).first(); + const code = await compose.innerText(); + for (const phrase of ['${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}', + 'pulse_data:/data', 'PULSE_DEPLOYMENT_METHOD=docker_compose']) + assert.ok(code.includes(phrase), phrase); + assert.ok(!code.includes('PULSE_AUTH_')); + await compose.scrollIntoViewIfNeeded(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-compose.png`) }); + const warning = page.getByText('For an existing installation, preserve its image', { exact: false }); + await warning.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${engine}-existing-install.png`) }); + + const bootstrap = page.getByRole('link', { name: 'bootstrap-token setup', exact: true }).last(); + await bootstrap.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('step-1-get-the-token'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/INSTALL#step-1-get-the-token')); + assert.ok((await page.locator('article').innerText()).includes('docker exec pulse /app/pulse bootstrap-token')); + await page.screenshot({ path: path.join(artifacts, `${engine}-bootstrap.png`) }); + const auth = page.getByRole('link', { name: 'authentication guide', exact: true }); + await auth.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('private-docker-authentication-file'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file')); + assert.ok((await page.locator('article').innerText()).includes('not out of Docker\'s container environment')); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, + noSharedComposeCredentials: true, paidImageAndDataPreserved: true, + existingInstallWarning: true, keyboardBootstrapAndPrivateFileLinks: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'rendered installation guidance and links; not native Docker or password changes', results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/INSTALL.md b/frontend-modern/public/docs/INSTALL.md index 7b0d724b83..70e326c93e 100644 --- a/frontend-modern/public/docs/INSTALL.md +++ b/frontend-modern/public/docs/INSTALL.md @@ -107,6 +107,9 @@ docker run -d \ rcourtman/pulse:vX.Y.Z ``` +For a new container, continue with [bootstrap-token setup](#step-1-get-the-token) +in your browser; do not add an example password to the command. + ### Docker Compose Create a `docker-compose.yml` file: @@ -122,8 +125,6 @@ services: - pulse_data:/data environment: - PULSE_DEPLOYMENT_METHOD=docker_compose - - PULSE_AUTH_USER=admin - - PULSE_AUTH_PASS=secret123 volumes: pulse_data: @@ -134,7 +135,22 @@ to the private Pulse Pro image shown on without rebuilding the file around a second deployment path. -> **Note**: Plain text passwords set via `PULSE_AUTH_PASS` are auto-hashed on startup. For production, prefer Quick Security Setup or a pre-hashed bcrypt value. +Leave authentication overrides unset for a new install and complete +[bootstrap-token setup](#step-1-get-the-token) in your browser. Do not deploy a +shared example password. If automation must skip setup, use a private +deployment-managed credential source. The +[authentication guide](CONFIGURATION.md#private-docker-authentication-file) +explains the visibility and override limits; its `docker run --env-file` +example is not a Compose interpolation recipe. + +For an existing installation, preserve its image, data mounts and managed +configuration; do not reset authentication to repeat first-time setup. If you +used a shared example password, replace it in your deployment's credential +source. A deployment-supplied password takes precedence over changes made in +Pulse's password-change UI. Hashing it inside Pulse does not remove the +original value from Docker's environment or your deployment file. Never share +full `docker inspect` or resolved Compose output. + > **Note**: Docker monitoring requires the unified agent on the Docker host with socket access; the Pulse server container does not need `/var/run/docker.sock`. See [UNIFIED_AGENT.md](UNIFIED_AGENT.md). --- diff --git a/scripts/tests/test_configuration_auth_docs.py b/scripts/tests/test_configuration_auth_docs.py index ab4972bd72..cbc44aa3ec 100644 --- a/scripts/tests/test_configuration_auth_docs.py +++ b/scripts/tests/test_configuration_auth_docs.py @@ -18,7 +18,7 @@ import unittest ROOT = Path(__file__).resolve().parents[2] -GUIDES = ("CONFIGURATION.md", "DOCKER.md", "i18n/de/README.md", "i18n/es/README.md") +GUIDES = ("CONFIGURATION.md", "INSTALL.md", "DOCKER.md", "i18n/de/README.md", "i18n/es/README.md") HASH = "$2a$12$" + "a" * 53 # Synthetic file/argv sentinel, not a real password. CONTENT = f"PULSE_AUTH_USER=admin\nPULSE_AUTH_PASS={HASH}\n" @@ -99,6 +99,21 @@ class ConfigurationAuthDocsTest(unittest.TestCase): "this new-container example is not an upgrade procedure"): self.assertIn(expected, text) + def test_install_quick_start_preserves_data_and_bootstrap_route(self): + text = (ROOT / "docs/INSTALL.md").read_text(encoding="utf-8") + compose = next(block for block in re.findall(r"```yaml\n(.*?)```", text, re.S) + if "services:" in block) + for expected in ("image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}", + "pulse_data:/data", "PULSE_DEPLOYMENT_METHOD=docker_compose"): + self.assertIn(expected, compose) + self.assertNotRegex(compose, r"PULSE_AUTH_(?:USER|PASS)") + for expected in ("[bootstrap-token setup](#step-1-get-the-token)", + "[authentication guide](CONFIGURATION.md#private-docker-authentication-file)", + "Leave authentication overrides unset for a new install", + "not a Compose interpolation recipe", "do not reset authentication", + "deployment-supplied password takes precedence"): + self.assertIn(expected, text) + def test_preparation_preserves_existing_file_and_protects_both_paths(self): for existing in (False, True): with self.subTest(existing=existing), tempfile.TemporaryDirectory(prefix="auth docs ") as temp: From 462cdfb170455a576064eceee883e8cf4296c8b0 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:25:52 +0100 Subject: [PATCH 38/86] Recover local logins without deleting authentication state Route Docker and FAQ readers through one credential-source-aware recovery guide. Replace only the active password, retain unrelated settings and agent tokens, and account for systemd drop-ins and container environment application. Check private prompt output, failure stops, bcrypt loading, precedence and rendered help links. Contract-Neutral: Documentation and test coverage only; existing authentication, setup and deployment contracts are unchanged. Change-source: pulse-maintainer --- docs/DOCKER.md | 12 +- docs/FAQ.md | 31 +---- docs/TROUBLESHOOTING.md | 113 ++++++++++----- .../browser-tests/password-recovery-help.cjs | 91 ++++++++++++ frontend-modern/public/docs/DOCKER.md | 12 +- frontend-modern/public/docs/FAQ.md | 31 +---- .../public/docs/TROUBLESHOOTING.md | 113 ++++++++++----- .../config/password_recovery_docs_test.go | 94 +++++++++++++ scripts/tests/test_password_recovery_docs.py | 131 ++++++++++++++++++ 9 files changed, 490 insertions(+), 138 deletions(-) create mode 100644 frontend-modern/browser-tests/password-recovery-help.cjs create mode 100644 internal/config/password_recovery_docs_test.go create mode 100644 scripts/tests/test_password_recovery_docs.py diff --git a/docs/DOCKER.md b/docs/DOCKER.md index c87e2e9d0f..1398425542 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -270,13 +270,11 @@ Pulse can start, stop, and restart Docker / Podman containers directly from the ## ๐Ÿ› ๏ธ Troubleshooting - **Forgot Password?** - ```bash - docker exec pulse rm /data/.env - docker restart pulse - # Access UI again. Pulse will require a bootstrap token for setup. - # Get it with: - docker exec pulse /app/pulse bootstrap-token - ``` + Follow the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). + Update the active credential source; do not delete `.env`, remove the data + volume or repeat setup. A deployment-supplied password overrides the generated + file, and changing Docker's managed environment needs a recreate/redeploy, + not just a restart. SSO accounts and temporary lockouts have separate paths. - **Logs** ```bash diff --git a/docs/FAQ.md b/docs/FAQ.md index 2786c6e5af..e5d221ca21 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -118,30 +118,13 @@ discovery are disabled by default. See trust boundary and rollout checklist. ### I forgot my password. How do I reset it? -**Docker**: -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` -**Systemd**: -Delete `/etc/pulse/.env` and restart the service. Pulse will require a bootstrap token for setup: - -```bash -sudo pulse bootstrap-token -``` -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: - -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. +Use the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). +For the local administrator, update only the password in its active credential +source, preserving the username, tokens, configuration and data; deleting `.env` +is not a universal reset. The guide covers Docker, systemd and Proxmox LXC, +including deployment overrides, private backups and verification. SSO login, +temporary lockouts and a fresh install's missed bootstrap token need different +steps; do not reset a working installation to retrieve that token. ### How do I enable HTTPS? Set `HTTPS_ENABLED=true` and provide `TLS_CERT_FILE` and `TLS_KEY_FILE` environment variables. See [Configuration](CONFIGURATION.md#-https--tls). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f7f21b286a..2ff1a7b0eb 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -7,8 +7,9 @@ Pulse does not provide an email password-reset flow. Choose the path that matches how this self-hosted instance authenticates: -- **Local Pulse username and password**: recovery requires shell access to the - Pulse host or container. Follow the deployment-specific steps below. +- **Local Pulse administrator username and password**: recovery requires + authorised access to the host or deployment's credential source. Follow the + steps below; keep the existing username. - **OIDC, SAML, or proxy authentication**: contact the identity-provider or Pulse administrator. If the administrator deliberately kept local login as a fallback, they can open the Pulse URL with `?show_local=true`; this only @@ -17,45 +18,81 @@ matches how this self-hosted instance authenticates: administrator can use the lockout reset in Pulse; password recovery is not required. -The local recovery steps remove only the Pulse-generated authentication file. -After restart, Pulse still requires the host-only bootstrap token before it -will accept replacement credentials. If `PULSE_AUTH_USER` or `PULSE_AUTH_PASS` -is supplied by Docker Compose, Kubernetes, systemd, or another deployment -manager, update that deployment configuration instead; its environment values -override the generated file. +#### Recover the existing local administrator login -**Docker**: +Do not delete `.env` or repeat first-time setup to recover a password. The file +can also hold deployment settings, and setup replaces the primary API token. +Replace only the password in its active credential source instead, preserving +the username, API tokens, encryption keys, monitoring configuration and data. +This is login recovery, not a complete response to a compromised account; +existing sessions and API tokens need separate review if credentials leaked. -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` +1. **Find the active credential source locally.** Deployment-supplied + `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` take precedence over Pulse's `.env`. + Do not post full service units, container inspections or resolved Compose + configuration; they can contain secrets. + - **Docker**: the generated file is in the mounted data directory, normally + `/data/.env` inside the container. If Compose, `--env-file`, Kubernetes or + another manager supplies the password, edit that managed source instead. + - **Systemd**: check the active service's unit, environment files and + drop-ins privately. Root-run setup can also save credentials in + `/etc/systemd/system/pulse.service.d/override.conf` (or the corresponding + `pulse-backend.service.d` path on legacy installs). Editing only + `/etc/pulse/.env` will not override those values. A custom + `PULSE_DATA_DIR` changes the generated file's location. + - **Proxmox LXC**: perform the systemd steps inside the Pulse container, not + on the Proxmox host. Use its console or `pct enter `. +2. **Prepare a new password hash privately.** Use a trusted local bcrypt tool + that prompts for a password of at least 12 characters, never one that needs + the password in its command arguments. For example, if Apache's `htpasswd` + is installed on your trusted administration machine: -**Systemd**: + ```bash + ( + set -eu + umask 077 + recovery_dir="$(mktemp -d "${TMPDIR:-/tmp}/pulse-password.XXXXXX")" + htpasswd -nB -C 12 pulse-recovery > "$recovery_dir/password-record" + printf 'Private password record saved in %s/password-record\n' "$recovery_dir" + ) + ``` -```bash -sudo rm /etc/pulse/.env -sudo systemctl restart pulse -sudo pulse bootstrap-token -``` + This prints only the file location. Open the record in a private editor and + copy the complete 60-character hash after `pulse-recovery:`; that label is + not a new Pulse username. Do not post the record or hash. If the tool fails, + stop rather than saving a partial hash or putting the password in a shell + command. +3. **Back up and edit only the active password setting.** Keep an owner-only + backup of each file you change, then replace `PULSE_AUTH_PASS` in a private + editor. Keep `PULSE_AUTH_USER` and unrelated settings unchanged. In a Pulse + `.env`, put the hash in single quotes with literal `$` characters. Compose + YAML has different interpolation rules; follow the + [authentication guide](CONFIGURATION.md#private-docker-authentication-file) + for the source you actually use. If a systemd drop-in supplies the password, + update it and the generated authentication file consistently so a later + restart or reload does not restore the old value. Preserve file permissions + and ownership; hashes and backups are sensitive too. +4. **Apply through the existing deployment.** For a generated Docker data-file + change, restart the same container. Changing Docker's managed environment + requires its recreate/redeploy operation, not just `docker restart`; preserve + the same image, mounted data and other settings. For systemd, reload the + service manager after a unit or drop-in change, then restart the active Pulse + service during a suitable maintenance window. Do not remove a data volume, + reinstall Pulse or re-enrol agents. +5. **Verify the login.** Use a fresh browser session to sign in with the same + local administrator username and new password, then check that monitoring + and agent connections remain intact. If it still fails, stop and reconcile + the effective source and any lockout; do not delete more state. Restore the + private backup through the same deployment path if you need to undo the + change. Remove the temporary password record after verification, + and retain or dispose of the backup under your normal credential policy. -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: +#### A fresh install's bootstrap token was missed -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. - -Treat the bootstrap token like a password: enter it only in the Pulse setup -screen for this instance and do not paste it into support requests or issue -reports. +If no local password has ever been set, there is nothing to reset. Follow +[first login](INSTALL.md#step-1-get-the-token) to read the existing bootstrap +token for that instance, using its actual data directory. Enter it only in the +Pulse setup screen, not in command arguments, URLs or issue reports. ### Port change didn't take effect @@ -605,9 +642,9 @@ success alone does not confirm the Pulse display has recovered. ### Recovery Mode -For a forgotten local password, follow [I forgot my password](#i-forgot-my-password) -above, using the steps for your deployment. Enter the host-only bootstrap token -in that instance's setup screen; do not paste it into a command or a report. +For a forgotten local administrator password, follow +[I forgot my password](#i-forgot-my-password) above to update its active +credential source without deleting configuration or repeating first-time setup. For OIDC, SAML or proxy login, use the identity-provider or administrator path described there instead. diff --git a/frontend-modern/browser-tests/password-recovery-help.cjs b/frontend-modern/browser-tests/password-recovery-help.cjs new file mode 100644 index 0000000000..e92a6d7130 --- /dev/null +++ b/frontend-modern/browser-tests/password-recovery-help.cjs @@ -0,0 +1,91 @@ +// Render actual shipped help through the production Docs/router/Markdown/CSS. +// No API, Docker, systemd or real credential is used here. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'password-recovery-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-password-recovery'), + server: { host: '127.0.0.1', port: 5252, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5252/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + for (const guide of ['DOCKER', 'FAQ']) { + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + const entry = page.locator(`a[data-doc-link][href="/docs/${guide}"]`).first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const link = page.getByRole('link', { name: 'password recovery guide', exact: true }); + await link.waitFor(); + await link.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('i-forgot-my-password'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/TROUBLESHOOTING#i-forgot-my-password')); + const text = await page.locator('article').innerText(); + for (const phrase of ['Do not delete .env', 'setup replaces the primary API token', + 'pulse.service.d/override.conf', 'not just docker restart', + 'existing sessions and API tokens need separate review', 'If it still fails, stop']) + assert.ok(text.includes(phrase), phrase); + assert.ok(!text.includes('rm /data/.env') && !text.includes('rm /etc/pulse/.env')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}-recovery.png`) }); + } + const code = page.locator('pre').filter({ hasText: 'htpasswd -nB -C 12' }); + await code.scrollIntoViewIfNeeded(); + assert.ok((await code.innerText()).includes('> "$recovery_dir/password-record"')); + await page.screenshot({ path: path.join(artifacts, `${engine}-private-hash.png`) }); + const auth = page.getByRole('link', { name: 'authentication guide', exact: true }); + await auth.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('private-docker-authentication-file'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/CONFIGURATION#private-docker-authentication-file')); + assert.deepEqual(errors, []); + results.push({ engine, browserVersion: browser.version(), width, tone, + keyboardDockerAndFAQLinks: true, fragmentInView: true, sourcePrecedenceWarning: true, + privateHashRecipeRendered: true, configurationLink: true, noBodyOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const docs = Object.fromEntries(['DOCKER', 'FAQ', 'TROUBLESHOOTING'].map((guide) => [ + `frontend-modern/public/docs/${guide}.md`, + createHash('sha256').update(fs.readFileSync(path.join(root, 'public/docs', `${guide}.md`))).digest('hex'), + ])); + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + scope: 'rendered recovery guidance/links; not native deployment/password/session recovery', docs, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index c87e2e9d0f..1398425542 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -270,13 +270,11 @@ Pulse can start, stop, and restart Docker / Podman containers directly from the ## ๐Ÿ› ๏ธ Troubleshooting - **Forgot Password?** - ```bash - docker exec pulse rm /data/.env - docker restart pulse - # Access UI again. Pulse will require a bootstrap token for setup. - # Get it with: - docker exec pulse /app/pulse bootstrap-token - ``` + Follow the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). + Update the active credential source; do not delete `.env`, remove the data + volume or repeat setup. A deployment-supplied password overrides the generated + file, and changing Docker's managed environment needs a recreate/redeploy, + not just a restart. SSO accounts and temporary lockouts have separate paths. - **Logs** ```bash diff --git a/frontend-modern/public/docs/FAQ.md b/frontend-modern/public/docs/FAQ.md index 2786c6e5af..e5d221ca21 100644 --- a/frontend-modern/public/docs/FAQ.md +++ b/frontend-modern/public/docs/FAQ.md @@ -118,30 +118,13 @@ discovery are disabled by default. See trust boundary and rollout checklist. ### I forgot my password. How do I reset it? -**Docker**: -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` -**Systemd**: -Delete `/etc/pulse/.env` and restart the service. Pulse will require a bootstrap token for setup: - -```bash -sudo pulse bootstrap-token -``` -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: - -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. +Use the [password recovery guide](TROUBLESHOOTING.md#i-forgot-my-password). +For the local administrator, update only the password in its active credential +source, preserving the username, tokens, configuration and data; deleting `.env` +is not a universal reset. The guide covers Docker, systemd and Proxmox LXC, +including deployment overrides, private backups and verification. SSO login, +temporary lockouts and a fresh install's missed bootstrap token need different +steps; do not reset a working installation to retrieve that token. ### How do I enable HTTPS? Set `HTTPS_ENABLED=true` and provide `TLS_CERT_FILE` and `TLS_KEY_FILE` environment variables. See [Configuration](CONFIGURATION.md#-https--tls). diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index f7f21b286a..2ff1a7b0eb 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -7,8 +7,9 @@ Pulse does not provide an email password-reset flow. Choose the path that matches how this self-hosted instance authenticates: -- **Local Pulse username and password**: recovery requires shell access to the - Pulse host or container. Follow the deployment-specific steps below. +- **Local Pulse administrator username and password**: recovery requires + authorised access to the host or deployment's credential source. Follow the + steps below; keep the existing username. - **OIDC, SAML, or proxy authentication**: contact the identity-provider or Pulse administrator. If the administrator deliberately kept local login as a fallback, they can open the Pulse URL with `?show_local=true`; this only @@ -17,45 +18,81 @@ matches how this self-hosted instance authenticates: administrator can use the lockout reset in Pulse; password recovery is not required. -The local recovery steps remove only the Pulse-generated authentication file. -After restart, Pulse still requires the host-only bootstrap token before it -will accept replacement credentials. If `PULSE_AUTH_USER` or `PULSE_AUTH_PASS` -is supplied by Docker Compose, Kubernetes, systemd, or another deployment -manager, update that deployment configuration instead; its environment values -override the generated file. +#### Recover the existing local administrator login -**Docker**: +Do not delete `.env` or repeat first-time setup to recover a password. The file +can also hold deployment settings, and setup replaces the primary API token. +Replace only the password in its active credential source instead, preserving +the username, API tokens, encryption keys, monitoring configuration and data. +This is login recovery, not a complete response to a compromised account; +existing sessions and API tokens need separate review if credentials leaked. -```bash -docker exec pulse rm /data/.env -docker restart pulse -# Access UI again. Pulse will require a bootstrap token for setup. -# Get it with: -docker exec pulse /app/pulse bootstrap-token -``` +1. **Find the active credential source locally.** Deployment-supplied + `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` take precedence over Pulse's `.env`. + Do not post full service units, container inspections or resolved Compose + configuration; they can contain secrets. + - **Docker**: the generated file is in the mounted data directory, normally + `/data/.env` inside the container. If Compose, `--env-file`, Kubernetes or + another manager supplies the password, edit that managed source instead. + - **Systemd**: check the active service's unit, environment files and + drop-ins privately. Root-run setup can also save credentials in + `/etc/systemd/system/pulse.service.d/override.conf` (or the corresponding + `pulse-backend.service.d` path on legacy installs). Editing only + `/etc/pulse/.env` will not override those values. A custom + `PULSE_DATA_DIR` changes the generated file's location. + - **Proxmox LXC**: perform the systemd steps inside the Pulse container, not + on the Proxmox host. Use its console or `pct enter `. +2. **Prepare a new password hash privately.** Use a trusted local bcrypt tool + that prompts for a password of at least 12 characters, never one that needs + the password in its command arguments. For example, if Apache's `htpasswd` + is installed on your trusted administration machine: -**Systemd**: + ```bash + ( + set -eu + umask 077 + recovery_dir="$(mktemp -d "${TMPDIR:-/tmp}/pulse-password.XXXXXX")" + htpasswd -nB -C 12 pulse-recovery > "$recovery_dir/password-record" + printf 'Private password record saved in %s/password-record\n' "$recovery_dir" + ) + ``` -```bash -sudo rm /etc/pulse/.env -sudo systemctl restart pulse -sudo pulse bootstrap-token -``` + This prints only the file location. Open the record in a private editor and + copy the complete 60-character hash after `pulse-recovery:`; that label is + not a new Pulse username. Do not post the record or hash. If the tool fails, + stop rather than saving a partial hash or putting the password in a shell + command. +3. **Back up and edit only the active password setting.** Keep an owner-only + backup of each file you change, then replace `PULSE_AUTH_PASS` in a private + editor. Keep `PULSE_AUTH_USER` and unrelated settings unchanged. In a Pulse + `.env`, put the hash in single quotes with literal `$` characters. Compose + YAML has different interpolation rules; follow the + [authentication guide](CONFIGURATION.md#private-docker-authentication-file) + for the source you actually use. If a systemd drop-in supplies the password, + update it and the generated authentication file consistently so a later + restart or reload does not restore the old value. Preserve file permissions + and ownership; hashes and backups are sensitive too. +4. **Apply through the existing deployment.** For a generated Docker data-file + change, restart the same container. Changing Docker's managed environment + requires its recreate/redeploy operation, not just `docker restart`; preserve + the same image, mounted data and other settings. For systemd, reload the + service manager after a unit or drop-in change, then restart the active Pulse + service during a suitable maintenance window. Do not remove a data volume, + reinstall Pulse or re-enrol agents. +5. **Verify the login.** Use a fresh browser session to sign in with the same + local administrator username and new password, then check that monitoring + and agent connections remain intact. If it still fails, stop and reconcile + the effective source and any lockout; do not delete more state. Restore the + private backup through the same deployment path if you need to undo the + change. Remove the temporary password record after verification, + and retain or dispose of the backup under your normal credential policy. -**Proxmox LXC** (installed from the Proxmox shell): -Pulse runs inside the container, so run the same steps through `pct exec` on the Proxmox host. The binary needs its absolute path here, because `pct exec` runs with `PATH=/sbin:/bin:/usr/sbin:/usr/bin` and that does not include `/usr/local/bin`: +#### A fresh install's bootstrap token was missed -```bash -pct exec -- rm /etc/pulse/.env -pct exec -- systemctl restart pulse -pct exec -- /usr/local/bin/pulse bootstrap-token -``` - -If you only missed the token during a fresh install (no password set yet), skip the first two commands and just read it back with the last one. - -Treat the bootstrap token like a password: enter it only in the Pulse setup -screen for this instance and do not paste it into support requests or issue -reports. +If no local password has ever been set, there is nothing to reset. Follow +[first login](INSTALL.md#step-1-get-the-token) to read the existing bootstrap +token for that instance, using its actual data directory. Enter it only in the +Pulse setup screen, not in command arguments, URLs or issue reports. ### Port change didn't take effect @@ -605,9 +642,9 @@ success alone does not confirm the Pulse display has recovered. ### Recovery Mode -For a forgotten local password, follow [I forgot my password](#i-forgot-my-password) -above, using the steps for your deployment. Enter the host-only bootstrap token -in that instance's setup screen; do not paste it into a command or a report. +For a forgotten local administrator password, follow +[I forgot my password](#i-forgot-my-password) above to update its active +credential source without deleting configuration or repeating first-time setup. For OIDC, SAML or proxy login, use the identity-provider or administrator path described there instead. diff --git a/internal/config/password_recovery_docs_test.go b/internal/config/password_recovery_docs_test.go new file mode 100644 index 0000000000..d004ac36a6 --- /dev/null +++ b/internal/config/password_recovery_docs_test.go @@ -0,0 +1,94 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/stretchr/testify/require" +) + +// Exercise the documented existing-login recovery at the real configuration +// loader, not by re-running setup. This is not a native deployment/session test. +func TestPasswordRecoveryDocsLoadPreservesSettingsAndAgentToken(t *testing.T) { + dir := t.TempDir() + t.Setenv("PULSE_DATA_DIR", dir) + clearFileEnv := func() { + for _, key := range []string{"PULSE_AUTH_USER", "PULSE_AUTH_PASS", "FRONTEND_PORT"} { + require.NoError(t, os.Unsetenv(key)) + } + } + for _, key := range []string{"PULSE_AUTH_USER", "PULSE_AUTH_PASS", "FRONTEND_PORT"} { + // Register restoration even though dotenv sets these outside t.Setenv. + t.Setenv(key, "") + } + clearFileEnv() + + oldPassword, newPassword := "synthetic-old-password", "synthetic-new-password" + oldHash, err := auth.HashPassword(oldPassword) + require.NoError(t, err) + newHash, err := auth.HashPassword(newPassword) + require.NoError(t, err) + // Apache's bcrypt records use the compatible $2y$ prefix. + newHash = strings.Replace(newHash, "$2a$", "$2y$", 1) + require.Len(t, newHash, 60) + require.True(t, auth.CheckPasswordHash(newPassword, newHash)) + + envPath := filepath.Join(dir, ".env") + content := "PULSE_AUTH_USER='existing-admin'\nPULSE_AUTH_PASS='" + oldHash + "'\nFRONTEND_PORT=8765\n# preserve unrelated settings\n" + require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600)) + token, err := NewAPITokenRecord("synthetic-existing-agent-token", "Existing agent", []string{ScopeAgentReport}) + require.NoError(t, err) + persistence := NewConfigPersistence(dir) + require.NoError(t, persistence.SaveAPITokens([]APITokenRecord{*token})) + tokenPath := filepath.Join(dir, "api_tokens.json") + originalTokens, err := os.ReadFile(tokenPath) + require.NoError(t, err) + + cfg, err := LoadWithoutLoggingInit() + require.NoError(t, err) + require.True(t, auth.CheckPasswordHash(oldPassword, cfg.AuthPass)) + + // Model a private editor changing only the complete quoted hash. + updated := strings.Replace(content, oldHash, newHash, 1) + require.NoError(t, os.WriteFile(envPath, []byte(updated), 0o600)) + clearFileEnv() // A fresh process does not inherit the last dotenv load. + cfg, err = LoadWithoutLoggingInit() + require.NoError(t, err) + require.Equal(t, "existing-admin", cfg.AuthUser) + require.Equal(t, 8765, cfg.FrontendPort) + require.Equal(t, newHash, cfg.AuthPass) + require.True(t, auth.CheckPasswordHash(newPassword, cfg.AuthPass)) + require.False(t, auth.CheckPasswordHash(oldPassword, cfg.AuthPass)) + _, valid := cfg.ValidateAPIToken("synthetic-existing-agent-token") + require.True(t, valid) + gotTokens, err := os.ReadFile(tokenPath) + require.NoError(t, err) + require.Equal(t, originalTokens, gotTokens) + gotEnv, err := os.ReadFile(envPath) + require.NoError(t, err) + require.Equal(t, updated, string(gotEnv)) + + // The old deployment-supplied value still wins until its owning source is + // updated too: editing the mounted file alone cannot repair that install. + clearFileEnv() + t.Setenv("PULSE_AUTH_PASS", oldHash) + cfg, err = LoadWithoutLoggingInit() + require.NoError(t, err) + require.Equal(t, oldHash, cfg.AuthPass) + require.False(t, auth.CheckPasswordHash(newPassword, cfg.AuthPass)) + + // Restoring the saved file returns the former credential and port, without + // changing the persisted agent token. This models source rollback only. + clearFileEnv() + require.NoError(t, os.WriteFile(envPath, []byte(content), 0o600)) + cfg, err = LoadWithoutLoggingInit() + require.NoError(t, err) + require.True(t, auth.CheckPasswordHash(oldPassword, cfg.AuthPass)) + require.False(t, auth.CheckPasswordHash(newPassword, cfg.AuthPass)) + require.Equal(t, 8765, cfg.FrontendPort) + _, valid = cfg.ValidateAPIToken("synthetic-existing-agent-token") + require.True(t, valid) +} diff --git a/scripts/tests/test_password_recovery_docs.py b/scripts/tests/test_password_recovery_docs.py new file mode 100644 index 0000000000..2cfc7d1cfd --- /dev/null +++ b/scripts/tests/test_password_recovery_docs.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Check recovery guidance and execute its private hash recipe with a recorder. + +The recorder proves shell disclosure/permission/failure behaviour, not Apache +hash generation or native Docker/systemd password recovery. The connected Go +config controls exercise bcrypt acceptance, source precedence and token retention. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import stat +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +GUIDES = ("TROUBLESHOOTING.md", "FAQ.md", "DOCKER.md") +RECORD = "pulse-recovery:$2y$12$" + "a" * 53 + "\n" # Synthetic disclosure sentinel. + + +def recovery() -> str: + return (ROOT / "docs/TROUBLESHOOTING.md").read_text().split( + "### I forgot my password\n", 1)[1].split("### Port change", 1)[0] + + +def recipe() -> str: + matches = re.findall(r"```bash\n(.*?)```", recovery(), re.S) + if len(matches) != 1: + raise AssertionError("expected one private hash preparation recipe") + return matches[0] + + +HTPASSWD = '''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +assert sys.argv[1:] == ['-nB', '-C', '12', 'pulse-recovery'] +Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:])) +if os.environ.get('FAIL_HASH'): + print('hash preparation failed', file=sys.stderr) + sys.exit(23) +sys.stdout.write(os.environ['FIXTURE_RECORD']) +''' + + +class PasswordRecoveryDocsTest(unittest.TestCase): + def test_entry_points_do_not_delete_auth_or_restart_setup(self): + for name in GUIDES: + with self.subTest(guide=name): + text = (ROOT / "docs" / name).read_text() + self.assertNotRegex(text, r"(?:rm\s+/(?:data|etc/pulse)/\.env|Delete `/etc/pulse/\.env`)") + if name != "TROUBLESHOOTING.md": + self.assertIn("TROUBLESHOOTING.md#i-forgot-my-password", text) + text = recovery() + self.assertIn("Replace only the password", text) + self.assertIn("setup replaces the primary API token", text) + self.assertIn("same\n local administrator username", text) + + def test_precedence_apply_and_failure_guidance_matches_current_sources(self): + text = " ".join(recovery().split()) + for expected in ("take precedence", "pulse.service.d/override.conf", + "pulse-backend.service.d", "PULSE_DATA_DIR", + "inside the Pulse container", "owner-only backup", + "complete 60-character hash", "literal `$`", + "not just `docker restart`", "same image, mounted data", + "reload the service manager", "If it still fails, stop", + "existing sessions and API tokens need separate review", + "If no local password has ever been set"): + with self.subTest(expected=expected): + self.assertIn(expected, text) + setup = (ROOT / "internal/api/security_setup_fix.go").read_text() + self.assertIn('/etc/systemd/system/%s.service.d/override.conf', setup) + self.assertIn('r.config.APITokens = []config.APITokenRecord{*tokenRecord}', setup) + loader = (ROOT / "internal/config/config.go").read_text() + self.assertIn('godotenv.Load(envFile)', loader) + self.assertIn('normalizeEnvAuthPassword(authPass)', loader) + + def run_recipe(self, directory: Path, *, fail: bool = False): + tools = directory / "tools" + tools.mkdir() + tool = tools / "htpasswd" + tool.write_text(HTPASSWD) + tool.chmod(0o700) + env = {**os.environ, "TMPDIR": str(directory), + "PATH": f"{tools}:{os.environ['PATH']}", + "ARGV_RECEIPT": str(directory / "argv.json"), + "FIXTURE_RECORD": RECORD, "FAIL_HASH": "yes" if fail else ""} + result = subprocess.run(["bash", "-c", recipe()], env=env, + capture_output=True, text=True, timeout=10) + self.assertNotIn(RECORD.strip(), result.stdout + result.stderr) + self.assertNotIn(RECORD.split(":", 1)[1].strip(), result.stdout + result.stderr) + self.assertEqual(json.loads((directory / "argv.json").read_text()), + ['-nB', '-C', '12', 'pulse-recovery']) + records = list(directory.glob("pulse-password.*/password-record")) + self.assertEqual(len(records), 1) + record = records[0] + self.assertEqual(stat.S_IMODE(record.parent.stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(record.stat().st_mode), 0o600) + return result, record + + def test_copied_recipe_keeps_hash_out_of_output_and_arguments(self): + # Include spaces in the directory to exercise the copied quoting. + with tempfile.TemporaryDirectory(prefix="password docs ") as temporary: + result, record = self.run_recipe(Path(temporary)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(record.read_text(), RECORD) + self.assertEqual(result.stdout, + f"Private password record saved in {record}\n") + + def test_hash_failure_stops_before_success_and_retains_private_partial_file(self): + with tempfile.TemporaryDirectory() as temporary: + result, record = self.run_recipe(Path(temporary), fail=True) + self.assertEqual(result.returncode, 23) + self.assertEqual(result.stdout, "") + self.assertEqual(record.read_text(), "") + self.assertIn("hash preparation failed", result.stderr) + + def test_recovery_guides_match_shipped_copies(self): + for name in GUIDES: + with self.subTest(guide=name): + self.assertEqual((ROOT / "docs" / name).read_bytes(), + (ROOT / "frontend-modern/public/docs" / name).read_bytes()) + subprocess.run(["bash", "-n", "-c", recipe()], check=True, capture_output=True) + + +if __name__ == "__main__": + unittest.main() From 480be082887b3b6434997a996892b0bd0e6a29fe Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:49:44 +0100 Subject: [PATCH 39/86] Preserve REST operator evidence and readable platform rows Adopt the five frontend maintenance constituents of #2426 against current source, retaining PMG counters, source health, server action refusals and technology through REST projection. Pin stale, failed and removed evidence at the real lifecycle consumer without inventing command grants. Preserve native storage vocabulary and shared-location identity, expose the fullest measured disk in Bars, and keep Docker phone columns and disclosure evidence usable. The production-component browser pass caught a clipped phone Swarm rollout label; wrap that state and compact the Running header, preserving raw keyboard/touch disclosure evidence. Include dependent subsystem contracts, connected regressions and the owning-parent browser receipt. The unchanged dependency advisory and returned backend compiler/native proof remain separate unfinished work. Original-PR: https://github.com/rcourtman/Pulse/pull/2426 Original-Commit: b08abe7471bdc07a3393f32e6ebc37531c56fee7 Original-Commit: 04d76e684ca6b3d7111794ad991545bc6aba56b7 Original-Commit: fad69433a144f943637b90c2c8c50f1e20a45600 Original-Commit: 563506fd6b7da77a563f4b93db662a0e4b5f088f Original-Commit: c2778fba1ee5b3ab9723617334f5d1fdbedeb549 Co-authored-by: rcourtman <8825017+rcourtman@users.noreply.github.com> Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 22 + .../subsystems/performance-and-scalability.md | 15 + .../internal/subsystems/storage-recovery.md | 14 + .../internal/subsystems/unified-resources.md | 23 + .../browser-tests/proxmox-docker-rows.cjs | 481 ++++++++++++++++++ .../browser-tests/proxmox-docker-rows.html | 12 + .../browser-tests/proxmox-docker-rows.tsx | 182 +++++++ frontend-modern/browser-verification.json | 130 ++--- .../components/Workloads/StackedDiskBar.tsx | 69 ++- .../__tests__/StackedDiskBar.test.tsx | 32 ++ ...stackedDiskBarModel.branchcov0712b.test.ts | 4 + .../Workloads/stackedDiskBarModel.ts | 8 + .../src/features/docker/DockerImagesTable.tsx | 14 +- .../features/docker/DockerServicesTable.tsx | 12 +- .../src/features/docker/DockerTasksTable.tsx | 10 +- .../__tests__/DockerNativeTables.test.tsx | 14 +- .../dockerContainerTableModel.test.ts | 4 +- .../docker/dockerContainerTableModel.ts | 30 +- .../platformOverviewLayout.guardrails.test.ts | 2 +- .../resourceStoragePresentation.test.ts | 8 + .../__tests__/storageAdapters.test.ts | 31 ++ .../resourceStoragePresentation.ts | 24 + .../storageBackups/storageAdapters.ts | 11 +- .../__tests__/useUnifiedResources.test.ts | 168 ++++++ .../src/hooks/useUnifiedResources.ts | 15 + 25 files changed, 1199 insertions(+), 136 deletions(-) create mode 100644 frontend-modern/browser-tests/proxmox-docker-rows.cjs create mode 100644 frontend-modern/browser-tests/proxmox-docker-rows.html create mode 100644 frontend-modern/browser-tests/proxmox-docker-rows.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 930119719d..a51a26ce5a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -805,6 +805,28 @@ that manual-channel consequence must stay visible at desktop and narrow widths. ## Shared Boundaries +### Transport-independent operator rows + +Proxmox/Storage and Docker presenters retain the shared table/detail, responsive +column weighting, relative-time and governed action primitives. Display names +for Proxmox storage plugins preserve their operator vocabulary (LVM-Thin, NFS, +SMB/CIFS and the other supported acronyms); only explicitly shared storage on a +synthetic cluster/shared location gets the shared label. That label does not +alter canonical identity, parent, History target or pool-to-disk ownership. + +The Docker image phone projection pairs hidden headers and cells and gives +measured size and update verdict room without hiding identity or dropping the +existing detail disclosure. The container projection retains its shared 30% +phone identity reservation and five scan signals; off-row restart/host/consumer +evidence remains in detail. Swarm rollout tokens read as words, with compact +Running headings and wrapping rollout states on phones; raw rollout and +placement evidence remain reachable through keyboard/touch row disclosure. +`platformOverviewLayout.guardrails.test.ts`, mounted +Docker table tests and the desktop/touch-phone production-component browser +fixture check these priorities, rather than treating an old percentage as an +immutable primitive contract. No new public route or navigation entry is added. + + ### Maintenance schedule copy Operator maintenance banners show local absolute start and end timestamps, diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 045e6db6a8..ecccb3fe0a 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -311,6 +311,21 @@ the existing bounded HTTP client and artifact-size checks. ## Shared Boundaries +### Multi-disk Bars summary + +The existing vertical-bars workload/node disk cell shows the fullest measured +disk's percentage beside equal-weight per-disk micro-bars. Its title retains +the actual mount and percentage. Unknown/invalid usage cannot produce a new +summary; an aggregate fallback must not masquerade as measured member usage. +The displayed maximum uses the existing shared disk measurement and threshold +logic; no collector, sorting key, metric units, History request, resampling or +performance threshold changes. `StackedDiskBar.test.tsx` and the model branch +controls cover real/unknown measurements and unchanged per-member bars. +`browser-tests/proxmox-docker-rows.cjs` checks the actual label, bars and title +at desktop and phone widths; this is presentation proof, not native collection +or a measured fleet performance improvement. + + 1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. 3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 6726bf78c3..ef5654ebc6 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -400,6 +400,20 @@ command-capable profile. ## Shared Boundaries +### Operator storage vocabulary without identity changes + +Storage topology names preserve Proxmox plugin vocabulary, including LVM-Thin, +NFS, SMB/CIFS, iSCSI, ZFS and BTRFS. Explicit topology overrides still win. +Only a storage explicitly marked shared whose retained location is the synthetic +cluster/shared marker reads Shared, with the supplied reporting-node count when +more than one node is present. Node-local storage and shared storage with a +real native location keep that location. This is host-label presentation, not +resource consolidation: IDs, parent/node hints, ownership evidence, metrics +targets, alerts, health and recovery authority are unchanged. Adapter/presentation +regressions and the production storage table browser fixture verify labels +without treating shared capacity as a backup or demonstrating native recovery. + + - After password configuration has been persisted and the runtime local-admin identity changes, authorizer synchronisation must also occur if subsequent API-token persistence fails. Development first-run reset must clear the configured-admin bypass only on successful auth reset, not on its persistence-failure rollback path. No role-store deletion or migration is part of this recovery. ### PBS host history correlation diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 93e6fa2f6b..c93d87e6c8 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -617,6 +617,29 @@ about the same disk cannot diverge. ## Shared Boundaries +### REST facets and container table projection + +The unified-resource REST mapper carries PMG metadata, per-source status, +server action-readiness refusals and technology at the same top-level paths +used by canonical websocket rows. Existing platformData compatibility paths +remain; transport order must not decide whether mail counters or lifecycle +refusal explanations are present. A replaced REST snapshot clears removed +facets instead of retaining old counters or refusal reasons. Mapping does not +invent capabilities or bypass planning, review, server authorisation or execution. +`useUnifiedResources.test.ts` checks actual lifecycle refusal consumers for stale, +failed and server-denied observations alongside a no-invented-grant control. + +The Docker phone container projection keeps identity, state when applicable, +CPU, memory and update; restart counts remain in the existing detail expansion +and return at mobile width. Images retain image, size and update verdict on +phones; host and consumers stay reachable in detail. Hidden header/body columns +are symmetric. Swarm rollout tokens render as words with raw state retained in +the title; task starts use the shared relative-time primitive with their exact +timestamp retained. Native table/model/guardrail tests and the production +`browser-tests/proxmox-docker-rows.cjs` exercise these surfaces without executing +a container action or claiming native appliance or release acceptance. + + ### PBS host history correlation PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target. diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs new file mode 100644 index 0000000000..5ca4cc46f1 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -0,0 +1,481 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +const now = '2026-10-03T12:00:00Z'; +const base = (id, type, extra = {}) => ({ + id, + type, + name: id, + status: 'online', + lastSeen: now, + sources: ['docker'], + ...extra, +}); +const observations = (mode) => [ + base('mail-gateway-eu', 'pmg', { + sources: ['pmg'], + technology: 'pmg', + uptime: 86400, + sourceStatus: { pmg: { status: 'online', lastSeen: now } }, + ...(mode === 'missing' + ? {} + : { + pmg: { + instanceId: 'pmg-eu', + hostname: 'mail-gateway-eu', + version: '8.1-2', + nodeCount: 2, + mailCountTotal: 2740, + spamIn: 321, + virusIn: 14, + quarantine: 3, + queueTotal: mode === 'fresh' ? 19 : 11, + queueDeferred: 5, + }, + }), + }), + base('nfs-iso', 'storage', { + sources: ['proxmox'], + parentName: 'cluster', + parentId: 'pve-cluster-a', + technology: 'nfs', + metrics: { disk: { total: 1e12, used: 3e11, percent: 30 } }, + storage: { type: 'nfs', shared: true, nodes: ['pve1', 'pve2', 'pve3'] }, + }), + base('local-lvm', 'storage', { + sources: ['proxmox'], + parentName: 'pve2', + parentId: 'pve2', + metrics: { disk: { total: 1e12, used: 6e11, percent: 60 } }, + storage: { type: 'lvmthin', shared: false }, + }), + base('edge-web', 'app-container', { + status: 'running', + technology: 'docker', + sourceStatus: { docker: { status: 'online', lastSeen: now } }, + actionReadiness: [{ name: 'restart', available: false, reason: 'Command agent disconnected' }], + metrics: { cpu: { percent: 42 }, memory: { percent: 50, used: 5e8, total: 1e9 } }, + docker: { + agentId: 'agent-edge', + hostSourceId: 'agent-edge', + containerId: 'native-edge-web', + hostname: 'edge-long-hostname', + runtime: 'docker', + runtimeVersion: '27.5.1', + image: 'nginx:latest', + containerState: 'running', + restartCount: 7, + updateStatus: { + updateAvailable: true, + currentDigest: 'sha256:current', + latestDigest: 'sha256:latest', + lastChecked: now, + }, + }, + }), + base('edge-cache', 'app-container', { + status: 'offline', + metrics: { cpu: { percent: 0 }, memory: { percent: 0 } }, + docker: { + agentId: 'agent-edge', + hostname: 'edge-long-hostname', + runtime: 'podman', + runtimeVersion: '5.2.1', + image: 'redis:7.4', + containerState: 'exited', + restartCount: 0, + }, + }), + base('nginx:latest', 'docker-image', { + docker: { + hostname: 'edge-long-hostname', + imageId: 'image-nginx', + repoTags: ['nginx:latest'], + sizeBytes: 2e8, + containers: ['edge-web'], + containerCount: 1, + }, + }), + base('web-service', 'docker-service', { + docker: { + hostname: 'edge-long-hostname', + image: 'nginx:latest', + serviceMode: 'replicated', + desiredTasks: 4, + runningTasks: 2, + mode: 'replicated', + serviceUpdate: { state: 'rollback_started', message: 'Synthetic rollout observation' }, + }, + }), + base('web.2', 'docker-task', { + docker: { + hostname: 'edge-long-hostname', + serviceName: 'web-service', + nodeName: 'worker-1', + taskState: 'running', + desiredState: 'running', + startedAt: '2026-10-03T11:30:00.123456Z', + }, + }), +]; + +(async () => { + const root = '/workspace/frontend-modern'; + const output = '/workspace/tmp/proxmox-docker-rows-verified'; + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwright = require('playwright/package.json').version; + assert.equal( + playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json'))); + const hashes = Object.fromEntries( + paths.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join('/workspace', p))) + .digest('hex'), + ]), + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5272, strictPort: true }, + }); + const results = [], + screenshots = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width] of [ + ['desktop', chromium, 1440], + ['phone', webkit, 390], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + isMobile: width === 390, + hasTouch: width === 390, + locale: 'en-GB', + timezoneId: 'UTC', + }); + await page.clock.setFixedTime(new Date(now)); + page.setDefaultTimeout(15000); + const checks = [], + errors = [], + offOrigin = [], + mutations = []; + let mode = 'initial'; + page.on('pageerror', (e) => errors.push(e.message)); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5272') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (request.method() !== 'GET') { + mutations.push({ path: url.pathname, method: request.method() }); + return route.fulfill({ status: 409, json: { error: 'No fixture mutation' } }); + } + if (url.pathname === '/api/resources') + return route.fulfill({ json: { data: observations(mode) } }); + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + blocked_capabilities: [], + runtime: { build: 'community' }, + }, + }); + if (url.pathname === '/api/security/status') + return route.fulfill({ json: { hasAuthentication: true, requiresAuth: true } }); + return route.fulfill({ + json: { + enabled: false, + data: [], + capabilities: [], + facets: [], + relationships: [], + recentChanges: [], + alerts: {}, + }, + }); + }); + const check = async (label, run) => { + try { + await run(); + checks.push({ label, passed: true }); + } catch (e) { + checks.push({ label, passed: false, error: e.message }); + } + }; + const select = (view) => + page + .getByRole('navigation', { name: 'Fixture views' }) + .getByRole('button', { name: view, exact: true }) + .click(); + const capture = async (state) => { + await page.waitForTimeout(180); // ResizeObserver and entrance layout, not freshness evidence. + const dimensions = await page.evaluate(() => ({ + width: innerWidth, + scroll: document.documentElement.scrollWidth, + })); + checks.push({ + label: `No horizontal page overflow: ${state}`, + passed: dimensions.scroll <= dimensions.width + 1, + dimensions, + }); + const file = `${name}-${state}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + screenshots.push({ file, state, engine: name, width, dimensions }); + }; + await page.goto('http://127.0.0.1:5272/browser-tests/proxmox-docker-rows.html', { + waitUntil: 'domcontentloaded', + timeout: 120000, + }); + if (width === 390) await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByText('8 REST-mapped rows', { exact: true }).waitFor(); + await check( + 'REST-first PMG counters and facets are present before any websocket hydration', + async () => { + const table = page.getByRole('region', { name: 'Mail table' }); + await table.getByText('2,740', { exact: true }).waitFor(); + await table.getByText('11', { exact: true }).waitFor(); + const row = await page.evaluate(() => + window.__operatorRows.rows().find((r) => r.type === 'pmg'), + ); + assert.equal(row.technology, 'pmg'); + assert.equal(row.sourceStatus.pmg.status, 'online'); + assert.equal(row.pmg.queueTotal, 11); + assert.deepEqual(row.pmg, row.platformData.pmg); + if (width > 390) await table.getByText('8.1-2', { exact: true }).waitFor(); + }, + ); + await capture('rest-mail'); + await check( + 'Canonical store hydration followed by REST refresh keeps the new PMG counters', + async () => { + await page.evaluate(() => window.__operatorRows.seedWebsocket()); + mode = 'fresh'; + await page.evaluate(() => window.__operatorRows.refresh()); + await page + .getByRole('region', { name: 'Mail table' }) + .getByText('19', { exact: true }) + .waitFor(); + }, + ); + await capture('refreshed-mail'); + await check('A removed REST PMG facet does not retain obsolete queue evidence', async () => { + mode = 'missing'; + await page.evaluate(() => window.__operatorRows.refresh()); + assert.equal( + await page + .getByRole('region', { name: 'Mail table' }) + .getByText('19', { exact: true }) + .count(), + 0, + ); + const row = await page.evaluate(() => + window.__operatorRows.rows().find((r) => r.type === 'pmg'), + ); + assert.equal(row.pmg, undefined); + mode = 'fresh'; + await page.evaluate(() => window.__operatorRows.refresh()); + }); + await select('Storage'); + await check('Shared storage vocabulary preserves native locations and IDs', async () => { + const table = page.getByRole('region', { name: 'Storage table' }); + await table.getByText('NFS', { exact: true }).waitFor({ state: 'attached' }); + await table.getByText('LVM-Thin', { exact: true }).waitFor({ state: 'attached' }); + await table.getByText('Shared ยท 3 nodes', { exact: true }).waitFor({ state: 'attached' }); + await table.getByText('pve2', { exact: true }).waitFor({ state: 'attached' }); + const rows = await page.evaluate(() => + window.__operatorRows.rows().filter((r) => r.type === 'storage'), + ); + assert.deepEqual( + rows.map((r) => [r.id, r.parentId]), + [ + ['nfs-iso', 'pve-cluster-a'], + ['local-lvm', 'pve2'], + ], + ); + }); + await capture('storage'); + await select('Containers'); + await check( + 'REST lifecycle refusal remains available; phone row keeps update and moves restarts to detail', + async () => { + await page + .locator('[data-lifecycle-refusal]') + .getByText('Command agent disconnected', { exact: true }) + .waitFor(); + const table = page.getByRole('region', { name: 'Container table' }); + const update = table.locator('.docker-container-update-cell button'); + await update.waitFor(); + assert.match(await update.innerText(), /Update/); + const bounds = await update.evaluate((e) => ({ + w: e.getBoundingClientRect().width, + cell: e.closest('td').getBoundingClientRect().width, + scroll: e.scrollWidth, + client: e.clientWidth, + })); + assert.ok( + bounds.w <= bounds.cell + 1 && bounds.scroll <= bounds.client + 1, + JSON.stringify(bounds), + ); + const headers = await table.locator('th:visible').allTextContents(); + if (width === 390) { + assert.ok(!headers.some((h) => h.includes('Restarts'))); + assert.ok(headers.some((h) => h.includes('State'))); + } + }, + ); + await capture('containers'); + await check( + 'Restart count and native identity remain reachable via row disclosure', + async () => { + const row = page.locator('[data-docker-container-row="edge-web"]'); + if (width === 390) await row.locator('td').first().tap(); + else await row.getByRole('button', { name: /details/i }).click(); + const detail = page.getByTestId('resource-docker-container-section'); + await detail.getByText('Restarts', { exact: true }).waitFor(); + await detail.getByText('7', { exact: true }).waitFor(); + }, + ); + await capture('container-detail'); + await select('Images'); + await check('Image verdict fits; phone hidden headers and cells are symmetric', async () => { + const table = page.getByRole('region', { name: 'Image table' }); + await table.getByText('nginx:latest', { exact: true }).waitFor(); + const headers = await table.locator('th:visible').allTextContents(); + const row = table.locator('[data-docker-image-row]'); + assert.equal(await row.locator('td:visible').count(), headers.length); + if (width === 390) { + assert.ok(!headers.some((h) => h.includes('Host') || h.includes('Used by'))); + assert.ok(headers.some((h) => h.includes('Size'))); + } + }); + await capture('images'); + await select('Swarm'); + await check( + 'Swarm state and started time use operator wording with raw evidence retained', + async () => { + const state = page.getByText('Rollback started', { exact: true }); + await state.waitFor({ state: 'attached' }); + assert.match( + await state.locator('xpath=ancestor-or-self::*[@title][1]').getAttribute('title'), + /rollback_started/, + ); + const visibleState = await state.evaluate((element) => { + const cell = element.closest('td').getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(element); + return [...range.getClientRects()].every( + (line) => + line.left >= cell.left && + line.right <= cell.right && + line.top >= cell.top && + line.bottom <= cell.bottom, + ); + }); + assert.equal(visibleState, true, 'Every word of the rollout state must fit its cell'); + if (width === 390) { + await page.getByText('Run', { exact: true }).waitFor({ state: 'visible' }); + } + const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]'); + await started.waitFor({ state: 'attached' }); + assert.match(await started.innerText(), /ago/); + }, + ); + await capture('swarm'); + await check( + 'Swarm touch or keyboard disclosure exposes raw rollout and placement evidence', + async () => { + const row = page.locator('[data-docker-service-row="web-service"]'); + if (width === 390) await row.locator('td').first().tap(); + else { + const toggle = row.getByRole('button', { + name: 'Expand details for web-service', + exact: true, + }); + await toggle.focus(); + await page.keyboard.press('Enter'); + } + const detail = page.locator('[data-inline-docker-service-detail-for="web-service"]'); + await detail + .getByText('rollback_started | Synthetic rollout observation', { exact: true }) + .waitFor(); + await detail.getByText('edge-long-hostname', { exact: true }).waitFor(); + await detail.getByText('4', { exact: true }).waitFor(); + await detail.getByText('2', { exact: true }).waitFor(); + }, + ); + await capture('swarm-detail'); + await select('Disk'); + await check( + 'Multi-disk Bars show the measured fullest disk and unchanged equal-weight bars', + async () => { + const label = page.locator('[data-stacked-disk-max-label]'); + assert.equal(await label.innerText(), '92%'); + assert.equal(await label.getAttribute('title'), 'Highest usage: /data 92%'); + assert.equal(await page.locator('[data-stacked-disk-fill="vertical"]').count(), 2); + }, + ); + await capture('disks'); + checks.push({ + label: 'No page errors, external requests or mutation requests', + passed: !errors.length && !offOrigin.length && !mutations.length, + errors, + offOrigin, + mutations, + }); + results.push({ engine: name, version: browser.version(), width, checks }); + await browser.close(); + browser = undefined; + } + } finally { + if (browser) await browser.close(); + await server.close(); + } + const result = { + playwright, + verified_at: new Date().toISOString(), + paths, + hashes, + results, + screenshots, + }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2)); + const failures = results.flatMap((r) => + r.checks.filter((c) => !c.passed).map((c) => ({ engine: r.engine, ...c })), + ); + console.log( + JSON.stringify({ + checked: results.reduce((n, r) => n + r.checks.length, 0), + screenshots: screenshots.length, + failures, + }), + ); + assert.equal(failures.length, 0, JSON.stringify(failures)); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.html b/frontend-modern/browser-tests/proxmox-docker-rows.html new file mode 100644 index 0000000000..5e96968d98 --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-docker-rows.html @@ -0,0 +1,12 @@ + + + + + + Transport facets and operator rows verification + + +
+ + + diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.tsx b/frontend-modern/browser-tests/proxmox-docker-rows.tsx new file mode 100644 index 0000000000..2f0285c0be --- /dev/null +++ b/frontend-modern/browser-tests/proxmox-docker-rows.tsx @@ -0,0 +1,182 @@ +// Synthetic REST observations through the real hook, tables, detail disclosures +// and CSS. No real websocket, appliance or installed/released acceptance claim. +import { createSignal, Show } from 'solid-js'; +import { createStore, unwrap } from 'solid-js/store'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { useUnifiedResources } from '../src/hooks/useUnifiedResources'; +import { ProxmoxMailGatewayTable } from '../src/features/proxmox/ProxmoxMailGatewayTable'; +import { DockerContainersTable } from '../src/features/docker/DockerContainersTable'; +import { DockerImagesTable } from '../src/features/docker/DockerImagesTable'; +import { DockerServicesTable } from '../src/features/docker/DockerServicesTable'; +import { DockerTasksTable } from '../src/features/docker/DockerTasksTable'; +import { StackedDiskBar } from '../src/components/Workloads/StackedDiskBar'; +import { StoragePoolsTable } from '../src/components/Storage/StoragePoolsTable'; +import { Table, TableBody, TableRow, TableCell } from '../src/components/shared/Table'; +import { getDockerContainerLifecycleDisabledReason } from '../src/features/docker/dockerContainerLifecycleActions'; +import { buildStorageRecords } from '../src/features/storageBackups/storageAdapters'; +import { groupStorageRecords } from '../src/features/storageBackups/storageModelCore'; +import { EMPTY_STORAGE_ALERT_STATE } from '../src/features/storageBackups/storageAlertState'; +import { DarkModeContext, WebSocketContext } from '../src/contexts/appRuntime'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +const [wsState, setWsState] = createStore({ + resources: [] as Resource[], + lastUpdate: 0, + nodes: [], + pbs: [], + pmg: [], + vms: [], + containers: [], +}); +const [connected, setConnected] = createSignal(false); +const ws = { + state: wsState, + connected, + initialDataReceived: () => true, + resourceChange: () => ({ version: wsState.lastUpdate, changedIds: null, changedKeys: null }), + activeAlerts: () => ({}), + shutdown: () => {}, +}; +window.__pulseWsStore = ws as any; + +function Fixture() { + const [view, setView] = createSignal('Mail'); + const [expandedPool, setExpandedPool] = createSignal(null); + const rows = useUnifiedResources({ query: '', cacheKey: 'operator-rows-browser' }); + const ofType = (type: string) => rows.resources().filter((row) => row.type === type); + const container = () => ofType('app-container')[0]; + const records = () => + buildStorageRecords({ state: wsState as any, resources: ofType('storage') }); + const grouped = () => groupStorageRecords(records(), 'none'); + (window as any).__operatorRows = { + rows: () => JSON.parse(JSON.stringify(unwrap(rows.resources()))), + refresh: () => rows.refetch(), + seedWebsocket: () => { + setWsState('resources', JSON.parse(JSON.stringify(unwrap(rows.resources())))); + setWsState('lastUpdate', Date.now()); + setConnected(true); + }, + }; + const nativeProps = { + emptyIcon: , + emptyTitle: 'No fixture rows', + emptyDescription: 'No fixture rows', + showToolbar: false, + }; + return ( + + document.documentElement.classList.contains('dark')}> +
+

Transport facets and operator rows

+ +

{rows.resources().length} REST-mapped rows

+ +
+ +
+
+ +
+ {}} + expandedPoolId={expandedPool()} + setExpandedPoolId={setExpandedPool} + storageGrowthBySeriesId={new Map()} + storageGrowthColumnLabel="Growth" + physicalDisks={[]} + nodeOnlineByLabel={new Map()} + highlightedRecordId={null} + getRecordAlertState={() => EMPTY_STORAGE_ALERT_STATE} + isLoading={false} + /> +
+
+ +
+

+ {container() && getDockerContainerLifecycleDisabledReason(container(), 'restart')} +

+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+ +
+ + + + pve-edge + + + + + +
+
+
+
+
+
+ ); +} +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 34fd143392..9ccfa044bd 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,101 +1,79 @@ { "version": 1, "result": "passed", - "base_sha": "697ad83e19fa5e8afee634c212c945600d2292b4", - "verified_at": "2026-10-03T06:30:17.630790Z", + "base_sha": "7127054c659913b75abe302c5d377b8b6f49e9f2", + "verified_at": "2026-10-03T08:47:10.724Z", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", - "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts", - "frontend-modern/src/components/Settings/UpdateHistorySection.tsx", - "frontend-modern/src/components/Storage/DiskDetail.tsx", - "frontend-modern/src/components/Storage/DiskList.tsx", - "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx", - "frontend-modern/src/components/Storage/StoragePoolRow.tsx", - "frontend-modern/src/components/Storage/StoragePoolsTable.tsx", - "frontend-modern/src/components/Storage/useDiskDetailModel.ts", - "frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts", - "frontend-modern/src/components/Workloads/guestRowModel.tsx", - "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx", - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", - "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", - "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", - "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", - "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts", - "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts", - "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", - "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", - "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts", - "frontend-modern/src/features/storageBackups/diskPresentation.ts", - "frontend-modern/src/features/storageBackups/rowPresentation.ts", - "frontend-modern/src/features/storageBackups/storageBarPresentation.ts", - "frontend-modern/src/features/storageBackups/storagePagePresentation.ts" + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", + "frontend-modern/src/features/docker/DockerImagesTable.tsx", + "frontend-modern/src/features/docker/DockerServicesTable.tsx", + "frontend-modern/src/features/docker/DockerTasksTable.tsx", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts", + "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts", + "frontend-modern/src/features/storageBackups/storageAdapters.ts", + "frontend-modern/src/hooks/useUnifiedResources.ts" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "5356c4527e5bccaf86d1dbcaf808cdbeacfce05c8bd58879ab8f90287db1cca8", - "frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts": "79a9a4414958bbd3034bc145ea5f5b5a1f58a52af4ff8de8b46f18888f5aed51", - "frontend-modern/src/components/Settings/UpdateHistorySection.tsx": "39e7f5daa49bd18fd71d91a09a06da47ec3abc5496108d3715020180ca439e52", - "frontend-modern/src/components/Storage/DiskDetail.tsx": "04e5bacb4900cc77a94fdb804363048ed53f4b84dc1c0e9610878a106a1db1a6", - "frontend-modern/src/components/Storage/DiskList.tsx": "47da15179f6c7e98583d9db2a858790233660f8dc4eb9ea85681f262b50514f5", - "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx": "ed72a33e754e96ebd03374c34f9732b02411cd9055667f4e0051d6f7e66ab653", - "frontend-modern/src/components/Storage/StoragePoolRow.tsx": "09417822bd543ddbc312d0c15a4ea74971066d6623d421d889858e56262b9d3d", - "frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "7b61e9fac5e3a9fbae831a3cf907a71f1be7158f06bfe7a9fe7d16fce7a2c928", - "frontend-modern/src/components/Storage/useDiskDetailModel.ts": "ee5059bb3a7e0069561e4f103e2fd89101e096ad32b3b27c6f4777c26c1388f8", - "frontend-modern/src/components/Storage/useEnhancedStorageBarModel.ts": "d03daecfe22ede32e46edc74a20e4ac11d732d4f9ca202c0b6f36c22a590aa44", - "frontend-modern/src/components/Workloads/guestRowModel.tsx": "7787854145257e13bc347c2513f89e595060e606857a9a7c4ae6c0a897b6c639", - "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "f6f1b090580e4bea0d6496b908f72049c886c4faeee7aca3a3b11e9417c67df7", - "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "adbae773cc485f5dc16d1d316bbd5e1b1ace6be24404861ce02bf7dd8890bb25", - "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "786be66c40d33006edc1eda234df083ee68e0296103882ff108711026dd2ea29", - "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "6a546a5c1e8a9bbe6a2bdeac61c988a8e9f35cf96888adff245a5efd7017769e", - "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "fdc9fc2720ed04771f5c9d271f8972a4dece25e165064183fcf59678466a970e", - "frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "b872437fb4cfb61b07e0c949ae6f323d6fd3f2622e57480931f7ef74fafe79c2", - "frontend-modern/src/features/proxmox/proxmoxBackupsTablePresentation.ts": "dc2641e74f12ed01b5e2a1424f2223c8ca136fa4e0321dd49a79e5ce458fc3cd", - "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "2ab0f6c58e18337f05ea85082ed5c465a625c5388ff82987eec41bbbe3005915", - "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "4aab84754245dda70c3b3be3e81b15875a8664d38c8d0325329b2074b6c26fd5", - "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "4366372e6acf67ac62e03a2c9c34e599d304724a1c79e17b60503bdae9aa6fe3", - "frontend-modern/src/features/storageBackups/diskPresentation.ts": "fc637ecfee6ffff80ee826bdb8ff546ef0dd8ffa5cd0790c19b1fdcf71dcfb65", - "frontend-modern/src/features/storageBackups/rowPresentation.ts": "cabd37e3f736467aecff68359fcb97be08bcabbe5c5ec6e0a0da2fe67d2e3261", - "frontend-modern/src/features/storageBackups/storageBarPresentation.ts": "562eac2212378e98823717f712c791133cebf1f44bd71c75c7db097b244f6cc0", - "frontend-modern/src/features/storageBackups/storagePagePresentation.ts": "2a93a0176b912283c77b384ee2c66c4bc62c21a04b645eb93917a41740ad2d6e" + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "54f1f84dac768c6e0e333254d5c65e59751fa78b5ac55826cdaa37085a73c608", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6", + "frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613", + "frontend-modern/src/features/docker/DockerServicesTable.tsx": "5a153e4b3533849ae952dadb50e512fadb87fcc9fba05403509168d8bb5caba6", + "frontend-modern/src/features/docker/DockerTasksTable.tsx": "58cf1656ba22475b52dc9a582a4d8f0cc8c01f38bfb3775518edac73dbf22b80", + "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72", + "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2", + "frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48", + "frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6" }, "routes": [ - "Existing storage pool and physical-disk tables / disk expansion", - "Existing Kubernetes controller table / production resource summary expansion", - "Existing Proxmox coverage, By date, PBS/node tables and guest row", - "Existing Settings Update History confirmation" + "Existing Proxmox Mail Gateway table through production REST mapper", + "Existing storage-pool table through production adapter", + "Existing Docker container, image, Swarm service and task tables", + "Existing multi-disk Bars cell" ], "viewports": [ { - "width": 1365, + "width": 1440, "height": 900 }, { "width": 390, "height": 844 - }, - { - "width": 768, - "height": 900 } ], "states": [ - "Completed backup stays 13d old alongside a separate fresh 30m guest snapshot; missing independent backup remains unit-covered", - "Structured ZFS scrub/resilver activity badges render at desktop/intermediate width; phone retains Degraded/Online and measured 59% capacity", - "Disk table verdict keeps full SMART reason in the expanded header without phone overflow", - "Job and CronJob absolute timestamps, elapsed duration, target, schedule, suspension and last-success remain reachable from phone row expansion", - "Consent names running and selected historical versions separately and declares absent restore scope; legacy and installation-only effects are distinguished", - "Rejected rollback keeps consent and error toast; pending actions cannot be duplicated or cancelled", - "All sixteen final screenshots inspected; Playwright 1.56.1 matches integration lock, light desktop/intermediate Chromium and dark touch phone WebKit" + "PMG REST-first queue 11, canonical in-memory store hydration then newer REST queue 19, removed PMG facet withdrawn", + "LVM-Thin and NFS plugin names; explicit shared cluster location reads Shared (3), local node remains pve2", + "Phone container identity, State/CPU/memory/Updates remain visible; Restarts 7 reachable by touch disclosure", + "Image phone projection keeps size and verdict; Host/Used by headers and cells hidden together", + "Swarm raw rollback_started reads as Rollback started; every word fits phone cell and Running heading uses Run; raw rollout and placement in keyboard/touch disclosure", + "Task started time reads as relative time on desktop with original timestamp retained; phone hides the started column", + "Fullest measured disk label 92% with /data evidence and unchanged two equal-weight member bars" ], "interactions": [ - "Expand/collapse real resource and disk rows", - "Open consent using keyboard Enter, cancel and return focus without a rollback write", - "Reject one synthetic rollback POST, then hold/accept a second while checking disabled duplicate/cancel and Escape behaviour", - "Select existing backup projections with independent archive and guest snapshot dates" + "REST-first load before in-memory canonical-store hydration", + "Canonical store hydration, newer REST refresh, removed facet refresh and restored observation", + "Touch container row expansion, keyboard Enter on Swarm disclosure and touch service row expansion", + "Fixture-only tab switching between production tables; no mutation clicked" ], + "browsers": [ + { + "engine": "desktop", + "version": "141.0.7390.37" + }, + { + "engine": "phone", + "version": "26.0" + } + ], + "playwright_version": "1.56.1", + "checks": 40, + "screenshots": 18, + "command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-docker-rows.cjs", + "evidence": "browser-tests/proxmox-docker-rows.cjs and invocation browser-verified-result.json / browser-images.json", "limitations": [ - "Synthetic observations and HTTP responses through production components and CSS in a local Vite preview. Not native appliance data/authorisation, reporter cause, released availability, actual rollback, Tailscale or SSE acceptance.", - "The History API exposes no restore-scope metadata; no version heuristic or free-form note establishes scope. The UI conservatively says it is unknown.", - "Full provider identifiers remain titles/search targets. Some long row names and secondary intermediate-width fields intentionally truncate; this does not prove every native estate or every value fits.", - "The frontend graph is unchanged and its seven high GHSA-vfj7-8cjw-p6xm findings remain; browser/component/build proof is not audit clearance." + "Synthetic REST and canonical in-memory store hydration, not a native websocket wire or appliance test", + "Not backend adoption, authorisation grant, mutative lifecycle execution, installed recovery or release acceptance", + "Seven high braces advisory findings in unchanged dependency graph remain unresolved; offline browser proof does not clear required audit" ] } diff --git a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx index 6c1f78d5fe..1ae9b1098d 100644 --- a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx @@ -189,44 +189,57 @@ export function StackedDiskBar(props: StackedDiskBarProps) { } > - {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold */} + {/* Vertical micro-bars: one per disk, fill height = utilization, color = threshold. + The fullest disk's percentage sits beside them so the cell still reads + as a number at a glance; every bar keeps equal weight. */}
-
- - {(bar) => ( -
+
+ + + {presentation().verticalBarsLabel} + + +
+ + {(bar) => (
- + +
-
- )} - + )} + +
diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx index e550f069a0..391101ac73 100644 --- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx @@ -587,4 +587,36 @@ describe('StackedDiskBar', () => { expect(bar?.getAttribute('fill')).toContain('239, 68, 68'); }); }); + + describe('vertical-bars mode', () => { + it('labels the micro-bars with the fullest disk so the cell reads as a number', () => { + const { container } = render(() => ( + + )); + + const label = container.querySelector('[data-stacked-disk-max-label]'); + expect(label).toHaveTextContent('52%'); + expect(label).toHaveAttribute('title', 'Highest usage: /data 52%'); + // Every disk keeps its own bar with equal weight; the label is derived. + expect(container.querySelectorAll('[data-stacked-disk-fill="vertical"]')).toHaveLength(2); + }); + + it('omits the label when no disk has measured usage', () => { + const { container } = render(() => ( + + )); + + expect(container.querySelector('[data-stacked-disk-max-label]')).toBeNull(); + }); + }); }); diff --git a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts index 054068a04c..b56964dfe4 100644 --- a/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/stackedDiskBarModel.branchcov0712b.test.ts @@ -372,6 +372,10 @@ describe('stackedDiskBarModel (branch coverage 2)', () => { title: '/x: 40% (40.0 B/100 B)', }); expect(p.verticalBars[1].color).toBe(CRITICAL); // 120% -> critical + // The fullest disk's usage labels the cell so a multi-disk host still + // reads as a number without a hover. + expect(p.verticalBarsLabel).toBe('120%'); + expect(p.verticalBarsLabelTitle).toBe('Highest usage: /y 120%'); expect(p.tooltipContent).toHaveLength(3); expect(p.tooltipContent[2]).toMatchObject({ label: '/z', percent: 'โ€”', used: '?' }); }); diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index 7b2adc3f65..ed72f85037 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -87,6 +87,10 @@ export interface StackedDiskBarPresentation { tooltipTitle: string; useStackedSegments: boolean; verticalBars: StackedDiskVerticalBar[]; + // The fullest disk's usage, shown beside the micro-bars so a multi-disk + // host answers "how close is this host to running out?" without a hover. + verticalBarsLabel: string; + verticalBarsLabelTitle: string; verticalBarsMode: boolean; } @@ -359,6 +363,8 @@ export function buildStackedDiskBarPresentation( }; }) : []; + const verticalBarsLabel = verticalBarsMode && maxInfo ? formatPercent(maxInfo.percent) : ''; + const verticalBarsLabelTitle = verticalBarsMode && maxInfo ? maxLabelFull : ''; const tooltipContent = buildTooltipContent(allDisks, { aggregateDisk: props.aggregateDisk, aggregateMode, @@ -402,6 +408,8 @@ export function buildStackedDiskBarPresentation( tooltipTitle: allDisks.length > 1 ? 'Disk Breakdown' : 'Disk Usage', useStackedSegments, verticalBars, + verticalBarsLabel, + verticalBarsLabelTitle, verticalBarsMode, }; } diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx index 88345be440..744bc18b71 100644 --- a/frontend-modern/src/features/docker/DockerImagesTable.tsx +++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx @@ -152,7 +152,7 @@ export const DockerImagesTable: Component< kind="text" sort={sort} sortKey="host" - class="platform-table-mobile-w-15 md:w-[18%]" + class="platform-table-phone-hidden md:w-[18%]" > Host @@ -160,7 +160,7 @@ export const DockerImagesTable: Component< kind="text" sort={sort} sortKey="usedBy" - class="platform-table-mobile-w-15 md:w-[24%]" + class="platform-table-phone-hidden md:w-[24%]" > Used by @@ -168,7 +168,7 @@ export const DockerImagesTable: Component< kind="numeric-value" sort={sort} sortKey="size" - class="platform-table-mobile-w-15 md:w-[12%]" + class="platform-table-mobile-w-20 md:w-[12%]" > Size @@ -176,7 +176,7 @@ export const DockerImagesTable: Component< kind="badge" sort={sort} sortKey="update" - class="platform-table-mobile-w-15 md:w-[16%]" + class="platform-table-mobile-w-25 md:w-[16%]" > @@ -211,13 +211,15 @@ export const DockerImagesTable: Component< /> } /> + {/* Host and consumers fit the row expansion on a phone; the + row keeps the image, its size and the update verdict. */} {dockerHostName(resource)} c.toUpperCase()) + : 'Updating'; const title = [state, message, completedAt].filter(Boolean).join(' | ') || label; return { label, title }; }; @@ -256,7 +262,7 @@ export const DockerServicesTable: Component<{ sortKey="running" class="platform-table-mobile-w-10 w-[10%] md:w-[8%]" > - Running + {update().label} diff --git a/frontend-modern/src/features/docker/DockerTasksTable.tsx b/frontend-modern/src/features/docker/DockerTasksTable.tsx index d5f01c409a..50a7e7e929 100644 --- a/frontend-modern/src/features/docker/DockerTasksTable.tsx +++ b/frontend-modern/src/features/docker/DockerTasksTable.tsx @@ -12,6 +12,7 @@ import { createPlatformTableSortState, getPlatformTableCellClassForKind, getPlatformTableDateTimeSortValue, + PlatformTableRelativeTimeValue, PlatformTableShell, type PlatformTableSortValue, withPlatformStatusCounts, @@ -255,11 +256,10 @@ export const DockerTasksTable: Component = (props) => { diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index 1695ae6c76..0e34de84ec 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -1505,6 +1505,8 @@ describe('Docker native tables', () => { )); expect(screen.getByText('Update')).toBeInTheDocument(); + expect(screen.getByText('Run')).toHaveClass('platform-table-label-compact'); + expect(screen.getByText('Running')).toHaveClass('platform-table-label-full'); expect(screen.getByText('Stack')).toBeInTheDocument(); expect(screen.getByText('shop')).toBeInTheDocument(); expect(screen.getByText('checkout-api')).toBeInTheDocument(); @@ -1512,7 +1514,12 @@ describe('Docker native tables', () => { expect(screen.getByText('replicated')).toBeInTheDocument(); expect(screen.getByText('4')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.getByText('rollback_started')).toBeInTheDocument(); + // The snake_case rollout token reads as words; the raw token stays in the title. + expect(screen.getByText('Rollback started')).toBeInTheDocument(); + expect(screen.getByText('Rollback started')).toHaveClass('whitespace-normal', 'break-words'); + expect( + screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'), + ).toContain('rollback_started'); expect(screen.getByText('18080:8080/tcp')).toBeInTheDocument(); expect(screen.getByText('manager-1')).toBeInTheDocument(); expect(document.querySelector('[data-docker-service-row="service-1"]')).not.toBeNull(); @@ -1651,7 +1658,10 @@ describe('Docker native tables', () => { expect(screen.getByText('web.2')).toBeInTheDocument(); expect(screen.getByText('web')).toBeInTheDocument(); expect(screen.getByText('running 2 minutes')).toBeInTheDocument(); - expect(screen.getByText('2026-05-24T13:05:00Z')).toBeInTheDocument(); + // The raw RFC3339 stamp moves to the title; the cell shows an age. + const started = screen.getByTitle('2026-05-24T13:05:00Z'); + expect(started).toBeInTheDocument(); + expect(started.textContent).toMatch(/ago$/); }); it('renders Docker Swarm secret API metadata without secret data', () => { diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts index f8a8fc36f4..41f10a0869 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerContainerTableModel.test.ts @@ -30,7 +30,9 @@ describe('dockerContainerTableModel', () => { const columns = getDockerContainerVisibleColumnsForLayout('phone', true, true, true); const ids = columns.map((column) => column.id); - expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'restarts', 'updates']); + // Restarts waits for mobile width: a phone row could not fit its header + // beside the Update control, and the count stays in the row expansion. + expect(ids).toEqual(['container', 'state', 'cpu', 'memory', 'updates']); expect(getDockerContainerColumnWidthStyle('container', 'phone', ids)).toEqual({ width: '30%', }); diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts index b573f3aba2..2961092c89 100644 --- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -70,7 +70,9 @@ const DOCKER_CONTAINER_COLUMN_MIN_LAYOUT: Record< state: 'narrow', cpu: 'narrow', memory: 'narrow', - restarts: 'phone', + // A phone row cannot fit the Restarts header beside the Update control; + // the count stays in the row expansion there and returns at mobile width. + restarts: 'mobile', updates: 'narrow', actions: 'mobile', host: 'tablet', @@ -126,11 +128,10 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< }, phone: { container: 32, - state: 13, - cpu: 15, - memory: 17, - restarts: 10, - updates: 13, + state: 14, + cpu: 14, + memory: 14, + updates: 16, }, mobile: { container: 30, @@ -151,17 +152,20 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< updates: 9, actions: 6, }, + // Compact is what a 1280-1536px laptop window gets. Host, Engine and the + // Update control are short values that were clipped ("docker 2โ€ฆ") while the + // percentage-only CPU and memory bars had room to spare. compact: { container: 18, - host: 10, - runtime: 8, - image: 18, + host: 12, + runtime: 9.5, + image: 16.5, state: 7, - cpu: 11, - memory: 12, - restarts: 9, + cpu: 9.5, + memory: 10, + restarts: 8.5, ports: 12, - updates: 9, + updates: 10, actions: 8, }, }; diff --git a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts index 2d6a7f9cf7..4bc6ed695f 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/platformOverviewLayout.guardrails.test.ts @@ -322,7 +322,7 @@ describe('platform overview layout guardrails', () => { expect(source).toContain('platform-table-mobile-w-30'); } expect(dockerImagesTableSource).toMatch( - /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-15 md:w-\[12%\]"/, + /sortKey="size"[\s\S]{0,120}?class="platform-table-mobile-w-20 md:w-\[12%\]"/, ); expect(dockerServicesTableSource).toMatch( /sortKey="mode"[\s\S]{0,120}?class="platform-table-phone-hidden md:w-\[8%\]"/, diff --git a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts index 14cd9deebd..5185db68c3 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/resourceStoragePresentation.test.ts @@ -62,6 +62,14 @@ describe('resourceStoragePresentation', () => { ), ).toBe('Datastore'); expect(getResourceStorageTopologyLabel(makeResource(), 'rbd')).toBe('Cluster Storage'); + // Proxmox plugin names are acronyms or compound words; title-casing them + // produced "Lvmthin" for every default local-lvm storage and "Nfs". + expect(getResourceStorageTopologyLabel(makeResource(), 'lvmthin')).toBe('LVM-Thin'); + expect(getResourceStorageTopologyLabel(makeResource(), 'lvm')).toBe('LVM'); + expect(getResourceStorageTopologyLabel(makeResource(), 'nfs')).toBe('NFS'); + expect(getResourceStorageTopologyLabel(makeResource(), 'cifs')).toBe('SMB/CIFS'); + expect(getResourceStorageTopologyLabel(makeResource(), 'iscsi')).toBe('iSCSI'); + expect(getResourceStorageTopologyLabel(makeResource(), 'btrfs')).toBe('BTRFS'); expect(getResourceStorageTopologyLabel(makeResource(), 'ignored', 'rebuild target')).toBe( 'Rebuild Target', ); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts index 96866fbeda..7e5917d601 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storageAdapters.test.ts @@ -467,6 +467,37 @@ describe('storageAdapters', () => { expect(records[0].hostLabel).toBe('pve1'); }); + it('labels a poller-merged shared storage as shared with its node count', () => { + const records = buildStorageRecords({ + state: baseState(), + resources: [ + makeResourceStorage({ + id: 'cluster-a-cluster-nfs-iso', + name: 'nfs-iso', + parentName: 'cluster', + platformData: { type: 'nfs', node: 'cluster', instance: 'cluster-a', shared: true }, + storage: { + type: 'nfs', + shared: true, + nodes: ['pve1', 'pve2', 'pve3'], + } as Resource['storage'], + }), + makeResourceStorage({ + id: 'pve2-local-lvm', + name: 'local-lvm', + parentName: 'pve2', + platformData: { type: 'lvmthin', node: 'pve2', instance: 'cluster-a', shared: false }, + storage: { type: 'lvmthin', shared: false } as Resource['storage'], + }), + ], + }); + + // The poller folds a shared storage into one entry on the synthetic + // "cluster" node; a node-local storage keeps its node name. + expect(records.map((record) => record.hostLabel)).toEqual(['Shared ยท 3 nodes', 'pve2']); + expect(records.map((record) => record.topologyLabel)).toEqual(['NFS', 'LVM-Thin']); + }); + it('keeps dependency impact separate from healthy storage primary issues', () => { const records = buildStorageRecords({ state: baseState(), diff --git a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts index 0c3b5d618a..9302b84d66 100644 --- a/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts +++ b/frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts @@ -244,6 +244,30 @@ export const getResourceStorageTopologyLabel = ( case 'rbd': case 'cephfs': return 'Cluster Storage'; + // Proxmox storage plugin names are acronyms or compound words that + // title-casing mangles ("Lvmthin" for the default local-lvm, "Nfs"); use + // the names the Proxmox UI shows for them. + case 'nfs': + return 'NFS'; + case 'cifs': + case 'smb': + return 'SMB/CIFS'; + case 'lvm': + return 'LVM'; + case 'lvmthin': + case 'lvm-thin': + return 'LVM-Thin'; + case 'iscsi': + case 'iscsidirect': + return 'iSCSI'; + case 'zfs': + return 'ZFS'; + case 'btrfs': + return 'BTRFS'; + case 'glusterfs': + return 'GlusterFS'; + case 'esxi': + return 'ESXi'; default: return titleize(storageType) || titleize(resource.type) || 'Storage'; } diff --git a/frontend-modern/src/features/storageBackups/storageAdapters.ts b/frontend-modern/src/features/storageBackups/storageAdapters.ts index 19b8ac6598..7e22fdd6d7 100644 --- a/frontend-modern/src/features/storageBackups/storageAdapters.ts +++ b/frontend-modern/src/features/storageBackups/storageAdapters.ts @@ -129,7 +129,16 @@ const mapResourceStorageRecord = (resource: Resource, adapterId: string): Storag const usedBytes = asNumberOrNull(resource.disk?.used); const freeBytes = asNumberOrNull(resource.disk?.free); const canonicalPlatform = getCanonicalStoragePlatformKey(resource, resource.storage?.platform); - const hostLabel = locationLabel; + // The poller folds a shared storage into one entry whose node is the + // synthetic "cluster" marker (the mock uses "shared"); show that as a + // shared storage with its reporting node count rather than a node name. + const sharedLocationMarkers = new Set(['cluster', 'shared']); + const hostLabel = + shared && sharedLocationMarkers.has(locationLabel.trim().toLowerCase()) + ? storageNodes.length > 1 + ? `Shared ยท ${storageNodes.length} nodes` + : 'Shared' + : locationLabel; const platformLabel = getResourceStoragePlatformLabel(canonicalPlatform); const issueLabel = getResourceStorageIssueLabel(resource); const issueSummary = getResourceStorageIssueSummary(resource); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index 3408c0079d..ef2dfae357 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -2,6 +2,7 @@ import { batch, createRoot, createSignal } from 'solid-js'; import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildProxmoxPageModel } from '@/features/proxmox/proxmoxPageModel'; +import { getDockerContainerLifecycleDisabledReason } from '@/features/docker/dockerContainerLifecycleActions'; import type { State } from '@/types/api'; import type { Resource } from '@/types/resource'; import useUnifiedResourcesSource from '../useUnifiedResources.ts?raw'; @@ -1626,6 +1627,173 @@ describe('useUnifiedResources', () => { dispose(); }); + it('keeps PMG metadata on the canonical resource for REST-first paints', async () => { + // The Mail Gateway table reads `resource.pmg`. The mapper used to keep it + // only under platformData, so a REST-sourced row rendered every PMG column + // as a dash until a websocket row happened to replace it. + apiFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { + id: 'pmg-1', + type: 'pmg', + name: 'mail-gateway-eu', + status: 'online', + lastSeen: '2026-02-06T12:00:00Z', + sources: ['pmg'], + uptime: 1_555_222, + technology: 'pmg', + sourceStatus: { pmg: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [{ name: 'restart', available: true }], + pmg: { + instanceId: 'pmg-eu', + hostname: 'pmg.example.lan', + version: '8.1-2', + nodeCount: 1, + mailCountTotal: 2_740, + spamIn: 321, + virusIn: 14, + queueTotal: 11, + queueDeferred: 5, + }, + }, + ], + }), + }); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ query: 'type=pmg&source=pmg', cacheKey: 'pmg-first-paint' }); + }); + + await result!.refetch(); + const row = result!.resources().find((resource) => resource.id === 'pmg-1'); + expect(row?.pmg).toEqual( + expect.objectContaining({ version: '8.1-2', spamIn: 321, queueDeferred: 5 }), + ); + expect(row?.platformData?.pmg).toEqual(row?.pmg); + // The per-source status, action readiness, and technology facets ride the + // same race: Docker lifecycle actions and storage freshness read them at + // the top level, where only websocket rows used to carry them. + expect(row?.sourceStatus?.pmg?.status).toBe('online'); + expect(row?.actionReadiness?.[0]?.name).toBe('restart'); + expect(row?.technology).toBe('pmg'); + + dispose(); + }); + + it.each([ + { + label: 'stale inventory', + sourceStatus: { docker: { status: 'stale', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [], + expected: 'Docker inventory is stale. Refresh inventory before running lifecycle actions.', + }, + { + label: 'failed inventory', + sourceStatus: { + docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z', error: 'Collector refused' }, + }, + actionReadiness: [], + expected: 'Docker inventory is not healthy: Collector refused', + }, + { + label: 'server action refusal', + sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [ + { name: 'restart', available: false, reason: 'Command agent disconnected' }, + ], + expected: 'Command agent disconnected', + }, + { + label: 'no invented action grant', + sourceStatus: { docker: { status: 'online', lastSeen: '2026-02-06T12:00:00Z' } }, + actionReadiness: [], + expected: + 'Pulse does not currently advertise a fresh restart command capability for this container.', + }, + ])('preserves $label through REST mapping into lifecycle controls', async (testCase) => { + apiFetchMock.mockResolvedValue( + resourceResponse([ + { + id: 'docker-container-1', + type: 'app-container', + name: 'edge-web', + status: 'running', + lastSeen: '2026-02-06T12:00:00Z', + sources: ['docker'], + technology: 'docker', + docker: { runtime: 'docker', agentId: 'agent-edge', containerState: 'running' }, + sourceStatus: testCase.sourceStatus, + actionReadiness: testCase.actionReadiness, + }, + ]), + ); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ query: 'type=app-container', cacheKey: testCase.label }); + }); + try { + await result!.refetch(); + const row = result!.resources()[0]; + expect(row.sourceStatus).toEqual(testCase.sourceStatus); + expect(row.actionReadiness).toEqual(testCase.actionReadiness); + expect(row.technology).toBe('docker'); + expect(getDockerContainerLifecycleDisabledReason(row, 'restart')).toBe(testCase.expected); + } finally { + dispose(); + } + }); + + it('replaces REST facets without retaining a removed PMG counter or action refusal', async () => { + const base = { + id: 'pmg-1', + type: 'pmg', + name: 'mail-gateway', + status: 'online', + sources: ['pmg'], + lastSeen: '2026-02-06T12:00:00Z', + }; + apiFetchMock.mockResolvedValue( + resourceResponse([ + { + ...base, + technology: 'pmg', + pmg: { version: '8.1-2', queueTotal: 11 }, + sourceStatus: { pmg: { status: 'stale', lastSeen: base.lastSeen } }, + actionReadiness: [{ name: 'restart', available: false, reason: 'Old refusal' }], + }, + ]), + ); + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ query: 'type=pmg', cacheKey: 'removed-rest-facets' }); + }); + try { + await result!.refetch(); + expect(result!.resources()[0].pmg?.queueTotal).toBe(11); + apiFetchMock.mockResolvedValue( + resourceResponse([{ ...base, actionReadiness: [], sourceStatus: {} }]), + ); + await result!.refetch(); + const row = result!.resources()[0]; + expect(row.pmg).toBeUndefined(); + expect(row.platformData?.pmg).toBeUndefined(); + expect(row.actionReadiness).toEqual([]); + expect(row.sourceStatus).toEqual({}); + expect(row.technology).toBeUndefined(); + } finally { + dispose(); + } + }); + it('falls back to canonical resource.uptime when no platform-specific field is set (vSphere)', async () => { // vSphere ESXi hosts and VMs populate only the canonical // Resource.Uptime field โ€” there's no vmware-specific UptimeSeconds diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 9093092194..dbb5d9b3d8 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -18,8 +18,10 @@ import type { ResourceDockerMeta, ResourceHealth, ResourceMetricsTarget, + ResourceActionReadiness, ResourceAvailabilityMeta, ResourcePBSMeta, + ResourcePMGMeta, ResourcePolicyPostureSummary, ResourcePhysicalDiskMeta, ResourcePoolHealth, @@ -210,6 +212,8 @@ type APIResource = { sources?: string[]; platformScopes?: string[]; sourceStatus?: Record; + actionReadiness?: ResourceActionReadiness[]; + technology?: string; health?: ResourceHealth; identity?: { machineId?: string; @@ -868,6 +872,17 @@ const toResource = (v2: APIResource): Resource => { truenas: v2.truenas as ResourceTrueNASMeta | undefined, vmware: v2.vmware as ResourceVMwareMeta | undefined, pbs: v2.pbs as ResourcePBSMeta | undefined, + // Mail Gateway rows read `resource.pmg` directly. Leaving it only under + // platformData meant a REST-first paint rendered every PMG column as a + // dash until a websocket row happened to replace it, and never did when + // the REST snapshot landed last. + pmg: v2.pmg as ResourcePMGMeta | undefined, + // Same race for the per-source status, action readiness, and technology + // facets: Docker lifecycle actions and storage freshness read them from + // the top level, and the websocket rows carry them there. + sourceStatus: v2.sourceStatus, + actionReadiness: v2.actionReadiness, + technology: v2.technology, availability: v2.availability as ResourceAvailabilityMeta | undefined, availabilityChecks: v2.availabilityChecks as ResourceAvailabilityMeta[] | undefined, physicalDisk: v2.physicalDisk, From 2839333474fa51550157907edfa452cce05d2260 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:01:49 +0100 Subject: [PATCH 40/86] Make operator-row browser fixture self-contained Derive the nine verified source paths from the committed fixture rather than a temporary per-run JSON file, so another maintainer can run the same checks from a clean checkout. Use the canonical Swarm currentState field and assert that actual task presentation. Runtime frontend files and their owning-parent receipt remain byte-identical; this is not a receipt rebind. Change-source: pulse-maintainer --- .../browser-tests/proxmox-docker-rows.cjs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs index 5ca4cc46f1..c75b745ed8 100644 --- a/frontend-modern/browser-tests/proxmox-docker-rows.cjs +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -115,7 +115,7 @@ const observations = (mode) => [ hostname: 'edge-long-hostname', serviceName: 'web-service', nodeName: 'worker-1', - taskState: 'running', + currentState: 'running', desiredState: 'running', startedAt: '2026-10-03T11:30:00.123456Z', }, @@ -124,7 +124,7 @@ const observations = (mode) => [ (async () => { const root = '/workspace/frontend-modern'; - const output = '/workspace/tmp/proxmox-docker-rows-verified'; + const output = '/workspace/tmp/proxmox-docker-rows-selfcontained'; fs.mkdirSync(output, { recursive: true }); process.chdir(root); const playwright = require('playwright/package.json').version; @@ -134,7 +134,19 @@ const observations = (mode) => [ 'node_modules/@playwright/test' ].version, ); - const paths = JSON.parse(fs.readFileSync(path.join(output, 'runtime-paths.json'))); + // Keep the fixture runnable from clean committed source, without requiring + // an invocation-specific untracked runtime-paths input. + const paths = [ + 'frontend-modern/src/components/Workloads/StackedDiskBar.tsx', + 'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts', + 'frontend-modern/src/features/docker/DockerImagesTable.tsx', + 'frontend-modern/src/features/docker/DockerServicesTable.tsx', + 'frontend-modern/src/features/docker/DockerTasksTable.tsx', + 'frontend-modern/src/features/docker/dockerContainerTableModel.ts', + 'frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts', + 'frontend-modern/src/features/storageBackups/storageAdapters.ts', + 'frontend-modern/src/hooks/useUnifiedResources.ts', + ]; const hashes = Object.fromEntries( paths.map((p) => [ p, @@ -403,6 +415,10 @@ const observations = (mode) => [ const started = page.locator('[title="2026-10-03T11:30:00.123456Z"]'); await started.waitFor({ state: 'attached' }); assert.match(await started.innerText(), /ago/); + await page + .getByRole('region', { name: 'Swarm tasks' }) + .getByText('running', { exact: true }) + .waitFor(); }, ); await capture('swarm'); From 9c3d79ba6a1c0e4985baf5d18df5e71e3275ba1d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:13:12 +0100 Subject: [PATCH 41/86] Scope Swarm browser check to the current-state evidence Desired and current task states can both read running. Select the canonical current-state title instead of an ambiguous text match, preserving the failed fixture-only attempt and unchanged runtime source/browser binding. Change-source: pulse-maintainer --- frontend-modern/browser-tests/proxmox-docker-rows.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend-modern/browser-tests/proxmox-docker-rows.cjs b/frontend-modern/browser-tests/proxmox-docker-rows.cjs index c75b745ed8..bcc376eded 100644 --- a/frontend-modern/browser-tests/proxmox-docker-rows.cjs +++ b/frontend-modern/browser-tests/proxmox-docker-rows.cjs @@ -124,7 +124,7 @@ const observations = (mode) => [ (async () => { const root = '/workspace/frontend-modern'; - const output = '/workspace/tmp/proxmox-docker-rows-selfcontained'; + const output = '/workspace/tmp/proxmox-docker-rows-final-source'; fs.mkdirSync(output, { recursive: true }); process.chdir(root); const playwright = require('playwright/package.json').version; @@ -417,7 +417,7 @@ const observations = (mode) => [ assert.match(await started.innerText(), /ago/); await page .getByRole('region', { name: 'Swarm tasks' }) - .getByText('running', { exact: true }) + .getByTitle('running', { exact: true }) .waitFor(); }, ); From 2789a15fe5ccd3e28e9f13f3ec0ae04267be94cf Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:22:45 +0100 Subject: [PATCH 42/86] Keep authentication diagnostics private and permission-aware Use private header files rather than shared passwords or proxy secrets in process arguments. Separate intentional AI settings writes from diagnostics, distinguish proxy denials from authentication/transport failures, and preserve the real proxy trust boundary and 5.x limitation. Exercise copied commands, private-file preparation and rendered help. Contract-Neutral: Documentation and executable help controls only; no authentication, authorization, AI autonomy or runtime contract change. Change-source: pulse-maintainer --- docs/AI_AUTONOMY.md | 57 +++-- docs/API.md | 24 ++- docs/PROXY_AUTH.md | 87 ++++++-- .../authentication-diagnostic-help.cjs | 94 +++++++++ frontend-modern/public/docs/AI_AUTONOMY.md | 57 +++-- frontend-modern/public/docs/API.md | 24 ++- frontend-modern/public/docs/PROXY_AUTH.md | 87 ++++++-- scripts/tests/test_api_auth_docs.py | 51 +++++ .../test_authentication_diagnostic_docs.py | 194 ++++++++++++++++++ 9 files changed, 599 insertions(+), 76 deletions(-) create mode 100644 frontend-modern/browser-tests/authentication-diagnostic-help.cjs create mode 100644 scripts/tests/test_authentication_diagnostic_docs.py diff --git a/docs/AI_AUTONOMY.md b/docs/AI_AUTONOMY.md index 66f2edff5d..110e22c5b0 100644 --- a/docs/AI_AUTONOMY.md +++ b/docs/AI_AUTONOMY.md @@ -37,19 +37,40 @@ Patrol mode sets how far Pulse can go when Patrol finds something that needs att **UI:** Patrol โ†’ Patrol mode -**API:** -```bash -# Get current Patrol mode settings -curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy +**API:** Use an administrator API token with `settings:write` in the +[private header file](API.md#api-token-recommended). This endpoint requires +that scope even for GET; a `monitoring:read` token is not sufficient. For a +one-off read, you can instead open the path in your signed-in administrator +browser without extracting its cookie. There is no shared example password +to configure or substitute into these commands. -# Update Patrol mode. -# The API keeps the autonomy_level field name for compatibility. -curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}' +Read the current settings first (curl 7.76 or later, on the Pulse host): + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/ai/patrol/autonomy ``` +The next command **changes Patrol mode**, not just connection health. Use it +only when you intend to enable investigation and queue fixes for approval on +a plan with that capability. The API retains `autonomy_level` for compatibility: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/ai/patrol/autonomy <<'JSON' +{"autonomy_level":"approval","investigation_budget":15,"investigation_timeout_sec":600} +JSON +``` + +For remote use, substitute your Pulse **HTTPS** URL and keep certificate +verification enabled. Do not add `--insecure`, verbose/trace output or redirect +following; keep `--disable` first to ignore local curl defaults. A nonzero exit, +401 or 403 is a failed check, not evidence that a change was saved. Stop and +resolve authentication, permissions or the reported error before proceeding; +after an uncertain write, read the saved settings before retrying. Keep +tokens, cookies and unredacted responses out of reports. + ### License Requirements - `monitor`: Available on all plans. Community and Relay can run Patrol with BYOK. @@ -77,12 +98,18 @@ Control levels govern what the interactive Pulse Assistant can do during chat se **UI:** Settings โ†’ Pulse Intelligence โ†’ Assistant โ†’ Chat command mode -**API:** +**API:** This intentionally changes chat command access. Use the same private +administrator header file with `settings:write`; the token must also have +permission to change settings. Prefer the UI for one-off changes, and verify +the saved **Chat command mode** there afterwards. Do not use a write as an +authentication test. + ```bash -curl -X PUT http://localhost:7655/api/settings/ai/update \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"control_level": "controlled"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/settings/ai/update <<'JSON' +{"control_level":"controlled"} +JSON ``` ### Approval Flow (Controlled Mode) diff --git a/docs/API.md b/docs/API.md index d0658a037f..e57302d49a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15,14 +15,26 @@ needed for your task. The read-only example below requires `monitoring:read`. Pass the token in the `X-API-Token` header, using a private header file so the secret does not appear in shell history or process arguments. -On the machine running curl, prepare the file and open it in an editor: +On a trusted machine, as the account running curl, prepare the file and open +it in an editor. This preserves an existing file, restricts its directory and +stops before editing if preparation fails or a credential path is a symlink: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/api-header" -chmod 600 "$HOME/.config/pulse/api-header" -vi "$HOME/.config/pulse/api-header" +( + set -eu + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/api-header" + if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` In the editor, save just this line, replacing `` with the API token: diff --git a/docs/PROXY_AUTH.md b/docs/PROXY_AUTH.md index 830ee82f99..d29887a3f6 100644 --- a/docs/PROXY_AUTH.md +++ b/docs/PROXY_AUTH.md @@ -5,8 +5,9 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl ## ๐Ÿš€ Quick Start 1. **Generate Secret**: Create a strong random string. -2. **Configure Pulse**: - ```bash +2. **Configure Pulse**: Set these in your deployment's private configuration, + not a shell command, shared Compose file or repository: + ```dotenv PROXY_AUTH_SECRET=your-random-secret PROXY_AUTH_USER_HEADER=X-Authentik-Username ``` @@ -40,6 +41,12 @@ Neither of these can be enforced from inside Pulse: a forged header and a genuin ## ๐Ÿ“ฆ Examples +The secret values below are placeholders, not defaults. Replace them with the +same strong unique secret in Pulse and the proxy, using private configuration +files or your deployment's secret mechanism. Keep the rendered configuration +and container environment private too; do not share `docker inspect` or +resolved Compose output. + ### Authentik (with Traefik) **docker-compose.yml**: ```yaml @@ -80,13 +87,60 @@ location / { | **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. | ### Verify Headers -Use `curl` to simulate a proxy request: -```bash -curl -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: admin" \ - http://localhost:7655/api/state +First use a normal signed-in browser through your proxy. Check that the intended +user has the expected access; do not extract its cookies or use **Copy as cURL**. +The shared proxy secret can assert any identity, so a direct test belongs only +on a trusted backend/proxy host or inside its private network. Do not expose the +backend port or change firewall rules to make this test possible. + +If an authorised administrator needs to isolate Pulse's role check, prepare a +temporary private header file in an editor. In that editor, enter the following, +substituting your configured user/role header names, real shared secret and a +test user's **non-admin** role (not the admin role). Do not add an API token or +session cookie: those would test a different credential path. + +```text +X-Proxy-Secret: +X-Authentik-Username: testuser +X-Proxy-Roles: none ``` +Run this read-only probe (curl 7.76 or later). It sends no settings changes and +prints only the HTTP status, not settings, cookies or credential-bearing logs: + +```bash +( + set -eu + umask 077 + probe_dir=$(mktemp -d) + header_file="$probe_dir/proxy-headers" + trap 'test ! -e "$header_file" || rm -- "$header_file"; rmdir -- "$probe_dir"' EXIT + touch "$header_file" + vi "$header_file" + curl --disable --silent --show-error --output /dev/null \ + --write-out 'HTTP %{http_code}\n' --header "@$header_file" \ + http://127.0.0.1:7655/api/system/settings +) +``` + +The loopback address applies only on the Pulse host with a loopback listener. +Otherwise use its existing private backend address over HTTPS with certificate +verification enabled. Do not send forged test identities through a public proxy +that should replace them. Keep `--disable` first to ignore local curl defaults; +do not enable verbose/trace output, `--insecure` or redirect following. + +With role gating configured on Pulse 6.2.2 or later, expect **403** for a valid +non-admin identity. Repeat the same read-only probe with the role line omitted, +then blank: both should still be **403**. **200** means admin access is allowed; +check whether role gating is configured. **401** means authentication failed, +not that the non-admin role was checked. A redirect, connection/TLS error or +`HTTP 000` is not a successful denial test. Curl deliberately does not fail on +403 here because it is the expected result; assess the printed status separately +from transport success. Stop on an unexpected result rather than granting more +access. This tests Pulse's direct header handling, **not** your proxy's header +replacement or end-to-end access boundary. Share only the status and redacted +configuration names, never the header file or secret. + ## Pulse 5.x (end-of-life) The 5.x line is end-of-life and does not receive fixes. **Upgrade to 6.2.2 or later**, which is where proxy-auth role gating behaves as documented above. @@ -96,15 +150,14 @@ If you cannot upgrade immediately, 5.x needs two configuration changes before ro 1. **Set `PROXY_AUTH_ADMIN_ROLE` explicitly.** 5.x never applies the documented `admin` default. While it is empty, 5.x skips role checking altogether and treats every proxy-authenticated user as an administrator, whatever their role header says. 2. **Send a non-empty role header on every authenticated request.** 5.x only evaluates roles when that header carries a value; an absent or empty header leaves the user an administrator. A user with no groups in your identity provider commonly produces exactly that, so have the proxy send a placeholder such as `none` rather than nothing. Any non-empty value that does not contain your admin role correctly resolves to non-admin. -Verify both with a request carrying a non-admin role โ€” it should be refused: - -```bash -curl -si -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: testuser" \ - -H "X-Proxy-Roles: none" \ - http://localhost:7655/api/system/settings | head -1 -``` - -Expected: `HTTP/1.1 403 Forbidden`. Repeat it with the role header omitted entirely โ€” that must also be refused. If either returns `200`, admin access is still open. +Use the private-file probe above with a non-empty non-admin role: it should +return **403**. A direct 5.x request with the role header omitted can still +return **200** despite the explicit admin role; that is the old behaviour, not +proof that the workaround failed. The mitigation depends on the real proxy +always supplying a non-empty role, including for users with no groups. Verify +that end-to-end with such a user; do not expose or bypass the proxy to rely on +this workaround. A successful direct test alone does not establish that every +proxied request carries the role. Upgrade rather than treating the workaround +as equivalent to the current fail-closed behaviour. 6.2.2 and later fix both behaviors: role gating activates on the presence of the role header alone, and an absent or blank header resolves to non-admin. diff --git a/frontend-modern/browser-tests/authentication-diagnostic-help.cjs b/frontend-modern/browser-tests/authentication-diagnostic-help.cjs new file mode 100644 index 0000000000..6f150285bd --- /dev/null +++ b/frontend-modern/browser-tests/authentication-diagnostic-help.cjs @@ -0,0 +1,94 @@ +// Render shipped authentication help through the real Docs/router/Markdown/CSS. +// No credential, proxy, API write or installation is exercised in the browser. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'authentication-diagnostic-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-authentication-diagnostic'), + server: { host: '127.0.0.1', port: 5253, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5253/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + for (const guide of ['AI_AUTONOMY', 'PROXY_AUTH']) { + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + const entry = page.locator(`a[data-doc-link][href="/docs/${guide}"]`).first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const heading = page.getByRole('heading', { name: guide === 'AI_AUTONOMY' ? 'Configuration' : 'Verify Headers', exact: true }).first(); + await heading.waitFor(); + await heading.scrollIntoViewIfNeeded(); + const text = await page.locator('article').innerText(); + assert.ok(!text.includes('admin:admin') && !text.includes('curl -H "X-Proxy-Secret')); + const phrases = guide === 'AI_AUTONOMY' + ? ['settings:write', 'even for GET', 'changes Patrol mode', 'after an uncertain write'] + : ['not expose the backend port', 'HTTP 000', 'old behaviour']; + for (const phrase of phrases) + assert.ok(text.includes(phrase), phrase); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + if (guide === 'AI_AUTONOMY') { + await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}.png`) }); + const link = page.getByRole('link', { name: 'private header file', exact: true }); + await link.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const h = document.getElementById('api-token-recommended'); + return h && h.getBoundingClientRect().top >= 0 && h.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/API#api-token-recommended')); + const code = page.locator('pre').filter({ hasText: 'Refusing a symlinked credential path.' }); + await code.scrollIntoViewIfNeeded(); + assert.ok((await code.innerText()).includes('chmod 700 "$auth_dir"')); + await page.screenshot({ path: path.join(artifacts, `${engine}-private-header.png`) }); + } else { + const code = page.locator('pre').filter({ hasText: "--write-out 'HTTP %{http_code}" }); + await code.scrollIntoViewIfNeeded(); + assert.ok((await code.innerText()).includes('--output /dev/null')); + assert.ok((await code.innerText()).includes('--header "@$header_file"')); + await page.screenshot({ path: path.join(artifacts, `${engine}-${guide.toLowerCase()}.png`) }); + } + } + assert.deepEqual(errors, []); + results.push({ engine, browserVersion: browser.version(), width, tone, + keyboardEntryAndPrivateHeaderLink: true, credentialFileRecipeRendered: true, + roleAndTransportLimitsRendered: true, noBodyOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const docs = Object.fromEntries(['API', 'AI_AUTONOMY', 'PROXY_AUTH'].map((guide) => [ + `frontend-modern/public/docs/${guide}.md`, + createHash('sha256').update(fs.readFileSync(path.join(root, 'public/docs', `${guide}.md`))).digest('hex'), + ])); + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + scope: 'rendered help/links; not native proxy, authentication or AI settings acceptance', docs, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/AI_AUTONOMY.md b/frontend-modern/public/docs/AI_AUTONOMY.md index 66f2edff5d..110e22c5b0 100644 --- a/frontend-modern/public/docs/AI_AUTONOMY.md +++ b/frontend-modern/public/docs/AI_AUTONOMY.md @@ -37,19 +37,40 @@ Patrol mode sets how far Pulse can go when Patrol finds something that needs att **UI:** Patrol โ†’ Patrol mode -**API:** -```bash -# Get current Patrol mode settings -curl -s -u admin:admin http://localhost:7655/api/ai/patrol/autonomy +**API:** Use an administrator API token with `settings:write` in the +[private header file](API.md#api-token-recommended). This endpoint requires +that scope even for GET; a `monitoring:read` token is not sufficient. For a +one-off read, you can instead open the path in your signed-in administrator +browser without extracting its cookie. There is no shared example password +to configure or substitute into these commands. -# Update Patrol mode. -# The API keeps the autonomy_level field name for compatibility. -curl -X PUT http://localhost:7655/api/ai/patrol/autonomy \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600}' +Read the current settings first (curl 7.76 or later, on the Pulse host): + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + http://127.0.0.1:7655/api/ai/patrol/autonomy ``` +The next command **changes Patrol mode**, not just connection health. Use it +only when you intend to enable investigation and queue fixes for approval on +a plan with that capability. The API retains `autonomy_level` for compatibility: + +```bash +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/ai/patrol/autonomy <<'JSON' +{"autonomy_level":"approval","investigation_budget":15,"investigation_timeout_sec":600} +JSON +``` + +For remote use, substitute your Pulse **HTTPS** URL and keep certificate +verification enabled. Do not add `--insecure`, verbose/trace output or redirect +following; keep `--disable` first to ignore local curl defaults. A nonzero exit, +401 or 403 is a failed check, not evidence that a change was saved. Stop and +resolve authentication, permissions or the reported error before proceeding; +after an uncertain write, read the saved settings before retrying. Keep +tokens, cookies and unredacted responses out of reports. + ### License Requirements - `monitor`: Available on all plans. Community and Relay can run Patrol with BYOK. @@ -77,12 +98,18 @@ Control levels govern what the interactive Pulse Assistant can do during chat se **UI:** Settings โ†’ Pulse Intelligence โ†’ Assistant โ†’ Chat command mode -**API:** +**API:** This intentionally changes chat command access. Use the same private +administrator header file with `settings:write`; the token must also have +permission to change settings. Prefer the UI for one-off changes, and verify +the saved **Chat command mode** there afterwards. Do not use a write as an +authentication test. + ```bash -curl -X PUT http://localhost:7655/api/settings/ai/update \ - -u admin:admin \ - -H "Content-Type: application/json" \ - -d '{"control_level": "controlled"}' +curl --disable --fail-with-body --header "@$HOME/.config/pulse/api-header" \ + --request PUT --header 'Content-Type: application/json' --data-binary @- \ + http://127.0.0.1:7655/api/settings/ai/update <<'JSON' +{"control_level":"controlled"} +JSON ``` ### Approval Flow (Controlled Mode) diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index d0658a037f..e57302d49a 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -15,14 +15,26 @@ needed for your task. The read-only example below requires `monitoring:read`. Pass the token in the `X-API-Token` header, using a private header file so the secret does not appear in shell history or process arguments. -On the machine running curl, prepare the file and open it in an editor: +On a trusted machine, as the account running curl, prepare the file and open +it in an editor. This preserves an existing file, restricts its directory and +stops before editing if preparation fails or a credential path is a symlink: ```bash -umask 077 -mkdir -p "$HOME/.config/pulse" -touch "$HOME/.config/pulse/api-header" -chmod 600 "$HOME/.config/pulse/api-header" -vi "$HOME/.config/pulse/api-header" +( + set -eu + umask 077 + auth_dir="$HOME/.config/pulse" + auth_file="$auth_dir/api-header" + if [ -L "$HOME/.config" ] || [ -L "$auth_dir" ] || [ -L "$auth_file" ]; then + printf 'Refusing a symlinked credential path.\n' >&2 + exit 1 + fi + mkdir -p "$auth_dir" + chmod 700 "$auth_dir" + touch "$auth_file" + chmod 600 "$auth_file" + vi "$auth_file" +) ``` In the editor, save just this line, replacing `` with the API token: diff --git a/frontend-modern/public/docs/PROXY_AUTH.md b/frontend-modern/public/docs/PROXY_AUTH.md index 830ee82f99..d29887a3f6 100644 --- a/frontend-modern/public/docs/PROXY_AUTH.md +++ b/frontend-modern/public/docs/PROXY_AUTH.md @@ -5,8 +5,9 @@ Authenticate users via your existing reverse proxy (Authentik, Authelia, Cloudfl ## ๐Ÿš€ Quick Start 1. **Generate Secret**: Create a strong random string. -2. **Configure Pulse**: - ```bash +2. **Configure Pulse**: Set these in your deployment's private configuration, + not a shell command, shared Compose file or repository: + ```dotenv PROXY_AUTH_SECRET=your-random-secret PROXY_AUTH_USER_HEADER=X-Authentik-Username ``` @@ -40,6 +41,12 @@ Neither of these can be enforced from inside Pulse: a forged header and a genuin ## ๐Ÿ“ฆ Examples +The secret values below are placeholders, not defaults. Replace them with the +same strong unique secret in Pulse and the proxy, using private configuration +files or your deployment's secret mechanism. Keep the rendered configuration +and container environment private too; do not share `docker inspect` or +resolved Compose output. + ### Authentik (with Traefik) **docker-compose.yml**: ```yaml @@ -80,13 +87,60 @@ location / { | **Logout Fails** | Ensure `PROXY_AUTH_LOGOUT_URL` is set to your IdP's logout endpoint. | ### Verify Headers -Use `curl` to simulate a proxy request: -```bash -curl -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: admin" \ - http://localhost:7655/api/state +First use a normal signed-in browser through your proxy. Check that the intended +user has the expected access; do not extract its cookies or use **Copy as cURL**. +The shared proxy secret can assert any identity, so a direct test belongs only +on a trusted backend/proxy host or inside its private network. Do not expose the +backend port or change firewall rules to make this test possible. + +If an authorised administrator needs to isolate Pulse's role check, prepare a +temporary private header file in an editor. In that editor, enter the following, +substituting your configured user/role header names, real shared secret and a +test user's **non-admin** role (not the admin role). Do not add an API token or +session cookie: those would test a different credential path. + +```text +X-Proxy-Secret: +X-Authentik-Username: testuser +X-Proxy-Roles: none ``` +Run this read-only probe (curl 7.76 or later). It sends no settings changes and +prints only the HTTP status, not settings, cookies or credential-bearing logs: + +```bash +( + set -eu + umask 077 + probe_dir=$(mktemp -d) + header_file="$probe_dir/proxy-headers" + trap 'test ! -e "$header_file" || rm -- "$header_file"; rmdir -- "$probe_dir"' EXIT + touch "$header_file" + vi "$header_file" + curl --disable --silent --show-error --output /dev/null \ + --write-out 'HTTP %{http_code}\n' --header "@$header_file" \ + http://127.0.0.1:7655/api/system/settings +) +``` + +The loopback address applies only on the Pulse host with a loopback listener. +Otherwise use its existing private backend address over HTTPS with certificate +verification enabled. Do not send forged test identities through a public proxy +that should replace them. Keep `--disable` first to ignore local curl defaults; +do not enable verbose/trace output, `--insecure` or redirect following. + +With role gating configured on Pulse 6.2.2 or later, expect **403** for a valid +non-admin identity. Repeat the same read-only probe with the role line omitted, +then blank: both should still be **403**. **200** means admin access is allowed; +check whether role gating is configured. **401** means authentication failed, +not that the non-admin role was checked. A redirect, connection/TLS error or +`HTTP 000` is not a successful denial test. Curl deliberately does not fail on +403 here because it is the expected result; assess the printed status separately +from transport success. Stop on an unexpected result rather than granting more +access. This tests Pulse's direct header handling, **not** your proxy's header +replacement or end-to-end access boundary. Share only the status and redacted +configuration names, never the header file or secret. + ## Pulse 5.x (end-of-life) The 5.x line is end-of-life and does not receive fixes. **Upgrade to 6.2.2 or later**, which is where proxy-auth role gating behaves as documented above. @@ -96,15 +150,14 @@ If you cannot upgrade immediately, 5.x needs two configuration changes before ro 1. **Set `PROXY_AUTH_ADMIN_ROLE` explicitly.** 5.x never applies the documented `admin` default. While it is empty, 5.x skips role checking altogether and treats every proxy-authenticated user as an administrator, whatever their role header says. 2. **Send a non-empty role header on every authenticated request.** 5.x only evaluates roles when that header carries a value; an absent or empty header leaves the user an administrator. A user with no groups in your identity provider commonly produces exactly that, so have the proxy send a placeholder such as `none` rather than nothing. Any non-empty value that does not contain your admin role correctly resolves to non-admin. -Verify both with a request carrying a non-admin role โ€” it should be refused: - -```bash -curl -si -H "X-Proxy-Secret: your-secret" \ - -H "X-Authentik-Username: testuser" \ - -H "X-Proxy-Roles: none" \ - http://localhost:7655/api/system/settings | head -1 -``` - -Expected: `HTTP/1.1 403 Forbidden`. Repeat it with the role header omitted entirely โ€” that must also be refused. If either returns `200`, admin access is still open. +Use the private-file probe above with a non-empty non-admin role: it should +return **403**. A direct 5.x request with the role header omitted can still +return **200** despite the explicit admin role; that is the old behaviour, not +proof that the workaround failed. The mitigation depends on the real proxy +always supplying a non-empty role, including for users with no groups. Verify +that end-to-end with such a user; do not expose or bypass the proxy to rely on +this workaround. A successful direct test alone does not establish that every +proxied request carries the role. Upgrade rather than treating the workaround +as equivalent to the current fail-closed behaviour. 6.2.2 and later fix both behaviors: role gating activates on the presence of the role header alone, and an absent or blank header resolves to non-admin. diff --git a/scripts/tests/test_api_auth_docs.py b/scripts/tests/test_api_auth_docs.py index 4f18ff5fdd..6ec083d45d 100644 --- a/scripts/tests/test_api_auth_docs.py +++ b/scripts/tests/test_api_auth_docs.py @@ -197,8 +197,59 @@ class APIAuthDocsTest(unittest.TestCase): header.chmod(0o644) subprocess.run(["bash", "-eu", "-c", preparation], env=env, check=True) self.assertEqual(stat.S_IMODE(header.stat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(header.parent.stat().st_mode), 0o700) self.assertEqual(header.read_text(), f"X-API-Token: {TEST_TOKEN}\n" if existing else "") + def test_preparation_rejects_symlinked_paths_without_editing_or_chmodding_targets(self): + preparation, _request = commands() + for component in (".config", ".config/pulse", ".config/pulse/api-header"): + with self.subTest(component=component), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) / "home" + home.mkdir() + target = Path(temporary) / "target" + if component.endswith("api-header"): + target.write_text("preserve target\n") + target.chmod(0o644) + else: + target.mkdir() + target.chmod(0o755) + link = home / component + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(target) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text('#!/bin/sh\ntouch "$HOME/editor-called"\n') + editor.chmod(0o700) + before = stat.S_IMODE(target.stat().st_mode) + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}") + result = subprocess.run(["bash", "-c", preparation], env=env, capture_output=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((home / "editor-called").exists()) + self.assertEqual(stat.S_IMODE(target.stat().st_mode), before) + if target.is_file(): + self.assertEqual(target.read_text(), "preserve target\n") + else: + self.assertEqual(list(target.iterdir()), []) + + def test_preparation_failure_stops_before_editor_in_an_ordinary_shell(self): + preparation, _request = commands() + for failing_command in ("mkdir", "touch", "chmod"): + with self.subTest(command=failing_command), tempfile.TemporaryDirectory() as temporary: + home = Path(temporary) + tools = home / "tools" + tools.mkdir() + failure = tools / failing_command + failure.write_text("#!/bin/sh\nexit 73\n") + failure.chmod(0o700) + editor = tools / "vi" + editor.write_text('#!/bin/sh\n: > "$HOME/editor-called"\n') + editor.chmod(0o700) + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}") + result = subprocess.run(["bash", "-c", preparation], env=env, capture_output=True, timeout=10) + self.assertEqual(result.returncode, 73) + self.assertFalse((home / "editor-called").exists()) + def run_documented_request(self, home: Path, header_text: str, port: int, request=None): return exercise_curl(self, home, header_text, port, request if request is not None else commands()[1]) diff --git a/scripts/tests/test_authentication_diagnostic_docs.py b/scripts/tests/test_authentication_diagnostic_docs.py new file mode 100644 index 0000000000..7323dad5e3 --- /dev/null +++ b/scripts/tests/test_authentication_diagnostic_docs.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Run the copied authentication recipes against synthetic local HTTP fixtures. + +Use pulse-worker-source-proof: no real credentials, IdP, proxy or AI write. +Handler controls are separate; these tests prove the commands readers copy. +""" +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import threading +import unittest + +from test_api_auth_docs import ROOT, TEST_TOKEN, exercise_curl, recording_server + + +DOCUMENTS = {name: ROOT / f"docs/{name}.md" for name in ("AI_AUTONOMY", "PROXY_AUTH")} +EXPECTED_AI = ( + ("GET", "/api/ai/patrol/autonomy", None), + ("PUT", "/api/ai/patrol/autonomy", { + "autonomy_level": "approval", "investigation_budget": 15, "investigation_timeout_sec": 600, + }), + ("PUT", "/api/settings/ai/update", {"control_level": "controlled"}), +) + + +def blocks(name): + return re.findall(r"```bash\n(.*?)```", DOCUMENTS[name].read_text(), re.DOTALL) + + +@contextmanager +def proxy_server(status): + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests.append((self.command, self.path, dict(self.headers))) + self.send_response(status) + self.send_header("Set-Cookie", "synthetic-session-must-not-be-printed") + self.send_header("Location", "/unexpected-redirect-target") + self.end_headers() + self.wfile.write(b"synthetic-private-settings-must-not-be-printed") + + def log_message(self, *_args): + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_address[1], requests + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +class AuthenticationDiagnosticDocsTest(unittest.TestCase): + def test_shell_recipes_never_contain_credentials_or_trace_options(self): + for name in DOCUMENTS: + with self.subTest(guide=name): + shell = "\n".join(blocks(name)) + self.assertNotRegex(shell, r"(?:admin:admin|\s-u\s|--user\b|X-Proxy-Secret:|\b\w*TOKEN=|--cookie\b)") + self.assertNotRegex(shell, r"(?:--insecure|--verbose|--trace\S*|--location|\s-k\b)") + self.assertIn("curl --disable", shell) + self.assertIn("--header \"@", shell) + + def test_docs_explain_permission_transport_and_end_to_end_limits(self): + ai = DOCUMENTS["AI_AUTONOMY"].read_text() + for text in ("settings:write", "even for GET", "changes Patrol mode", "after an uncertain write", + "HTTPS", "not use a write as an", "private header file"): + self.assertIn(text, ai) + proxy = DOCUMENTS["PROXY_AUTH"].read_text() + for text in ("not expose the", "not a successful denial test", "not** your proxy's header", + "Do not add an API token", "old behaviour", "always supplying a non-empty role"): + self.assertIn(text, proxy) + self.assertNotIn("that must also be refused", proxy.split("## Pulse 5.x", 1)[1]) + + def test_shipped_mirrors_match(self): + for name, doc in DOCUMENTS.items(): + self.assertEqual(doc.read_bytes(), (ROOT / f"frontend-modern/public/docs/{name}.md").read_bytes()) + + def test_ai_recipes_send_only_the_intended_method_path_and_body(self): + recipes = blocks("AI_AUTONOMY") + self.assertEqual(len(recipes), len(EXPECTED_AI)) + with recording_server() as (port, requests): + for key, value in (("X-API-Token", TEST_TOKEN), ("Authorization", "Bearer " + TEST_TOKEN)): + for recipe, (method, path, body) in zip(recipes, EXPECTED_AI): + with self.subTest(header=key, method=method, path=path), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = exercise_curl(self, Path(temporary), f"{key}: {value}", port, recipe) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(len(requests), before + 1) + sent_path, headers, sent_method, sent_body = requests[-1] + self.assertEqual((sent_method, sent_path), (method, path)) + self.assertEqual(headers[key], value) + self.assertNotIn("X-Curlrc-Injected", headers) + self.assertEqual(json.loads(sent_body) if body is not None else sent_body, + body if body is not None else b"") + + def test_ai_recipes_report_401_and_403_without_submitting_a_later_step(self): + for status in (401, 403): + with recording_server(status) as (port, requests): + for recipe in blocks("AI_AUTONOMY"): + with self.subTest(status=status, recipe=recipe), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = exercise_curl(self, Path(temporary), f"X-API-Token: {TEST_TOKEN}", port, recipe) + self.assertEqual(result.returncode, 22) + self.assertEqual(len(requests), before + 1) + + def exercise_proxy(self, home, port, role, *, editor_exit=0): + recipes = blocks("PROXY_AUTH") + self.assertEqual(len(recipes), 1) + recipe = recipes[0].replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") + headers = home / "synthetic-proxy-headers" + headers.write_text(f"X-Proxy-Secret: {TEST_TOKEN}\nX-Authentik-Username: testuser\n" + role) + tools = home / "tools" + tools.mkdir() + editor = tools / "vi" + editor.write_text( + "#!/usr/bin/env python3\nimport json, os, stat, sys\nfrom pathlib import Path\n" + "p=Path(sys.argv[1]); d={'file':str(p), 'file_mode':stat.S_IMODE(p.stat().st_mode), " + "'directory_mode':stat.S_IMODE(p.parent.stat().st_mode)}\n" + "Path(os.environ['EDITOR_RECEIPT']).write_text(json.dumps(d))\n" + "p.write_bytes(Path(os.environ['FIXTURE_HEADERS']).read_bytes())\n" + "sys.exit(int(os.environ['EDITOR_EXIT']))\n" + ) + editor.chmod(0o700) + recorder = tools / "curl" + recorder.write_text( + "#!/usr/bin/env python3\nimport json, os, sys\nfrom pathlib import Path\n" + "Path(os.environ['ARGV_RECEIPT']).write_text(json.dumps(sys.argv[1:]))\n" + "os.execv(os.environ['REAL_CURL'], [os.environ['REAL_CURL'], *sys.argv[1:]])\n" + ) + recorder.chmod(0o700) + real_curl = shutil.which("curl") + self.assertIsNotNone(real_curl) + trace = home / "curl-trace" + (home / ".curlrc").write_text(f'header = "X-Curlrc-Injected: yes"\ntrace-ascii = "{trace}"\n') + env = dict(os.environ, HOME=str(home), PATH=f"{tools}:{os.environ['PATH']}", CURL_HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), TMPDIR=str(home), + FIXTURE_HEADERS=str(headers), EDITOR_RECEIPT=str(home / "editor.json"), + ARGV_RECEIPT=str(home / "argv.json"), REAL_CURL=real_curl, EDITOR_EXIT=str(editor_exit)) + for key in list(env): + if key.lower().endswith("_proxy"): + del env[key] + result = subprocess.run(["bash", "-c", recipe], env=env, capture_output=True, timeout=10) + edited = json.loads((home / "editor.json").read_text()) + self.assertEqual((edited["directory_mode"], edited["file_mode"]), (0o700, 0o600)) + self.assertFalse(Path(edited["file"]).parent.exists(), "temporary credential directory must be cleaned") + self.assertFalse(trace.exists()) + if not editor_exit: + argv = json.loads((home / "argv.json").read_text()) + self.assertEqual(argv[0], "--disable") + self.assertIn("@" + edited["file"], argv) + self.assertNotIn(TEST_TOKEN, " ".join(argv)) + else: + self.assertFalse((home / "argv.json").exists()) + self.assertNotIn(TEST_TOKEN.encode(), result.stdout + result.stderr) + return result + + def test_proxy_probe_preserves_roles_reports_status_only_and_never_follows_redirects(self): + for status in (200, 401, 403, 302): + with proxy_server(status) as (port, requests): + for role in ("X-Proxy-Roles: none\n", "", "X-Proxy-Roles:\n"): + with self.subTest(status=status, role=role), tempfile.TemporaryDirectory() as temporary: + before = len(requests) + result = self.exercise_proxy(Path(temporary), port, role) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.assertEqual(result.stdout, f"HTTP {status}\n".encode()) + self.assertEqual(result.stderr, b"") + self.assertEqual(len(requests), before + 1) + method, path, headers = requests[-1] + self.assertEqual((method, path), ("GET", "/api/system/settings")) + self.assertEqual(headers["X-Proxy-Secret"], TEST_TOKEN) + self.assertEqual(headers["X-Authentik-Username"], "testuser") + self.assertEqual(headers.get("X-Proxy-Roles"), None if not role else ("none" if "none" in role else "")) + for unexpected in ("X-API-Token", "Authorization", "Cookie", "X-Curlrc-Injected"): + self.assertNotIn(unexpected, headers) + + def test_proxy_editor_failure_cleans_up_and_sends_no_request(self): + with proxy_server(200) as (port, requests), tempfile.TemporaryDirectory() as temporary: + result = self.exercise_proxy(Path(temporary), port, "", editor_exit=73) + self.assertEqual(result.returncode, 73) + self.assertEqual(requests, []) + + +if __name__ == "__main__": + unittest.main() From 43b81bbb2b917604e24333f4efcb0c4186bf626f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:02:53 +0100 Subject: [PATCH 43/86] Exercise curl header removal and explicit empty roles separately The first complete authentication-help proof exposed a fixture expectation error: curl removes a colon-only header rather than sending an empty value. Preserve that case and add the semicolon form, checking the exact transmitted value across success, denial and redirect controls. Documentation and runtime behaviour are unchanged. Contract-Neutral: Executable documentation control correction only; no authentication, authorisation, frontend or runtime contract delta. Change-source: pulse-maintainer --- scripts/tests/test_authentication_diagnostic_docs.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/tests/test_authentication_diagnostic_docs.py b/scripts/tests/test_authentication_diagnostic_docs.py index 7323dad5e3..b3b129fcfc 100644 --- a/scripts/tests/test_authentication_diagnostic_docs.py +++ b/scripts/tests/test_authentication_diagnostic_docs.py @@ -167,7 +167,14 @@ class AuthenticationDiagnosticDocsTest(unittest.TestCase): def test_proxy_probe_preserves_roles_reports_status_only_and_never_follows_redirects(self): for status in (200, 401, 403, 302): with proxy_server(status) as (port, requests): - for role in ("X-Proxy-Roles: none\n", "", "X-Proxy-Roles:\n"): + # curl's colon-only form removes a header. A semicolon sends + # an explicitly empty header; cover both as well as omission. + for role, expected_role in ( + ("X-Proxy-Roles: none\n", "none"), + ("", None), + ("X-Proxy-Roles:\n", None), + ("X-Proxy-Roles;\n", ""), + ): with self.subTest(status=status, role=role), tempfile.TemporaryDirectory() as temporary: before = len(requests) result = self.exercise_proxy(Path(temporary), port, role) @@ -179,7 +186,7 @@ class AuthenticationDiagnosticDocsTest(unittest.TestCase): self.assertEqual((method, path), ("GET", "/api/system/settings")) self.assertEqual(headers["X-Proxy-Secret"], TEST_TOKEN) self.assertEqual(headers["X-Authentik-Username"], "testuser") - self.assertEqual(headers.get("X-Proxy-Roles"), None if not role else ("none" if "none" in role else "")) + self.assertEqual(headers.get("X-Proxy-Roles"), expected_role) for unexpected in ("X-API-Token", "Authorization", "Cookie", "X-Curlrc-Injected"): self.assertNotIn(unexpected, headers) From 3a98883de1f92ceb0c51ab2c71effa67274940c4 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:09:42 +0100 Subject: [PATCH 44/86] Preserve notification verdicts through SMTP cleanup and ntfy diagnostics Stop resending mail after the final DATA acceptance solely because QUIT failed. Keep interrupted ntfy recovery rejections authoritative in the retry decision and audit. Preserve missing acceptance, TLS checks, retry budgets and explicit operator recovery; verify all SMTP transports and persistent queue/audit readback with synthetic local receivers. Change-source: pulse-maintainer --- .../v6/internal/subsystems/notifications.md | 31 ++ .../notifications/email_acceptance_test.go | 264 ++++++++++++++++++ internal/notifications/email_enhanced.go | 21 +- internal/notifications/notifications.go | 6 + .../ntfy_response_verdict_test.go | 119 ++++++++ 5 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 internal/notifications/email_acceptance_test.go create mode 100644 internal/notifications/ntfy_response_verdict_test.go diff --git a/docs/release-control/v6/internal/subsystems/notifications.md b/docs/release-control/v6/internal/subsystems/notifications.md index ae96dfcbce..1e63866860 100644 --- a/docs/release-control/v6/internal/subsystems/notifications.md +++ b/docs/release-control/v6/internal/subsystems/notifications.md @@ -15,6 +15,37 @@ ## Purpose +### Delivery verdicts survive session cleanup and interrupted diagnostics + +A successful final SMTP DATA reply establishes server acceptance. Plain SMTP, +TLS and STARTTLS delivery must not resend an accepted message because QUIT +fails, nor turn that cleanup failure into a terminal delivery audit. Cleanup +still runs, with only a fixed local diagnostic; the provider's reply text is +not copied into it. Missing acknowledgement, certificate failures and rejected +DATA replies remain errors with their existing retry policy. A connection test +without a completed DATA transaction still reports QUIT failure. Server +acceptance is not proof of arrival in a recipient's inbox or exactly-once +recovery after a process crash. + +The ntfy plain-text recovery path, like the shared firing sender, retains the +HTTP rejection class when reading the diagnostic response body fails. It +preserves the wrapped read error, existing retry budgets and bounded body read; +401/403/422 remain permanent failures, while transient HTTP verdicts remain +retryable. Incomplete 2xx responses retain the existing failure behaviour. +Historical audit rows are not rewritten and operator retry stays explicit. + +Verification: `email_acceptance_test.go` uses actual guest-local plain/TLS/ +STARTTLS SMTP sessions for clean, missing and rejected QUIT, permanent and +transient DATA rejection, missing acknowledgement, threaded/attachment sends, +connection-only tests and certificate refusal. Its connected persistent queue +control retains one accepted send and zero terminal failures after reopen. +`ntfy_response_verdict_test.go` exercises real interrupted HTTP bodies, then +queue/audit/telemetry readback after reopen and an explicit repaired-destination +retry without erasing the original rejection. These are synthetic source +controls, not a cause attribution for aggregate fleet counters, installed +provider acceptance or a reporter's confirmation. + + ### Pending firing groups count occurrences, not callbacks Before a grouping window expires, repeated firing callbacks with the same full diff --git a/internal/notifications/email_acceptance_test.go b/internal/notifications/email_acceptance_test.go new file mode 100644 index 0000000000..b81b61c069 --- /dev/null +++ b/internal/notifications/email_acceptance_test.go @@ -0,0 +1,264 @@ +package notifications + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/textproto" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// Real guest-local SMTP connections cover all three transports. A message is +// accepted only after the final DATA reply, not when its bytes arrive. QUIT +// errors deliberately contain private-looking prose: cleanup diagnostics must +// neither resend the accepted message nor copy that prose into a new log. +func acceptanceSMTPServer(t *testing.T, mode string, dataCode int, quitReply string) (*EnhancedEmailManager, *atomic.Int32, *atomic.Int32) { + t.Helper() + certServer := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + tlsConfig := certServer.TLS.Clone() + certServer.Close() + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + var sessions sync.WaitGroup + connections, accepted := &atomic.Int32{}, &atomic.Int32{} + done := make(chan struct{}) + go func() { + defer close(done) + for { + raw, err := listener.Accept() + if err != nil { + return + } + connections.Add(1) + sessions.Add(1) + go func() { + defer sessions.Done() + defer raw.Close() + _ = raw.SetDeadline(time.Now().Add(5 * time.Second)) + wire := raw + if mode == "TLS" { + wire = tls.Server(raw, tlsConfig) + } + conn := textproto.NewConn(wire) + if err := conn.PrintfLine("220 localhost ESMTP"); err != nil { + return + } + upgraded := mode == "TLS" + for { + line, err := conn.ReadLine() + if err != nil { + return + } + switch strings.Fields(line)[0] { + case "EHLO", "HELO": + if mode == "STARTTLS" && !upgraded { + err = conn.PrintfLine("250-localhost\r\n250 STARTTLS") + } else { + err = conn.PrintfLine("250 localhost") + } + case "STARTTLS": + if err = conn.PrintfLine("220 Ready for TLS"); err == nil { + conn = textproto.NewConn(tls.Server(raw, tlsConfig)) + upgraded = true + } + case "MAIL", "RCPT": + err = conn.PrintfLine("250 OK") + case "DATA": + if err = conn.PrintfLine("354 Send message"); err != nil { + return + } + if _, err = conn.ReadDotBytes(); err != nil { + return + } + if dataCode == 0 { // No acknowledgement: acceptance is unknown. + return + } + err = conn.PrintfLine("%d transaction verdict", dataCode) + if err == nil && dataCode == 250 { + accepted.Add(1) + } + case "QUIT": + if quitReply != "" { + _ = conn.PrintfLine("%s", quitReply) + } + return + default: + t.Errorf("unexpected SMTP command %q", line) + return + } + if err != nil { + return + } + } + }() + } + }() + t.Cleanup(func() { _ = listener.Close(); <-done; sessions.Wait() }) + _, rawPort, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(rawPort) + if err != nil { + t.Fatal(err) + } + manager := NewEnhancedEmailManager(EmailProviderConfig{ + EmailConfig: EmailConfig{SMTPHost: "127.0.0.1", SMTPPort: port, + From: "pulse@example.test", To: []string{"recipient@example.test"}, TLS: mode == "TLS"}, + StartTLS: mode == "STARTTLS", SkipTLSVerify: true, // Ephemeral fixture certificate only. + MaxRetries: 2, RetryDelay: 0, + }) + return manager, connections, accepted +} + +func TestSMTPDeliveryAcceptanceSurvivesCleanup(t *testing.T) { + for _, mode := range []string{"plain", "TLS", "STARTTLS"} { + for _, tc := range []struct { + name, quit string + dataCode int + class NotificationFailureClass + attempts int32 + }{ + {"clean", "221 Bye", 250, "", 1}, + {"closed at QUIT", "", 250, "", 1}, + {"rejected QUIT", "500 private-cleanup-token", 250, "", 1}, + {"auth prose at QUIT", "535 private-cleanup-token", 250, "", 1}, + {"message rejected", "221 Bye", 550, NotificationFailureRejected, 1}, + {"temporary message failure", "221 Bye", 451, NotificationFailureServerError, 3}, + {"missing acceptance", "221 Bye", 0, NotificationFailureUnknown, 3}, + } { + t.Run(mode+"/"+tc.name, func(t *testing.T) { + manager, connections, accepted := acceptanceSMTPServer(t, mode, tc.dataCode, tc.quit) + captured := captureNotificationQueueLogs(t) + err := manager.SendEmailWithRetry("test", "

test

", "test") + if (err == nil) != (tc.class == "") { + t.Errorf("delivery error = %v; want class %q", err, tc.class) + } + if tc.class != "" && ClassifyNotificationFailureError(err) != tc.class { + t.Errorf("failure class = %q; want %q", ClassifyNotificationFailureError(err), tc.class) + } + if got := connections.Load(); got != tc.attempts { + t.Errorf("connections = %d; want %d", got, tc.attempts) + } + wantAccepted := int32(0) + if tc.dataCode == 250 { + wantAccepted = 1 + } + if got := accepted.Load(); got != wantAccepted { + t.Errorf("accepted messages = %d; want %d", got, wantAccepted) + } + if strings.Contains(captured.String(), "private-cleanup-token") { + t.Error("cleanup diagnostics exposed response text") + } + }) + } + } +} + +func TestSMTPAcceptedThreadedAndAttachmentDelivery(t *testing.T) { + for _, mode := range []string{"plain", "TLS", "STARTTLS"} { + for _, variant := range []string{"threaded", "attachment"} { + t.Run(mode+"/"+variant, func(t *testing.T) { + manager, connections, accepted := acceptanceSMTPServer(t, mode, 250, "") + var err error + if variant == "threaded" { + err = manager.SendEmailThreaded("test", "

test

", "test", "") + } else { + err = manager.SendEmailWithAttachments("test", "

test

", "test", + []EmailAttachment{{Filename: "test.txt", ContentType: "text/plain", Data: []byte("test")}}) + } + if err != nil || connections.Load() != 1 || accepted.Load() != 1 { + t.Errorf("error=%v connections=%d accepted=%d; want one accepted message", err, connections.Load(), accepted.Load()) + } + }) + } + } +} + +func TestSMTPConnectionCheckAndCertificateFailureRemainFailures(t *testing.T) { + for _, mode := range []string{"plain", "TLS", "STARTTLS"} { + t.Run(mode, func(t *testing.T) { + manager, connections, accepted := acceptanceSMTPServer(t, mode, 250, "500 cleanup rejected") + if err := manager.TestConnection(); err == nil { + t.Error("connection test without DATA reported success after rejected QUIT") + } + if accepted.Load() != 0 || connections.Load() != 1 { + t.Errorf("connection check sent a message or retried: %d / %d", accepted.Load(), connections.Load()) + } + if mode != "plain" { + manager.config.SkipTLSVerify = false + manager.config.MaxRetries = 0 + err := manager.SendEmailWithRetry("test", "test", "test") + if err == nil || ClassifyNotificationFailureError(err) != NotificationFailureTLS || accepted.Load() != 0 { + t.Errorf("certificate failure was hidden: error=%v accepted=%d", err, accepted.Load()) + } + } + }) + } +} + +func TestSMTPAcceptedMessageQueueAuditAcrossRestart(t *testing.T) { + manager, connections, accepted := acceptanceSMTPServer(t, "plain", 250, "") + dir := t.TempDir() + q, err := NewNotificationQueue(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = q.Stop() }) + q.SetProcessor(func(*QueuedNotification) error { return manager.SendEmailWithRetry("test", "test", "test") }) + if err := q.Enqueue(&QueuedNotification{ID: "accepted", Type: "email", Config: []byte(`{}`), MaxAttempts: 1}); err != nil { + t.Fatal(err) + } + waitVerdictAudit(t, q, "accepted") + if err := q.Stop(); err != nil { + t.Fatal(err) + } + q, err = NewNotificationQueue(dir) + if err != nil { + t.Fatal(err) + } + var status, class string + var attempts, success int + if err := q.db.QueryRow(`SELECT status, attempts, success, failure_class FROM notification_audit WHERE notification_id = 'accepted'`).Scan(&status, &attempts, &success, &class); err != nil { + t.Fatal(err) + } + stats, err := q.GetTelemetryStats(time.Now().Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + if status != string(QueueStatusSent) || attempts != 1 || success != 1 || class != "" || + stats.Attempts != 1 || stats.Deliveries != 1 || stats.Failures != 0 || connections.Load() != 1 || accepted.Load() != 1 { + t.Errorf("accepted message became duplicate/failure: audit=%s/%d/%d/%s stats=%+v connections=%d accepted=%d", + status, attempts, success, class, stats, connections.Load(), accepted.Load()) + } + if count, err := q.RetryTerminalFailures(); err != nil || count != 0 { + t.Errorf("accepted message available for operator retry: %d / %v", count, err) + } +} + +func waitVerdictAudit(t *testing.T, q *NotificationQueue, id string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + var count int + if err := q.db.QueryRow(`SELECT count(*) FROM notification_audit WHERE notification_id = ?`, id).Scan(&count); err != nil { + t.Fatal(err) + } + if count > 0 { + return + } + if time.Now().After(deadline) { + t.Fatal(fmt.Sprintf("missing delivery audit for %s", id)) + } + time.Sleep(time.Millisecond) + } +} diff --git a/internal/notifications/email_enhanced.go b/internal/notifications/email_enhanced.go index ffc644fc32..63c411fe7a 100644 --- a/internal/notifications/email_enhanced.go +++ b/internal/notifications/email_enhanced.go @@ -669,7 +669,8 @@ func (e *EnhancedEmailManager) sendTLS(addr string, msg []byte, addresses resolv return fmt.Errorf("message close failed: %w", err) } - return client.Quit() + finishAcceptedSMTPSession(client) + return nil } // sendStartTLS sends email using STARTTLS @@ -739,7 +740,8 @@ func (e *EnhancedEmailManager) sendStartTLS(addr string, msg []byte, addresses r return fmt.Errorf("message close failed: %w", err) } - return client.Quit() + finishAcceptedSMTPSession(client) + return nil } // TestConnection tests the email server connection @@ -859,5 +861,18 @@ func (e *EnhancedEmailManager) sendPlain(addr string, msg []byte, addresses reso return fmt.Errorf("message close failed: %w", err) } - return client.Quit() + finishAcceptedSMTPSession(client) + return nil +} + +// The successful final DATA reply has transferred responsibility for the +// message to the server. QUIT is session cleanup, not another delivery verdict: +// returning its error would resend an accepted message at either retry layer. +// Missing or rejected DATA replies still take the ordinary failure path. +func finishAcceptedSMTPSession(client *smtp.Client) { + if err := client.Quit(); err != nil { + // Reply text may contain private provider details; keep this diagnostic + // fixed and separate from the successful delivery audit. + log.Debug().Msg("SMTP session cleanup failed after message acceptance") + } } diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 1741712f9d..18d01ea67a 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -3035,6 +3035,12 @@ func (n *NotificationManager) sendResolvedWebhookNtfy(webhook WebhookConfig, ale Str("webhook", webhook.Name). Str("service", "ntfy"). Msg("failed to read resolved ntfy webhook response body") + // A rejected recovery has the same authoritative HTTP verdict as a + // firing delivery, even if its diagnostic body is interrupted. Keep + // permanent refusals out of the connectivity retry path. + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return FailfWithClass(ClassFromHTTPStatus(resp.StatusCode), "ntfy webhook returned HTTP %d: failed to read ntfy webhook response: %w", resp.StatusCode, err) + } return fmt.Errorf("failed to read ntfy webhook response: %w", err) } diff --git a/internal/notifications/ntfy_response_verdict_test.go b/internal/notifications/ntfy_response_verdict_test.go new file mode 100644 index 0000000000..9fdf4576bb --- /dev/null +++ b/internal/notifications/ntfy_response_verdict_test.go @@ -0,0 +1,119 @@ +package notifications + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "sync/atomic" + "testing" + "time" +) + +func TestResolvedNtfyTruncatedResponseRetainsVerdict(t *testing.T) { + for _, code := range []int{200, 401, 403, 421, 422, 423, 425, 429, 503} { + t.Run(strconv.Itoa(code), func(t *testing.T) { + server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "100") + w.WriteHeader(code) + _, _ = fmt.Fprint(w, "short") + })) + defer server.Close() + manager := &NotificationManager{webhookClient: server.Client(), webhookRateLimits: make(map[string]*webhookRateLimit)} + if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { + t.Fatal(err) + } + err := manager.sendResolvedWebhookNtfy(WebhookConfig{URL: server.URL, Service: "ntfy"}, nil, time.Now()) + if !errors.Is(err, io.ErrUnexpectedEOF) { + t.Fatalf("response read error lost: %v", err) + } + want := ClassFromHTTPStatus(code) + if code == 200 { // Do not manufacture success for an incomplete 2xx response. + want = NotificationFailureConnectivity + } + if got := ClassifyNotificationFailureError(err); got != want { + t.Errorf("class = %s; want %s", got, want) + } + }) + } +} + +func TestResolvedNtfyRejectionAuditAndOperatorRetry(t *testing.T) { + var repaired atomic.Bool + var requests atomic.Int32 + server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if repaired.Load() { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Length", "100") + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, "short") + })) + defer server.Close() + manager := &NotificationManager{webhookClient: server.Client(), webhookRateLimits: make(map[string]*webhookRateLimit)} + if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32"); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + open := func() *NotificationQueue { + q, err := NewNotificationQueue(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = q.Stop() }) + return q + } + processor := func(*QueuedNotification) error { + return manager.sendResolvedWebhookNtfy(WebhookConfig{URL: server.URL, Service: "ntfy"}, nil, time.Now()) + } + q := open() + q.SetProcessor(processor) + if err := q.Enqueue(&QueuedNotification{ID: "rejected", Type: "webhook_resolved", Config: []byte(`{}`), MaxAttempts: 3}); err != nil { + t.Fatal(err) + } + waitVerdictAudit(t, q, "rejected") + if err := q.Stop(); err != nil { + t.Fatal(err) + } + q = open() + var status, class string + var attempts int + if err := q.db.QueryRow(`SELECT status, attempts, failure_class FROM notification_audit WHERE notification_id = 'rejected'`).Scan(&status, &attempts, &class); err != nil { + t.Fatal(err) + } + stats, err := q.GetTelemetryStats(time.Now().Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + if status != string(QueueStatusDLQ) || attempts != 1 || class != string(NotificationFailureAuthentication) || + stats.Attempts != 1 || stats.Failures != 1 || stats.Deliveries != 0 || stats.FailureClasses.Authentication != 1 || requests.Load() != 1 { + t.Fatalf("rejection lost: audit=%s/%d/%s stats=%+v HTTP requests=%d", status, attempts, class, stats, requests.Load()) + } + // Only an explicit operator retry after the destination is repaired resumes + // delivery. It retains the original terminal failure and the successful send. + repaired.Store(true) + if count, err := q.RetryTerminalFailures(); err != nil || count != 1 { + t.Fatalf("operator retry = %d / %v; want one retained failure", count, err) + } + q.SetProcessor(processor) + deadline := time.Now().Add(5 * time.Second) + for { + stats, err = q.GetTelemetryStats(time.Now().Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + if stats.Deliveries == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("operator retry did not deliver") + } + time.Sleep(time.Millisecond) + } + if stats.Attempts != 2 || stats.Failures != 1 || stats.FailureClasses.Authentication != 1 || requests.Load() != 2 { + t.Errorf("operator retry erased history or duplicated delivery: stats=%+v HTTP requests=%d", stats, requests.Load()) + } +} From 9df89142d3ffff5ac81c423537e287b194b589f5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:45:44 +0100 Subject: [PATCH 45/86] Keep notification activity truthful when evidence reads fail Track held-event availability independently of delivery attempts. Do not call a pending or partial read empty, withdraw only the unreadable source's rows, and retain the warning through retry. Preserve current-request ownership, editor state and all notification policies. Qualify the production Notifications tab with desktop Chromium and phone WebKit keyboard/touch read-and-retry controls. Record the owning-parent browser binding and both frontend/alert contracts. Change-source: pulse-maintainer --- .../v6/internal/subsystems/alerts.md | 22 + .../subsystems/frontend-primitives.md | 18 + .../browser-tests/notification-evidence.cjs | 383 ++++++++++++++++++ .../browser-tests/notification-evidence.html | 12 + .../browser-tests/notification-evidence.tsx | 69 ++++ frontend-modern/browser-verification.json | 98 +++-- .../alerts/AlertDeliveryLogCard.test.tsx | 111 ++++- .../features/alerts/AlertDeliveryLogCard.tsx | 206 ++++++---- .../DestinationsTab.recoveryediting.test.tsx | 2 +- .../useAlertDestinationsTabState.test.tsx | 11 + .../useNotificationDeliveryLog.test.tsx | 58 +++ .../features/alerts/tabs/DestinationsTab.tsx | 2 + .../alerts/useAlertDestinationsTabState.ts | 4 + .../alerts/useNotificationDeliveryLog.ts | 15 +- .../alertDestinationsPresentation.test.ts | 25 ++ .../utils/alertDestinationsPresentation.ts | 14 +- 16 files changed, 909 insertions(+), 141 deletions(-) create mode 100644 frontend-modern/browser-tests/notification-evidence.cjs create mode 100644 frontend-modern/browser-tests/notification-evidence.html create mode 100644 frontend-modern/browser-tests/notification-evidence.tsx diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index fefabf9828..20fd82c066 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -3236,3 +3236,25 @@ The monitor delegates recovery eligibility to persisted occurrence/destination receipts even when an old retry remains or restart has lost RAM cooldown state. The partial-destination monitor control establishes a real local HTTP 200/503 split and preserves the newer firing and the old recipient's recovery. + +### Notification activity reports each evidence source independently + +The existing Notifications activity card distinguishes an initial or pending +read from a confirmed empty window. Held/deferred event reads have their own +loading and unavailable state; a failed read withdraws their prior rows and +keeps the warning until a later successful read. Only the current refresh owns +those states, and disposed readers cannot publish late completions. A slow +held-event read does not delay delivery attempts or disable their refresh. + +A delivery-attempt failure withdraws only attempt rows, not independently +readable held events. Conversely, missing held evidence does not hide readable +attempts or imply that no notifications were held. The combined empty state +requires both sources to be readable and no refresh pending. Retention, event +limits, queue actions, permissions and notification delivery policy are unchanged. + +`useNotificationDeliveryLog.test.tsx`, `useAlertDestinationsTabState.test.tsx` +and `AlertDeliveryLogCard.test.tsx` exercise independent failure, recovery, +initial loading and current-request ownership; the production Notifications +tab browser fixture verifies the connected read/retry states at desktop and +phone widths. These are synthetic frontend controls, not recipient delivery +or containing-release acceptance. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a51a26ce5a..698506168e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7908,3 +7908,21 @@ kind normalisation, the kind switch, the email-only payload, and the plain cover the gating, the reduced form, the pinned fields, and the saved payload. This is a presentation and form-state boundary; the schedule API contract, report rendering, and email delivery are unchanged. + +### Notifications activity has independent evidence-read states + +The Notifications tab composes the delivery-log card with both attempt-read +and held/deferred-event loading and unavailable state from its owned reader. +Pending or failed evidence is never rendered as a confirmed empty activity +window. Each failed source withdraws its own rows without hiding independently +readable rows from the other source. Source-specific warnings have `role="alert"` +and loading text has `role="status"`; a slow held-event read does not block the +existing attempt-refresh button. Recovery clears a warning only after a +successful read, without remounting the destination editors or changing their +unfinished input. + +The connected Notifications browser fixture uses the real tab/state/API/card +chain at desktop and phone widths with keyboard/touch refresh, class-dark CSS, +403/503 controls and pending recovery. Unit controls separately cover current +request ownership and disposal. No mutation, route, navigation entry, queue +policy, permission or recipient-delivery contract is added by these states. diff --git a/frontend-modern/browser-tests/notification-evidence.cjs b/frontend-modern/browser-tests/notification-evidence.cjs new file mode 100644 index 0000000000..8a6fef7319 --- /dev/null +++ b/frontend-modern/browser-tests/notification-evidence.cjs @@ -0,0 +1,383 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const output = '/workspace/tmp/notification-evidence-proof'; +const root = '/workspace/frontend-modern'; +const runtime = [ + 'frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx', + 'frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts', + 'frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts', + 'frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx', + 'frontend-modern/src/utils/alertDestinationsPresentation.ts', +]; +const log = (entries = []) => ({ + entries, + window_days: 30, + completed_retention_days: 7, + dead_letter_retention_days: 30, +}); +const sent = { + notificationId: 'synthetic-attempt', + type: 'email', + outcome: 'sent', + alertIds: ['synthetic-disk-alert'], + alertCount: 1, + attempts: 1, + success: true, + timestamp: '2026-10-03T10:00:00Z', +}; +const held = [ + { + id: 42, + type: 'notification_deferred', + alertId: 'synthetic-held-alert', + resourceName: 'synthetic-nas', + alertType: 'usage', + reason: 'quiet_hours:performance', + occurredAt: '2026-10-03T10:01:00Z', + }, +]; +(async () => { + fs.mkdirSync(output, { recursive: true }); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const hashes = Object.fromEntries( + runtime.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join('/workspace', p))) + .digest('hex'), + ]), + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5291, strictPort: true }, + }); + const results = [], + screens = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop', chromium, 1365, false], + ['phone', webkit, 390, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + isMobile: width === 390, + hasTouch: width === 390, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(20000); + const errors = [], + offOrigin = [], + writes = [], + requests = [], + checks = [], + releases = []; + let attemptMode = 'hold-empty', + heldMode = 'hold-empty'; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', async (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5291') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (request.method() !== 'GET') { + writes.push({ method: request.method(), path: url.pathname }); + return route.abort(); + } + if ( + url.pathname === '/api/notifications/delivery-log' || + url.pathname === '/api/alerts/events' + ) { + const isHeld = url.pathname === '/api/alerts/events'; + const mode = isHeld ? heldMode : attemptMode; + requests.push({ path: url.pathname, query: url.search, mode }); + if (mode.startsWith('hold-')) + await new Promise((resolve) => releases.push({ isHeld, resolve })); + if (mode.endsWith('denied')) + return route.fulfill({ + status: isHeld ? 403 : 503, + json: { error: 'Synthetic unavailable evidence' }, + }); + return route.fulfill({ + json: isHeld + ? mode.endsWith('known') + ? held + : [] + : log(mode.endsWith('known') ? [sent] : []), + }); + } + if (url.pathname === '/api/notifications/health') + return route.fulfill({ + json: { + overall_healthy: true, + queue: { + pending: 0, + sending: 0, + sent: 1, + failed: 0, + dlq: 0, + healthy: true, + status: 'healthy', + attention_required: 0, + reason_codes: [], + completed_retention_days: 7, + dead_letter_retention_days: 30, + counts_are_retention_bounded: true, + }, + }, + }); + if (url.pathname === '/api/license/runtime-capabilities') + return route.fulfill({ + json: { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + }, + }); + if (url.pathname === '/api/license/entitlements') + return route.fulfill({ json: { entitlements: [] } }); + if (url.pathname === '/api/security/status') + return route.fulfill({ json: { hasAuthentication: true, requiresAuth: false } }); + return route.fulfill({ json: [] }); + }); + const check = async (label, action) => { + await action(); + checks.push({ label, passed: true }); + }; + const activity = page.locator('#notification-delivery-activity'); + const empty = activity.getByText(/No alert deliveries were attempted/); + const heldWarning = activity.getByText(/could not read held or deferred notifications/); + const attemptWarning = activity.getByText(/could not read the delivery log/); + const refresh = activity.getByRole('button', { name: 'Refresh delivery status' }); + const release = (isHeld) => { + for (const pending of releases.splice(0).filter((item) => { + if (item.isHeld === isHeld) { + item.resolve(); + return false; + } + return true; + })) + releases.push(pending); + }; + const capture = async (state) => { + const filename = `${name}-${state}.png`; + await activity.screenshot({ path: path.join(output, filename) }); + screens.push({ + file: filename, + state, + viewport: { width, height: width === 390 ? 844 : 900 }, + sha256: crypto + .createHash('sha256') + .update(fs.readFileSync(path.join(output, filename))) + .digest('hex'), + }); + }; + try { + await page.goto('http://127.0.0.1:5291/browser-tests/notification-evidence.html'); + await page.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + dark, + ); + await check('Initial reads are loading, not empty', async () => { + await activity.getByText('Loading delivery attempts...').waitFor(); + await activity.getByText('Loading held and deferred notifications...').waitFor(); + assert.equal(await empty.count(), 0); + }); + await check( + 'A slow held read does not block the successful attempts read or its refresh', + async () => { + release(false); + await activity.getByText('Loading delivery attempts...').waitFor({ state: 'hidden' }); + assert.equal(await refresh.isEnabled(), true); + assert.equal(await empty.count(), 0); + await activity.getByText('Loading held and deferred notifications...').waitFor(); + }, + ); + await capture('held-pending'); + await check('Only two completed empty reads establish the empty window', async () => { + release(true); + await empty.waitFor(); + assert.equal(await activity.getByRole('status').count(), 0); + }); + await check( + 'Keyboard or touch refresh shows attempts and held events from their real API adapters', + async () => { + attemptMode = heldMode = 'known'; + if (width === 390) await refresh.tap(); + else { + await refresh.focus(); + await page.keyboard.press('Enter'); + } + await activity.getByText('Delivered', { exact: true }).waitFor(); + await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor(); + assert.equal(await empty.count(), 0); + }, + ); + await check( + 'Held-event 403 withdraws held rows and warns without hiding delivered evidence', + async () => { + heldMode = 'denied'; + await refresh.click(); + await heldWarning.waitFor(); + assert.equal( + await activity.getByText('synthetic-nas (usage)', { exact: true }).count(), + 0, + ); + assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1); + assert.equal(await empty.count(), 0); + }, + ); + await capture('held-unavailable'); + await check('The held warning persists during a pending retry', async () => { + heldMode = 'hold-empty'; + await refresh.click(); + await activity.getByText('Loading held and deferred notifications...').waitFor(); + assert.equal(await heldWarning.count(), 1); + await refresh.waitFor({ state: 'visible' }); + await page.waitForFunction( + () => !document.querySelector('#notification-delivery-activity button').disabled, + ); + assert.equal(await empty.count(), 0); + release(true); + await heldWarning.waitFor({ state: 'hidden' }); + }); + await check( + 'An attempt-read failure keeps independently readable held evidence', + async () => { + attemptMode = 'denied'; + heldMode = 'known'; + await refresh.click(); + await attemptWarning.waitFor(); + await activity.getByText('synthetic-nas (usage)', { exact: true }).waitFor(); + assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 0); + assert.equal(await empty.count(), 0); + }, + ); + await capture('attempts-unavailable'); + await check('Both failed sources are reported, not called empty', async () => { + heldMode = 'denied'; + await refresh.click(); + await heldWarning.waitFor(); + assert.equal(await attemptWarning.count(), 1); + assert.equal(await activity.getByRole('listitem').count(), 0); + assert.equal(await empty.count(), 0); + }); + await check( + 'Both successful reads clear the warnings and restore a genuine empty state', + async () => { + attemptMode = heldMode = 'empty'; + await refresh.click(); + await empty.waitFor(); + assert.equal(await activity.getByRole('alert').count(), 0); + assert.equal(await activity.getByRole('status').count(), 0); + }, + ); + await check( + 'No horizontal clipping, JavaScript page error, off-origin request or mutation', + async () => { + assert.equal( + await activity.evaluate((element) => element.scrollWidth <= element.clientWidth + 1), + true, + ); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert.deepEqual(writes, []); + assert( + requests + .filter((r) => r.path === '/api/alerts/events') + .every( + (r) => r.query.includes('limit=100') && r.query.includes('notification_deferred'), + ), + ); + assert( + requests + .filter((r) => r.path === '/api/notifications/delivery-log') + .every((r) => r.query === '?limit=200'), + ); + }, + ); + results.push({ + name, + browser_version: browser.version(), + dark, + checks, + requests, + errors, + offOrigin, + writes, + }); + } finally { + for (const pending of releases) pending.resolve(); + await browser.close(); + browser = null; + } + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + passed: true, + playwright_version: playwrightVersion, + content_sha256: hashes, + changed_paths: runtime, + results, + screenshots: screens, + }, + null, + 2, + ) + '\n', + ); + console.log( + JSON.stringify({ + passed: true, + checks: results.reduce((n, r) => n + r.checks.length, 0), + screenshots: screens.length, + playwright_version: playwrightVersion, + results: results.map((r) => ({ name: r.name, browser_version: r.browser_version })), + }), + ); + } catch (error) { + fs.writeFileSync( + path.join(output, 'failed.json'), + JSON.stringify( + { passed: false, error: error.message, results, screenshots: screens }, + null, + 2, + ) + '\n', + ); + throw error; + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/notification-evidence.html b/frontend-modern/browser-tests/notification-evidence.html new file mode 100644 index 0000000000..71c910e8fe --- /dev/null +++ b/frontend-modern/browser-tests/notification-evidence.html @@ -0,0 +1,12 @@ + + + + + + Notification evidence verification + + +
+ + + diff --git a/frontend-modern/browser-tests/notification-evidence.tsx b/frontend-modern/browser-tests/notification-evidence.tsx new file mode 100644 index 0000000000..cd358e54b1 --- /dev/null +++ b/frontend-modern/browser-tests/notification-evidence.tsx @@ -0,0 +1,69 @@ +// The production Notifications tab and API readers with synthetic read responses. +// No notification is sent, retried or dismissed by this fixture. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { DestinationsTab } from '../src/features/alerts/tabs/DestinationsTab'; +import type { UIEmailConfig, UIAppriseConfig } from '../src/features/alerts/types'; +import '../src/index.css'; + +function Fixture() { + const [emailConfig, setEmailConfig] = createSignal({ + enabled: false, + provider: 'smtp', + server: '', + port: 587, + username: '', + password: '', + from: '', + to: [], + tls: true, + startTLS: true, + replyTo: '', + maxRetries: 3, + retryDelay: 5, + rateLimit: 60, + }); + const [appriseConfig, setAppriseConfig] = createSignal({ + enabled: false, + mode: 'cli', + targetsText: '', + cliPath: '', + timeoutSeconds: 20, + serverUrl: '', + configKey: '', + apiKey: '', + apiKeyHeader: 'X-API-KEY', + skipTlsVerify: false, + hasApiKey: false, + }); + return ( +
+

Notifications

+ {}} + configLoadError={() => null} + isRetrying={() => false} + isLoadingDestinations={() => false} + onRetryLoad={() => {}} + webhooks={() => []} + deadManPingUrl={() => ''} + setDeadManPingUrl={() => {}} + pushMinimumSeverity={() => 'all'} + setPushMinimumSeverity={() => {}} + /> +
+ ); +} +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 9ccfa044bd..453fe2f009 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,39 +1,28 @@ { "version": 1, "result": "passed", - "base_sha": "7127054c659913b75abe302c5d377b8b6f49e9f2", - "verified_at": "2026-10-03T08:47:10.724Z", + "base_sha": "2d6a10ab59d327b461ef762ec69a9c5d0440929a", + "verified_at": "2026-10-03T11:37:53.905511Z", "changed_paths": [ - "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", - "frontend-modern/src/features/docker/DockerImagesTable.tsx", - "frontend-modern/src/features/docker/DockerServicesTable.tsx", - "frontend-modern/src/features/docker/DockerTasksTable.tsx", - "frontend-modern/src/features/docker/dockerContainerTableModel.ts", - "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts", - "frontend-modern/src/features/storageBackups/storageAdapters.ts", - "frontend-modern/src/hooks/useUnifiedResources.ts" + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx", + "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts", + "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", + "frontend-modern/src/utils/alertDestinationsPresentation.ts" ], "content_sha256": { - "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "54f1f84dac768c6e0e333254d5c65e59751fa78b5ac55826cdaa37085a73c608", - "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9fde1740e300724ef9cab4a3b036c942bdab6e5d2060f262606b27f4b1a40ea6", - "frontend-modern/src/features/docker/DockerImagesTable.tsx": "29284b34d9631f118a449da1c274b1028e3cea57aed37a3ac056e48e05b2b613", - "frontend-modern/src/features/docker/DockerServicesTable.tsx": "5a153e4b3533849ae952dadb50e512fadb87fcc9fba05403509168d8bb5caba6", - "frontend-modern/src/features/docker/DockerTasksTable.tsx": "58cf1656ba22475b52dc9a582a4d8f0cc8c01f38bfb3775518edac73dbf22b80", - "frontend-modern/src/features/docker/dockerContainerTableModel.ts": "b821b9caf65d5c247e543a6d6dbf0925f735b617c6393066d0f8c15a10e05e72", - "frontend-modern/src/features/storageBackups/resourceStoragePresentation.ts": "933723fb9afeeb53afa46946a346daca19d50c0d61a81fc0730498ebd8c09bb2", - "frontend-modern/src/features/storageBackups/storageAdapters.ts": "daa85c5d99903d5a5fb10070837106b91f06ad130cae320472b2d980b979fb48", - "frontend-modern/src/hooks/useUnifiedResources.ts": "166e45835c56d00f1a3ddbc37f7127348a0e36dc7c42c921f972db7750c603c6" + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "cac90b57e94b42360989262b550c9b0beaca8dd6eecdd7f7882911bb49de9fc2", + "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "e8ce66a9ca6ae82861a9d03a7d565f6c246f9b5fc547e7c2e64f0a3e6bd12014", + "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "eaf519a7f205be0b0e88ea17b19552f30afef025251262a1ea1b8896442e2673", + "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "9278ab7cf249eea451040b722b244d1447b05c07557d641187fa51f3bc88a225", + "frontend-modern/src/utils/alertDestinationsPresentation.ts": "5afa5dbce8eb1602377b7b27f6298438da76e3ebf2f80986677c9988bf36c96c" }, "routes": [ - "Existing Proxmox Mail Gateway table through production REST mapper", - "Existing storage-pool table through production adapter", - "Existing Docker container, image, Swarm service and task tables", - "Existing multi-disk Bars cell" + "Existing Alerts Notifications tab through production DestinationsTab, destination state, notification/alert API adapters and activity card, under synthetic read-only responses" ], "viewports": [ { - "width": 1440, + "width": 1365, "height": 900 }, { @@ -42,19 +31,18 @@ } ], "states": [ - "PMG REST-first queue 11, canonical in-memory store hydration then newer REST queue 19, removed PMG facet withdrawn", - "LVM-Thin and NFS plugin names; explicit shared cluster location reads Shared (3), local node remains pve2", - "Phone container identity, State/CPU/memory/Updates remain visible; Restarts 7 reachable by touch disclosure", - "Image phone projection keeps size and verdict; Host/Used by headers and cells hidden together", - "Swarm raw rollback_started reads as Rollback started; every word fits phone cell and Running heading uses Run; raw rollout and placement in keyboard/touch disclosure", - "Task started time reads as relative time on desktop with original timestamp retained; phone hides the started column", - "Fullest measured disk label 92% with /data evidence and unchanged two equal-weight member bars" + "Initial attempt and held-event reads pending without an empty claim", + "Successful attempts do not wait for held events; refresh remains usable", + "Both completed empty reads establish the empty window", + "Independent readable attempts and held-event rows", + "Held-event HTTP403 clears its rows and warns, without hiding attempt evidence", + "Pending held retry retains the warning until success", + "Attempt HTTP503 clears only attempt evidence, preserving readable held rows", + "Both failures warn without empty claims; successful reads clear warnings" ], "interactions": [ - "REST-first load before in-memory canonical-store hydration", - "Canonical store hydration, newer REST refresh, removed facet refresh and restored observation", - "Touch container row expansion, keyboard Enter on Swarm disclosure and touch service row expansion", - "Fixture-only tab switching between production tables; no mutation clicked" + "Desktop keyboard Enter and phone touch refresh through the real existing Notifications tab", + "Partial failure, pending retry and recovery via synthetic API replies; no mutation" ], "browsers": [ { @@ -67,13 +55,35 @@ } ], "playwright_version": "1.56.1", - "checks": 40, - "screenshots": 18, - "command": "pulse-worker-browser frontend-modern/browser-tests/proxmox-docker-rows.cjs", - "evidence": "browser-tests/proxmox-docker-rows.cjs and invocation browser-verified-result.json / browser-images.json", - "limitations": [ - "Synthetic REST and canonical in-memory store hydration, not a native websocket wire or appliance test", - "Not backend adoption, authorisation grant, mutative lifecycle execution, installed recovery or release acceptance", - "Seven high braces advisory findings in unchanged dependency graph remain unresolved; offline browser proof does not clear required audit" + "checks": 20, + "screenshots": [ + { + "path": "tmp/notification-evidence-proof/desktop-held-pending.png", + "sha256": "58ddaf1dae1e797e6f938c3e74b705fe75276eaf560de307fe7bfe1644c2df21" + }, + { + "path": "tmp/notification-evidence-proof/desktop-held-unavailable.png", + "sha256": "fe343a2b7eb67ae1283e4eb411462073e3db26d36d226ef62d9fd49cf9a095c7" + }, + { + "path": "tmp/notification-evidence-proof/desktop-attempts-unavailable.png", + "sha256": "a4ae26cc87b0bbf91b4dc3150f375a10c6701201deed4eaa3bdfbfce08ade4de" + }, + { + "path": "tmp/notification-evidence-proof/phone-held-pending.png", + "sha256": "66262c32b745e68f32aba48db19dee2a03c7c62d763efd765ed4da154ec9e00f" + }, + { + "path": "tmp/notification-evidence-proof/phone-held-unavailable.png", + "sha256": "534cd02e8b5392bd5e73d7f4a7a3d8e2a78596a230d055daef4479ecf0d1fdbc" + }, + { + "path": "tmp/notification-evidence-proof/phone-attempts-unavailable.png", + "sha256": "09652b8023be5e527712d42384e9fee8e114575026aefaf703e62cfa30bc0884" + } + ], + "proof_script": "frontend-modern/browser-tests/notification-evidence.cjs", + "limits": [ + "Synthetic source/UI evidence only, not notification receipt, native provider delivery, release availability or dependency advisory clearance" ] } diff --git a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx index 312340f152..4fb6b60f9a 100644 --- a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx +++ b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.test.tsx @@ -273,7 +273,7 @@ describe('AlertDeliveryLogCard', () => { expect(screen.queryByRole('list')).not.toBeInTheDocument(); }); - it('hides held rows during an outage and restores their current state on recovery', () => { + it('hides held rows when their own read fails and restores them on recovery', () => { const [unavailable, setUnavailable] = createSignal(false); const [heldEvents, setHeldEvents] = createSignal([ { @@ -290,7 +290,8 @@ describe('AlertDeliveryLogCard', () => { render(() => ( { expect(screen.getByText('Deferred')).toBeInTheDocument(); setUnavailable(true); - expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/); + expect(screen.getByRole('alert')).toHaveTextContent( + /could not read held or deferred notifications/, + ); expect(screen.queryByRole('list')).not.toBeInTheDocument(); expect(screen.queryByText('nas-quiet (usage)')).not.toBeInTheDocument(); expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); @@ -332,4 +335,106 @@ describe('AlertDeliveryLogCard', () => { expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/); expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); }); + it('keeps independently readable held evidence when delivery attempts are unavailable', () => { + render(() => ( + + )); + expect(screen.getByRole('alert')).toHaveTextContent(/could not read the delivery log/); + expect(screen.getByText('held-known')).toBeInTheDocument(); + expect(screen.getByText('Quiet hours')).toBeInTheDocument(); + expect(screen.queryByText('Delivered')).not.toBeInTheDocument(); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + }); + + it('reports missing held evidence without hiding readable attempts', () => { + render(() => ( + + )); + expect(screen.getByRole('alert')).toHaveTextContent( + /could not read held or deferred notifications/, + ); + expect(screen.getByText('Delivered')).toBeInTheDocument(); + expect(screen.getByText('Ops Discord')).toBeInTheDocument(); + expect(screen.queryByText('stale-held')).not.toBeInTheDocument(); + }); + + it('does not claim an empty activity window while held evidence is pending', () => { + render(() => ( + + )); + expect(screen.getByRole('status')).toHaveTextContent( + 'Loading held and deferred notifications...', + ); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + // A slow held read must not prevent a new delivery-attempt refresh. + expect(screen.getByRole('button', { name: 'Refresh delivery status' })).toBeEnabled(); + }); + + it('does not claim an empty activity window when held evidence is unavailable', () => { + render(() => ( + + )); + expect(screen.getByRole('alert')).toHaveTextContent( + /could not read held or deferred notifications/, + ); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + }); + + it('labels the initial attempt read as loading rather than empty', () => { + render(() => ( + + )); + expect(screen.getByRole('status')).toHaveTextContent('Loading delivery attempts...'); + expect(screen.queryByText(/No alert deliveries were attempted/)).not.toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx index c11b0f8c07..b45de97225 100644 --- a/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx +++ b/frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx @@ -15,6 +15,9 @@ import { getAlertDestinationsDeliveryLogEmpty, getAlertDestinationsDeliveryLogTitle, getAlertDestinationsDeliveryLogUnavailable, + getAlertDestinationsDeliveryLogLoading, + getAlertDestinationsHeldEventsLoading, + getAlertDestinationsHeldEventsUnavailable, getAlertDestinationsDeliveryRefreshLabel, } from '@/utils/alertDestinationsPresentation'; import { formatRelativeTime } from '@/utils/format'; @@ -28,6 +31,8 @@ interface AlertDeliveryLogCardProps { onRefresh: () => void; webhooks: Webhook[]; heldEvents?: AlertEvent[]; + heldEventsUnavailable?: boolean; + refreshingHeldEvents?: boolean; } type DeliveryLogRow = @@ -99,10 +104,18 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) { return rest.length > 0 ? `${first} +${rest.length} more` : first; }; - const entries = () => props.log?.entries ?? []; + const entries = () => (props.unavailable ? [] : (props.log?.entries ?? [])); const completedRetentionDays = () => props.log?.completedRetentionDays ?? 7; const deadLetterRetentionDays = () => props.log?.deadLetterRetentionDays ?? 30; - const rows = () => mergeDeliveryLogRows(entries(), props.heldEvents ?? []); + const rows = () => + mergeDeliveryLogRows(entries(), props.heldEventsUnavailable ? [] : (props.heldEvents ?? [])); + const attemptsLoading = () => props.refreshing || (!props.log && !props.unavailable); + const confirmedEmpty = () => + props.log !== null && + !props.unavailable && + !attemptsLoading() && + !props.heldEventsUnavailable && + !props.refreshingHeldEvents; const absoluteTimestamp = (value: string): string => { const parsed = new Date(value); @@ -149,100 +162,113 @@ export function AlertDeliveryLogCard(props: AlertDeliveryLogCardProps) {
+ + + + + + + +

+ {getAlertDestinationsDeliveryLogLoading()} +

+
+ +

+ {getAlertDestinationsHeldEventsLoading()} +

+
0} fallback={ - - } - > - 0} - fallback={ +

{getAlertDestinationsDeliveryLogEmpty()}

- } - > -
    - - {(row) => - row.kind === 'attempt' ? ( -
  • -
    - - {getAlertDeliveryLogOutcomeLabel(row.entry.outcome)} - - - {destinationLabel(row.entry)} - - - {alertSummary(row.entry)} - - -
    - + } + > +
      + + {(row) => + row.kind === 'attempt' ? ( +
    • +
      + -

      - - {(failureClass) => ( - - {getAlertDeliveryLogFailureClassLabel(failureClass())} - {row.entry.errorMessage ? ': ' : ''} - - )} - - {row.entry.errorMessage} -

      - -
    • - ) : ( -
    • -
      - - {row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'} - - - {heldResourceLabel(row.event)} - - - {describeAlertEventReason(row.event.reason)} - - -
      -
    • - ) - } -
      -
    -
    + {getAlertDeliveryLogOutcomeLabel(row.entry.outcome)} + + + {destinationLabel(row.entry)} + + + {alertSummary(row.entry)} + + +
+ +

+ + {(failureClass) => ( + + {getAlertDeliveryLogFailureClassLabel(failureClass())} + {row.entry.errorMessage ? ': ' : ''} + + )} + + {row.entry.errorMessage} +

+
+ + ) : ( +
  • +
    + + {row.event.type === 'notification_deferred' ? 'Deferred' : 'Held'} + + + {heldResourceLabel(row.event)} + + + {describeAlertEventReason(row.event.reason)} + + +
    +
  • + ) + } + +
    diff --git a/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx index 2c07536cad..2d3589247e 100644 --- a/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/DestinationsTab.recoveryediting.test.tsx @@ -259,7 +259,7 @@ describe('DestinationsTab recovery while editing SMTP', () => { ).toBeInTheDocument(); expect( await screen.findByText( - 'Pulse could not read the delivery log, so recent delivery activity cannot be shown.', + 'Pulse could not read the delivery log. Refresh to check delivery attempts.', ), ).toBeInTheDocument(); expect(screen.queryByText('SMTP fixture rejected')).not.toBeInTheDocument(); diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx index b01d8e43b0..2515c6f393 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertDestinationsTabState.test.tsx @@ -156,6 +156,17 @@ describe('useAlertDestinationsTabState', () => { await Promise.resolve(); expect(result.deliveryLog()).toEqual(current); expect(result.deliveryLogUnavailable()).toBe(false); + expect(result.heldEventsUnavailable()).toBe(false); + expect(result.refreshingHeldEvents()).toBe(false); + vi.mocked(AlertsAPI.getEvents).mockRejectedValueOnce(new Error('held events denied')); + vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValueOnce(current); + await result.loadDeliveryLog(); + expect(result.heldEvents()).toEqual([]); + expect(result.heldEventsUnavailable()).toBe(true); + expect(result.refreshingHeldEvents()).toBe(false); + expect(result.deliveryLogUnavailable()).toBe(false); + expect(result.deliveryLog()).toEqual(current); + expect(result.refreshingDeliveryLog()).toBe(false); } finally { cleanup(); diff --git a/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx b/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx index e4398987af..ad497217a0 100644 --- a/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useNotificationDeliveryLog.test.tsx @@ -107,6 +107,8 @@ describe('useNotificationDeliveryLog', () => { expect(state.deliveryLog()).toEqual(emptyLog); expect(state.refreshingDeliveryLog()).toBe(false); expect(state.heldEvents()).toEqual([]); + expect(state.refreshingHeldEvents()).toBe(true); + expect(state.heldEventsUnavailable()).toBe(false); } finally { held.resolve([]); dispose(); @@ -132,6 +134,8 @@ describe('useNotificationDeliveryLog', () => { expect(state.heldEvents()).toHaveLength(1); await state.loadDeliveryLog(); expect(state.heldEvents()).toEqual([]); + expect(state.heldEventsUnavailable()).toBe(true); + expect(state.refreshingHeldEvents()).toBe(false); expect(state.deliveryLog()).toEqual(emptyLog); expect(state.deliveryLogUnavailable()).toBe(false); expect(state.refreshingDeliveryLog()).toBe(false); @@ -222,6 +226,8 @@ describe('useNotificationDeliveryLog', () => { else older.reject(new Error('stale held read')); await Promise.resolve(); expect(state.heldEvents()).toEqual(current); + expect(state.heldEventsUnavailable()).toBe(false); + expect(state.refreshingHeldEvents()).toBe(false); } finally { dispose(); } @@ -264,4 +270,56 @@ describe('useNotificationDeliveryLog', () => { expect(AlertsAPI.getEvents).toHaveBeenCalledTimes(1); }), ); + it('retains the held-read warning during retry until that read succeeds', () => + createRoot(async (dispose) => { + const recovery = deferred>>(); + vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog); + vi.mocked(AlertsAPI.getEvents) + .mockRejectedValueOnce(new Error('permission denied')) + .mockReturnValueOnce(recovery.promise); + const state = useNotificationDeliveryLog(); + try { + await state.loadDeliveryLog(); + expect(state.heldEventsUnavailable()).toBe(true); + await state.loadDeliveryLog(); + expect(state.heldEventsUnavailable()).toBe(true); + expect(state.refreshingHeldEvents()).toBe(true); + expect(state.refreshingDeliveryLog()).toBe(false); + recovery.resolve([]); + await recovery.promise; + expect(state.heldEventsUnavailable()).toBe(false); + expect(state.refreshingHeldEvents()).toBe(false); + } finally { + dispose(); + } + })); + + it.each(['success', 'failure'])( + 'keeps the newest held read pending after an older %s', + (outcome) => + createRoot(async (dispose) => { + const older = deferred>>(); + const newer = deferred>>(); + vi.mocked(NotificationsAPI.getDeliveryLog).mockResolvedValue(emptyLog); + vi.mocked(AlertsAPI.getEvents) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); + const state = useNotificationDeliveryLog(); + try { + await state.loadDeliveryLog(); + await state.loadDeliveryLog(); + if (outcome === 'success') older.resolve([]); + else older.reject(new Error('stale denied request')); + await Promise.resolve(); + expect(state.refreshingHeldEvents()).toBe(true); + expect(state.heldEventsUnavailable()).toBe(false); + newer.resolve([]); + await newer.promise; + expect(state.refreshingHeldEvents()).toBe(false); + expect(state.heldEventsUnavailable()).toBe(false); + } finally { + dispose(); + } + }), + ); }); diff --git a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx index 3f2e0f7718..d6462cefe9 100644 --- a/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx +++ b/frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx @@ -102,6 +102,8 @@ export function DestinationsTab(props: DestinationsTabProps) { onRefresh={() => void state.loadDeliveryLog()} webhooks={state.webhooks()} heldEvents={state.heldEvents()} + heldEventsUnavailable={state.heldEventsUnavailable()} + refreshingHeldEvents={state.refreshingHeldEvents()} /> diff --git a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts index 26d815d52c..86c90f37f1 100644 --- a/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts +++ b/frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts @@ -53,6 +53,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro deliveryLogUnavailable, refreshingDeliveryLog, heldEvents, + heldEventsUnavailable, + refreshingHeldEvents, loadDeliveryLog, } = useNotificationDeliveryLog(); const webhookState = useAlertWebhookDestinationsState({ @@ -173,6 +175,8 @@ export function useAlertDestinationsTabState(props: AlertDestinationsTabStatePro deliveryLogUnavailable, deliveryNeedsAttention, heldEvents, + heldEventsUnavailable, + refreshingHeldEvents, dismissTerminalFailures, dismissingTerminalFailures, handleRetry, diff --git a/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts b/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts index 1ddceb830f..e36c6036df 100644 --- a/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts +++ b/frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts @@ -19,12 +19,15 @@ const DELIVERY_LOG_LIMIT = 200; // Held events are the third half of that story: notifications that were // deliberately not attempted, with the mechanism that held them. They come // from the alert event log and degrade independently โ€” a failed events read -// hides the held rows without marking delivery attempts unavailable. +// withdraws the held rows and reports that gap without hiding readable attempts. export function useNotificationDeliveryLog() { const [deliveryLog, setDeliveryLog] = createSignal(null); const [deliveryLogUnavailable, setDeliveryLogUnavailable] = createSignal(false); const [refreshingDeliveryLog, setRefreshingDeliveryLog] = createSignal(false); const [heldEvents, setHeldEvents] = createSignal([]); + const [heldEventsUnavailable, setHeldEventsUnavailable] = createSignal(false); + // Before the first read, an empty array means unknown, not no held alerts. + const [refreshingHeldEvents, setRefreshingHeldEvents] = createSignal(true); // Both reads belong to the same refresh, but held events never block the log. let latestRequest = 0; @@ -35,6 +38,7 @@ export function useNotificationDeliveryLog() { const ownsRequest = (request: number) => !disposed && request === latestRequest; const loadHeldEvents = async (request: number) => { + setRefreshingHeldEvents(true); try { const since = new Date( Date.now() - HELD_EVENT_WINDOW_DAYS * 24 * 60 * 60 * 1000, @@ -44,11 +48,16 @@ export function useNotificationDeliveryLog() { since, limit: HELD_EVENT_LIMIT, }); - if (ownsRequest(request)) setHeldEvents(events); + if (!ownsRequest(request)) return; + setHeldEvents(events); + setHeldEventsUnavailable(false); } catch (error) { if (!ownsRequest(request)) return; logger.error('Failed to load held alert notification events', error); setHeldEvents([]); + setHeldEventsUnavailable(true); + } finally { + if (ownsRequest(request)) setRefreshingHeldEvents(false); } }; @@ -82,6 +91,8 @@ export function useNotificationDeliveryLog() { deliveryLogUnavailable, refreshingDeliveryLog, heldEvents, + heldEventsUnavailable, + refreshingHeldEvents, loadDeliveryLog, }; } diff --git a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts index 839f7bc178..8cf26ffba5 100644 --- a/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/alertDestinationsPresentation.test.ts @@ -35,6 +35,9 @@ import { getAlertDestinationsDeliveryLogEmpty, getAlertDestinationsDeliveryLogTitle, getAlertDestinationsDeliveryLogUnavailable, + getAlertDestinationsDeliveryLogLoading, + getAlertDestinationsHeldEventsLoading, + getAlertDestinationsHeldEventsUnavailable, getAlertDestinationsTestPausedWarning, getAlertDestinationsAppriseTargetsHelp, getAlertDestinationsAppriseTestLabel, @@ -267,6 +270,28 @@ describe('alert destinations delivery log copy', () => { ); }); + it('distinguishes pending and unavailable activity sources without implying delivery failure', () => { + expect(getAlertDestinationsDeliveryLogLoading()).toBe('Loading delivery attempts...'); + expect(getAlertDestinationsHeldEventsLoading()).toBe( + 'Loading held and deferred notifications...', + ); + expect(getAlertDestinationsHeldEventsUnavailable()).toBe( + 'Pulse could not read held or deferred notifications. Refresh to try again.', + ); + expect(getAlertDestinationsDeliveryLogUnavailable()).toBe( + 'Pulse could not read the delivery log. Refresh to check delivery attempts.', + ); + for (const message of [ + getAlertDestinationsDeliveryLogLoading(), + getAlertDestinationsHeldEventsLoading(), + getAlertDestinationsHeldEventsUnavailable(), + getAlertDestinationsDeliveryLogUnavailable(), + ]) { + expect(message).not.toContain('No alert deliveries'); + expect(message).not.toContain('not delivered'); + } + }); + it('warns that a passing test does not mean live alerts flow while delivery is paused', () => { const warning = getAlertDestinationsTestPausedWarning(); expect(warning).toContain('Test sent'); diff --git a/frontend-modern/src/utils/alertDestinationsPresentation.ts b/frontend-modern/src/utils/alertDestinationsPresentation.ts index 92ad797dc7..1fc3454103 100644 --- a/frontend-modern/src/utils/alertDestinationsPresentation.ts +++ b/frontend-modern/src/utils/alertDestinationsPresentation.ts @@ -277,7 +277,19 @@ export const ALERT_DESTINATIONS_DELIVERY_LOG_TITLE = 'Recent delivery activity'; export const ALERT_DESTINATIONS_DELIVERY_LOG_EMPTY = 'No alert deliveries were attempted in this window.'; export const ALERT_DESTINATIONS_DELIVERY_LOG_UNAVAILABLE = - 'Pulse could not read the delivery log, so recent delivery activity cannot be shown.'; + 'Pulse could not read the delivery log. Refresh to check delivery attempts.'; + +export function getAlertDestinationsHeldEventsUnavailable() { + return 'Pulse could not read held or deferred notifications. Refresh to try again.'; +} + +export function getAlertDestinationsDeliveryLogLoading() { + return 'Loading delivery attempts...'; +} + +export function getAlertDestinationsHeldEventsLoading() { + return 'Loading held and deferred notifications...'; +} export function getAlertDestinationsDeliveryLogTitle() { return ALERT_DESTINATIONS_DELIVERY_LOG_TITLE; From ec5c96a290a34266c2dc55e0f0a22e502ea87384 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:13:21 +0100 Subject: [PATCH 46/86] Keep scheduler diagnostics private and failure-aware Use one bounded, authenticated snapshot and local queries instead of secret-bearing argv and repeated requests. Preserve configuration precedence and rollback state, and judge successful polls rather than a universal queue-depth threshold. Exercise copied commands with HTTP, transport, schema and credential controls. Contract-Neutral: Documentation and copied-command tests only; scheduler runtime and API contracts are unchanged. Change-source: pulse-maintainer --- docs/api/SCHEDULER_HEALTH.md | 70 +++++- docs/monitoring/ADAPTIVE_POLLING.md | 9 +- docs/operations/ADAPTIVE_POLLING_ROLLOUT.md | 82 +++++-- scripts/tests/test_scheduler_polling_docs.py | 228 +++++++++++++++++++ 4 files changed, 358 insertions(+), 31 deletions(-) create mode 100644 scripts/tests/test_scheduler_polling_docs.py diff --git a/docs/api/SCHEDULER_HEALTH.md b/docs/api/SCHEDULER_HEALTH.md index 28381da1c9..c25ecbb4b4 100644 --- a/docs/api/SCHEDULER_HEALTH.md +++ b/docs/api/SCHEDULER_HEALTH.md @@ -1,7 +1,8 @@ # ๐Ÿฉบ Scheduler Health API **Endpoint**: `GET /api/monitoring/scheduler/health` -**Auth**: Required (`Authorization: Bearer `, `X-API-Token`, or session cookie) +**Auth**: Required. Use a signed-in Pulse browser or an API token in a private +header file; do not copy a session cookie or token into a command. Returns a real-time snapshot of the adaptive scheduler, including queue state, circuit breakers, and dead-letter tasks. @@ -64,7 +65,7 @@ Returns a real-time snapshot of the adaptive scheduler, including queue state, c "firstFailureAt": "2025-10-20T13:05:20Z" }, "breaker": { - "state": "half_open", // closed, open, half_open + "state": "half_open", "retryAt": "2025-10-20T13:06:15Z", "failureCount": 3, "since": "2025-10-20T12:58:10Z", @@ -99,20 +100,73 @@ The authoritative source for per-instance health. ## ๐Ÿ› ๏ธ Common Queries (jq) +For a one-off check, open `/api/monitoring/scheduler/health` in your signed-in +browser on that same Pulse instance. For curl, first prepare the private header +file from [API authentication](../API.md#api-token-recommended), using a token +with only the access needed for your monitoring task. These commands require +curl 7.76 or later and jq. They make one read-only request and keep its response +in a new private directory: + +```bash +( + set -eu + umask 077 + snapshot_dir="$(mktemp -d "${TMPDIR:-/tmp}/pulse-scheduler.XXXXXX")" + snapshot="$snapshot_dir/health.json" + status="$(curl --disable --fail-with-body --connect-timeout 5 --max-time 15 \ + --header "@$HOME/.config/pulse/api-header" --output "$snapshot" \ + --write-out '%{http_code}' http://127.0.0.1:7655/api/monitoring/scheduler/health)" + if [ "$status" != 200 ]; then + printf 'Scheduler snapshot unavailable (HTTP %s); no health conclusion.\n' "$status" >&2 + exit 1 + fi + jq -e 'type == "object" and (.updatedAt | type == "string") and + (.enabled | type == "boolean") and (.deadLetter.count | type == "number") and + (.queue.depth | type == "number") and (.instances | type == "array")' \ + "$snapshot" >/dev/null + printf 'Saved scheduler snapshot: %s\n' "$snapshot" +) +``` + +The loopback URL is only for curl running on the Pulse host. Elsewhere, use your +Pulse HTTPS URL with certificate verification enabled, never `--insecure`. +Keep `--disable` first to ignore curl configuration that could enable trace +output. HTTP errors, transport failures and unexpected response shapes stop the +recipe; none is a healthy or empty scheduler result. A 401/403 concerns access, +not polling health. Do not use verbose/trace output or share the header file. + +Use the printed filename in the following local queries. They make no further +requests. Keep the raw response private: it includes infrastructure names, +addresses and error text; redact these before sharing selected observations. +Check `enabled` first: a disabled scheduler's empty queue is not a health pass. +`updatedAt` is the snapshot time; per-instance `pollStatus.lastSuccess` records +the last successful poll. Compare freshness with the effective poll intervals, +not a universal queue-depth threshold. + +**Summarise the Snapshot:** +```bash +snapshot='/absolute/path/to/health.json' # use the printed filename +jq '{updatedAt, enabled, queueDepth: .queue.depth, deadLetterCount: .deadLetter.count, + instanceCount: (.instances | length)}' "$snapshot" +``` + **Find Failing Instances:** ```bash -curl -s http://HOST:7655/api/monitoring/scheduler/health | \ -jq '.instances[] | select(.pollStatus.consecutiveFailures > 0) | {key, failures: .pollStatus.consecutiveFailures}' +snapshot='/absolute/path/to/health.json' # use the printed filename +jq '.instances[] | select(.pollStatus.consecutiveFailures > 0) | + {key, lastSuccess: .pollStatus.lastSuccess, failures: .pollStatus.consecutiveFailures}' "$snapshot" ``` **Check Dead Letter Queue:** ```bash -curl -s http://HOST:7655/api/monitoring/scheduler/health | \ -jq '.instances[] | select(.deadLetter.present) | {key, reason: .deadLetter.reason}' +snapshot='/absolute/path/to/health.json' # use the printed filename +jq '.instances[] | select(.deadLetter.present) | + {key, reason: .deadLetter.reason}' "$snapshot" ``` **Find Open Breakers:** ```bash -curl -s http://HOST:7655/api/monitoring/scheduler/health | \ -jq '.instances[] | select(.breaker.state != "closed") | {key, state: .breaker.state}' +snapshot='/absolute/path/to/health.json' # use the printed filename +jq '.instances[] | select(.breaker.state != "closed") | + {key, state: .breaker.state}' "$snapshot" ``` diff --git a/docs/monitoring/ADAPTIVE_POLLING.md b/docs/monitoring/ADAPTIVE_POLLING.md index 126f6b6d0f..a7036ce187 100644 --- a/docs/monitoring/ADAPTIVE_POLLING.md +++ b/docs/monitoring/ADAPTIVE_POLLING.md @@ -55,7 +55,9 @@ There is currently no dedicated UI for adaptive polling. Use environment variabl You can also set `adaptivePollingEnabled` (and related interval fields) in `system.json` and restart Pulse. ## ๐Ÿ“Š Metrics -Exposed at `:9091/metrics`. +The separate metrics listener defaults to port 9091; `METRICS_PORT` can override +it. Use an existing authorised scrape if available, not a newly exposed port for +diagnostics. A missing scrape is not evidence of zero errors. | Metric | Type | Description | | :--- | :--- | :--- | @@ -84,3 +86,8 @@ Returns: - Dead-letter tasks. - Circuit breaker states. - Per-instance staleness. + +Use the [authenticated snapshot and local queries](../api/SCHEDULER_HEALTH.md#-common-queries-jq) +to inspect it without putting credentials in command arguments or repeatedly +requesting the same data. Follow the [rollout and rollback checks](../operations/ADAPTIVE_POLLING_ROLLOUT.md) +when changing the flag; queue depth alone is not a monitoring-health verdict. diff --git a/docs/operations/ADAPTIVE_POLLING_ROLLOUT.md b/docs/operations/ADAPTIVE_POLLING_ROLLOUT.md index 0bf8a9f16f..1637e58ffc 100644 --- a/docs/operations/ADAPTIVE_POLLING_ROLLOUT.md +++ b/docs/operations/ADAPTIVE_POLLING_ROLLOUT.md @@ -1,32 +1,70 @@ # ๐Ÿš€ Adaptive Polling Rollout -Safely enable dynamic scheduling. +Adaptive polling is disabled by default. Change it during a maintenance window, +not as an unmeasured remedy for high CPU, excessive writes or a stalled backup. +A Pulse restart interrupts monitoring and notifications until it is running again. ## ๐Ÿ“‹ Pre-Flight -1. **Snapshot Health**: - ```bash - curl -s -H "X-API-Token: $TOKEN" http://localhost:7655/api/monitoring/scheduler/health | jq . - ``` -2. **Check Metrics**: Ensure `pulse_monitor_poll_queue_depth` is stable. + +1. Record the running version, fleet size, current polling settings and whether + Pulse dashboards are open. Save the existing environment overrides and + `adaptivePollingEnabled` value so rollback restores the same configuration. +2. Take one [scheduler health snapshot](../api/SCHEDULER_HEALTH.md#-common-queries-jq). + Use a signed-in browser or the private header-file recipe there. Keep the + response local: instance names, addresses and error text can be sensitive. +3. Establish each instance's last successful poll and current errors before + changing settings. A small or empty queue alone does not prove healthy polls; + scheduled tasks may be waiting for their next run, and polling may be disabled. ## ๐ŸŸข Enable -Choose one method: -- **UI**: Not currently exposed in the UI (use CLI or env vars). -- **CLI**: - - systemd/LXC: `jq '.adaptivePollingEnabled=true' /etc/pulse/system.json > /tmp/system.json && sudo mv /tmp/system.json /etc/pulse/system.json` - - Docker/Kubernetes: edit `/data/system.json` in the volume and restart the container/pod -- **Env**: `ADAPTIVE_POLLING_ENABLED=true` (Docker/K8s). + +There is no dedicated UI toggle. Choose one configuration source: + +- **Managed environment**: Set `ADAPTIVE_POLLING_ENABLED=true` in the existing + service, Compose or Kubernetes deployment configuration, then restart or + redeploy Pulse using that deployment's normal procedure. +- **Persistent settings**: In the instance's actual data directory, edit only + `adaptivePollingEnabled` in `system.json`, preserving its other settings, + ownership and permissions, then restart Pulse. Back up the original privately + first; this file can contain secrets. Do not replace it through a shared + `/tmp/system.json` or replace the whole document with a single property. + +An explicit environment value overrides `system.json`. Do not change both +sources at once or assume a file edit took effect while an override remains. +See [configuration](../monitoring/ADAPTIVE_POLLING.md#-configuration) for intervals. +After restart, the health response's `enabled` field confirms the effective flag, +not successful monitoring. ## ๐Ÿ” Monitor (First 15m) -Watch for stability: -```bash -watch -n 5 'curl -s http://localhost:9091/metrics | grep pulse_monitor_poll_queue_depth' -``` -- **Success**: Queue depth < 50, no permanent errors. -- **Failure**: High queue depth, open breakers. + +For a responsive install, take another snapshot after about 1, 5 and 15 minutes; +run the local jq queries against each saved file, not a separate HTTP request per +query. Stop collection if the system becomes unresponsive. Do not add an +unbounded `watch` loop, enable Debug or expose a metrics port for this check. + +Compare per-instance `pollStatus.lastSuccess`, `consecutiveFailures`, breaker +state and dead-letter tasks with the baseline. Last-success times should advance +in line with the effective polling intervals. Check that the affected dashboard +data still refreshes. A snapshot's `updatedAt` is its collection time, not a +successful provider poll. There is no fleet-independent queue-depth success +threshold, and an open breaker is a backoff state, not proof of a new regression. + +If using an already configured Prometheus scrape, compare the queue and error +metrics too. Missing metrics or a failed read are unavailable evidence, not zero +errors. Do not prolong a failure merely to finish the 15-minute observation. ## โ†ฉ๏ธ Rollback -If instability occurs > 10m: -1. **Disable**: Remove the env var override or set `adaptivePollingEnabled=false` in `system.json`. -2. **Restart**: Required if using Env/CLI overrides. -3. **Verify**: Confirm queue drains. + +If freshness deteriorates or failures rise after the change: + +1. Restore the saved environment and persistent settings. For an originally + disabled install, an explicit `ADAPTIVE_POLLING_ENABLED=false` override disables + the flag; merely removing `true` can uncover a persisted `true` value. +2. Restart or redeploy through the same procedure. This pauses monitoring again. +3. Take a fresh snapshot to confirm the effective flag, then verify successful + polls and dashboard freshness against the baseline. An empty queue or + `enabled: false` alone is not recovery evidence. + +Keep the versions, times, changed settings and relevant redacted observations +when reporting a problem. Do not share the header file, raw health response, +deployment environment or `system.json`. diff --git a/scripts/tests/test_scheduler_polling_docs.py b/scripts/tests/test_scheduler_polling_docs.py new file mode 100644 index 0000000000..6643abf5c0 --- /dev/null +++ b/scripts/tests/test_scheduler_polling_docs.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Exercise copied scheduler diagnostics against secret-free local HTTP fixtures. + +Run the HTTP tests through pulse-worker-source-proof for guest-local loopback. +The documentation, not a second hand-written client, supplies every command. +""" + +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import tempfile +import threading +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +DOC = ROOT / "docs/api/SCHEDULER_HEALTH.md" +ROLLOUT = ROOT / "docs/operations/ADAPTIVE_POLLING_ROLLOUT.md" +ENDPOINT = "/api/monitoring/scheduler/health" +TOKEN = "synthetic-scheduler-doc-token" + + +def blocks(): + return re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL) + + +def request(): + # This also selects the old first query for a parent-source differential. + return next(block for block in blocks() if ENDPOINT in block) + + +def snapshot(enabled=True): + return { + "updatedAt": "2026-10-03T12:00:00Z", "enabled": enabled, + "queue": {"depth": 3, "dueWithinSeconds": 1, "perType": {"pve": 3}}, + "deadLetter": {"count": 1, "tasks": []}, "breakers": [], "staleness": [], + "instances": [ + {"key": "pve::healthy", "pollStatus": {"lastSuccess": "2026-10-03T11:59:58Z", "consecutiveFailures": 0}, + "breaker": {"state": "closed"}, "deadLetter": {"present": False}}, + {"key": "pve::retrying", "pollStatus": {"lastSuccess": "2026-10-03T11:59:00Z", "consecutiveFailures": 2}, + "breaker": {"state": "half_open"}, "deadLetter": {"present": False}}, + {"key": "pve::failed", "pollStatus": {"lastSuccess": "2026-10-03T11:55:00Z", "consecutiveFailures": 5}, + "breaker": {"state": "open"}, "deadLetter": {"present": True, "reason": "permanent_failure"}}, + ], + } + + +@contextmanager +def server(payload, status=200): + calls = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + calls.append({"path": self.path, "method": self.command, "headers": dict(self.headers)}) + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(payload if isinstance(payload, bytes) else json.dumps(payload).encode()) + + def log_message(self, *_args): + pass + + httpd = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield httpd.server_address[1], calls + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +class SchedulerPollingDocsTest(unittest.TestCase): + def exercise(self, payload=None, status=200, *, header="X-API-Token", missing_header=False, + refused_connection=False): + payload = snapshot() if payload is None else payload + with tempfile.TemporaryDirectory(prefix="pulse polling ") as temporary: + directory = Path(temporary) + home = directory / "home" + credential = home / ".config/pulse/api-header" + credential.parent.mkdir(parents=True, mode=0o700) + if not missing_header: + credential.write_text(f"{header}: {'Bearer ' if header == 'Authorization' else ''}{TOKEN}\n") + credential.chmod(0o600) + # --disable must defeat unsafe user defaults, not just omit -v itself. + (home / ".curlrc").write_text("verbose\n") + tools = directory / "tools" + tools.mkdir() + curl = tools / "curl" + curl.write_text("#!/usr/bin/env python3\nimport json, os, sys\n" + "from pathlib import Path\n" + "Path(os.environ['CURL_ARGV']).write_text(json.dumps(sys.argv[1:]))\n" + "os.execv(os.environ['REAL_CURL'], ['curl', *sys.argv[1:]])\n") + curl.chmod(0o700) + argv_file = directory / "argv.json" + env = dict(os.environ, HOME=str(home), TMPDIR=str(directory), + PATH=f"{tools}:{os.environ['PATH']}", CURL_ARGV=str(argv_file), + REAL_CURL=shutil.which("curl")) + with server(payload, status) as (port, calls): + # A closed fixture port supplies an actual connection refusal. + if refused_connection: + with server(payload) as (closed_port, _): + pass + port = closed_port + code = request().replace("http://127.0.0.1:7655", f"http://127.0.0.1:{port}") + code = code.replace("http://HOST:7655", f"http://127.0.0.1:{port}") + result = subprocess.run(["bash", "-c", code], env=env, text=True, + capture_output=True, timeout=25) + saved = list(directory.glob("pulse-scheduler.*/health.json")) + files = [(path.read_bytes(), stat.S_IMODE(path.stat().st_mode), + stat.S_IMODE(path.parent.stat().st_mode)) for path in saved] + return result, calls, json.loads(argv_file.read_text()), files + + def test_one_authenticated_request_with_both_supported_header_files(self): + for header in ("X-API-Token", "Authorization"): + with self.subTest(header=header): + result, calls, args, files = self.exercise(header=header) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(calls), 1) + self.assertEqual((calls[0]["path"], calls[0]["method"]), (ENDPOINT, "GET")) + self.assertEqual(calls[0]["headers"][header], + ("Bearer " if header == "Authorization" else "") + TOKEN) + self.assertNotIn("Authorization" if header == "X-API-Token" else "X-API-Token", + calls[0]["headers"]) + self.assertEqual(args[0], "--disable") + self.assertNotIn(TOKEN, " ".join(args) + result.stdout + result.stderr) + self.assertIn("Saved scheduler snapshot:", result.stdout) + self.assertEqual(json.loads(files[0][0]), snapshot()) + self.assertEqual(files[0][1:], (0o600, 0o700)) + + def test_request_has_bounded_timeouts_and_never_follows_redirects(self): + _, _, args, _ = self.exercise() + self.assertEqual(args[args.index("--connect-timeout") + 1], "5") + self.assertEqual(args[args.index("--max-time") + 1], "15") + self.assertNotIn("--location", args) + self.assertNotIn("--insecure", args) + + def test_http_errors_and_redirects_never_become_successful_snapshots(self): + for status in (301, 401, 403, 503): + with self.subTest(status=status): + # Even a body shaped like health must not hide a failed request. + result, calls, _, _ = self.exercise(snapshot(), status) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(calls), 1) + self.assertNotIn("Saved scheduler snapshot:", result.stdout) + + def test_transport_failure_is_not_an_empty_queue(self): + result, calls, _, _ = self.exercise(refused_connection=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + self.assertNotIn("Saved scheduler snapshot:", result.stdout) + + def test_missing_header_file_stops_without_an_anonymous_request(self): + result, calls, _, _ = self.exercise(missing_header=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + self.assertNotIn("Saved scheduler snapshot:", result.stdout) + + def test_invalid_json_and_unexpected_shapes_are_unavailable(self): + for payload in (b"login", {}, {"enabled": True}, + dict(snapshot(), enabled="true"), dict(snapshot(), instances=None)): + with self.subTest(payload=payload): + result, _, _, _ = self.exercise(payload) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("Saved scheduler snapshot:", result.stdout) + + def test_disabled_empty_scheduler_is_preserved_not_reported_as_healthy(self): + payload = dict(snapshot(False), instances=[], queue={"depth": 0}, deadLetter={"count": 0}) + result, _, _, files = self.exercise(payload) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(json.loads(files[0][0])["enabled"]) + self.assertNotIn("healthy", result.stdout.lower()) + + def test_local_queries_reuse_one_snapshot_and_preserve_failing_instances(self): + queries = [block for block in blocks() if "jq " in block and ENDPOINT not in block] + self.assertEqual(len(queries), 4) + with tempfile.TemporaryDirectory(prefix="saved scheduler ") as temporary: + path = Path(temporary) / "health.json" + path.write_text(json.dumps(snapshot())) + results = [] + for query in queries: + self.assertNotIn("curl", query) + code = query.replace("/absolute/path/to/health.json", str(path)) + result = subprocess.run(["bash", "-eu", "-c", code], capture_output=True, + text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + decoder = json.JSONDecoder() + text = result.stdout.strip() + rows = [] + while text: + row, end = decoder.raw_decode(text) + rows.append(row) + text = text[end:].strip() + results.append(rows) + self.assertEqual(results[0], [{"updatedAt": snapshot()["updatedAt"], "enabled": True, + "queueDepth": 3, "deadLetterCount": 1, "instanceCount": 3}]) + self.assertEqual([r["key"] for r in results[1]], ["pve::retrying", "pve::failed"]) + self.assertEqual(results[2], [{"key": "pve::failed", "reason": "permanent_failure"}]) + self.assertEqual(results[3], [{"key": "pve::retrying", "state": "half_open"}, + {"key": "pve::failed", "state": "open"}]) + + def test_response_example_is_valid_json_with_the_current_field_names(self): + example = json.loads(re.findall(r"```json\n(.*?)```", DOC.read_text(), re.DOTALL)[0]) + self.assertEqual(set(example), set(snapshot())) + self.assertIsInstance(example["enabled"], bool) + self.assertIn("lastSuccess", example["instances"][0]["pollStatus"]) + + def test_rollout_restores_precedence_and_does_not_rewrite_shared_temp_settings(self): + text = ROLLOUT.read_text() + self.assertNotIn("sudo mv", text) + self.assertNotIn("watch -n", text) + self.assertNotIn('X-API-Token: $TOKEN', text) + self.assertIn("environment value overrides `system.json`", text) + self.assertIn("ADAPTIVE_POLLING_ENABLED=false", text) + self.assertIn("merely removing `true`", text) + self.assertIn("interrupts monitoring and notifications", text) + self.assertIn("not a monitoring-health verdict", (ROOT / "docs/monitoring/ADAPTIVE_POLLING.md").read_text()) + + +if __name__ == "__main__": + unittest.main() From 2df28d587741d2ab576597bb16777b783059fb56 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:58:05 +0100 Subject: [PATCH 47/86] Defer and coordinate Proxmox guest-agent reads around backup locks Check every QGA read against fresh VM config locks, coordinate configured endpoint aliases and VM migration, discard in-flight backup payloads and cool down uncertain completions without retries. Preserve labelled cache evidence and keep carried samples out of History. Includes real-client monitoring, memory and counter provenance controls; native freeze/thaw acceptance remains separate. Refs #2439. Change-source: pulse-maintainer --- .../v6/internal/subsystems/monitoring.md | 41 ++++ .../monitoring/canonical_guardrails_test.go | 5 + .../guest_agent_backup_integration_test.go | 190 ++++++++++++++++ internal/monitoring/guest_agent_evidence.go | 2 +- internal/monitoring/guest_disk_stability.go | 6 + internal/monitoring/guest_memory_agent.go | 7 + internal/monitoring/guest_memory_sources.go | 4 +- internal/monitoring/guest_metadata.go | 20 +- .../memory_trust_characterization_test.go | 29 +++ .../monitoring/monitor_pve_guest_builders.go | 53 ++++- .../monitoring/monitor_pve_guest_helpers.go | 13 +- .../monitoring/monitor_pve_node_vm_builder.go | 1 + pkg/proxmox/client.go | 8 + pkg/proxmox/client_api_more3_test.go | 42 ++++ pkg/proxmox/client_api_more_test.go | 12 + pkg/proxmox/client_vma_version_test.go | 11 + pkg/proxmox/cluster_client.go | 13 +- .../cluster_client_additional_api6_test.go | 6 + .../cluster_client_additional_api_test.go | 15 ++ pkg/proxmox/cluster_client_api_test.go | 6 + pkg/proxmox/cluster_client_test.go | 11 + pkg/proxmox/guest_agent_backup_test.go | 206 +++++++++++++++++ pkg/proxmox/guest_agent_guard.go | 210 ++++++++++++++++++ pkg/proxmox/guest_agent_guard_test.go | 191 ++++++++++++++++ pkg/proxmox/io_counters_test.go | 23 ++ 25 files changed, 1112 insertions(+), 13 deletions(-) create mode 100644 internal/monitoring/guest_agent_backup_integration_test.go create mode 100644 pkg/proxmox/guest_agent_backup_test.go create mode 100644 pkg/proxmox/guest_agent_guard.go create mode 100644 pkg/proxmox/guest_agent_guard_test.go diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 9660e69b59..24cf2bb889 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -17,6 +17,47 @@ ## Purpose +### Guest-agent coordination around backups โ€” issue #2439 + +All PVE QEMU guest-agent reads, including disk diagnostics and legacy meminfo +callers, share fail-fast per-guest admission within this Pulse process. Clients +for the same endpoint and configured cluster aliases coordinate across methods; +VMIDs remain serial through node migration. Other VMs and independent endpoints +remain independent. No guest payload or credentials enter coordination state. +Before each command and before returning its fully received bounded payload, +Pulse reads the operation lock from VM config. Any nonempty lock (not just +backup), missing/malformed config or an unverifiable lock defers the command. +Known locks in node inventory, cluster resources or detailed status suppress +polling enrichment even when status fails and recent agent evidence exists. + +Admission is held through the complete response, not just headers. A transport +failure, timeout, wrong-command-ID response or incomplete body defers every +command for that guest for a minute; callers neither immediately retry nor +replay through another cluster endpoint. Coordination retains at most 4,096 +active/cooling endpoint-guest entries and expires cooldowns. Explicit API +permission refusals and unsupported commands remain errors, not successes. + +Deferrals retain the original metadata/memory cache timestamps and supported-OS +failure evidence. Locked guests retain labelled last-known disks/identity and +previous-snapshot memory, or unavailable memory without trustworthy evidence. +Current non-QGA memory, CPU and I/O may still be observed. History never writes +retained disk values or deferred cached memory as newly observed telemetry; +normal unlocked polling resumes and replaces them with fresh data. + +`TestGuestAgentBackupMonitoringContract` exercises healthy โ†’ status lock โ†’ +resource lock with failed status โ†’ node fallback โ†’ unlocked resumption through +the real client/builders and History. The client controls cover every read, +unknown lock state, diagnostic overlap, aliases/source/VM isolation, in-flight +lock changes, incomplete bodies, cooldown and absence of failover replay. +These reproduce missing Pulse safety controls, **not** the reported native +PVE/QGA command-ID collision or successful backup thaw. HTTP lock checks cannot +atomically reserve PVE's serial channel against a backup beginning afterwards, +cancel work already running in pvedaemon, detect a guest left frozen after its +lock clears, or coordinate separate Pulse processes. Native freeze/thaw acceptance +on a containing build remains required. Until then, stopping Pulse before a +freeze-enabled backup and restarting only after verified thaw remains the safe +workaround, with monitoring and alerts unavailable during that window. + ### Exact TrueNAS subscription termination โ€” issue #2396 JSON-RPC stream readers and the subscription-acknowledgement wait recognise diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index abb02e0309..3a37762ae5 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -2969,3 +2969,8 @@ func TestBroadcastProjectionMatchesPreviousPipeline(t *testing.T) { t.Fatal("broadcast decoration mutated registry") } } + +// The runtime contract includes both poll builders, protocol admission and truthful History. +func TestGuestAgentBackupMonitoringContract(t *testing.T) { + testGuestAgentBackupMonitoringLifecycle(t) +} diff --git a/internal/monitoring/guest_agent_backup_integration_test.go b/internal/monitoring/guest_agent_backup_integration_test.go new file mode 100644 index 0000000000..3afe1c8a27 --- /dev/null +++ b/internal/monitoring/guest_agent_backup_integration_test.go @@ -0,0 +1,190 @@ +package monitoring + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" +) + +func testGuestAgentBackupMonitoringLifecycle(t *testing.T) { + const mib = uint64(1024 * 1024) + var phase, calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := phase.Load() + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/config"): + if p == 1 || p == 2 { + fmt.Fprint(w, `{"data":{"lock":"backup"}}`) + } else { + fmt.Fprint(w, `{"data":{}}`) + } + case strings.HasSuffix(r.URL.Path, "/status/current"): + if p == 2 { + http.Error(w, "unavailable", 503) + return + } + lock := "" + if p == 1 { + lock = `,"lock":"backup"` + } + fmt.Fprintf(w, `{"data":{"status":"running","agent":1,"maxmem":%d,"mem":%d%s}}`, 8*mib, 8*mib, lock) + case strings.Contains(r.URL.Path, "/agent/"): + calls.Add(1) + switch { + case strings.HasSuffix(r.URL.Path, "file-read"): + available := 5 * 1024 + if p == 3 { + available = 4 * 1024 + } + json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"content": fmt.Sprintf("MemTotal: 8192 kB\nMemAvailable: %d kB\n", available)}}) + case strings.HasSuffix(r.URL.Path, "get-fsinfo"): + used := 300 * mib + if p == 3 { + used = 400 * mib + } + fmt.Fprintf(w, `{"data":{"result":[{"mountpoint":"/","type":"ext4","total-bytes":%d,"used-bytes":%d}]}}`, 1000*mib, used) + case strings.HasSuffix(r.URL.Path, "network-get-interfaces"): + fmt.Fprint(w, `{"data":{"result":[{"name":"eth0","hardware-address":"02:00:00:00:00:01","ip-addresses":[{"ip-address":"192.0.2.10","ip-address-type":"ipv4","prefix":24}]}]}}`) + case strings.HasSuffix(r.URL.Path, "get-osinfo"): + fmt.Fprint(w, `{"data":{"name":"Linux","version":"fixture"}}`) + default: + fmt.Fprint(w, `{"data":{"result":{"version":"1.0"}}}`) + } + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + m := &Monitor{config: &config.Config{}, rateTracker: NewRateTracker(), metricsHistory: NewMetricsHistory(32, time.Hour), guestMetadataLimiter: make(map[string]time.Time)} + res := proxmox.ClusterResource{Type: "qemu", Node: "node", Name: "guest", VMID: 105, Status: "running", MaxMem: 8 * mib, Mem: 8 * mib, MaxDisk: 1000 * mib, CPU: 0.1} + var previous *models.VM + identity := "fixture:node:105" + build := func() (models.VM, string) { + vm, raw, source, notes, at, ok := m.buildVMFromClusterResource(context.Background(), "fixture", res, client, identity, nil, previous) + if !ok { + t.Fatal("VM disappeared") + } + m.recordGuestSnapshot("fixture", "qemu", "node", 105, GuestMemorySnapshot{Name: vm.Name, Status: vm.Status, RetrievedAt: at, MemorySource: source, Memory: vm.Memory, Raw: raw, Notes: notes}) + m.recordGuestMetrics([]models.VM{vm}, nil, time.Now().Add(-time.Second)) + previous = &vm + return vm, source + } + initial, source := build() + if source != "guest-agent-meminfo" || initial.Memory.Used != int64(3*mib) || initial.Disk.Used != int64(300*mib) { + t.Fatalf("healthy readings absent: %s %#v %#v", source, initial.Memory, initial.Disk) + } + before := calls.Load() + if before != 5 { + t.Fatalf("healthy agent command count = %d, want five", before) + } + metadataKey := guestMetadataCacheKey("fixture", "node", 105) + memKey := guestMemoryCacheKey("fixture", "node", 105) + originalMetadata := m.guestMetadataCache[metadataKey] + originalMemory := m.vmAgentMemCache[memKey] + phase.Store(1) + res.CPU = 0.2 + locked, source := build() + if locked.ID != initial.ID || locked.GuestAgentStatus != "deferred" || locked.DiskStatusReason != "prev-vm-locked" { + t.Fatalf("lock lost continuity/truth: %#v", locked) + } + if locked.Memory.Used != initial.Memory.Used || source != "previous-snapshot" || locked.Disk.Used != initial.Disk.Used || !reflect.DeepEqual(locked.NetworkInterfaces, initial.NetworkInterfaces) { + t.Fatalf("last known evidence lost: %s %#v", source, locked) + } + if calls.Load() != before { + t.Fatalf("status-lock sent agent commands: %d -> %d", before, calls.Load()) + } + if !reflect.DeepEqual(m.guestMetadataCache[metadataKey], originalMetadata) || !reflect.DeepEqual(m.vmAgentMemCache[memKey], originalMemory) { + t.Fatal("lock renewed/replaced cached observations") + } + for metric, want := range map[string]int{"cpu": 2, "memory": 1, "memoryused": 1, "disk": 1} { + if got := len(m.metricsHistory.GetGuestMetrics(identity, metric, time.Hour)); got != want { + t.Errorf("%s history points = %d, want %d", metric, got, want) + } + } + // Cluster lock must survive a failed status call and the recent-evidence fallback. + phase.Store(2) + if err := json.Unmarshal([]byte(`{"type":"qemu","node":"node","name":"guest","vmid":105,"status":"running","lock":"backup","maxmem":8388608,"mem":8388608,"maxdisk":1048576000}`), &res); err != nil { + t.Fatal(err) + } + locked, _ = build() + if calls.Load() != before || locked.GuestAgentStatus != "deferred" || locked.AgentVersion != initial.AgentVersion { + t.Fatalf("resource-lock fallback queried/lost identity: calls=%d %#v", calls.Load(), locked) + } + // The per-node inventory fallback must propagate its lock to the same builder. + manager := alerts.NewManagerWithDataDir(t.TempDir()) + defer manager.Stop() + m.alertManager = manager + nodeVMs, _ := m.pollNodeVMsWithClusterResourceBuilder(context.Background(), "fixture", "node", []proxmox.VM{{VMID: 105, Name: "guest", Status: "running", Lock: "backup", MaxMem: 8 * mib, Mem: 8 * mib, MaxDisk: 1000 * mib}}, client, map[string]models.VM{makeGuestID("fixture", "node", 105): locked}, nil) + if len(nodeVMs) != 1 || nodeVMs[0].GuestAgentStatus != "deferred" || calls.Load() != before { + t.Fatalf("node fallback lost lock or sent guest commands: %#v calls=%d", nodeVMs, calls.Load()) + } + // Resume from expired, unrenewed caches; new data must replace the retained view. + phase.Store(3) + res.Lock = "" + cached := m.vmAgentMemCache[memKey] + cached.fetchedAt = time.Now().Add(-2 * vmAgentMemCacheTTL) + m.vmAgentMemCache[memKey] = cached + metadata := m.guestMetadataCache[metadataKey] + metadata.fetchedAt = time.Now().Add(-2 * guestMetadataCacheTTL) + m.guestMetadataCache[metadataKey] = metadata + m.guestMetadataLimiter = make(map[string]time.Time) + resumed, source := build() + if resumed.ID != initial.ID || resumed.GuestAgentStatus != "available" || resumed.DiskStatusReason != "" || resumed.Disk.Used != int64(400*mib) || resumed.Memory.Used != int64(4*mib) || source != "guest-agent-meminfo" { + t.Fatalf("healthy resumption failed: %s %#v", source, resumed) + } + if calls.Load() != before+5 { + t.Errorf("fresh resume commands = %d, want %d", calls.Load(), before+5) + } + if got := len(m.metricsHistory.GetGuestMetrics(identity, "disk", time.Hour)); got != 2 { + t.Errorf("cached disk was written as new telemetry: %d points", got) + } +} + +func TestGuestAgentDeferralPreservesMetadataAndMemoryCache(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } + calls.Add(1) + http.Error(w, "QEMU command timed out", 500) + })) + defer server.Close() + client, err := proxmox.NewClient(proxmox.ClientConfig{Host: server.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + now := time.Now().Add(-10 * time.Minute) + metadata := guestMetadataCacheEntry{ipAddresses: []string{"192.0.2.10"}, osName: "Linux", agentVersion: "1.0", fetchedAt: now, osInfoFailureCount: 2} + memory := agentMemCacheEntry{available: 1024, fetchedAt: now, info: proxmox.LinuxMemoryAvailability{Source: "meminfo-available", EffectiveAvailable: 1024}} + mk, gk := guestMemoryCacheKey("fixture", "node", 105), guestMetadataCacheKey("fixture", "node", 105) + m := &Monitor{guestMetadataCache: map[string]guestMetadataCacheEntry{gk: metadata}, vmAgentMemCache: map[string]agentMemCacheEntry{mk: memory}, guestMetadataLimiter: make(map[string]time.Time)} + ips, _, os, _, version := m.fetchGuestAgentMetadata(context.Background(), client, "fixture", "node", "guest", 105, &proxmox.VMStatus{Agent: proxmox.VMAgentField{Value: 1}}, false) + if len(ips) != 1 || os != "Linux" || version != "1.0" { + t.Fatal("deferral lost last known metadata") + } + if _, err := m.getVMAgentMemoryAvailability(context.Background(), client, "fixture", "node", 105); proxmox.GuestAgentDeferredReason(err) != "agent-cooldown" { + t.Fatalf("memory retried deferred VM: %v", err) + } + if calls.Load() != 1 || !reflect.DeepEqual(m.guestMetadataCache[gk], metadata) || !reflect.DeepEqual(m.vmAgentMemCache[mk], memory) { + t.Fatal("deferral queued work, renewed cache or poisoned supported-OS evidence") + } +} diff --git a/internal/monitoring/guest_agent_evidence.go b/internal/monitoring/guest_agent_evidence.go index caf19fd1ce..001c33d176 100644 --- a/internal/monitoring/guest_agent_evidence.go +++ b/internal/monitoring/guest_agent_evidence.go @@ -39,7 +39,7 @@ func hasRecentGuestAgentEvidence(prev *models.VM, now time.Time) bool { func shouldQueryGuestAgent(vmStatus *proxmox.VMStatus, prev *models.VM, now time.Time) bool { if vmStatus != nil { - return vmStatus.Agent.IsAvailable() + return vmStatus.Lock == "" && vmStatus.Agent.IsAvailable() } return hasRecentGuestAgentEvidence(prev, now) } diff --git a/internal/monitoring/guest_disk_stability.go b/internal/monitoring/guest_disk_stability.go index 87cb04f5b0..53a99390d5 100644 --- a/internal/monitoring/guest_disk_stability.go +++ b/internal/monitoring/guest_disk_stability.go @@ -4,6 +4,8 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" + "github.com/rcourtman/pulse-go-rewrite/internal/models" ) @@ -19,6 +21,10 @@ func classifyGuestAgentDiskStatusError(err error) string { return "" } + if reason := proxmox.GuestAgentDeferredReason(err); reason != "" { + return reason + } + errStr := err.Error() errStrLower := strings.ToLower(errStr) diff --git a/internal/monitoring/guest_memory_agent.go b/internal/monitoring/guest_memory_agent.go index 8b29b23279..3439899636 100644 --- a/internal/monitoring/guest_memory_agent.go +++ b/internal/monitoring/guest_memory_agent.go @@ -2,6 +2,7 @@ package monitoring import ( "context" + "errors" "fmt" "strings" "time" @@ -93,6 +94,12 @@ func (m *Monitor) getVMAgentMemoryAvailability(ctx context.Context, client PVECl return proxmox.LinuxMemoryAvailability{}, fmt.Errorf("guest agent meminfo fallback unsupported") } + if errors.Is(err, proxmox.ErrGuestAgentDeferred) { + // A coordination pause is not a failed guest observation. Preserve the + // last successful cache and its original timestamp for normal resumption. + return proxmox.LinuxMemoryAvailability{}, err + } + m.rrdCacheMu.Lock() defer m.rrdCacheMu.Unlock() if m.vmAgentMemCache == nil { diff --git a/internal/monitoring/guest_memory_sources.go b/internal/monitoring/guest_memory_sources.go index d26d7c48f0..709038169c 100644 --- a/internal/monitoring/guest_memory_sources.go +++ b/internal/monitoring/guest_memory_sources.go @@ -173,7 +173,7 @@ func selectGuestLowTrustUsedMemory(memTotal uint64, status *proxmox.VMStatus) (u } func shouldPreferGuestAgentMemAvailable(status *proxmox.VMStatus, memTotal uint64) bool { - if status == nil || !status.Agent.IsAvailable() || status.Mem == 0 { + if status == nil || status.Lock != "" || !status.Agent.IsAvailable() || status.Mem == 0 { return false } if guestStatusFreeMem(status) > 0 { @@ -311,7 +311,7 @@ func (m *Monitor) resolveGuestStatusMemory( // only in node RRD, so a guest RRD lookup can never yield memory evidence // (#1634). - if !hasMemAvailable && !hasSelectedUsed && status.Agent.IsAvailable() && !triedGuestAgentMemAvailable { + if !hasMemAvailable && !hasSelectedUsed && status.Lock == "" && status.Agent.IsAvailable() && !triedGuestAgentMemAvailable { if agentAvailable, agentSource, ok := m.tryGuestAgentMemAvailable(ctx, client, instanceName, guestName, node, vmid, memTotal, guestRaw); ok { memAvailable = agentAvailable hasMemAvailable = true diff --git a/internal/monitoring/guest_metadata.go b/internal/monitoring/guest_metadata.go index ec955ed3cc..ce22b3636c 100644 --- a/internal/monitoring/guest_metadata.go +++ b/internal/monitoring/guest_metadata.go @@ -3,6 +3,7 @@ package monitoring import ( "context" "encoding/json" + "errors" "fmt" "math" "sort" @@ -206,7 +207,7 @@ func (m *Monitor) retryGuestAgentCall(ctx context.Context, timeout time.Duration lastErr = err // Don't retry non-timeout errors or if this was the last attempt - if attempt >= maxRetries || !strings.Contains(err.Error(), "timeout") { + if errors.Is(err, proxmox.ErrGuestAgentDeferred) || attempt >= maxRetries || !strings.Contains(err.Error(), "timeout") { break } @@ -229,6 +230,11 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI cached, ok := m.guestMetadataCache[key] m.guestMetadataMu.RUnlock() + if vmStatus != nil && vmStatus.Lock != "" { + // Deliberately retained identity, not a newly fetched observation. + return cloneStringSlice(cached.ipAddresses), cloneGuestNetworkInterfaces(cached.networkInterfaces), cached.osName, cached.osVersion, cached.agentVersion + } + agentAvailable := client != nil && ((vmStatus != nil && vmStatus.Agent.IsAvailable()) || allowWithoutStatus) if !agentAvailable { if ok && now.Sub(cached.fetchedAt) < guestMetadataCacheEntryTTL(cached) { @@ -274,6 +280,10 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI interfaces, err := m.retryGuestAgentCall(ctx, m.guestAgentNetworkTimeout, m.guestAgentRetries, func(ctx context.Context) (interface{}, error) { return client.GetVMNetworkInterfaces(ctx, nodeName, vmid) }) + if errors.Is(err, proxmox.ErrGuestAgentDeferred) { + m.deferGuestMetadataRetry(key, time.Now()) + return cloneStringSlice(cached.ipAddresses), cloneGuestNetworkInterfaces(cached.networkInterfaces), cached.osName, cached.osVersion, cached.agentVersion + } if err != nil { log.Debug(). Str("instance", instanceName). @@ -311,6 +321,10 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI agentInfoRaw, err := m.retryGuestAgentCall(ctx, m.guestAgentOSInfoTimeout, m.guestAgentRetries, func(ctx context.Context) (interface{}, error) { return client.GetVMAgentInfo(ctx, nodeName, vmid) }) + if errors.Is(err, proxmox.ErrGuestAgentDeferred) { + m.deferGuestMetadataRetry(key, time.Now()) + return cloneStringSlice(cached.ipAddresses), cloneGuestNetworkInterfaces(cached.networkInterfaces), cached.osName, cached.osVersion, cached.agentVersion + } if err != nil { if isGuestAgentOSInfoUnsupportedError(err) { osInfoSkip = true @@ -365,6 +379,10 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI versionRaw, err := m.retryGuestAgentCall(ctx, m.guestAgentVersionTimeout, m.guestAgentRetries, func(ctx context.Context) (interface{}, error) { return client.GetVMAgentVersion(ctx, nodeName, vmid) }) + if errors.Is(err, proxmox.ErrGuestAgentDeferred) { + m.deferGuestMetadataRetry(key, time.Now()) + return cloneStringSlice(cached.ipAddresses), cloneGuestNetworkInterfaces(cached.networkInterfaces), cached.osName, cached.osVersion, cached.agentVersion + } if err != nil { log.Debug(). Str("instance", instanceName). diff --git a/internal/monitoring/memory_trust_characterization_test.go b/internal/monitoring/memory_trust_characterization_test.go index 2d12f5091a..60c583c3a1 100644 --- a/internal/monitoring/memory_trust_characterization_test.go +++ b/internal/monitoring/memory_trust_characterization_test.go @@ -2,6 +2,8 @@ package monitoring import ( "context" + "fmt" + "strings" "testing" "time" @@ -1171,3 +1173,30 @@ func TestGracePeriodGuestContributesNoMemorySample(t *testing.T) { t.Fatalf("grace-period guest recorded %d memory sample(s): %+v", len(got), got) } } + +func TestBackupLockMemoryTrustKeepsNativeEvidenceWithoutAgentReads(t *testing.T) { + const gib = uint64(1024 * 1024 * 1024) + for _, native := range []bool{false, true} { + t.Run(fmt.Sprintf("native-%t", native), func(t *testing.T) { + m := &Monitor{} + client := &vmMemoryTrustStubClient{stubPVEClient: &stubPVEClient{}, vmAgentMemAvailable: 5 * gib} + status := &proxmox.VMStatus{Status: "running", Agent: proxmox.VMAgentField{Value: 1}, Lock: "backup", MaxMem: 8 * gib, Mem: 8 * gib} + if native { + status.MemInfo = &proxmox.VMMemInfo{Total: 8 * gib, Available: 5 * gib} + } + _, used, source := m.resolveGuestStatusMemory(context.Background(), client, "fixture", "guest", "node", 105, "fixture:node:105", status, nil, 8*gib, "", &VMMemoryRaw{}) + if client.vmAgentMemCalls != 0 || strings.HasPrefix(source, "guest-agent-meminfo") { + t.Fatalf("backup used fresh/cached guest-agent evidence: calls=%d source=%s", client.vmAgentMemCalls, source) + } + if native && (used != 3*gib || source != "available-field") { + t.Fatalf("current native memory lost: %d %s", used, source) + } + status.Lock = "" + status.MemInfo = nil + _, _, source = m.resolveGuestStatusMemory(context.Background(), client, "fixture", "guest", "node", 105, "fixture:node:105", status, nil, 8*gib, "", &VMMemoryRaw{}) + if client.vmAgentMemCalls != 1 || source != "guest-agent-meminfo" { + t.Fatalf("unlocked memory did not resume: %d %s", client.vmAgentMemCalls, source) + } + }) + } +} diff --git a/internal/monitoring/monitor_pve_guest_builders.go b/internal/monitoring/monitor_pve_guest_builders.go index af16aa7dbc..3f1423fa2c 100644 --- a/internal/monitoring/monitor_pve_guest_builders.go +++ b/internal/monitoring/monitor_pve_guest_builders.go @@ -58,6 +58,12 @@ func (m *Monitor) applyVMStatusDetails( return } + if res.Lock != "" { + copy := *status + copy.Lock = res.Lock + status = © + } + res.Lock = status.Lock state.detailedStatus = status state.guestAgentStatus, state.guestAgentExpected = vmGuestAgentRuntimeState(status, recentGuestAgentEvidence) state.memTotal, state.memUsed, state.memorySource = m.resolveGuestStatusMemory( @@ -111,6 +117,9 @@ func (m *Monitor) applyVMStatusDetails( if len(fsDisks) > 0 { state.individualDisks = fsDisks } + if guestAgentDiskDeferred(state.diskStatusReason) { + state.guestAgentStatus = "deferred" + } } else { // Agent disabled - show allocated disk size if state.diskTotal > 0 { @@ -172,6 +181,9 @@ func vmGuestAgentRuntimeState(status *proxmox.VMStatus, recentGuestAgentEvidence if status == nil { return "", false } + if status.Lock != "" { + return "deferred", status.Agent.IsEnabled() + } if status.Agent.IsAvailable() { return "available", true } @@ -265,10 +277,13 @@ func (m *Monitor) buildVMFromClusterResource( Msg("Could not get VM status, using cluster/resources disk data") } + if state.detailedStatus != nil && state.detailedStatus.Lock != "" { + res.Lock = state.detailedStatus.Lock + } now := time.Now() guestAgentAvailable := shouldQueryGuestAgent(state.detailedStatus, prevVM, now) || m.hasRecentGuestMetadataEvidence(instanceName, res.Node, res.VMID, now) - if guestAgentAvailable && state.detailedStatus == nil { + if guestAgentAvailable && res.Lock == "" && state.detailedStatus == nil { guestIPs, guestIfaces, guestOSName, guestOSVersion, guestAgentVersion := m.fetchGuestAgentMetadata( ctx, client, @@ -308,12 +323,31 @@ func (m *Monitor) buildVMFromClusterResource( if len(fsDisks) > 0 { state.individualDisks = fsDisks } + if guestAgentDiskDeferred(state.diskStatusReason) { + state.guestAgentStatus = "deferred" + } state.guestAgentExpected = true - if len(guestIPs) > 0 || len(guestIfaces) > 0 || guestOSName != "" || guestOSVersion != "" || guestAgentVersion != "" || state.diskFromAgent { + if state.guestAgentStatus != "deferred" && (len(guestIPs) > 0 || len(guestIfaces) > 0 || guestOSName != "" || guestOSVersion != "" || guestAgentVersion != "" || state.diskFromAgent) { state.guestAgentStatus = "available" } } + if res.Lock != "" { + state.guestAgentStatus = "deferred" + state.diskStatusReason = "vm-locked" + state.diskFromAgent = false + state.diskUsage = -1 + if guestMemorySourceReliability(state.memorySource) == guestMemoryReliabilityLow { + state.memorySource = "unavailable" + state.memUsed = 0 + } + if prevVM != nil { + state.ipAddresses = cloneStringSlice(prevVM.IPAddresses) + state.networkInterfaces = cloneGuestNetworkInterfaces(prevVM.NetworkInterfaces) + state.osName, state.osVersion, state.agentVersion = prevVM.OSName, prevVM.OSVersion, prevVM.AgentVersion + } + } + var preferred bool state.diskTotal, state.diskUsed, state.diskFree, state.diskUsage, state.individualDisks, state.diskStatusReason, preferred = preferLinkedHostAgentDiskInventory( guestID, @@ -449,6 +483,7 @@ func (m *Monitor) buildVMFromClusterResource( Disks: state.individualDisks, DiskStatusReason: state.diskStatusReason, GuestAgentStatus: state.guestAgentStatus, + Lock: res.Lock, GuestAgentExpected: state.guestAgentExpected, IPAddresses: state.ipAddresses, OSName: state.osName, @@ -531,6 +566,9 @@ type vmFSInfoSummary struct { } func (m *Monitor) fetchVMFSInfo(ctx context.Context, instanceName string, res proxmox.ClusterResource, client PVEClientInterface) ([]proxmox.VMFileSystem, string, bool) { + if res.Lock != "" { + return nil, "vm-locked", false + } // Use retry logic for guest agent calls to handle transient timeouts (refs #630) fsInfoRaw, err := m.retryGuestAgentCall(ctx, m.guestAgentFSInfoTimeout, m.guestAgentRetries, func(ctx context.Context) (interface{}, error) { return client.GetVMFSInfo(ctx, res.Node, res.VMID) @@ -542,6 +580,9 @@ func (m *Monitor) fetchVMFSInfo(ctx context.Context, instanceName string, res pr } } if err != nil { + if reason := proxmox.GuestAgentDeferredReason(err); reason != "" { + return nil, reason, false + } // Log more helpful error messages based on the error type errMsg := err.Error() if strings.Contains(errMsg, "500") || strings.Contains(errMsg, "QEMU guest agent is not running") { @@ -847,3 +888,11 @@ func (m *Monitor) updateVMDisksFromGuestAgentFSInfo( return diskTotal, diskUsed, diskTotal - diskUsed, diskUsage, nil, false, "special-filesystems-only" } + +func guestAgentDiskDeferred(reason string) bool { + switch strings.TrimPrefix(reason, "prev-") { + case "vm-locked", "lock-unverified", "agent-busy", "agent-cooldown", "agent-capacity", "agent-timeout", "agent-response-incomplete": + return true + } + return false +} diff --git a/internal/monitoring/monitor_pve_guest_helpers.go b/internal/monitoring/monitor_pve_guest_helpers.go index 65885359ff..86fe424d2f 100644 --- a/internal/monitoring/monitor_pve_guest_helpers.go +++ b/internal/monitoring/monitor_pve_guest_helpers.go @@ -1,6 +1,7 @@ package monitoring import ( + "strings" "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" @@ -134,7 +135,17 @@ func (m *Monitor) recordGuestMetrics(allVMs []models.VM, allContainers []models. } if vm.Status == "running" { diskRead, diskWrite, networkIn, networkOut := guestHistoryRates(vm.DiskRead, vm.DiskWrite, vm.NetworkIn, vm.NetworkOut, vm.IORateValidity) - m.recordGuestMetric("vm", vm.ID, unifiedresources.ProxmoxGuestCPUPercent(vm.CPU), historyMemoryUsage(vm.Memory), historyMemoryUsed(vm.Memory), vm.Disk.Usage, diskRead, diskWrite, networkIn, networkOut, now) + memoryUsage, memoryUsed, diskUsage := historyMemoryUsage(vm.Memory), historyMemoryUsed(vm.Memory), vm.Disk.Usage + if strings.HasPrefix(vm.DiskStatusReason, "prev-") { + diskUsage = -1 + } + if vm.GuestAgentStatus == "deferred" { + snapshot := m.previousGuestSnapshot(vm.Instance, "qemu", vm.Node, vm.VMID) + if snapshot == nil || snapshot.MemorySource == "previous-snapshot" || strings.HasPrefix(CanonicalMemorySource(snapshot.MemorySource), "guest-agent-meminfo") { + memoryUsage, memoryUsed = -1, -1 + } + } + m.recordGuestMetric("vm", vm.ID, unifiedresources.ProxmoxGuestCPUPercent(vm.CPU), memoryUsage, memoryUsed, diskUsage, diskRead, diskWrite, networkIn, networkOut, now) } } for _, ct := range allContainers { diff --git a/internal/monitoring/monitor_pve_node_vm_builder.go b/internal/monitoring/monitor_pve_node_vm_builder.go index 755c249e48..c5182072be 100644 --- a/internal/monitoring/monitor_pve_node_vm_builder.go +++ b/internal/monitoring/monitor_pve_node_vm_builder.go @@ -51,6 +51,7 @@ func (m *Monitor) pollNodeVMsWithClusterResourceBuilder( Uptime: vm.Uptime, Template: vm.Template, Tags: vm.Tags, + Lock: vm.Lock, IOCounters: vm.IOCounters, ObservedAt: vm.ObservedAt, }, diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 5dc74c8d6f..1b3dcee276 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -640,6 +640,12 @@ func (c *Client) requestWithRetry(ctx context.Context, method, path string, data // get performs a GET request func (c *Client) get(ctx context.Context, path string) (*http.Response, error) { + if node, vmid, ok := guestAgentPath(path); ok { + return c.getGuestAgent(ctx, path, node, vmid) + } + if strings.Contains(path, "/agent/") { + return nil, &guestAgentDeferredError{reason: "invalid-guest-key"} + } return c.request(ctx, "GET", path, nil) } @@ -2432,6 +2438,7 @@ type ClusterResource struct { Tags string `json:"tags,omitempty"` IOCounters IOCounterPresence `json:"-"` ObservedAt time.Time `json:"-"` + Lock string `json:"lock,omitempty"` } // GetClusterResources returns all resources (VMs, containers) across the cluster @@ -2715,6 +2722,7 @@ type VMStatus struct { Agent VMAgentField `json:"agent"` IOCounters IOCounterPresence `json:"-"` ObservedAt time.Time `json:"-"` + Lock string `json:"lock,omitempty"` } // GetZFSPoolStatus gets the status of ZFS pools on a node diff --git a/pkg/proxmox/client_api_more3_test.go b/pkg/proxmox/client_api_more3_test.go index 12a4aafc58..240dce4cef 100644 --- a/pkg/proxmox/client_api_more3_test.go +++ b/pkg/proxmox/client_api_more3_test.go @@ -2,12 +2,19 @@ package proxmox import ( "context" + "fmt" "net/http" + "strings" "testing" ) func TestClientVMFSInfoParsing(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/get-fsinfo": writeJSON(t, w, map[string]interface{}{ @@ -56,6 +63,11 @@ func TestClientVMFSInfoParsing(t *testing.T) { func TestClientVMFSInfoParsingCentOSXFSResult(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/pve1/qemu/1376/agent/get-fsinfo": w.Header().Set("Content-Type", "application/json") @@ -87,6 +99,11 @@ func TestClientVMFSInfoParsingCentOSXFSResult(t *testing.T) { func TestClientVMFSInfoParsingPrivilegedCapacityFallback(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/get-fsinfo": writeJSON(t, w, map[string]interface{}{ @@ -126,6 +143,11 @@ func TestClientVMFSInfoParsingPrivilegedCapacityFallback(t *testing.T) { func TestClientVMFSInfoParsingWindowsVolumeGUIDMountpointFallback(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/get-fsinfo": writeJSON(t, w, map[string]interface{}{ @@ -164,6 +186,11 @@ func TestClientVMFSInfoParsingWindowsVolumeGUIDMountpointFallback(t *testing.T) func TestClientVMFSInfoParsingIssue1319WindowsVolumePayload(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/pve7/qemu/116/agent/get-fsinfo": w.Header().Set("Content-Type", "application/json") @@ -200,6 +227,11 @@ func TestClientVMFSInfoParsingIssue1319WindowsVolumePayload(t *testing.T) { func TestClientVMFSInfoSkipsMalformedEntries(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/get-fsinfo": writeJSON(t, w, map[string]interface{}{ @@ -242,6 +274,11 @@ func TestClientVMFSInfoSkipsMalformedEntries(t *testing.T) { func TestClientVMFSInfoObjectResult(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/get-fsinfo": writeJSON(t, w, map[string]interface{}{ @@ -266,6 +303,11 @@ func TestClientVMFSInfoObjectResult(t *testing.T) { func TestClientVMFSInfoObjectFilesystemResult(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/get-fsinfo": writeJSON(t, w, map[string]interface{}{ diff --git a/pkg/proxmox/client_api_more_test.go b/pkg/proxmox/client_api_more_test.go index 095d6cc9a3..bf66d6c96f 100644 --- a/pkg/proxmox/client_api_more_test.go +++ b/pkg/proxmox/client_api_more_test.go @@ -3,8 +3,10 @@ package proxmox import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -217,6 +219,11 @@ func TestClientClusterAndAgentInfo(t *testing.T) { func TestClientGetVMNetworkInterfaces_ObjectAndPartialPayloads(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/network-get-interfaces": writeJSON(t, w, map[string]interface{}{ @@ -340,6 +347,11 @@ func TestClientStatusAndResources(t *testing.T) { func TestClientGetVMMemAvailableFromAgent(t *testing.T) { client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes/node1/qemu/100/agent/file-read": if got := r.URL.Query().Get("file"); got != "/proc/meminfo" { diff --git a/pkg/proxmox/client_vma_version_test.go b/pkg/proxmox/client_vma_version_test.go index cc8a57c18f..4d957a5f03 100644 --- a/pkg/proxmox/client_vma_version_test.go +++ b/pkg/proxmox/client_vma_version_test.go @@ -5,11 +5,17 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" ) func TestClient_GetVMAgentVersion_MapResult(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/info" { fmt.Fprint(w, `{"data":{"result":{"version":{"version":"3.1"}}}}`) @@ -40,6 +46,11 @@ func TestClient_GetVMAgentVersion_MapResult(t *testing.T) { func TestClient_GetVMAgentVersion_NoVersion(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api2/json/nodes/node1/qemu/100/agent/info" { fmt.Fprint(w, `{"data":{"result":{"foo":"bar"}}}`) diff --git a/pkg/proxmox/cluster_client.go b/pkg/proxmox/cluster_client.go index be6d44c381..64338a616d 100644 --- a/pkg/proxmox/cluster_client.go +++ b/pkg/proxmox/cluster_client.go @@ -172,6 +172,7 @@ func sanitizeEndpointError(errMsg string) string { // endpointFingerprints is an optional map of endpoint URL -> TLS fingerprint for per-node certificate verification. // This enables TOFU (Trust On First Use) for clusters with unique self-signed certs per node. func NewClusterClient(name string, config ClientConfig, endpoints []string, endpointFingerprints map[string]string) *ClusterClient { + registerGuestAgentEndpoints(config.Host, endpoints) if endpointFingerprints == nil { endpointFingerprints = make(map[string]string) } @@ -1373,7 +1374,7 @@ func (cc *ClusterClient) GetVMConfig(ctx context.Context, node string, vmid int) func (cc *ClusterClient) GetVMAgentInfo(ctx context.Context, node string, vmid int) (map[string]interface{}, error) { var result map[string]interface{} - err := cc.executeWithFailover(ctx, func(client *Client) error { + err := cc.executeGuestAgent(ctx, func(client *Client) error { info, err := client.GetVMAgentInfo(ctx, node, vmid) if err != nil { return err @@ -1387,7 +1388,7 @@ func (cc *ClusterClient) GetVMAgentInfo(ctx context.Context, node string, vmid i // GetVMAgentVersion returns the guest agent version for the VM. func (cc *ClusterClient) GetVMAgentVersion(ctx context.Context, node string, vmid int) (string, error) { var version string - err := cc.executeWithFailover(ctx, func(client *Client) error { + err := cc.executeGuestAgent(ctx, func(client *Client) error { v, err := client.GetVMAgentVersion(ctx, node, vmid) if err != nil { return err @@ -1401,7 +1402,7 @@ func (cc *ClusterClient) GetVMAgentVersion(ctx context.Context, node string, vmi // GetVMFSInfo returns filesystem information from QEMU guest agent func (cc *ClusterClient) GetVMFSInfo(ctx context.Context, node string, vmid int) ([]VMFileSystem, error) { var result []VMFileSystem - err := cc.executeWithFailover(ctx, func(client *Client) error { + err := cc.executeGuestAgent(ctx, func(client *Client) error { info, err := client.GetVMFSInfo(ctx, node, vmid) if err != nil { return err @@ -1415,7 +1416,7 @@ func (cc *ClusterClient) GetVMFSInfo(ctx context.Context, node string, vmid int) // GetVMNetworkInterfaces returns guest network interfaces from the QEMU agent func (cc *ClusterClient) GetVMNetworkInterfaces(ctx context.Context, node string, vmid int) ([]VMNetworkInterface, error) { var result []VMNetworkInterface - err := cc.executeWithFailover(ctx, func(client *Client) error { + err := cc.executeGuestAgent(ctx, func(client *Client) error { interfaces, err := client.GetVMNetworkInterfaces(ctx, node, vmid) if err != nil { return err @@ -1429,7 +1430,7 @@ func (cc *ClusterClient) GetVMNetworkInterfaces(ctx context.Context, node string // GetVMMemAvailableFromAgent reads /proc/meminfo via the QEMU guest agent to get MemAvailable. func (cc *ClusterClient) GetVMMemAvailableFromAgent(ctx context.Context, node string, vmid int) (uint64, error) { var result uint64 - err := cc.executeWithFailover(ctx, func(client *Client) error { + err := cc.executeGuestAgent(ctx, func(client *Client) error { available, err := client.GetVMMemAvailableFromAgent(ctx, node, vmid) if err != nil { return err @@ -1444,7 +1445,7 @@ func (cc *ClusterClient) GetVMMemAvailableFromAgent(ctx context.Context, node st // through the first healthy cluster client. func (cc *ClusterClient) GetVMMemoryAvailabilityFromAgent(ctx context.Context, node string, vmid int) (LinuxMemoryAvailability, error) { var result LinuxMemoryAvailability - err := cc.executeWithFailover(ctx, func(client *Client) error { + err := cc.executeGuestAgent(ctx, func(client *Client) error { availability, err := client.GetVMMemoryAvailabilityFromAgent(ctx, node, vmid) if err != nil { return err diff --git a/pkg/proxmox/cluster_client_additional_api6_test.go b/pkg/proxmox/cluster_client_additional_api6_test.go index d969ed0461..ca085c0376 100644 --- a/pkg/proxmox/cluster_client_additional_api6_test.go +++ b/pkg/proxmox/cluster_client_additional_api6_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -33,6 +34,11 @@ func TestClusterClient_GetNodePendingUpdates_NoHealthyNodes(t *testing.T) { func TestClusterClient_GetVMAgentVersion_QemuGA(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api2/json/nodes" { fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) diff --git a/pkg/proxmox/cluster_client_additional_api_test.go b/pkg/proxmox/cluster_client_additional_api_test.go index 17d825eeb3..0c6d65ccff 100644 --- a/pkg/proxmox/cluster_client_additional_api_test.go +++ b/pkg/proxmox/cluster_client_additional_api_test.go @@ -146,6 +146,11 @@ func TestClusterClient_GetVMConfig(t *testing.T) { func TestClusterClient_GetVMAgentInfo(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api2/json/nodes" { fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) @@ -173,6 +178,11 @@ func TestClusterClient_GetVMAgentInfo(t *testing.T) { func TestClusterClient_GetVMAgentVersion(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api2/json/nodes" { fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) @@ -200,6 +210,11 @@ func TestClusterClient_GetVMAgentVersion(t *testing.T) { func TestClusterClient_GetVMNetworkInterfaces(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } w.Header().Set("Content-Type", "application/json") if r.URL.Path == "/api2/json/nodes" { fmt.Fprint(w, `{"data":[{"node":"node1","status":"online"}]}`) diff --git a/pkg/proxmox/cluster_client_api_test.go b/pkg/proxmox/cluster_client_api_test.go index 516f373a48..bbf27e5310 100644 --- a/pkg/proxmox/cluster_client_api_test.go +++ b/pkg/proxmox/cluster_client_api_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -37,6 +38,11 @@ func TestClusterClient_GetCephStatus(t *testing.T) { func TestClusterClient_GetVMMemoryAvailabilityFromAgent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api2/json/nodes": diff --git a/pkg/proxmox/cluster_client_test.go b/pkg/proxmox/cluster_client_test.go index e5eaf068fe..19ebf4f59c 100644 --- a/pkg/proxmox/cluster_client_test.go +++ b/pkg/proxmox/cluster_client_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" "time" @@ -67,6 +68,11 @@ func TestClusterClientHandlesRateLimitWithoutMarkingUnhealthy(t *testing.T) { func TestClusterClientIgnoresGuestAgentTimeoutForHealth(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes": w.Header().Set("Content-Type", "application/json") @@ -295,6 +301,11 @@ func TestIsEndpointConnectivityError(t *testing.T) { func TestClusterClientIgnoresPlainGuestAgent500ForHealth(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Guest reads require fresh operation-lock evidence, independently of their payload fixture. + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } switch r.URL.Path { case "/api2/json/nodes": w.Header().Set("Content-Type", "application/json") diff --git a/pkg/proxmox/guest_agent_backup_test.go b/pkg/proxmox/guest_agent_backup_test.go new file mode 100644 index 0000000000..f4b1d3ff1a --- /dev/null +++ b/pkg/proxmox/guest_agent_backup_test.go @@ -0,0 +1,206 @@ +package proxmox + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func backupTestClient(t *testing.T, host string) *Client { + t.Helper() + c, err := NewClient(ClientConfig{Host: host, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + return c +} + +func backupAgentReads() map[string]func(context.Context, *Client, int) error { + return map[string]func(context.Context, *Client, int) error{ + "os": func(ctx context.Context, c *Client, id int) error { + _, err := c.GetVMAgentInfo(ctx, "node", id) + return err + }, + "version": func(ctx context.Context, c *Client, id int) error { + _, err := c.GetVMAgentVersion(ctx, "node", id) + return err + }, + "filesystem": func(ctx context.Context, c *Client, id int) error { + _, err := c.GetVMFSInfo(ctx, "node", id) + return err + }, + "network": func(ctx context.Context, c *Client, id int) error { + _, err := c.GetVMNetworkInterfaces(ctx, "node", id) + return err + }, + "meminfo": func(ctx context.Context, c *Client, id int) error { + _, err := c.GetVMMemoryAvailabilityFromAgent(ctx, "node", id) + return err + }, + "legacy-memory": func(ctx context.Context, c *Client, id int) error { + _, err := c.GetVMMemAvailableFromAgent(ctx, "node", id) + return err + }, + } +} + +func backupAgentPayload(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "file-read"): + fmt.Fprint(w, `{"data":{"content":"MemTotal: 1024 kB\nMemAvailable: 512 kB\n"}}`) + case strings.HasSuffix(r.URL.Path, "info") && !strings.HasSuffix(r.URL.Path, "fsinfo") && !strings.HasSuffix(r.URL.Path, "osinfo"): + fmt.Fprint(w, `{"data":{"result":{"version":"1.0"}}}`) + case strings.HasSuffix(r.URL.Path, "get-osinfo"): + fmt.Fprint(w, `{"data":{"id":"linux"}}`) + default: + fmt.Fprint(w, `{"data":{"result":[]}}`) + } +} + +func TestGuestAgentBackupLockBlocksEveryReadAndResumes(t *testing.T) { + var locked atomic.Bool + locked.Store(true) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/config") { + if locked.Load() { + fmt.Fprint(w, `{"data":{"lock":"backup"}}`) + } else { + fmt.Fprint(w, `{"data":{}}`) + } + return + } + if strings.Contains(r.URL.Path, "/agent/") { + calls.Add(1) + backupAgentPayload(w, r) + return + } + http.NotFound(w, r) + })) + defer server.Close() + c := backupTestClient(t, server.URL) + for name, read := range backupAgentReads() { + t.Run(name, func(t *testing.T) { + if err := read(context.Background(), c, 105); err == nil { + t.Fatal("backup-locked guest was queried") + } + }) + } + if got := calls.Load(); got != 0 { + t.Errorf("agent requests while locked = %d, want zero", got) + } + locked.Store(false) + for name, read := range backupAgentReads() { + t.Run("resumed-"+name, func(t *testing.T) { + if err := read(context.Background(), c, 105); err != nil { + t.Fatal(err) + } + }) + } + if got := calls.Load(); got != 6 { + t.Errorf("agent requests after unlock = %d, want six", got) + } +} + +func TestGuestAgentUnknownLockStateFailsClosed(t *testing.T) { + for _, body := range []string{`{"data":null}`, `{}`, `{"data":{"lock":true}}`, `{"data":{"lock":"snapshot"}}`} { + t.Run(body, func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, body) + return + } + calls.Add(1) + backupAgentPayload(w, r) + })) + defer server.Close() + _, err := backupTestClient(t, server.URL).GetVMFSInfo(context.Background(), "node", 105) + if err == nil || calls.Load() != 0 { + t.Fatalf("unverified lock state queried agent: err=%v calls=%d", err, calls.Load()) + } + }) + } +} + +func TestGuestAgentConcurrentDiagnosticsDoNotOverlap(t *testing.T) { + var active, peak atomic.Int32 + entered := make(chan struct{}, 10) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } + n := active.Add(1) + for old := peak.Load(); n > old; old = peak.Load() { + if peak.CompareAndSwap(old, n) { + break + } + } + entered <- struct{}{} + <-release + active.Add(-1) + backupAgentPayload(w, r) + })) + defer server.Close() + // Separate Client instances model a diagnostic alongside ordinary polling. + c1, c2 := backupTestClient(t, server.URL), backupTestClient(t, server.URL) + var wg sync.WaitGroup + wg.Add(1) + go func() { defer wg.Done(); _, _ = c1.GetVMFSInfo(context.Background(), "node", 105) }() + <-entered + wg.Add(1) + done := make(chan struct{}) + go func() { + defer wg.Done() + _, _ = c2.GetVMNetworkInterfaces(context.Background(), "node", 105) + close(done) + }() + select { + case <-done: + case <-entered: + case <-time.After(100 * time.Millisecond): + } + close(release) + wg.Wait() + if got := peak.Load(); got != 1 { + t.Fatalf("simultaneous guest-agent requests = %d, want one", got) + } +} + +func TestGuestAgentTimeoutDefersOtherCommands(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } + calls.Add(1) + if strings.HasSuffix(r.URL.Path, "get-fsinfo") { + <-r.Context().Done() + return + } + backupAgentPayload(w, r) + })) + defer server.Close() + c := backupTestClient(t, server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) + defer cancel() + if _, err := c.GetVMFSInfo(ctx, "node", 105); err == nil { + t.Fatal("missing timeout") + } + if _, err := c.GetVMAgentInfo(context.Background(), "node", 105); err == nil { + t.Error("another command was sent immediately after timeout") + } + if got := calls.Load(); got != 1 { + t.Fatalf("guest-agent requests = %d, want one", got) + } +} diff --git a/pkg/proxmox/guest_agent_guard.go b/pkg/proxmox/guest_agent_guard.go new file mode 100644 index 0000000000..d273d7ae02 --- /dev/null +++ b/pkg/proxmox/guest_agent_guard.go @@ -0,0 +1,210 @@ +package proxmox + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" +) + +// ErrGuestAgentDeferred means no further guest-agent commands should be queued +// by the caller. In particular, a timed-out HTTP request may still be running +// in pvedaemon; another command is not a safe way to recover it. +var ErrGuestAgentDeferred = errors.New("guest agent deferred") + +type guestAgentDeferredError struct { + reason string + cause error +} + +func (e *guestAgentDeferredError) Error() string { return "guest agent deferred: " + e.reason } +func (e *guestAgentDeferredError) Is(target error) bool { return target == ErrGuestAgentDeferred } +func (e *guestAgentDeferredError) Unwrap() error { return e.cause } + +// GuestAgentDeferredReason returns a fixed reason, never provider response text. +func GuestAgentDeferredReason(err error) string { + var deferred *guestAgentDeferredError + if errors.As(err, &deferred) { + return deferred.reason + } + return "" +} + +const guestAgentUncertainCooldown = time.Minute +const maxGuestAgentGuardEntries = 4096 + +type guestAgentGuardKey struct { + endpoint string + vmid int // VMIDs are cluster-wide, including while a VM migrates nodes. +} +type guestAgentGuardEntry struct { + busy bool + until time.Time +} + +// Share admission with independently constructed diagnostic clients, not just +// the polling client. Only coordination is shared: no credentials or guest data. +var guestAgentGuards = struct { + sync.Mutex + aliases map[string][]string + entries map[guestAgentGuardKey]guestAgentGuardEntry + nextCleanup time.Time +}{aliases: make(map[string][]string), entries: make(map[guestAgentGuardKey]guestAgentGuardEntry)} + +func registerGuestAgentEndpoints(host string, endpoints []string) { + guestAgentGuards.Lock() + defer guestAgentGuards.Unlock() + group := make(map[string]bool) + for _, endpoint := range append([]string{host}, endpoints...) { + u, err := securityutil.NormalizeHTTPBaseURL(endpoint, "https") + if err != nil { + continue + } + key := securityutil.AppendURLPath(u, "api2", "json").String() + group[key] = true + for _, alias := range guestAgentGuards.aliases[key] { + group[alias] = true + } + } + keys := make([]string, 0, len(group)) + for key := range group { + keys = append(keys, key) + } + for key := range group { + guestAgentGuards.aliases[key] = keys + } +} + +func acquireGuestAgent(endpoint string, vmid int) (func(bool), error) { + guestAgentGuards.Lock() + defer guestAgentGuards.Unlock() + now := time.Now() + if !now.Before(guestAgentGuards.nextCleanup) || len(guestAgentGuards.entries) >= maxGuestAgentGuardEntries { + for key, entry := range guestAgentGuards.entries { + if !entry.busy && !now.Before(entry.until) { + delete(guestAgentGuards.entries, key) + } + } + guestAgentGuards.nextCleanup = now.Add(time.Minute) + } + endpoints := guestAgentGuards.aliases[endpoint] + if len(endpoints) == 0 { + endpoints = []string{endpoint} + } + keys := make([]guestAgentGuardKey, 0, len(endpoints)) + for _, endpoint := range endpoints { + key := guestAgentGuardKey{endpoint: endpoint, vmid: vmid} + entry := guestAgentGuards.entries[key] + if entry.busy { + return nil, &guestAgentDeferredError{reason: "agent-busy"} + } + if now.Before(entry.until) { + return nil, &guestAgentDeferredError{reason: "agent-cooldown"} + } + keys = append(keys, key) + } + if len(guestAgentGuards.entries)+len(keys) > maxGuestAgentGuardEntries { + return nil, &guestAgentDeferredError{reason: "agent-capacity"} + } + for _, key := range keys { + guestAgentGuards.entries[key] = guestAgentGuardEntry{busy: true} + } + return func(uncertain bool) { + guestAgentGuards.Lock() + defer guestAgentGuards.Unlock() + for _, key := range keys { + if uncertain { + guestAgentGuards.entries[key] = guestAgentGuardEntry{until: time.Now().Add(guestAgentUncertainCooldown)} + } else { + delete(guestAgentGuards.entries, key) + } + } + }, nil +} + +func guestAgentPath(path string) (string, int, bool) { + parts := strings.Split(strings.SplitN(path, "?", 2)[0], "/") + if len(parts) != 7 || parts[1] != "nodes" || parts[3] != "qemu" || parts[5] != "agent" { + return "", 0, false + } + vmid, err := strconv.Atoi(parts[4]) + return parts[2], vmid, err == nil && vmid > 0 +} + +func (c *Client) verifyGuestAgentUnlocked(ctx context.Context, node string, vmid int) error { + // The config endpoint contains the authoritative PVE operation lock even + // when a PVE version omits it from status/current or cluster/resources. + config, err := c.GetVMConfig(ctx, node, vmid) + if err != nil || config == nil { + return &guestAgentDeferredError{reason: "lock-unverified", cause: err} + } + if value, exists := config["lock"]; exists { + lock, ok := value.(string) + if !ok { + return &guestAgentDeferredError{reason: "lock-unverified"} + } + if strings.TrimSpace(lock) != "" { + return &guestAgentDeferredError{reason: "vm-locked"} + } + } + return nil +} + +func (c *Client) getGuestAgent(ctx context.Context, path, node string, vmid int) (*http.Response, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + release, err := acquireGuestAgent(c.baseURL, vmid) + if err != nil { + return nil, err + } + uncertain := false + defer func() { release(uncertain) }() + if err := c.verifyGuestAgentUnlocked(ctx, node, vmid); err != nil { + return nil, err + } + resp, err := c.request(ctx, http.MethodGet, path, nil) + if err != nil { + // Explicit refusals/unsupported commands remain ordinary errors. An + // uncertain completion blocks every command, not just this method. + lower := strings.ToLower(err.Error()) + uncertain = !strings.Contains(lower, "api error") || ctx.Err() != nil || strings.Contains(lower, "timeout") || strings.Contains(lower, "timed out") || strings.Contains(lower, "wrong command id") + if uncertain { + return nil, &guestAgentDeferredError{reason: "agent-timeout", cause: err} + } + return nil, err + } + // Hold admission until the entire bounded response has arrived. Returning + // at response headers would allow another command while the first stalls. + body, readErr := readResponseBodyLimited(resp.Body) + closeErr := resp.Body.Close() + if readErr != nil || closeErr != nil { + uncertain = true + return nil, &guestAgentDeferredError{reason: "agent-response-incomplete", cause: errors.Join(readErr, closeErr)} + } + // A backup may have started while the command was in flight. Do not publish + // its payload as fresh telemetry if lock clearance cannot still be verified. + if err := c.verifyGuestAgentUnlocked(ctx, node, vmid); err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil +} + +// Guest calls do not fail over or replay: another HTTP worker still addresses +// the same serial QGA channel. Transport recovery belongs to ordinary API reads. +func (cc *ClusterClient) executeGuestAgent(ctx context.Context, fn func(*Client) error) error { + client, err := cc.getHealthyClient(ctx) + if err != nil { + return fmt.Errorf("select guest agent endpoint: %w", err) + } + return fn(client) +} diff --git a/pkg/proxmox/guest_agent_guard_test.go b/pkg/proxmox/guest_agent_guard_test.go new file mode 100644 index 0000000000..bf27f30353 --- /dev/null +++ b/pkg/proxmox/guest_agent_guard_test.go @@ -0,0 +1,191 @@ +package proxmox + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestGuestAgentClusterAliasesAndVMIsolation(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/nodes"): + fmt.Fprint(w, `{"data":[]}`) + case strings.HasSuffix(r.URL.Path, "/config"): + fmt.Fprint(w, `{"data":{}}`) + case strings.Contains(r.URL.Path, "/agent/"): + close(entered) + <-release + backupAgentPayload(w, r) + default: + http.NotFound(w, r) + } + })) + defer first.Close() + var calls atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/nodes") { + fmt.Fprint(w, `{"data":[]}`) + return + } + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } + calls.Add(1) + backupAgentPayload(w, r) + }) + second := httptest.NewServer(handler) + defer second.Close() + independent := httptest.NewServer(handler) + defer independent.Close() + NewClusterClient("aliases", ClientConfig{Host: first.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second}, []string{first.URL, second.URL}, nil) + c1, c2, c3 := backupTestClient(t, first.URL), backupTestClient(t, second.URL), backupTestClient(t, independent.URL) + done := make(chan error, 1) + go func() { _, err := c1.GetVMFSInfo(context.Background(), "node", 105); done <- err }() + <-entered + defer func() { + close(release) + if err := <-done; err != nil { + t.Error(err) + } + }() + if _, err := c2.GetVMAgentInfo(context.Background(), "migrated-node", 105); !errors.Is(err, ErrGuestAgentDeferred) { + t.Errorf("alias bypassed busy VM: %v", err) + } + if _, err := c2.GetVMAgentInfo(context.Background(), "node", 106); err != nil { + t.Errorf("different VM blocked: %v", err) + } + if _, err := c3.GetVMAgentInfo(context.Background(), "node", 105); err != nil { + t.Errorf("independent source blocked: %v", err) + } + if calls.Load() != 2 { + t.Errorf("healthy isolated calls = %d", calls.Load()) + } +} + +func TestGuestAgentLockAppearingInFlightDiscardsPayload(t *testing.T) { + var locked atomic.Bool + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + if locked.Load() { + fmt.Fprint(w, `{"data":{"lock":"backup"}}`) + } else { + fmt.Fprint(w, `{"data":{}}`) + } + return + } + calls.Add(1) + locked.Store(true) + backupAgentPayload(w, r) + })) + defer server.Close() + c := backupTestClient(t, server.URL) + if value, err := c.GetVMFSInfo(context.Background(), "node", 105); value != nil || GuestAgentDeferredReason(err) != "vm-locked" { + t.Fatalf("in-flight backup payload published: %#v %v", value, err) + } + if _, err := c.GetVMAgentInfo(context.Background(), "node", 105); GuestAgentDeferredReason(err) != "vm-locked" { + t.Fatal(err) + } + if calls.Load() != 1 { + t.Fatalf("another command queued: %d", calls.Load()) + } +} + +func TestGuestAgentIncompleteBodyBlocksWholeVM(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } + calls.Add(1) + w.Header().Set("Content-Length", "1000") + fmt.Fprint(w, `{"data":{"result":[]}}`) + })) + defer server.Close() + c := backupTestClient(t, server.URL) + if _, err := c.GetVMFSInfo(context.Background(), "node", 105); GuestAgentDeferredReason(err) != "agent-response-incomplete" { + t.Fatalf("incomplete body not deferred: %v", err) + } + for name, read := range backupAgentReads() { + t.Run(name, func(t *testing.T) { + if err := read(context.Background(), c, 105); GuestAgentDeferredReason(err) != "agent-cooldown" { + t.Fatal(err) + } + }) + } + if calls.Load() != 1 { + t.Fatalf("commands after incomplete body = %d", calls.Load()) + } +} + +func TestGuestAgentClusterDoesNotReplayUncertainCall(t *testing.T) { + var calls atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/nodes"): + fmt.Fprint(w, `{"data":[]}`) + case strings.HasSuffix(r.URL.Path, "/config"): + fmt.Fprint(w, `{"data":{}}`) + default: + calls.Add(1) + http.Error(w, "QEMU guest command timed out", 500) + } + }) + a, b := httptest.NewServer(handler), httptest.NewServer(handler) + defer a.Close() + defer b.Close() + cc := NewClusterClient("timeouts", ClientConfig{Host: a.URL, TokenName: "fixture@pve!pulse", TokenValue: "fixture", Timeout: time.Second}, []string{a.URL, b.URL}, nil) + if _, err := cc.GetVMFSInfo(context.Background(), "node", 105); !errors.Is(err, ErrGuestAgentDeferred) { + t.Fatalf("uncertain completion lost: %v", err) + } + if _, err := cc.GetVMAgentInfo(context.Background(), "node", 105); GuestAgentDeferredReason(err) != "agent-cooldown" { + t.Fatalf("another cluster command sent: %v", err) + } + if calls.Load() != 1 { + t.Fatalf("uncertain call replayed: %d", calls.Load()) + } + for endpoint, healthy := range cc.GetHealthStatus() { + if !healthy { + t.Errorf("VM timeout poisoned endpoint %s", endpoint) + } + } +} + +func TestGuestAgentCooldownExpiresWithoutSleepOrReplay(t *testing.T) { + endpoint := "http://fixture.invalid/api2/json" + release, err := acquireGuestAgent(endpoint, 105) + if err != nil { + t.Fatal(err) + } + release(true) + if _, err := acquireGuestAgent(endpoint, 105); GuestAgentDeferredReason(err) != "agent-cooldown" { + t.Fatal(err) + } + guestAgentGuards.Lock() + key := guestAgentGuardKey{endpoint: endpoint, vmid: 105} + entry := guestAgentGuards.entries[key] + entry.until = time.Now().Add(-time.Second) + guestAgentGuards.entries[key] = entry + guestAgentGuards.Unlock() + release, err = acquireGuestAgent(endpoint, 105) + if err != nil { + t.Fatal(err) + } + release(false) + guestAgentGuards.Lock() + _, retained := guestAgentGuards.entries[key] + guestAgentGuards.Unlock() + if retained { + t.Fatal("healthy completed operation retained estate state") + } +} diff --git a/pkg/proxmox/io_counters_test.go b/pkg/proxmox/io_counters_test.go index 8259242b34..fc210c76ab 100644 --- a/pkg/proxmox/io_counters_test.go +++ b/pkg/proxmox/io_counters_test.go @@ -115,3 +115,26 @@ func TestInternalCounterMetadataNeverChangesProxmoxWireShape(t *testing.T) { } } } + +func TestBackupLocksRetainCumulativeCounterPresence(t *testing.T) { + payload := []byte(`{"lock":"backup","diskread":0,"diskwrite":null,"netin":42}`) + var listing VM + var resource ClusterResource + var status VMStatus + for _, dst := range []any{&listing, &resource, &status} { + if err := json.Unmarshal(payload, dst); err != nil { + t.Fatal(err) + } + } + for name, value := range map[string]struct { + lock string + presence IOCounterPresence + }{"listing": {listing.Lock, listing.IOCounters}, "resource": {resource.Lock, resource.IOCounters}, "status": {status.Lock, status.IOCounters}} { + t.Run(name, func(t *testing.T) { + p := value.presence.Effective() + if value.lock != "backup" || !p.DiskRead || !p.NetworkIn || p.DiskWrite || p.NetworkOut { + t.Fatalf("lock decoding corrupted presence: %#v", value) + } + }) + } +} From 35914abab7c3c02286d88d0686ab1aa5644fa909 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:03:09 +0100 Subject: [PATCH 48/86] Validate release notes by their exact first title Align Python preparation with the existing Go reader-facing contract. Accept authored titles with or without the legacy suffix, require exact first-heading identity, and reject malformed headings even when prose repeats the version. Contract-Neutral: Test-only alignment with the existing release-note identity contract; no runtime, policy, packet, qualification requirement or public content changes. Change-source: pulse-maintainer --- .../release_promotion_policy_test.py | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index e27d53e1b9..1bb4a84c2d 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -45,6 +45,16 @@ def normalize_ws(text: str) -> str: return " ".join(text.split()) +def release_notes_have_exact_title(notes: str, version: str) -> bool: + """Match the first title, as the reader-facing Go notes contract does. + + The optional legacy suffix is presentation, not version identity. Neither + a later heading nor a version repeated in prose establishes that identity. + """ + first = notes.strip().split("\n", 1)[0].strip() + return re.fullmatch(r"# Pulse v" + re.escape(version) + r"(?: Release Notes)?", first) is not None + + _MATERIAL_APPROVAL_RE = re.compile( r"(?i)(?:" r"\brichard[- ]approved\b|" @@ -1590,6 +1600,24 @@ class ReleasePromotionPolicyTest(unittest.TestCase): ) self.assertNotIn("Limits are applied to canonical top-level monitored systems", changelog) + def test_release_notes_title_uses_exact_first_heading(self) -> None: + for version in ("6.4.5", "6.5.0", "6.5.0-rc.1"): + for suffix in ("", " Release Notes"): + with self.subTest(version=version, suffix=suffix): + self.assertTrue(release_notes_have_exact_title( + f"\n# Pulse v{version}{suffix}\n\nUser-facing changes.\n", version)) + for title in ( + "# Pulse v6.5.1", "# Pulse v6.5.0-rc.1", "# Pulse v6.5.00", + "# Pulse v6.5.0-beta.1", "## Pulse v6.5.0", "# Other Pulse v6.5.0", + "# Pulse v6x5x0", "# Pulse v6.5.0 Release Notes extra", + "# Pulse v6.5.0extra", "", + ): + with self.subTest(title=title): + self.assertFalse(release_notes_have_exact_title( + title + "\n\n# Pulse v6.5.0 Release Notes\n\n`v6.5.0`\n", "6.5.0")) + authored = read("scripts/installtests/testdata/release-notes-v6.4.5-authored.md") + self.assertTrue(release_notes_have_exact_title(authored, "6.4.5")) + def test_version_file_matches_current_rc_packet(self) -> None: current_version = read("VERSION").strip() release_index = read("docs/RELEASE_NOTES.md") @@ -1600,8 +1628,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): changelog = read(changelog_path) self.assertIn(release_notes_path, release_index) self.assertIn(changelog_path, release_index) - self.assertIn(f"Pulse v{current_version} Release Notes", release_notes) - self.assertIn(f"`v{current_version}`", release_notes) + self.assertTrue(release_notes_have_exact_title(release_notes, current_version)) self.assertIn(f"Pulse v{current_version}", changelog) else: packet_paths = rc_packet_paths_for_version(current_version) @@ -1636,7 +1663,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("current v6 release candidate packet", release_index) self.assertIn(release_notes_path, release_index) self.assertIn(changelog_path, release_index) - self.assertIn(f"Pulse v{current_version} Release Notes", release_notes) + self.assertTrue(release_notes_have_exact_title(release_notes, current_version)) self.assertIn(f"Pulse v{current_version}", changelog) def test_v611_packet_records_proxmox_backup_posture_identity_fix(self) -> None: From 41457c8a82bc2a3e6f1e214cdcf431833f7c2fc9 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:05:15 +0100 Subject: [PATCH 49/86] Make copied webhook verification freshness-aware Reject stale or malformed signed requests and empty keys in the existing receiver example. Explain authenticated payload deduplication and unsigned correlation headers, and exercise the exact copied code with adverse and healthy controls. Contract-Neutral: Documentation and its executable safety checks only; the outbound signing format, transport, endpoints and runtime are unchanged. Change-source: pulse-maintainer --- docs/WEBHOOKS.md | 48 +++++- .../webhook-verification-help.cjs | 81 ++++++++++ frontend-modern/public/docs/WEBHOOKS.md | 48 +++++- .../tests/test_webhook_verification_docs.py | 151 ++++++++++++++++++ 4 files changed, 322 insertions(+), 6 deletions(-) create mode 100644 frontend-modern/browser-tests/webhook-verification-help.cjs create mode 100644 scripts/tests/test_webhook_verification_docs.py diff --git a/docs/WEBHOOKS.md b/docs/WEBHOOKS.md index 9899d5706d..3acf6649b4 100644 --- a/docs/WEBHOOKS.md +++ b/docs/WEBHOOKS.md @@ -86,16 +86,58 @@ These fields and behaviors are stable; ticket-routing integrations can rely on t - `X-Pulse-Timestamp`: Unix seconds at send time. - `X-Pulse-Signature`: `v1=` + hex HMAC-SHA256 over `timestamp + "." + body`, keyed with the shared secret. -To verify: recompute the HMAC over the received timestamp and raw body, compare with constant-time equality, and reject requests whose timestamp is outside your tolerance window (e.g. 5 minutes) to block replays. +Verify before parsing the JSON or performing any action. Read the original body +as bytes and the complete `X-Pulse-Timestamp` and `X-Pulse-Signature` header +values; reject missing or duplicate signing headers. Do not re-serialize JSON, +trim the body, or trust a timestamp from the payload instead of the header. + +This Python example accepts timestamps within five minutes of the receiver's +clock, rejects malformed headers, and compares the HMAC in constant time. Keep +both machines' clocks synchronised. Load the shared secret from private receiver +configuration, not from the request, a command argument or a log. Pulse trims +surrounding whitespace from its configured secret; the verifier does the same. +An empty secret must never authenticate a request. ```python -import hashlib, hmac +import hashlib +import hmac +import re +import time + +MAX_SKEW_SECONDS = 300 + def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool: - expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() + secret = secret.strip() + if not secret or not isinstance(body, bytes): + return False + if not isinstance(timestamp, str) or re.fullmatch(r"[0-9]{1,12}", timestamp) is None: + return False + if not isinstance(signature, str) or re.fullmatch(r"v1=[0-9a-f]{64}", signature) is None: + return False + if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS: + return False + expected = "v1=" + hmac.new( + secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256 + ).hexdigest() return hmac.compare_digest(expected, signature) ``` +A time window rejects old captures, **not every replay**: the same signed +request can arrive more than once within that window, and Pulse signs each +retry with its current send time. After verification, deduplicate atomically +before creating a ticket or taking another action. Use a durable key scoped to +the configured Pulse sender and destination, built from the authenticated +payload's alert ID and event (`{{.ID}}` and `{{.Event}}` in your template); keep +it across retries and receiver restarts. An `alert` and its later `resolved` +are different events, not duplicates. The `X-Pulse-Event-ID` header is a useful +correlation hint, but is **not covered by this HMAC**: do not let a changed +header bypass deduplication of an otherwise identical signed event. The sample +PSA payloads below include `alertId` and `event` for this purpose. A valid HMAC +establishes integrity, not that processing succeeded; retain your receiver's +normal durable processing and retry handling. Never log the secret or full +credential-bearing request while diagnosing a rejection. + The secret is write-only through the API: list responses mask it, and an update that echoes the masked placeholder keeps the stored secret. ```http diff --git a/frontend-modern/browser-tests/webhook-verification-help.cjs b/frontend-modern/browser-tests/webhook-verification-help.cjs new file mode 100644 index 0000000000..f093455341 --- /dev/null +++ b/frontend-modern/browser-tests/webhook-verification-help.cjs @@ -0,0 +1,81 @@ +// Verify the copyable receiver example through production Docs/router/Markdown. +// This does not send a webhook or exercise a native receiver or deduplication. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'webhook-verification-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const guide = fs.readFileSync(path.join(root, 'public/docs/WEBHOOKS.md'), 'utf8'); + const example = guide.match(/```python\n([\s\S]*?)```/)[1].trim(); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-webhook-verification'), + server: { host: '127.0.0.1', port: 5255, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5255/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + const entry = page.locator('a[data-doc-link][href="/docs/WEBHOOKS"]').first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const heading = page.getByRole('heading', { name: '๐Ÿ“ฆ Delivery Contract', exact: true }); + await heading.waitFor(); + const code = page.locator('pre').filter({ hasText: 'MAX_SKEW_SECONDS = 300' }); + await code.scrollIntoViewIfNeeded(); + assert.equal((await code.locator('code').textContent()).trim(), example); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-verifier.png`) }); + const warning = page.locator('p').filter({ hasText: 'A time window rejects old captures' }); + await warning.scrollIntoViewIfNeeded(); + const text = await page.locator('article').innerText(); + for (const phrase of ['within five minutes', 'empty secret', 'not every replay', + 'deduplicate atomically', 'receiver restarts', 'not covered by this HMAC', + 'missing or duplicate signing headers', 'before parsing the JSON']) { + assert.ok(text.includes(phrase), phrase); + } + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-deduplication.png`) }); + const payloadHeading = page.getByRole('heading', { name: 'Sample PSA payloads', exact: true }); + await payloadHeading.scrollIntoViewIfNeeded(); + assert.ok((await page.locator('pre').filter({ hasText: '"alertId": "{{.ID | jsonString}}"' }) + .textContent()).includes('"event": "{{.Event}}"')); + assert.deepEqual(errors, []); + results.push({ engine, browserVersion: browser.version(), width, tone, + keyboardEntry: true, exactCopiedPython: true, freshnessAndReplayLimits: true, + payloadIdentity: true, noBodyOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + scope: 'rendered guide and exact copied code; not webhook delivery or receiver processing', + docs: { 'frontend-modern/public/docs/WEBHOOKS.md': + createHash('sha256').update(guide).digest('hex') }, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/WEBHOOKS.md b/frontend-modern/public/docs/WEBHOOKS.md index 9899d5706d..3acf6649b4 100644 --- a/frontend-modern/public/docs/WEBHOOKS.md +++ b/frontend-modern/public/docs/WEBHOOKS.md @@ -86,16 +86,58 @@ These fields and behaviors are stable; ticket-routing integrations can rely on t - `X-Pulse-Timestamp`: Unix seconds at send time. - `X-Pulse-Signature`: `v1=` + hex HMAC-SHA256 over `timestamp + "." + body`, keyed with the shared secret. -To verify: recompute the HMAC over the received timestamp and raw body, compare with constant-time equality, and reject requests whose timestamp is outside your tolerance window (e.g. 5 minutes) to block replays. +Verify before parsing the JSON or performing any action. Read the original body +as bytes and the complete `X-Pulse-Timestamp` and `X-Pulse-Signature` header +values; reject missing or duplicate signing headers. Do not re-serialize JSON, +trim the body, or trust a timestamp from the payload instead of the header. + +This Python example accepts timestamps within five minutes of the receiver's +clock, rejects malformed headers, and compares the HMAC in constant time. Keep +both machines' clocks synchronised. Load the shared secret from private receiver +configuration, not from the request, a command argument or a log. Pulse trims +surrounding whitespace from its configured secret; the verifier does the same. +An empty secret must never authenticate a request. ```python -import hashlib, hmac +import hashlib +import hmac +import re +import time + +MAX_SKEW_SECONDS = 300 + def verify(secret: str, timestamp: str, body: bytes, signature: str) -> bool: - expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest() + secret = secret.strip() + if not secret or not isinstance(body, bytes): + return False + if not isinstance(timestamp, str) or re.fullmatch(r"[0-9]{1,12}", timestamp) is None: + return False + if not isinstance(signature, str) or re.fullmatch(r"v1=[0-9a-f]{64}", signature) is None: + return False + if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS: + return False + expected = "v1=" + hmac.new( + secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256 + ).hexdigest() return hmac.compare_digest(expected, signature) ``` +A time window rejects old captures, **not every replay**: the same signed +request can arrive more than once within that window, and Pulse signs each +retry with its current send time. After verification, deduplicate atomically +before creating a ticket or taking another action. Use a durable key scoped to +the configured Pulse sender and destination, built from the authenticated +payload's alert ID and event (`{{.ID}}` and `{{.Event}}` in your template); keep +it across retries and receiver restarts. An `alert` and its later `resolved` +are different events, not duplicates. The `X-Pulse-Event-ID` header is a useful +correlation hint, but is **not covered by this HMAC**: do not let a changed +header bypass deduplication of an otherwise identical signed event. The sample +PSA payloads below include `alertId` and `event` for this purpose. A valid HMAC +establishes integrity, not that processing succeeded; retain your receiver's +normal durable processing and retry handling. Never log the secret or full +credential-bearing request while diagnosing a rejection. + The secret is write-only through the API: list responses mask it, and an update that echoes the masked placeholder keeps the stored secret. ```http diff --git a/scripts/tests/test_webhook_verification_docs.py b/scripts/tests/test_webhook_verification_docs.py new file mode 100644 index 0000000000..6092c377b5 --- /dev/null +++ b/scripts/tests/test_webhook_verification_docs.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Execute the receiver verifier copied from the shipped webhook guide. + +Synthetic signatures exercise freshness, malformed inputs and byte integrity. +This is not a running receiver, durable deduplication or notification delivery. +""" + +from __future__ import annotations + +import hashlib +import hmac +from pathlib import Path +import re +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[2] +DOC = ROOT / "docs/WEBHOOKS.md" +NOW = 1_791_030_000 +SECRET = "synthetic-receiver-test-secret" +BODY = b'{"alertId":"alert-42","event":"alert","message":"test"}\r\n' + + +def copied_verifier(): + blocks = re.findall(r"```python\n(.*?)```", DOC.read_text(), re.DOTALL) + if len(blocks) != 1: + raise AssertionError("expected one copyable webhook verifier") + namespace = {} + exec(compile(blocks[0], str(DOC), "exec"), namespace) + return namespace["verify"] + + +def sign(timestamp: str, body: bytes = BODY, secret: str = SECRET) -> str: + # Independent wire-format oracle; do not use the copied implementation. + message = timestamp.encode("utf-8") + b"." + body + return "v1=" + hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest() + + +class WebhookVerificationDocsTest(unittest.TestCase): + def check(self, timestamp=str(NOW), *, body=BODY, signature=None, secret=SECRET, now=NOW): + if signature is None and isinstance(timestamp, str): + signature = sign(timestamp, secret=secret.strip()) + with patch("time.time", return_value=now): + return copied_verifier()(secret, timestamp, body, signature) + + def test_shipped_copy_matches_the_executed_guide(self): + self.assertEqual(DOC.read_bytes(), + (ROOT / "frontend-modern/public/docs/WEBHOOKS.md").read_bytes()) + + def test_current_signed_payload_is_accepted(self): + self.assertIs(self.check(), True) + self.assertIs(self.check(secret=" " + SECRET + "\n"), True) + + def test_five_minute_boundary_and_nearby_delivery_are_accepted(self): + for delta in (-300, -1, 0, 1, 300): + with self.subTest(delta=delta): + self.assertIs(self.check(str(NOW + delta)), True) + + def test_old_valid_signature_cannot_be_replayed_after_the_window(self): + timestamp = str(NOW) + self.assertIs(self.check(timestamp), True) + for delta in (301, 3_600, 86_400): + with self.subTest(delta=delta): + self.assertIs(self.check(timestamp, now=NOW + delta), False) + + def test_future_valid_signature_is_not_accepted_beyond_the_window(self): + for delta in (301, 3_600, 86_400): + with self.subTest(delta=delta): + self.assertIs(self.check(str(NOW + delta)), False) + + def test_signed_malformed_timestamps_are_rejected_without_an_exception(self): + # These are valid MACs for invalid headers, not ordinary bad signatures. + # Bound integer parsing and accept only the sender's ASCII seconds. + values = ("", " " + str(NOW), str(NOW) + "\n", "+" + str(NOW), + str(NOW) + ".0", "1e9", "ูกูงูฉูกู ูฃู ู ู ู ", str(NOW) + "," + str(NOW), + "9" * 5_000) + for timestamp in values: + with self.subTest(timestamp=timestamp[:30]): + self.assertIs(self.check(timestamp), False) + self.assertIs(self.check(None, signature=sign(str(NOW))), False) + + def test_malformed_or_missing_signatures_fail_closed(self): + good = sign(str(NOW)) + for signature in ("", "v2=" + good[3:], good.upper(), good + "\n", + good + "," + good, "v1=" + "รฉ" * 64, b"v1=" + b"a" * 64): + with self.subTest(signature=str(signature)[:30]): + self.assertIs(self.check(signature=signature), False) + with patch("time.time", return_value=NOW): + self.assertIs(copied_verifier()(SECRET, str(NOW), BODY, None), False) + + def test_empty_or_whitespace_key_cannot_authenticate(self): + for secret in ("", " \n\t"): + with self.subTest(secret=repr(secret)): + self.assertIs(self.check(secret=secret), False) + + def test_body_timestamp_key_and_signature_are_all_authenticated(self): + good = sign(str(NOW)) + cases = ( + {"body": BODY.replace(b"alert-42", b"alert-43")}, + {"timestamp": str(NOW + 1)}, + {"secret": SECRET + "-other"}, + {"signature": "v1=" + ("0" if good[3] != "0" else "1") + good[4:]}, + ) + for changed in cases: + with self.subTest(changed=list(changed)): + self.assertIs(self.check(**{"signature": good, **changed}), False) + + def test_raw_bytes_not_reserialised_json_are_signed(self): + body = '{"message":"hรฉllo", "alertId":"alert-42", "event":"alert"}\r\n'.encode() + signature = sign(str(NOW), body=body) + self.assertIs(self.check(body=body, signature=signature), True) + self.assertIs(self.check(body=body.rstrip(), signature=signature), False) + self.assertIs(self.check(body=body.replace(b", ", b","), signature=signature), False) + self.assertIs(self.check(body=body.decode(), signature=signature), False) + + def test_go_sender_deterministic_vector_uses_the_same_wire_format(self): + # The production sender's TestSignWebhookPayloadDeterministic uses + # these exact key/timestamp/body bytes and an independent Go MAC. + sender_test = (ROOT / "internal/notifications/webhook_signing_test.go").read_text() + self.assertIn('signWebhookPayload("secret", "1700000000", []byte(`{"a":1}`))', sender_test) + signature = "v1=" + hmac.new(b"secret", b'1700000000.{"a":1}', hashlib.sha256).hexdigest() + self.assertIs(self.check("1700000000", body=b'{"a":1}', signature=signature, + secret="secret", now=1_700_000_000), True) + + def test_digest_comparison_remains_constant_time(self): + verifier = copied_verifier() + compare = hmac.compare_digest + with patch("time.time", return_value=NOW), patch("hmac.compare_digest", wraps=compare) as spy: + self.assertIs(verifier(SECRET, str(NOW), BODY, sign(str(NOW))), True) + spy.assert_called_once_with(sign(str(NOW)), sign(str(NOW))) + + def test_signed_retry_is_valid_but_not_a_new_logical_event(self): + # Freshness alone is not at-most-once processing. A receiver needs + # durable/atomic payload-derived deduplication, not a new MAC per retry. + first, retry = sign(str(NOW)), sign(str(NOW + 60)) + self.assertNotEqual(first, retry) + self.assertIs(self.check(signature=first), True) + self.assertIs(self.check(str(NOW + 60), signature=retry, now=NOW + 60), True) + # Changing the correlation header cannot change a timestamp/body MAC. + # The docs must not claim that X-Pulse-Event-ID is authenticated. + text = " ".join(DOC.read_text().split()) + for phrase in ("not every replay", "deduplicate atomically", "authenticated payload's", + "receiver restarts", "different events", "not covered by this HMAC", + "missing or duplicate signing headers", "before parsing the JSON", + "not from the request", "Never log the secret"): + self.assertIn(phrase, text) + + +if __name__ == "__main__": + unittest.main() From 577e40edf3215b637c219160b0762790950f5d56 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:08:53 +0100 Subject: [PATCH 50/86] Keep guest-agent continuity guard aligned with backup deferral The real-client backup lifecycle passes, but the old source guard still requires its unguarded status-failure fallback. Retain continuity only when no operation lock is present; the connected lifecycle covers both cached fallback and unlock resumption. Contract-Neutral: Test assertion follows the already documented backup-lock condition; no additional runtime or contract change. Change-source: pulse-maintainer --- internal/monitoring/canonical_guardrails_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 3a37762ae5..30ab89b6c5 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -1913,7 +1913,7 @@ func TestProxmoxGuestAgentContinuityUsesCanonicalEvidenceAndRetryPaths(t *testin "monitor_pve_guest_builders.go": { "guestAgentAvailable := shouldQueryGuestAgent(state.detailedStatus, prevVM, now) ||", "m.hasRecentGuestMetadataEvidence(instanceName, res.Node, res.VMID, now)", - "if guestAgentAvailable && state.detailedStatus == nil {", + `if guestAgentAvailable && res.Lock == "" && state.detailedStatus == nil {`, }, "monitor_polling_vm.go": { "prevVMByID := prevGuests.vmsByID", From a090a6775613903e2d7dc05838fb9cbfaa67fa27 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:21:03 +0100 Subject: [PATCH 51/86] Keep guest-agent admission single-attempt for password sessions The generic password-session client retries once after a 401; that implicit retry can bypass a fresh lock check inside a guest admission. Disable it only for QGA commands, retain the refusal, and prove healthy guest reads plus unchanged ordinary API session recovery. Contract-Neutral: Completes the documented no-replay guest admission rule without adding a contract or surface; adds password refusal and healthy recovery controls. Change-source: pulse-maintainer --- pkg/proxmox/guest_agent_guard.go | 5 ++- pkg/proxmox/guest_agent_guard_test.go | 52 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/pkg/proxmox/guest_agent_guard.go b/pkg/proxmox/guest_agent_guard.go index d273d7ae02..1f8df97621 100644 --- a/pkg/proxmox/guest_agent_guard.go +++ b/pkg/proxmox/guest_agent_guard.go @@ -171,7 +171,10 @@ func (c *Client) getGuestAgent(ctx context.Context, path, node string, vmid int) if err := c.verifyGuestAgentUnlocked(ctx, node, vmid); err != nil { return nil, err } - resp, err := c.request(ctx, http.MethodGet, path, nil) + // A password-session 401 must not replay this command after re-authentication: + // the operation lock could change between attempts. Ordinary API reads still + // own their usual session recovery; this guest admission is single-attempt. + resp, err := c.requestWithRetry(ctx, http.MethodGet, path, nil, true) if err != nil { // Explicit refusals/unsupported commands remain ordinary errors. An // uncertain completion blocks every command, not just this method. diff --git a/pkg/proxmox/guest_agent_guard_test.go b/pkg/proxmox/guest_agent_guard_test.go index bf27f30353..62d77db187 100644 --- a/pkg/proxmox/guest_agent_guard_test.go +++ b/pkg/proxmox/guest_agent_guard_test.go @@ -189,3 +189,55 @@ func TestGuestAgentCooldownExpiresWithoutSleepOrReplay(t *testing.T) { t.Fatal("healthy completed operation retained estate state") } } + +func TestGuestAgentPasswordSessionDoesNotReplayRefusedCommand(t *testing.T) { + for _, refused := range []bool{false, true} { + t.Run(fmt.Sprintf("refused-%t", refused), func(t *testing.T) { + var authCalls, agentCalls, nodeCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/access/ticket"): + authCalls.Add(1) + fmt.Fprint(w, `{"data":{"ticket":"fixture-ticket","CSRFPreventionToken":"fixture-csrf"}}`) + case strings.HasSuffix(r.URL.Path, "/config"): + fmt.Fprint(w, `{"data":{}}`) + case strings.Contains(r.URL.Path, "/agent/"): + if agentCalls.Add(1) == 1 && refused { + http.Error(w, "session refused", http.StatusUnauthorized) + return + } + backupAgentPayload(w, r) + case strings.HasSuffix(r.URL.Path, "/nodes"): + if nodeCalls.Add(1) == 1 { + http.Error(w, "expired ordinary API session", http.StatusUnauthorized) + return + } + fmt.Fprint(w, `{"data":[]}`) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client, err := NewClient(ClientConfig{Host: server.URL, User: "fixture@pam", Password: "fixture-password", Timeout: time.Second}) + if err != nil { + t.Fatal(err) + } + _, err = client.GetVMFSInfo(context.Background(), "node", 105) + if refused && (err == nil || !strings.Contains(err.Error(), "401")) { + t.Fatalf("guest refusal lost: %v", err) + } + if !refused && err != nil { + t.Fatal(err) + } + if agentCalls.Load() != 1 || authCalls.Load() != 1 { + t.Fatalf("guest replay: commands=%d authentications=%d", agentCalls.Load(), authCalls.Load()) + } + if _, err := client.GetNodes(context.Background()); err != nil { + t.Fatalf("ordinary API session recovery broken: %v", err) + } + if agentCalls.Load() != 1 || nodeCalls.Load() != 2 || authCalls.Load() != 2 { + t.Fatalf("ordinary recovery replayed guest or did not recover: guest=%d nodes=%d auth=%d", agentCalls.Load(), nodeCalls.Load(), authCalls.Load()) + } + }) + } +} From 98e2a5ef69d88cfb9f8c9e4d5008b2252b4b5f67 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:21:41 +0100 Subject: [PATCH 52/86] Keep release guards aligned with signed runtime recovery Correct the empty-heading control and retain current signed-archive selection and loss-aware rollback terminal checks. Reviewed runtime commits a2fc29b70b and 227657b1b0 replaced the old installer and unconditional receipt writes; this changes no runtime or release gates. Contract-Neutral: Test-only alignment with already-reviewed release signing and recovery contracts; no runtime, policy, qualification requirement or public content changes. Change-source: pulse-maintainer --- .../release_promotion_policy_test.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 1bb4a84c2d..351a017957 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -1610,11 +1610,12 @@ class ReleasePromotionPolicyTest(unittest.TestCase): "# Pulse v6.5.1", "# Pulse v6.5.0-rc.1", "# Pulse v6.5.00", "# Pulse v6.5.0-beta.1", "## Pulse v6.5.0", "# Other Pulse v6.5.0", "# Pulse v6x5x0", "# Pulse v6.5.0 Release Notes extra", - "# Pulse v6.5.0extra", "", + "# Pulse v6.5.0extra", "User-facing changes before the title.", ): with self.subTest(title=title): self.assertFalse(release_notes_have_exact_title( title + "\n\n# Pulse v6.5.0 Release Notes\n\n`v6.5.0`\n", "6.5.0")) + self.assertFalse(release_notes_have_exact_title("\n \n", "6.5.0")) authored = read("scripts/installtests/testdata/release-notes-v6.4.5-authored.md") self.assertTrue(release_notes_have_exact_title(authored, "6.4.5")) @@ -2190,7 +2191,12 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("scripts/install-mcp.ps1 release/install-mcp.ps1", candidate_workflow) self.assertIn("$PinnedReleaseSshPublicKey = '${TRUSTED_SSH_PUBLIC_KEY}'", candidate_workflow) self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow) - self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow) + self.assertIn("Verify signed release and select only demo server runtime", update_demo_workflow) + self.assertIn('printf \'pulse-installer %s\\n\' "$TRUSTED_SSH_PUBLIC_KEY" > /tmp/pulse-demo-signers', update_demo_workflow) + self.assertIn("ssh-keygen -Y verify -f /tmp/pulse-demo-signers -I pulse-installer", update_demo_workflow) + self.assertIn("-n pulse-install -s /tmp/pulse-demo-release.sshsig < /tmp/pulse-demo-release.tgz", update_demo_workflow) + self.assertIn("python3 .github/scripts/dispatch-demo-runtime.py prepare", update_demo_workflow) + self.assertNotIn("bash /tmp/pulse-install.sh", update_demo_workflow) self.assertIn("bash .github/scripts/setup-demo-ssh.sh", update_demo_workflow) self.assertIn("bash .github/scripts/check-demo-reachability.sh", update_demo_workflow) self.assertIn("ping: ${{ secrets.DEMO_SERVER_HOST }}", update_demo_workflow) @@ -2803,8 +2809,12 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('WINDOW = 300', transaction) self.assertIn('self.watch("recovery", original_version, cursors)', transaction) self.assertIn('signal.signal(sig, signal.SIG_IGN)', transaction) - self.assertIn('self.save("rolled_back")', transaction) - self.assertIn('self.save("rollback_failed")', transaction) + self.assertIn('outcome = "rolled_back"', transaction) + self.assertIn('outcome = "rollback_failed"', transaction) + self.assertIn('self.finish(outcome)', transaction) + self.assertIn('self.receipt["observed_outcome"] = status', transaction) + self.assertIn('self.receipt["status"] = "observation_failed"', transaction) + self.assertIn('self.save(terminal)', transaction) for required in ("alerts/events.db", "alerts/alert-history.json.imported", "alerts/alert-history.backup.json.imported", "ai_incidents.json", "snapshot_retained"): self.assertIn(required, transaction) From 6ec491bdf30434eb20419ebf9dcc89b9f16a3737 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:48:52 +0100 Subject: [PATCH 53/86] Keep VM disk diagnostics off the guest-agent channel Replace active guest probes with bounded passive host reads. Preserve errors and private configuration, distinguish setup from responsiveness, and put backup/thaw precautions ahead of troubleshooting. Exercise the actual helper and copied command with adverse and healthy controls. Contract-Neutral: Operator diagnostics and existing help only; Proxmox polling, guest-agent coordination, APIs, storage and runtime behaviour are unchanged. Change-source: pulse-maintainer --- docs/TROUBLESHOOTING.md | 19 +- docs/VM_DISK_MONITORING.md | 114 ++++++-- .../browser-tests/vm-disk-safety-help.cjs | 93 +++++++ .../public/docs/TROUBLESHOOTING.md | 19 +- .../public/docs/VM_DISK_MONITORING.md | 114 ++++++-- scripts/test-vm-disk.sh | 224 ++++++++-------- scripts/tests/test_vm_disk_diagnostics.py | 252 ++++++++++++++++++ 7 files changed, 652 insertions(+), 183 deletions(-) create mode 100644 frontend-modern/browser-tests/vm-disk-safety-help.cjs create mode 100644 scripts/tests/test_vm_disk_diagnostics.py diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2ff1a7b0eb..b5e498ebb3 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -227,10 +227,14 @@ installed service must contain `SyslogLevelPrefix=true` and repair an older generated unit rather than adding a JSON-parsing wrapper. #### VMs show "-" for disk usage -- Install **QEMU Guest Agent** in the VM. -- Enable "QEMU Guest Agent" in Proxmox VM Options. -- Restart the VM. -- See [VM Disk Monitoring](VM_DISK_MONITORING.md). +- Read the disk value's explanation and observation time first; a dash is not + proof that the agent is missing. +- Check the guest-local service, current VM Options and the configured API + token's read permissions. Schedule any setup change or restart outside backups. +- Do not run guest-agent probes during backup freeze/thaw. An OK backup task + or an absent lock does not confirm thaw. +- See [VM Disk Monitoring](VM_DISK_MONITORING.md) for the passive host preflight + and backup safety precaution; it does not verify a fresh disk poll. #### Temperature data missing - Install `lm-sensors` on the host. @@ -615,7 +619,12 @@ For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges. Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token. After upgrading to PVE 9, add the `VM.GuestAgent.*` privileges and remove legacy `VM.Monitor` from the custom role. -**Rocky Linux / RHEL VMs**: The default qemu-guest-agent configuration may block file-read RPCs (`guest-file-open`, `guest-file-read`, `guest-file-close`). If memory or disk data is missing for these VMs, check `/etc/sysconfig/qemu-ga` and ensure those operations are not blocked, then restart the agent. Refer to your distro's qemu-guest-agent documentation for the exact config syntax. +**Rocky Linux / RHEL VMs**: File-read restrictions in `/etc/sysconfig/qemu-ga` +can explain missing guest memory; they do not by themselves establish why disk +usage is absent. Review the guest's policy before changing it. Schedule any +allowlist change or agent restart outside backups, following the guest OS's +documentation. See [VM Disk Monitoring](VM_DISK_MONITORING.md) for the distinct +permissions and backup safety boundary. ### Proxmox pending-update access diff --git a/docs/VM_DISK_MONITORING.md b/docs/VM_DISK_MONITORING.md index cbf6a5a328..9c0b762933 100644 --- a/docs/VM_DISK_MONITORING.md +++ b/docs/VM_DISK_MONITORING.md @@ -1,42 +1,102 @@ # ๐Ÿ’พ VM Disk Monitoring -Monitor actual disk usage inside your VMs using the QEMU Guest Agent. +Pulse can read filesystem usage inside a Proxmox VM through the QEMU Guest +Agent. Allocated virtual-disk size is not the same as used space inside the +guest. LXC filesystem collection uses a different path. -## ๐Ÿš€ Quick Start +## Backup safety -1. **Install Guest Agent**: - * **Linux**: `apt install qemu-guest-agent` (Debian/Ubuntu) or `yum install qemu-guest-agent` (RHEL). - * **Windows**: Install **virtio-win** drivers. -2. **Enable in Proxmox**: - * VM Options โ†’ **QEMU Guest Agent** โ†’ Enabled. - * Restart the VM. -3. **Verify**: - * Run `qm agent ping` on the Proxmox host. - * Check Pulse dashboard for disk usage (e.g., "5.2GB used of 32GB"). +**Do not run manual guest-agent probes during a backup, freeze or thaw.** A +guest-agent request is an active command on the same channel used by +freeze-enabled backups, even when it only reads disk or memory information. +There is a [report of a VM remaining frozen despite an OK backup task](https://github.com/rcourtman/Pulse/issues/2439). +The reported command-ID collision is not established as a reproduced cause. -## โš™๏ธ Requirements +If your installation is affected, stop the Pulse service before the backup +and restart it only after independently confirming the guest has thawed, using +your existing guest console or workload checks. **Pulse monitoring and alerts +are unavailable while it is stopped.** An OK backup task or an absent VM lock +does not prove thaw succeeded. This is a temporary precaution, not a claim +that the monitoring defect is fixed. -* **QEMU Guest Agent**: Must be installed and running inside the VM. -* **Proxmox Permissions**: `VM.Monitor` (Proxmox 8) or `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (Proxmox 9+). Note: `PVEAuditor` is a built-in read-only role that cannot be modified โ€” create a custom role instead. +Do not test freeze/thaw commands, clear backup locks, disable backup freezing +or force-reset a guest as a disk-monitoring diagnostic. Those operations can +affect workloads or backup consistency. Preserve the existing backup task and +redacted error, rather than repeat a potentially harmful backup for a report. + +## ๐Ÿš€ Setup + +Plan changes outside backup windows and follow the guest's normal maintenance +procedure; a reboot interrupts its workloads. + +1. **Install the guest agent inside the VM.** Use the guest OS's supported + package: `qemu-guest-agent` on Linux, or the QEMU Guest Agent service from + the virtio-win distribution on Windows. +2. **Enable it in Proxmox.** Review **VM Options โ†’ QEMU Guest Agent** and apply + any required VM restart in that maintenance window. +3. **Check the existing observations.** In Pulse, hover over the disk dash or + open the resource details for the collection explanation and observation + time. Check the guest-agent service from inside the guest, not by repeatedly + sending `qm agent` commands from the hypervisor. A configured agent is not + proof it responded, and a retained disk value is not proof of a fresh poll. + +## โš™๏ธ Permissions + +Use the dedicated account and token actually configured for that Pulse +connection, not a guessed account name. Review their effective privileges for +the affected VM; a host-root diagnostic does not test the API token's access. + +| Collection | Proxmox VE 9+ | Proxmox VE 8 | +| --- | --- | --- | +| Filesystem usage and guest information | `VM.GuestAgent.Audit` | `VM.Monitor` | +| Reading guest memory via `/proc/meminfo` | Also `VM.GuestAgent.FileRead` | `VM.Monitor` | + +Do not grant Administrator or guest execution/write permissions to diagnose a +missing disk value. The built-in `PVEAuditor` role cannot be modified; use a +custom monitoring role if the required read privileges are absent. ## ๐Ÿ”ง Troubleshooting -| Issue | Solution | -| :--- | :--- | -| **Disk shows "-"** | Hover over the dash for details. Common causes: Agent not running, disabled in config, or permission denied. | -| **Permission Denied** | Ensure your Proxmox token/user has `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (PVE 9+) or `VM.Monitor` (PVE 8). | -| **Rocky Linux / RHEL: memory or disk data missing** | The default qemu-guest-agent config may block file-read RPCs. Check `/etc/sysconfig/qemu-ga` and ensure `guest-file-open`, `guest-file-read`, and `guest-file-close` are not blocked, then restart the agent. See your distro's qemu-guest-agent docs for exact syntax. | -| **Agent Timeout** | Increase timeouts via env vars if network is slow: `GUEST_AGENT_FSINFO_TIMEOUT=10s`. | -| **Windows VMs** | Ensure the **QEMU Guest Agent** service is running in Windows Services. | +| Observation | Useful next check | +| --- | --- | +| **Disk shows โ€œ-โ€** | Read its explanation and observation time. Check the owning host, current VM options, guest-local service and configured API token access. | +| **Permission denied** | Review the token and account's effective read privileges on that VM, including privilege-separated token ACLs. A successful root command would not disprove this error. | +| **Timeout or backup lock** | Check the existing backup/task timeline and guest workload locally. Defer active guest-agent probes; increasing a timeout is not a contention or thaw repair. | +| **Rocky Linux / RHEL memory missing** | Review `/etc/sysconfig/qemu-ga` inside the guest. File-read restrictions can explain memory collection failure; they do not by themselves prove why filesystem usage is absent. Change the guest's allowlist or restart its agent only through your normal maintenance procedure, outside backups. | +| **Windows service stopped** | Check the QEMU Guest Agent service inside Windows. Schedule any restart outside backups. | + +### Passive host preflight + +The current `scripts/test-vm-disk.sh` helper reads only local VM status and +current configuration, with bounded timeouts. It sends **no guest-agent +commands**, reads no guest files, and changes no service, lock or ACL. Use it +on the VM's owning Proxmox host with an account permitted to read that local +configuration (normally through `sudo`). + +Use a reviewed checkout containing this passive helper. Inspect the file +before running it: older copies send `ping` and `get-fsinfo` to the guest. Do +not pipe a network download into a root shell. -### Diagnostic Script -Run this on your Proxmox host to debug specific VMs: ```bash -curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/test-vm-disk.sh | bash +# From the reviewed Pulse checkout; replace 100 with the affected VM ID. +sudo bash ./scripts/test-vm-disk.sh 100 ``` +The output distinguishes the running/stopped state, agent configuration and +a reported backup/other lock without dumping the full VM configuration. A +missing or unreadable lock is not permission to probe the guest: a backup can +start immediately after the read. A non-zero exit means a required read failed +or the response could not be interpreted; it is not an empty or healthy result. +**A successful preflight does not verify disk freshness, guest-agent +responsiveness or thaw.** + +Keep only the relevant time, Pulse and Proxmox versions, observation/error and +backup task sequence for a report. Redact private VM names, paths and addresses; +never include credentials, full VM configuration, service environments or a +whole host journal. See [Getting help](TROUBLESHOOTING.md#-getting-help). + ## ๐Ÿ“ Notes -* **Network Mounts**: NFS/SMB mounts are automatically excluded. -* **Databases**: Usage reflects filesystem usage, which may differ from database-internal metrics. -* **Containers**: LXC containers are monitored natively without the guest agent. +- **Network mounts:** NFS/SMB mounts are excluded from filesystem usage. +- **Databases:** Filesystem usage can differ from database-internal metrics. +- **Containers:** LXC collection does not use the VM's QEMU Guest Agent. diff --git a/frontend-modern/browser-tests/vm-disk-safety-help.cjs b/frontend-modern/browser-tests/vm-disk-safety-help.cjs new file mode 100644 index 0000000000..b12a380c1e --- /dev/null +++ b/frontend-modern/browser-tests/vm-disk-safety-help.cjs @@ -0,0 +1,93 @@ +// Exercise production Docs/router/Markdown. No Proxmox or guest is contacted. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = path.resolve('frontend-modern'); + const artifacts = path.join(root, 'node_modules', 'vm-disk-safety-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const guides = Object.fromEntries(['VM_DISK_MONITORING', 'TROUBLESHOOTING'].map(name => + [name, fs.readFileSync(path.join(root, 'public/docs', `${name}.md`), 'utf8')])); + const recipe = guides.VM_DISK_MONITORING.match(/```bash\n([\s\S]*?)```/)[1].trim(); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-vm-disk-safety'), + server: { host: '127.0.0.1', port: 5265, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ viewport: { width, height: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + await page.goto('http://127.0.0.1:5265/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + const entry = page.locator('a[data-doc-link][href="/docs/VM_DISK_MONITORING"]').first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const safety = page.getByRole('heading', { name: 'Backup safety', exact: true }); + await safety.waitFor(); + await safety.scrollIntoViewIfNeeded(); + let text = await page.locator('article').innerText(); + for (const phrase of ['Do not run manual guest-agent probes', 'monitoring and alerts', + 'does not prove thaw', 'temporary precaution', 'not established as a reproduced cause', + 'clear backup locks', 'host-root diagnostic does not test']) { + assert.ok(text.replace(/\s+/g, ' ').includes(phrase), phrase); + } + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-backup-safety.png`) }); + const passive = page.getByRole('heading', { name: 'Passive host preflight', exact: true }); + await passive.scrollIntoViewIfNeeded(); + const code = page.locator('pre').filter({ hasText: 'sudo bash ./scripts/test-vm-disk.sh 100' }); + assert.equal((await code.locator('code').textContent()).trim(), recipe); + const section = text.replace(/\s+/g, ' ').split('Passive host preflight')[1]; + for (const phrase of ['no guest-agent commands', 'older copies', 'bounded timeouts', + 'non-zero exit', 'does not verify disk freshness', 'before running it']) { + assert.ok(section.includes(phrase), phrase); + } + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-passive-preflight.png`) }); + const related = page.getByRole('link', { name: 'Getting help', exact: true }); + await related.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('heading', { name: '๐Ÿ†˜ Getting Help', exact: true }).waitFor(); + text = (await page.locator('article').innerText()).replace(/\s+/g, ' '); + for (const phrase of ['Do not run guest-agent probes during backup freeze/thaw', + 'passive host preflight', 'they do not by themselves establish why disk usage is absent']) { + assert.ok(text.includes(phrase), phrase); + } + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + assert.deepEqual(errors, []); + results.push({ engine, browserVersion: browser.version(), width, tone, + keyboardEntry: true, backupPrecautionAndOutage: true, exactCopiedShell: true, + passiveLimits: true, relatedHelpNavigation: true, noBodyOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + scope: 'rendered production help and copied recipe; no native guest or backup acceptance', + docs: Object.fromEntries(Object.entries(guides).map(([name, content]) => + [`frontend-modern/public/docs/${name}.md`, createHash('sha256').update(content).digest('hex')])), + results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/TROUBLESHOOTING.md b/frontend-modern/public/docs/TROUBLESHOOTING.md index 2ff1a7b0eb..b5e498ebb3 100644 --- a/frontend-modern/public/docs/TROUBLESHOOTING.md +++ b/frontend-modern/public/docs/TROUBLESHOOTING.md @@ -227,10 +227,14 @@ installed service must contain `SyslogLevelPrefix=true` and repair an older generated unit rather than adding a JSON-parsing wrapper. #### VMs show "-" for disk usage -- Install **QEMU Guest Agent** in the VM. -- Enable "QEMU Guest Agent" in Proxmox VM Options. -- Restart the VM. -- See [VM Disk Monitoring](VM_DISK_MONITORING.md). +- Read the disk value's explanation and observation time first; a dash is not + proof that the agent is missing. +- Check the guest-local service, current VM Options and the configured API + token's read permissions. Schedule any setup change or restart outside backups. +- Do not run guest-agent probes during backup freeze/thaw. An OK backup task + or an absent lock does not confirm thaw. +- See [VM Disk Monitoring](VM_DISK_MONITORING.md) for the passive host preflight + and backup safety precaution; it does not verify a fresh disk poll. #### Temperature data missing - Install `lm-sensors` on the host. @@ -615,7 +619,12 @@ For PVE 8 only, use `VM.Monitor` instead of the `VM.GuestAgent.*` privileges. Note: The built-in `PVEAuditor` role cannot be modified. Create a custom role (e.g. `PulseMonitor`) with the above privileges added, and assign it to your Pulse API token. After upgrading to PVE 9, add the `VM.GuestAgent.*` privileges and remove legacy `VM.Monitor` from the custom role. -**Rocky Linux / RHEL VMs**: The default qemu-guest-agent configuration may block file-read RPCs (`guest-file-open`, `guest-file-read`, `guest-file-close`). If memory or disk data is missing for these VMs, check `/etc/sysconfig/qemu-ga` and ensure those operations are not blocked, then restart the agent. Refer to your distro's qemu-guest-agent documentation for the exact config syntax. +**Rocky Linux / RHEL VMs**: File-read restrictions in `/etc/sysconfig/qemu-ga` +can explain missing guest memory; they do not by themselves establish why disk +usage is absent. Review the guest's policy before changing it. Schedule any +allowlist change or agent restart outside backups, following the guest OS's +documentation. See [VM Disk Monitoring](VM_DISK_MONITORING.md) for the distinct +permissions and backup safety boundary. ### Proxmox pending-update access diff --git a/frontend-modern/public/docs/VM_DISK_MONITORING.md b/frontend-modern/public/docs/VM_DISK_MONITORING.md index cbf6a5a328..9c0b762933 100644 --- a/frontend-modern/public/docs/VM_DISK_MONITORING.md +++ b/frontend-modern/public/docs/VM_DISK_MONITORING.md @@ -1,42 +1,102 @@ # ๐Ÿ’พ VM Disk Monitoring -Monitor actual disk usage inside your VMs using the QEMU Guest Agent. +Pulse can read filesystem usage inside a Proxmox VM through the QEMU Guest +Agent. Allocated virtual-disk size is not the same as used space inside the +guest. LXC filesystem collection uses a different path. -## ๐Ÿš€ Quick Start +## Backup safety -1. **Install Guest Agent**: - * **Linux**: `apt install qemu-guest-agent` (Debian/Ubuntu) or `yum install qemu-guest-agent` (RHEL). - * **Windows**: Install **virtio-win** drivers. -2. **Enable in Proxmox**: - * VM Options โ†’ **QEMU Guest Agent** โ†’ Enabled. - * Restart the VM. -3. **Verify**: - * Run `qm agent ping` on the Proxmox host. - * Check Pulse dashboard for disk usage (e.g., "5.2GB used of 32GB"). +**Do not run manual guest-agent probes during a backup, freeze or thaw.** A +guest-agent request is an active command on the same channel used by +freeze-enabled backups, even when it only reads disk or memory information. +There is a [report of a VM remaining frozen despite an OK backup task](https://github.com/rcourtman/Pulse/issues/2439). +The reported command-ID collision is not established as a reproduced cause. -## โš™๏ธ Requirements +If your installation is affected, stop the Pulse service before the backup +and restart it only after independently confirming the guest has thawed, using +your existing guest console or workload checks. **Pulse monitoring and alerts +are unavailable while it is stopped.** An OK backup task or an absent VM lock +does not prove thaw succeeded. This is a temporary precaution, not a claim +that the monitoring defect is fixed. -* **QEMU Guest Agent**: Must be installed and running inside the VM. -* **Proxmox Permissions**: `VM.Monitor` (Proxmox 8) or `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (Proxmox 9+). Note: `PVEAuditor` is a built-in read-only role that cannot be modified โ€” create a custom role instead. +Do not test freeze/thaw commands, clear backup locks, disable backup freezing +or force-reset a guest as a disk-monitoring diagnostic. Those operations can +affect workloads or backup consistency. Preserve the existing backup task and +redacted error, rather than repeat a potentially harmful backup for a report. + +## ๐Ÿš€ Setup + +Plan changes outside backup windows and follow the guest's normal maintenance +procedure; a reboot interrupts its workloads. + +1. **Install the guest agent inside the VM.** Use the guest OS's supported + package: `qemu-guest-agent` on Linux, or the QEMU Guest Agent service from + the virtio-win distribution on Windows. +2. **Enable it in Proxmox.** Review **VM Options โ†’ QEMU Guest Agent** and apply + any required VM restart in that maintenance window. +3. **Check the existing observations.** In Pulse, hover over the disk dash or + open the resource details for the collection explanation and observation + time. Check the guest-agent service from inside the guest, not by repeatedly + sending `qm agent` commands from the hypervisor. A configured agent is not + proof it responded, and a retained disk value is not proof of a fresh poll. + +## โš™๏ธ Permissions + +Use the dedicated account and token actually configured for that Pulse +connection, not a guessed account name. Review their effective privileges for +the affected VM; a host-root diagnostic does not test the API token's access. + +| Collection | Proxmox VE 9+ | Proxmox VE 8 | +| --- | --- | --- | +| Filesystem usage and guest information | `VM.GuestAgent.Audit` | `VM.Monitor` | +| Reading guest memory via `/proc/meminfo` | Also `VM.GuestAgent.FileRead` | `VM.Monitor` | + +Do not grant Administrator or guest execution/write permissions to diagnose a +missing disk value. The built-in `PVEAuditor` role cannot be modified; use a +custom monitoring role if the required read privileges are absent. ## ๐Ÿ”ง Troubleshooting -| Issue | Solution | -| :--- | :--- | -| **Disk shows "-"** | Hover over the dash for details. Common causes: Agent not running, disabled in config, or permission denied. | -| **Permission Denied** | Ensure your Proxmox token/user has `VM.GuestAgent.Audit` + `VM.GuestAgent.FileRead` (PVE 9+) or `VM.Monitor` (PVE 8). | -| **Rocky Linux / RHEL: memory or disk data missing** | The default qemu-guest-agent config may block file-read RPCs. Check `/etc/sysconfig/qemu-ga` and ensure `guest-file-open`, `guest-file-read`, and `guest-file-close` are not blocked, then restart the agent. See your distro's qemu-guest-agent docs for exact syntax. | -| **Agent Timeout** | Increase timeouts via env vars if network is slow: `GUEST_AGENT_FSINFO_TIMEOUT=10s`. | -| **Windows VMs** | Ensure the **QEMU Guest Agent** service is running in Windows Services. | +| Observation | Useful next check | +| --- | --- | +| **Disk shows โ€œ-โ€** | Read its explanation and observation time. Check the owning host, current VM options, guest-local service and configured API token access. | +| **Permission denied** | Review the token and account's effective read privileges on that VM, including privilege-separated token ACLs. A successful root command would not disprove this error. | +| **Timeout or backup lock** | Check the existing backup/task timeline and guest workload locally. Defer active guest-agent probes; increasing a timeout is not a contention or thaw repair. | +| **Rocky Linux / RHEL memory missing** | Review `/etc/sysconfig/qemu-ga` inside the guest. File-read restrictions can explain memory collection failure; they do not by themselves prove why filesystem usage is absent. Change the guest's allowlist or restart its agent only through your normal maintenance procedure, outside backups. | +| **Windows service stopped** | Check the QEMU Guest Agent service inside Windows. Schedule any restart outside backups. | + +### Passive host preflight + +The current `scripts/test-vm-disk.sh` helper reads only local VM status and +current configuration, with bounded timeouts. It sends **no guest-agent +commands**, reads no guest files, and changes no service, lock or ACL. Use it +on the VM's owning Proxmox host with an account permitted to read that local +configuration (normally through `sudo`). + +Use a reviewed checkout containing this passive helper. Inspect the file +before running it: older copies send `ping` and `get-fsinfo` to the guest. Do +not pipe a network download into a root shell. -### Diagnostic Script -Run this on your Proxmox host to debug specific VMs: ```bash -curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/test-vm-disk.sh | bash +# From the reviewed Pulse checkout; replace 100 with the affected VM ID. +sudo bash ./scripts/test-vm-disk.sh 100 ``` +The output distinguishes the running/stopped state, agent configuration and +a reported backup/other lock without dumping the full VM configuration. A +missing or unreadable lock is not permission to probe the guest: a backup can +start immediately after the read. A non-zero exit means a required read failed +or the response could not be interpreted; it is not an empty or healthy result. +**A successful preflight does not verify disk freshness, guest-agent +responsiveness or thaw.** + +Keep only the relevant time, Pulse and Proxmox versions, observation/error and +backup task sequence for a report. Redact private VM names, paths and addresses; +never include credentials, full VM configuration, service environments or a +whole host journal. See [Getting help](TROUBLESHOOTING.md#-getting-help). + ## ๐Ÿ“ Notes -* **Network Mounts**: NFS/SMB mounts are automatically excluded. -* **Databases**: Usage reflects filesystem usage, which may differ from database-internal metrics. -* **Containers**: LXC containers are monitored natively without the guest agent. +- **Network mounts:** NFS/SMB mounts are excluded from filesystem usage. +- **Databases:** Filesystem usage can differ from database-internal metrics. +- **Containers:** LXC collection does not use the VM's QEMU Guest Agent. diff --git a/scripts/test-vm-disk.sh b/scripts/test-vm-disk.sh index 4e7dc187dc..b6464e7b94 100755 --- a/scripts/test-vm-disk.sh +++ b/scripts/test-vm-disk.sh @@ -1,138 +1,124 @@ #!/usr/bin/env bash -set -uo pipefail +# Passive Proxmox preflight only. A lock read is not a synchronisation gate: +# a backup can start immediately afterwards. Never send guest-agent commands. +set -euo pipefail -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -info() { - printf "%b[INFO]%b %s\n" "$BLUE" "$NC" "$1" +usage() { + printf '%s\n' 'Usage: test-vm-disk.sh VMID' \ + 'Read local VM status and current configuration on its Proxmox host.' \ + 'No guest-agent, guest filesystem, ACL or service changes are made.' } -ok() { - printf " %bโœ“%b %s\n" "$GREEN" "$NC" "$1" -} +if [[ $# -eq 1 && $1 == --help ]]; then + usage + exit 0 +fi +if [[ $# -ne 1 || ! $1 =~ ^[1-9][0-9]{0,8}$ ]]; then + usage >&2 + exit 2 +fi +vmid=$1 -warn() { - printf " %bโš %b %s\n" "$YELLOW" "$NC" "$1" -} - -fail() { - printf " %bโœ—%b %s\n" "$RED" "$NC" "$1" -} - -require_cmd() { - if ! command -v "$1" >/dev/null 2>&1; then - printf "%b[ERROR]%b Required command '%s' not found.\n" "$RED" "$NC" "$1" >&2 +for command in qm timeout; do + if ! command -v "$command" >/dev/null 2>&1; then + printf 'Required command unavailable: %s. Run on the owning Proxmox host.\n' "$command" >&2 exit 1 fi +done + +read_failure() { + local operation=$1 code=$2 + if [[ $code -eq 124 || $code -eq 137 ]]; then + printf 'VM %s read timed out; no guest-agent commands were sent.\n' "$operation" >&2 + else + printf 'VM %s read failed (exit %s); no guest-agent commands were sent.\n' "$operation" "$code" >&2 + fi + printf '%s\n' 'Check the VM ID, owning host and local read permissions. Inspect any error privately.' >&2 + exit 1 } -# Ensure we are running on a Proxmox host -require_cmd qm -require_cmd pveversion +printf '%s\n' 'Passive VM preflight: no guest-agent commands will be sent.' +# Bound even host-local reads. Do not print raw config or command errors: +# those can contain private guest names, paths and other infrastructure data. +if status_output=$(timeout --signal=TERM --kill-after=2s 10s qm status "$vmid" 2>/dev/null); then + case "$status_output" in + 'status: running') status=running ;; + 'status: stopped') status=stopped ;; + *) printf '%s\n' 'VM status response was not understood; preflight incomplete.' >&2; exit 1 ;; + esac +else + read_failure status "$?" +fi +printf 'VM status: %s\n' "$status" -if [[ $EUID -ne 0 ]]; then - printf "%b[ERROR]%b This script must be run as root on the Proxmox host.\n" "$RED" "$NC" >&2 +if config=$(timeout --signal=TERM --kill-after=2s 10s qm config "$vmid" --current 2>/dev/null); then + if [[ -z $config ]]; then + printf '%s\n' 'VM current configuration was empty; preflight incomplete.' >&2 + exit 1 + fi +else + read_failure configuration "$?" +fi + +agent_seen=false +lock_seen=false +field_seen=false +agent_value='' +lock_value='' +while IFS= read -r line; do + if [[ $line =~ ^[A-Za-z][A-Za-z0-9_-]*: ]]; then + field_seen=true + fi + case "$line" in + agent:*) + if [[ $agent_seen == true ]]; then + printf '%s\n' 'Repeated agent configuration; preflight incomplete.' >&2 + exit 1 + fi + agent_seen=true + agent_value=${line#agent:} + agent_value=${agent_value//[[:space:]]/} + ;; + lock:*) + if [[ $lock_seen == true ]]; then + printf '%s\n' 'Repeated VM lock configuration; preflight incomplete.' >&2 + exit 1 + fi + lock_seen=true + lock_value=${line#lock:} + lock_value=${lock_value//[[:space:]]/} + ;; + esac +done <<< "$config" +if [[ $field_seen != true ]]; then + printf '%s\n' 'VM current configuration was not understood; preflight incomplete.' >&2 exit 1 fi -VMID=${1:-} -if [[ -z "${VMID}" ]]; then - read -rp "Enter the VMID to inspect: " VMID +agent=disabled +if [[ $agent_seen == true ]]; then + agent=unknown + enabled_options=0 + IFS=, read -r -a options <<< "$agent_value" + for option in "${options[@]}"; do + case "$option" in + 1|enabled=1) agent=enabled; enabled_options=$((enabled_options + 1)) ;; + 0|enabled=0) agent=disabled; enabled_options=$((enabled_options + 1)) ;; + esac + done + if [[ $enabled_options -ne 1 ]]; then + agent=unknown + fi fi - -if [[ ! $VMID =~ ^[0-9]+$ ]]; then - printf "%b[ERROR]%b VMID must be a numeric value.\n" "$RED" "$NC" >&2 - exit 1 -fi - -info "Running disk diagnostics for VMID ${VMID}" - -# --- Check VM existence and status --- -status_output=$(qm status "$VMID" 2>&1) -if [[ $? -ne 0 ]]; then - printf "%b[ERROR]%b Unable to query VM %s.\n" "$RED" "$NC" "$VMID" >&2 - printf "%s\n" "$status_output" >&2 - exit 1 -fi - -status=$(awk -F': ' '/status/ {print $2}' <<<"$status_output") -status=${status:-unknown} -ok "VM found (status: ${status})" - -running=false -if [[ $status == "running" ]]; then - running=true -else - warn "VM is not running. Start the VM before re-running disk checks for accurate results." -fi - -# --- Check guest agent configuration --- -agent_line=$(qm config "$VMID" | grep '^agent:' || true) -agent_enabled=false -if [[ -n $agent_line ]]; then - if grep -Eq '1|enabled' <<<"$agent_line"; then - agent_enabled=true - ok "QEMU guest agent is enabled in VM configuration (${agent_line})." +printf 'Guest agent configured: %s (not a responsiveness test).\n' "$agent" +if [[ $lock_seen == true ]]; then + if [[ $lock_value == backup ]]; then + printf '%s\n' 'VM lock: backup. Defer guest-agent diagnostics and configuration changes.' else - warn "QEMU guest agent is defined but not enabled (current config: ${agent_line})." + printf '%s\n' 'VM lock: present or unknown. Check the existing Proxmox task before making changes.' fi else - warn "QEMU guest agent is not enabled for this VM. Add 'agent: 1' in the VM options." + printf '%s\n' 'VM lock: not reported. This does not establish that a backup is idle or the guest is thawed.' fi - -if [[ $agent_enabled == true && $running == true ]]; then - # Guest agent ping - ping_output=$(qm agent "$VMID" ping 2>&1) - if [[ $? -eq 0 ]]; then - ok "Guest agent responded to ping." - else - warn "Guest agent did not respond to ping." - printf " %s\n" "$ping_output" - fi - - # Filesystem info - fs_output=$(qm agent "$VMID" get-fsinfo 2>&1) - if [[ $? -eq 0 ]]; then - if grep -Eq "(mount|fsname|filesystem)" <<<"$fs_output"; then - ok "Fetched filesystem information from guest." - else - warn "Guest agent returned no filesystem entries. Review the guest OS permissions." - fi - else - warn "Failed to retrieve filesystem information via guest agent." - printf " %s\n" "$fs_output" - fi -else - warn "Skipping guest agent checks because the VM is not running or the agent is disabled." -fi - -# --- Validate pulse monitoring account (optional) --- -if pveum user list 2>/dev/null | grep -q 'pulse_monitor@pam'; then - acl_output=$(pveum acl list / -user pulse_monitor@pam 2>&1) - if [[ $? -eq 0 && -n $acl_output ]]; then - ok "pulse_monitor@pam user has ACL entries configured." - else - warn "pulse_monitor@pam user exists but no ACLs were found. Ensure it has proper permissions." - fi -else - warn "pulse_monitor@pam user not found. Create it if Pulse is expected to collect agent data." -fi - -cat <<'SUMMARY' - -Next steps: - โ€ข If the guest agent is disabled, enable it in the VM Options tab (set "QEMU Guest Agent" to "Enabled"). - โ€ข Inside the guest OS, ensure the qemu-guest-agent service is installed, running, and has access to disk information. - โ€ข Verify the pulse_monitor@pam user (or your service account) has proper permissions: - - Proxmox 9: VM.GuestAgent.Audit privilege (Pulse setup adds via PulseMonitor role) - - Proxmox 8: VM.Monitor privilege (Pulse setup adds via PulseMonitor role) - - Sys.Audit is recommended for Ceph metrics and included when available - - Both API tokens and passwords work fine for guest agent access - -Diagnostics complete. -SUMMARY +printf '%s\n' 'Read-only preflight completed. Disk freshness, guest-agent responsiveness and guest thaw were not tested.' diff --git a/scripts/tests/test_vm_disk_diagnostics.py b/scripts/tests/test_vm_disk_diagnostics.py new file mode 100644 index 0000000000..3e25f290fb --- /dev/null +++ b/scripts/tests/test_vm_disk_diagnostics.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +"""Exercise the actual passive helper with synthetic Proxmox commands. + +No hypervisor, guest, backup or host service is touched. These controls prove +command selection, failure handling and private output, not native thaw. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import time +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts/test-vm-disk.sh" +DOC = ROOT / "docs/VM_DISK_MONITORING.md" +BASH = shutil.which("bash") +REAL_TIMEOUT = shutil.which("timeout") +PRIVATE = "synthetic-private-infrastructure-marker" +READS = [["status", "100"], ["config", "100", "--current"]] + +# Record every attempted operation, including prohibited ones. The timeout +# adapter checks the helper's real deadline arguments; one test uses real +# coreutils timeout around the synthetic blocked qm child. +TOOL = '''#!/usr/bin/env python3 +import json, os, pathlib, subprocess, sys, time +name = pathlib.Path(sys.argv[0]).name +args = sys.argv[1:] +root = pathlib.Path(os.environ['VM_FIXTURE']) +with (root / 'calls.jsonl').open('a') as stream: + stream.write(json.dumps({'name': name, 'args': args}) + '\\n') +settings = json.loads((root / 'settings.json').read_text()) +if name == 'timeout': + assert args[:4] == ['--signal=TERM', '--kill-after=2s', '10s', 'qm'] + if settings.get('real_timeout'): + result = subprocess.run([os.environ['REAL_TIMEOUT'], *args]) + else: + result = subprocess.run(args[3:]) + sys.exit(result.returncode) +elif name == 'qm': + if args == ['status', '100']: + operation = 'status' + default = 'status: running\\n' + elif args == ['config', '100', '--current']: + operation = 'config' + default = 'agent: 1\\nname: synthetic-private-infrastructure-marker\\n' + else: + # No fake succeeds on an unrecognised or guest-agent operation. + sys.exit(90) + if settings.get('sleep_operation') == operation: + time.sleep(20) + sys.stdout.write(settings.get(operation + '_output', default)) + sys.stderr.write('synthetic-private-infrastructure-marker\\n') + sys.exit(settings.get(operation + '_exit', 0)) +elif name == 'sudo': + assert args == ['bash', './scripts/test-vm-disk.sh', '100'] + os.execv(os.environ['REAL_BASH'], [os.environ['REAL_BASH'], *args[1:]]) +else: + sys.exit(91) +''' + + +class VMDiskDiagnosticsTest(unittest.TestCase): + def exercise(self, *, settings=None, args=None, recipe=None, source=None, + missing=None): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "settings.json").write_text(json.dumps(settings or {})) + for name in ("qm", "timeout", "sudo", "pveum", "pveversion", "curl", "systemctl"): + if name == missing: + continue + tool = root / name + tool.write_text(TOOL) + tool.chmod(0o700) + env = dict(os.environ, VM_FIXTURE=str(root), REAL_BASH=BASH, + REAL_TIMEOUT=REAL_TIMEOUT, PATH=f"{root}:{os.environ['PATH']}") + if missing: + # The adapters use an absolute interpreter, so an empty + # fallback PATH can genuinely model unavailable dependencies. + env["PATH"] = str(root) + for tool in root.iterdir(): + if tool.name in ("settings.json",): + continue + tool.write_text(TOOL.replace("/usr/bin/env python3", os.sys.executable)) + script = SCRIPT + if source is not None: + script = root / "candidate.sh" + script.write_text(source) + invocation = [BASH, str(script), *(args if args is not None else ["100"])] + if recipe: + invocation = [BASH, "-c", recipe] + result = subprocess.run(invocation, env=env, cwd=ROOT, + capture_output=True, text=True, timeout=16) + calls_file = root / "calls.jsonl" + calls = [json.loads(line) for line in calls_file.read_text().splitlines()] if calls_file.exists() else [] + self.assertNotIn(PRIVATE, result.stdout + result.stderr) + return result, calls + + def assert_passive(self, calls, *, operations=READS): + self.assertEqual([c["args"] for c in calls if c["name"] == "qm"], operations) + self.assertTrue(all(c["name"] in ("timeout", "qm", "sudo") for c in calls)) + for call in calls: + if call["name"] == "timeout": + self.assertEqual(call["args"][:4], ["--signal=TERM", "--kill-after=2s", "10s", "qm"]) + + def test_running_vm_preflight_is_passive_even_without_a_lock(self): + result, calls = self.exercise() + self.assertEqual(result.returncode, 0, result.stderr) + self.assert_passive(calls) + self.assertIn("VM status: running", result.stdout) + self.assertIn("Guest agent configured: enabled", result.stdout) + self.assertIn("VM lock: not reported", result.stdout) + self.assertIn("does not establish", result.stdout) + self.assertIn("guest thaw were not tested", result.stdout) + + def test_stopped_guest_is_not_started_or_probed(self): + result, calls = self.exercise(settings={"status_output": "status: stopped\n"}) + self.assertEqual(result.returncode, 0) + self.assertIn("VM status: stopped", result.stdout) + self.assert_passive(calls) + + def test_backup_other_empty_and_future_locks_never_send_a_probe(self): + for lock in ("backup", "snapshot", "migrate", "", PRIVATE): + with self.subTest(lock=lock): + result, calls = self.exercise(settings={"config_output": f"agent: 1\nlock: {lock}\nname: {PRIVATE}\n"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("VM lock: backup" if lock == "backup" else "VM lock: present or unknown", result.stdout) + self.assert_passive(calls) + + def test_agent_boolean_is_not_inferred_from_other_numeric_options(self): + cases = { + None: "disabled", "0": "disabled", "1": "enabled", + "0,freeze-fs-on-backup=1": "disabled", "enabled=0,fstrim_cloned_disks=1": "disabled", + "enabled=1,freeze-fs-on-backup=0": "enabled", "freeze-fs-on-backup=1,enabled=0": "disabled", + "fstrim_cloned_disks=1": "unknown", "enabled=2": "unknown", "": "unknown", + "0,enabled=1": "unknown", "1,enabled=1": "unknown", "disabled": "unknown", + } + for value, expected in cases.items(): + with self.subTest(value=value): + config = f"name: {PRIVATE}\n" + (f"agent: {value}\n" if value is not None else "") + result, calls = self.exercise(settings={"config_output": config}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"Guest agent configured: {expected} (not a responsiveness test)", result.stdout) + self.assert_passive(calls) + + def test_failed_status_never_reads_configuration_or_reports_completion(self): + for code in (1, 13, 124, 137, 143): + with self.subTest(code=code): + result, calls = self.exercise(settings={"status_exit": code, "status_output": PRIVATE}) + self.assertEqual(result.returncode, 1) + self.assertNotIn("preflight completed", result.stdout) + self.assertNotIn("VM status:", result.stdout) + self.assertIn("timed out" if code in (124, 137) else f"exit {code}", result.stderr) + self.assert_passive(calls, operations=READS[:1]) + + def test_config_failure_cannot_be_presented_as_an_absent_lock(self): + for code in (1, 13, 124, 137): + with self.subTest(code=code): + result, calls = self.exercise(settings={"config_exit": code, "config_output": f"agent: 1\nlock: backup\n{PRIVATE}"}) + self.assertEqual(result.returncode, 1) + self.assertNotIn("preflight completed", result.stdout) + self.assertNotIn("VM lock:", result.stdout) + self.assertIn("configuration read", result.stderr) + self.assert_passive(calls) + + def test_empty_unknown_and_repeated_responses_fail_without_dumping_config(self): + cases = [ + {"status_output": ""}, {"status_output": "status: " + PRIVATE}, + {"config_output": ""}, {"config_output": PRIVATE}, + {"config_output": f"agent: 1\nagent: 0\nname: {PRIVATE}\n"}, + {"config_output": f"lock: backup\nlock: migrate\nname: {PRIVATE}\n"}, + ] + for settings in cases: + with self.subTest(settings=settings): + result, calls = self.exercise(settings=settings) + self.assertEqual(result.returncode, 1) + self.assertIn("preflight incomplete", result.stderr) + self.assertNotIn("preflight completed", result.stdout) + self.assert_passive(calls, operations=READS[:1] if "status_output" in settings else READS) + + def test_help_and_invalid_arguments_never_contact_proxmox(self): + for args in ([], ["0"], ["-1"], ["100;touch BAD"], ["100", "--probe"], + ["9999999999"], ["--probe"], ["--help"]): + with self.subTest(args=args): + result, calls = self.exercise(args=args) + self.assertEqual(result.returncode, 0 if args == ["--help"] else 2) + self.assertEqual(calls, []) + + def test_missing_dependencies_do_not_run_a_partial_preflight(self): + for missing in ("qm", "timeout"): + with self.subTest(missing=missing): + result, calls = self.exercise(missing=missing) + self.assertEqual(result.returncode, 1) + self.assertIn(f"unavailable: {missing}", result.stderr) + self.assertEqual(calls, []) + + def test_real_deadline_stops_a_blocked_synthetic_local_read(self): + self.assertIsNotNone(REAL_TIMEOUT) + start = time.monotonic() + result, calls = self.exercise(settings={"real_timeout": True, "sleep_operation": "status"}) + elapsed = time.monotonic() - start + self.assertEqual(result.returncode, 1) + self.assertIn("status read timed out", result.stderr) + self.assertLess(elapsed, 14) + self.assert_passive(calls, operations=READS[:1]) + + def test_exact_documented_command_uses_the_passive_helper(self): + recipes = re.findall(r"```bash\n(.*?)```", DOC.read_text(), re.DOTALL) + self.assertEqual(len(recipes), 1) + result, calls = self.exercise(recipe=recipes[0]) + self.assertEqual(result.returncode, 0, result.stderr) + self.assert_passive(calls) + self.assertEqual([c["args"] for c in calls if c["name"] == "sudo"], + [["bash", "./scripts/test-vm-disk.sh", "100"]]) + + def test_safety_oracle_rejects_reintroduced_guest_probes_and_mutations(self): + source = SCRIPT.read_text() + for unsafe in ('qm agent "$vmid" ping', 'qm agent "$vmid" get-fsinfo', + 'qm unlock "$vmid"', 'qm reset "$vmid"', + 'pveum acl modify /', 'systemctl restart pulse'): + with self.subTest(operation=unsafe): + mutated = source.replace("vmid=$1", "vmid=$1\n" + unsafe) + result, calls = self.exercise(source=mutated, settings={"config_output": "agent: 1\nlock: backup\n"}) + self.assertNotEqual(result.returncode, 0) + with self.assertRaises(AssertionError): + self.assert_passive(calls) + + +class VMDiskHelpTest(unittest.TestCase): + def test_guides_are_mirrored_and_preserve_the_safety_and_proof_boundaries(self): + for name in ("VM_DISK_MONITORING", "TROUBLESHOOTING"): + source = (ROOT / f"docs/{name}.md").read_bytes() + self.assertEqual(source, (ROOT / f"frontend-modern/public/docs/{name}.md").read_bytes()) + guide = DOC.read_text() + for phrase in ("monitoring and alerts", "not established as a reproduced cause", + "does not prove thaw", "a backup can", "older copies", "non-zero exit", + "host-root diagnostic does not test", "not a claim", "bounded timeouts"): + self.assertIn(phrase, guide) + for block in re.findall(r"```bash\n(.*?)```", guide, re.DOTALL): + self.assertNotRegex(block, r"\b(curl|wget|qm agent|pveum|systemctl)\b") + self.assertNotIn("GUEST_AGENT_FSINFO_TIMEOUT=", guide) + + +if __name__ == "__main__": + unittest.main() From 3c57c2d20728f9e711ae2930e282b853eb218730 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:10:01 +0100 Subject: [PATCH 54/86] Defer guest commands after incomplete HTTP error responses The HTTP error wrapper hid truncated response bodies from guest admission. Preserve the bounded read failure as a private wrapping type so error statuses also cool down the whole guest. Cover both 200 and 500 truncation, and retain complete permission refusals plus subsequent healthy reads. Contract-Neutral: Completes the existing full-response/incomplete-body guest safety contract; error text and ordinary API errors retain compatibility, with no new interface or surface. Change-source: pulse-maintainer --- pkg/proxmox/client.go | 11 ++++++-- pkg/proxmox/guest_agent_guard.go | 5 ++++ pkg/proxmox/guest_agent_guard_test.go | 38 ++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 1b3dcee276..dec68ba461 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -52,13 +52,20 @@ func classifyAPIErrorLog(status int, path string) (apiErrorLogLevel, string) { return apiErrorLogNone, "" } +// Preserve an incomplete body through the HTTP error-response wrapper too. +// An error status alone is not a completed refusal if its body was truncated. +type responseBodyReadError struct{ cause error } + +func (e *responseBodyReadError) Error() string { return e.cause.Error() } +func (e *responseBodyReadError) Unwrap() error { return e.cause } + func readResponseBodyLimited(r io.Reader) ([]byte, error) { body, err := io.ReadAll(io.LimitReader(r, maxResponseBodyBytes+1)) if err != nil { - return nil, err + return nil, &responseBodyReadError{cause: err} } if int64(len(body)) > maxResponseBodyBytes { - return nil, fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes) + return nil, &responseBodyReadError{cause: fmt.Errorf("response body exceeds %d bytes", maxResponseBodyBytes)} } return body, nil } diff --git a/pkg/proxmox/guest_agent_guard.go b/pkg/proxmox/guest_agent_guard.go index 1f8df97621..fce6be56b2 100644 --- a/pkg/proxmox/guest_agent_guard.go +++ b/pkg/proxmox/guest_agent_guard.go @@ -176,6 +176,11 @@ func (c *Client) getGuestAgent(ctx context.Context, path, node string, vmid int) // own their usual session recovery; this guest admission is single-attempt. resp, err := c.requestWithRetry(ctx, http.MethodGet, path, nil, true) if err != nil { + var incomplete *responseBodyReadError + if errors.As(err, &incomplete) { + uncertain = true + return nil, &guestAgentDeferredError{reason: "agent-response-incomplete", cause: err} + } // Explicit refusals/unsupported commands remain ordinary errors. An // uncertain completion blocks every command, not just this method. lower := strings.ToLower(err.Error()) diff --git a/pkg/proxmox/guest_agent_guard_test.go b/pkg/proxmox/guest_agent_guard_test.go index 62d77db187..7fcdc823d4 100644 --- a/pkg/proxmox/guest_agent_guard_test.go +++ b/pkg/proxmox/guest_agent_guard_test.go @@ -101,6 +101,14 @@ func TestGuestAgentLockAppearingInFlightDiscardsPayload(t *testing.T) { } func TestGuestAgentIncompleteBodyBlocksWholeVM(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusInternalServerError} { + t.Run(fmt.Sprintf("status-%d", status), func(t *testing.T) { + testGuestAgentIncompleteBodyBlocksWholeVM(t, status) + }) + } +} + +func testGuestAgentIncompleteBodyBlocksWholeVM(t *testing.T, status int) { var calls atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/config") { @@ -109,12 +117,13 @@ func TestGuestAgentIncompleteBodyBlocksWholeVM(t *testing.T) { } calls.Add(1) w.Header().Set("Content-Length", "1000") + w.WriteHeader(status) fmt.Fprint(w, `{"data":{"result":[]}}`) })) defer server.Close() c := backupTestClient(t, server.URL) if _, err := c.GetVMFSInfo(context.Background(), "node", 105); GuestAgentDeferredReason(err) != "agent-response-incomplete" { - t.Fatalf("incomplete body not deferred: %v", err) + t.Errorf("incomplete body not deferred: %v", err) } for name, read := range backupAgentReads() { t.Run(name, func(t *testing.T) { @@ -128,6 +137,33 @@ func TestGuestAgentIncompleteBodyBlocksWholeVM(t *testing.T) { } } +func TestGuestAgentDeferralDoesNotHideCompletedRefusal(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/config") { + fmt.Fprint(w, `{"data":{}}`) + return + } + calls.Add(1) + if strings.HasSuffix(r.URL.Path, "/get-fsinfo") { + http.Error(w, "permission denied", http.StatusForbidden) + return + } + backupAgentPayload(w, r) + })) + defer server.Close() + c := backupTestClient(t, server.URL) + if _, err := c.GetVMFSInfo(context.Background(), "node", 105); err == nil || errors.Is(err, ErrGuestAgentDeferred) || !strings.Contains(err.Error(), "403") { + t.Fatalf("completed refusal hidden or lost: %v", err) + } + if _, err := c.GetVMAgentInfo(context.Background(), "node", 105); err != nil { + t.Fatalf("completed refusal poisoned other healthy reads: %v", err) + } + if calls.Load() != 2 { + t.Fatalf("refused/healthy command count = %d", calls.Load()) + } +} + func TestGuestAgentClusterDoesNotReplayUncertainCall(t *testing.T) { var calls atomic.Int32 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From b22d3b4490bdf065bad1d1c7f46cd9e011d46f9c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:15:10 +0100 Subject: [PATCH 55/86] Explain deferred guest reads without unsafe backup advice Use one readable reason vocabulary across workload rows, disk breakdown/list and guest Overview. Mark retained disk values as last-known, retain unavailable cases, and remove the notice on fresh same-VM observations. Never recommend restarting QGA during backup uncertainty. Desktop Chromium and phone WebKit source-bound controls cover all eight new/uncertain reasons, absent versus retained data, responsive status layout and fresh resumption; native coordination/thaw and containing release remain separate Core/Delivery obligations. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 25 ++ .../subsystems/performance-and-scalability.md | 10 + .../internal/subsystems/unified-resources.md | 13 + .../browser-tests/guest-disk-deferral.cjs | 273 ++++++++++++++++++ .../browser-tests/guest-disk-deferral.html | 12 + .../browser-tests/guest-disk-deferral.tsx | 127 ++++++++ frontend-modern/browser-verification.json | 198 ++++++++----- .../src/components/Workloads/DiskList.tsx | 3 + .../Workloads/GuestDrawerOverview.tsx | 17 +- .../src/components/Workloads/GuestRow.tsx | 13 +- .../components/Workloads/StackedDiskBar.tsx | 3 + .../__fixtures__/guestDiskDeferrals.ts | 32 ++ .../Workloads/__tests__/DiskList.test.tsx | 36 ++- .../GuestDrawerOverview.filesystems.test.tsx | 44 ++- .../Workloads/__tests__/GuestRow.test.tsx | 38 ++- .../__tests__/StackedDiskBar.test.tsx | 11 + .../Workloads/stackedDiskBarModel.ts | 1 + .../workloadGuestPresentation.test.ts | 13 + .../src/utils/workloadGuestPresentation.ts | 20 +- 19 files changed, 806 insertions(+), 83 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-disk-deferral.cjs create mode 100644 frontend-modern/browser-tests/guest-disk-deferral.html create mode 100644 frontend-modern/browser-tests/guest-disk-deferral.tsx create mode 100644 frontend-modern/src/components/Workloads/__fixtures__/guestDiskDeferrals.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 698506168e..23f91dd104 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -20,6 +20,31 @@ ## Purpose +### Guest disk deferral and retained observations + +The existing workload row, disk breakdown tooltip/list and guest Overview use +one presentation for the fixed guest-read reason vocabulary. Operation locks, +unverified lock clearance, an in-flight request, cooldown, incomplete response +and capacity deferrals are not evidence that QGA is missing. Invalid VM identity +remains distinct. A timeout describes uncertain completion and must not recommend +restarting QGA during a backup or changing freeze settings. + +`prev-` marks last-known disk values, not a fresh sample. Keep those values and +their identity visible, with the explanation beside the disk evidence, including +the breakdown tooltip and the phone-readable Overview. Show readable status even +before the first filesystem sample; do not hide it behind an empty disk list or +expose only a raw reason. A same-VM fresh observation withdraws the notice and +updates values without remounting the row. Permission, disabled-agent and actual +unavailable cases retain their own explanations. + +Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`, +`DiskList`, `StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions, +and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no +previous data and fresh resumption with production components and CSS on desktop +Chromium and touch WebKit. Synthetic observations do not establish the native +command-ID cause, guest thaw, fresh History collection or containing release. + + ### Composed operator tables and conservative rollback consent Phone storage tables use the canonical weighted-width helper and matching diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index ecccb3fe0a..9d9e0731fd 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -15,6 +15,16 @@ ## Purpose +### Guest disk status updates retain row ownership + +Disk deferral copy follows reactive same-VM props in the existing row and drawer, +without new guest commands, client polling, History writes or row remounts. Retained +values are labelled rather than replaced with invented fresh percentages; fresh +observations update values and remove the status. Mounted row/list/drawer controls +and `browser-tests/guest-disk-deferral.cjs` verify same-row ownership in bar and +sparkline modes with desktop and phone CSS. This is client behaviour proof, not +native backup coordination, new History samples or a fleet performance result. + ### Phone workload identity weights The existing phone guest projection allocates 38 percent to the name cell, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index c93d87e6c8..cf80b93be1 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,19 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Guest disk freshness presentation + +Existing workload disk evidence retains its source reason through the row, +breakdown and Overview. `prev-` values are explicitly last-known, while a +deferral with no previous disks explains the missing observation without a +measured percentage. Lock/uncertain-completion deferrals must not become an +install/restart recommendation. Human-readable Overview status remains visible +without filesystem rows and is cleared by a same-identity fresh observation. +The shared presentation and mounted regressions plus the production-component +`browser-tests/guest-disk-deferral.cjs` fixture verify this client behaviour; +they do not prove native QGA coordination, History freshness or delivery. + + ### Narrow controller and backup summaries retain their source facts Kubernetes controller row expansions expose supplied kind-specific targets, diff --git a/frontend-modern/browser-tests/guest-disk-deferral.cjs b/frontend-modern/browser-tests/guest-disk-deferral.cjs new file mode 100644 index 0000000000..349adae709 --- /dev/null +++ b/frontend-modern/browser-tests/guest-disk-deferral.cjs @@ -0,0 +1,273 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/guest-disk-deferral-proof'; +const runtime = [ + 'frontend-modern/src/utils/workloadGuestPresentation.ts', + 'frontend-modern/src/components/Workloads/GuestRow.tsx', + 'frontend-modern/src/components/Workloads/stackedDiskBarModel.ts', + 'frontend-modern/src/components/Workloads/StackedDiskBar.tsx', + 'frontend-modern/src/components/Workloads/DiskList.tsx', + 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx', +]; +const deferrals = [ + ['vm-locked', 'VM operation lock'], + ['lock-unverified', 'cannot verify that the VM is unlocked'], + ['agent-busy', 'earlier guest request is still in progress'], + ['agent-cooldown', 'after the cooldown'], + ['agent-response-incomplete', 'previous response was incomplete'], + ['agent-capacity', 'guest-read capacity'], + ['invalid-guest-key', 'VM identity is invalid'], + ['agent-timeout', 'Do not restart the guest agent during a backup'], +]; +(async () => { + fs.mkdirSync(output, { recursive: true }); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const hashes = Object.fromEntries( + runtime.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join('/workspace', p))) + .digest('hex'), + ]), + ); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5294, strictPort: true }, + }); + const results = [], + screenshots = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop', chromium, 1365, false], + ['phone', webkit, 390, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + isMobile: width === 390, + hasTouch: width === 390, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(15000); + page.setDefaultNavigationTimeout(60000); + const errors = [], + checks = []; + page.on('pageerror', (e) => { + errors.push(e.message); + process.stderr.write(`BROWSER_PAGE_ERROR ${e.message}\n`); + }); + page.on('console', (m) => { + if (m.type() === 'error') + process.stderr.write(`BROWSER_CONSOLE ${m.text().slice(0, 1500)}\n`); + }); + page.on('response', (r) => { + if (r.status() >= 400) + process.stderr.write(`BROWSER_HTTP ${r.status()} ${new URL(r.url()).pathname}\n`); + }); + await page.route('http://127.0.0.1:5294/api/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: [], + alerts: [], + anomalies: [], + config: null, + }), + }), + ); + await page.goto('http://127.0.0.1:5294/browser-tests/guest-disk-deferral.html', { + waitUntil: 'domcontentloaded', + }); + try { + await page.waitForFunction(() => window.__guestDiskEvidence, null, { timeout: 15000 }); + } catch (error) { + fs.writeFileSync( + path.join(output, 'failure.json'), + JSON.stringify({ phase: 'fixture-readiness', errors }, null, 2), + ); + throw error; + } + if (dark) await page.evaluate(() => document.documentElement.classList.add('dark')); + const row = page.locator('[data-guest-id="fixture-pve:pve-a:101"]'); + const cell = row.locator('[data-workload-col="disk"]'); + await row.click(); + const drawer = page.getByRole('region', { name: 'Guest Overview' }); + await drawer.waitFor({ state: 'visible' }); + await page.evaluate( + () => + (window.__originalDiskRow = document.querySelector( + '[data-guest-id="fixture-pve:pve-a:101"]', + )), + ); + const update = async (reason, data, usage = 50) => { + await page.evaluate((value) => window.__guestDiskEvidence.update(value), { + reason, + data, + usage, + }); + await page.waitForFunction( + () => + document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') === + window.__originalDiskRow, + ); + assert.equal( + await page.evaluate(() => window.__guestDiskEvidence.identity()), + 'fixture-pve:pve-a:101', + ); + }; + const capture = async (suffix) => { + const p = path.join(output, `${name}-${suffix}.png`); + await page.screenshot({ path: p, fullPage: true }); + screenshots.push({ + path: path.relative('/workspace', p), + sha256: crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'), + }); + }; + for (const [reason, expected] of deferrals) { + await update(`prev-${reason}`, true); + await page.waitForFunction( + (text) => document.querySelector('[data-workload-col="disk"]').title.includes(text), + expected, + ); + const title = await cell.getAttribute('title'); + assert(title.startsWith('Using last known disk stats. ')); + assert(title.includes(expected)); + assert( + !title.includes('may not be installed') && !title.includes('may need to be restarted'), + ); + assert((await drawer.innerText()).includes(title)); + const statusBox = await drawer.getByText(title, { exact: true }).boundingBox(); + assert( + statusBox.width >= 100 && statusBox.height < 220, + 'status explanation fits its card', + ); + assert((await drawer.innerText()).includes('/data')); + assert( + (await page.getByRole('region', { name: 'Guest disk list' }).innerText()).includes(title), + ); + if (name === 'desktop') { + // The disclosure click can land on this bar and dismiss its hover + // overlay on pointerdown. Leave it before exercising a new hover. + await page.locator('h1').hover(); + await cell.locator('[data-stacked-disk-trigger]').hover(); + await page.locator('[data-tooltip-portal="true"]').waitFor({ state: 'visible' }); + assert((await page.locator('[data-tooltip-portal="true"]').innerText()).includes(title)); + await page.locator('h1').hover(); + } + checks.push(`${reason}: retained row, tooltip, drawer, list and identity`); + if (reason === 'vm-locked') await capture('retained-backup'); + await update(reason, false); + await page.waitForFunction( + (text) => document.querySelector('[data-workload-col="disk"]').title.includes(text), + expected, + ); + assert(!(await cell.getAttribute('title')).includes('Using last known')); + assert((await drawer.innerText()).includes(expected)); + assert(!(await drawer.innerText()).includes('/data')); + assert.equal(await cell.locator('[data-stacked-disk-trigger]').count(), 0); + checks.push(`${reason}: no previous disk values and no fresh percentage`); + if (reason === 'agent-timeout') await capture('uncertain-timeout'); + } + for (const [reason, expected] of [ + ['permission-denied', 'Permission denied'], + ['agent-disabled', 'disabled in VM configuration'], + ['agent-not-running', 'Guest agent not running'], + ['no-data', 'No disk data available'], + ['vm-stopped', 'VM is stopped'], + ['no-status', 'could not read the VM status'], + ['agent-error', 'Error communicating with guest agent'], + ]) { + await update(reason, false); + await page.waitForFunction( + (text) => document.querySelector('[data-workload-col="disk"]').title.includes(text), + expected, + ); + assert((await drawer.innerText()).includes(expected)); + checks.push(`${reason}: existing unavailable case remains distinct`); + } + await update('prev-agent-cooldown', true); + await page.evaluate(() => window.__guestDiskEvidence.mode('sparklines')); + assert((await cell.getAttribute('title')).includes('Using last known')); + await update('', true, 75); + await page.waitForFunction( + () => !document.querySelector('[data-workload-col="disk"]').hasAttribute('title'), + ); + assert(!(await drawer.innerText()).includes('Using last known')); + assert(!(await drawer.innerText()).includes('Guest reads')); + assert.equal(await drawer.getByRole('progressbar').getAttribute('aria-valuenow'), '75'); + await page.evaluate(() => window.__guestDiskEvidence.mode('bars')); + assert.equal( + await page.evaluate( + () => + document.querySelector('[data-guest-id="fixture-pve:pve-a:101"]') === + window.__originalDiskRow, + ), + true, + ); + checks.push( + 'same-VM fresh resumption removes notice and updates values in both row modes without remount', + ); + await capture('fresh-resumption'); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + true, + ); + assert.deepEqual(errors, []); + results.push({ + name, + browser_version: browser.version(), + viewport: { width, height: width === 390 ? 844 : 900 }, + checks, + page_errors: errors, + }); + await browser.close(); + browser = null; + } + const receipt = { + version: 1, + result: 'passed', + verified_at: new Date().toISOString(), + content_sha256: hashes, + playwright_version: playwrightVersion, + results, + screenshots, + limitation: + 'Synthetic same-VM observations through production components and CSS; no native QGA/backup, History sample, installation or release acceptance.', + }; + fs.writeFileSync( + path.join(output, 'browser-result.json'), + JSON.stringify(receipt, null, 2) + '\n', + ); + process.stdout.write(JSON.stringify(receipt) + '\n'); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((e) => { + process.stderr.write(e.stack + '\n'); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/guest-disk-deferral.html b/frontend-modern/browser-tests/guest-disk-deferral.html new file mode 100644 index 0000000000..16926a6671 --- /dev/null +++ b/frontend-modern/browser-tests/guest-disk-deferral.html @@ -0,0 +1,12 @@ + + + + + + Guest disk deferral verification + + +
    + + + diff --git a/frontend-modern/browser-tests/guest-disk-deferral.tsx b/frontend-modern/browser-tests/guest-disk-deferral.tsx new file mode 100644 index 0000000000..d1ad8dfdd2 --- /dev/null +++ b/frontend-modern/browser-tests/guest-disk-deferral.tsx @@ -0,0 +1,127 @@ +// Production row, disk tooltip, list and disclosed Overview with synthetic +// same-VM observations. No collector, QGA, native backup or release proof. +import { createSignal, Show } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { GuestRow } from '../src/components/Workloads/GuestRow'; +import { GuestDrawerOverview } from '../src/components/Workloads/GuestDrawerOverview'; +import { DiskList } from '../src/components/Workloads/DiskList'; +import type { VM } from '../src/types/api'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import '../src/index.css'; + +function Fixture() { + const [observation, setObservation] = createSignal({ + reason: 'prev-vm-locked', + data: true, + usage: 50, + }); + const [expanded, setExpanded] = createSignal(false); + const [mode, setMode] = createSignal<'bars' | 'sparklines'>('bars'); + const guest = (): VM => ({ + id: 'fixture-pve:pve-a:101', + vmid: 101, + name: 'backup-guest', + node: 'pve-a', + instance: 'fixture-pve', + status: 'running', + type: 'qemu', + cpu: 0.1, + cpus: 2, + memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 }, + disk: { + total: 10 * 1024 ** 3, + used: observation().data ? (observation().usage / 100) * 10 * 1024 ** 3 : 0, + usage: observation().data ? observation().usage : -1, + }, + disks: observation().data + ? [ + { + mountpoint: '/data', + type: 'ext4', + total: 10 * 1024 ** 3, + used: (observation().usage / 100) * 10 * 1024 ** 3, + usage: observation().usage, + }, + ] + : [], + diskStatusReason: observation().reason, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: 0, + tags: [], + lock: observation().reason.endsWith('vm-locked') ? 'backup' : '', + backupInProgress: observation().reason.endsWith('vm-locked'), + lastSeen: '2026-10-03T14:00:00Z', + }); + (window as any).__guestDiskEvidence = { + update: setObservation, + mode: setMode, + identity: () => guest().id, + }; + return ( +
    +

    Guest disk observation

    + + + setExpanded(!expanded())} + isExpanded={expanded()} + metricDisplayMode={mode()} + workloadTableLayoutMode={window.innerWidth <= 768 ? 'phone' : 'wide'} + /> + +
    + +
    + 0} + hasNetworkInterfaces={false} + hasOsInfo={false} + hasWorkloadActionAgent={false} + showInGuestAgentInstallCue={false} + ipAddresses={[]} + networkInterfaces={[]} + normalizedTags={[]} + backupPresentation={null} + workloadActionAgentTitle="" + /> +
    +
    +
    +

    Filesystems

    + +
    +
    + ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 453fe2f009..66454d4ff9 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,24 +1,136 @@ { "version": 1, "result": "passed", - "base_sha": "2d6a10ab59d327b461ef762ec69a9c5d0440929a", - "verified_at": "2026-10-03T11:37:53.905511Z", - "changed_paths": [ - "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx", - "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts", - "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", - "frontend-modern/src/utils/alertDestinationsPresentation.ts" - ], + "verified_at": "2026-10-03T14:13:34.794Z", "content_sha256": { - "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "cac90b57e94b42360989262b550c9b0beaca8dd6eecdd7f7882911bb49de9fc2", - "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "e8ce66a9ca6ae82861a9d03a7d565f6c246f9b5fc547e7c2e64f0a3e6bd12014", - "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "eaf519a7f205be0b0e88ea17b19552f30afef025251262a1ea1b8896442e2673", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "9278ab7cf249eea451040b722b244d1447b05c07557d641187fa51f3bc88a225", - "frontend-modern/src/utils/alertDestinationsPresentation.ts": "5afa5dbce8eb1602377b7b27f6298438da76e3ebf2f80986677c9988bf36c96c" + "frontend-modern/src/utils/workloadGuestPresentation.ts": "bd8ca98831f1da1dfb1b9fd38019940a86626be38e52977d06af8c9a5d611a71", + "frontend-modern/src/components/Workloads/GuestRow.tsx": "5b48d53997488fc88c8735f959a325fc242992acba8d4bc7f1f51169ef8398b5", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "f68f8843a64b120d535f6557ef039a246dd65eb2a30d6e4abef223d5dd086de2", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "f6bb9a340a77fe749b4a5d0921055397815068008259f5ab9bded7172d750235", + "frontend-modern/src/components/Workloads/DiskList.tsx": "b80ffe047b61760061f95a9adc85c3cd3e0cf68674214a64d1d911681f81b240", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "8f5d8ff247f1f9ed3077e20b55d0dca47994ff7f571d90463fc8b7ee68237bcc" }, + "playwright_version": "1.56.1", + "results": [ + { + "name": "desktop", + "browser_version": "141.0.7390.37", + "viewport": { + "width": 1365, + "height": 900 + }, + "checks": [ + "vm-locked: retained row, tooltip, drawer, list and identity", + "vm-locked: no previous disk values and no fresh percentage", + "lock-unverified: retained row, tooltip, drawer, list and identity", + "lock-unverified: no previous disk values and no fresh percentage", + "agent-busy: retained row, tooltip, drawer, list and identity", + "agent-busy: no previous disk values and no fresh percentage", + "agent-cooldown: retained row, tooltip, drawer, list and identity", + "agent-cooldown: no previous disk values and no fresh percentage", + "agent-response-incomplete: retained row, tooltip, drawer, list and identity", + "agent-response-incomplete: no previous disk values and no fresh percentage", + "agent-capacity: retained row, tooltip, drawer, list and identity", + "agent-capacity: no previous disk values and no fresh percentage", + "invalid-guest-key: retained row, tooltip, drawer, list and identity", + "invalid-guest-key: no previous disk values and no fresh percentage", + "agent-timeout: retained row, tooltip, drawer, list and identity", + "agent-timeout: no previous disk values and no fresh percentage", + "permission-denied: existing unavailable case remains distinct", + "agent-disabled: existing unavailable case remains distinct", + "agent-not-running: existing unavailable case remains distinct", + "no-data: existing unavailable case remains distinct", + "vm-stopped: existing unavailable case remains distinct", + "no-status: existing unavailable case remains distinct", + "agent-error: existing unavailable case remains distinct", + "same-VM fresh resumption removes notice and updates values in both row modes without remount" + ], + "page_errors": [] + }, + { + "name": "phone", + "browser_version": "26.0", + "viewport": { + "width": 390, + "height": 844 + }, + "checks": [ + "vm-locked: retained row, tooltip, drawer, list and identity", + "vm-locked: no previous disk values and no fresh percentage", + "lock-unverified: retained row, tooltip, drawer, list and identity", + "lock-unverified: no previous disk values and no fresh percentage", + "agent-busy: retained row, tooltip, drawer, list and identity", + "agent-busy: no previous disk values and no fresh percentage", + "agent-cooldown: retained row, tooltip, drawer, list and identity", + "agent-cooldown: no previous disk values and no fresh percentage", + "agent-response-incomplete: retained row, tooltip, drawer, list and identity", + "agent-response-incomplete: no previous disk values and no fresh percentage", + "agent-capacity: retained row, tooltip, drawer, list and identity", + "agent-capacity: no previous disk values and no fresh percentage", + "invalid-guest-key: retained row, tooltip, drawer, list and identity", + "invalid-guest-key: no previous disk values and no fresh percentage", + "agent-timeout: retained row, tooltip, drawer, list and identity", + "agent-timeout: no previous disk values and no fresh percentage", + "permission-denied: existing unavailable case remains distinct", + "agent-disabled: existing unavailable case remains distinct", + "agent-not-running: existing unavailable case remains distinct", + "no-data: existing unavailable case remains distinct", + "vm-stopped: existing unavailable case remains distinct", + "no-status: existing unavailable case remains distinct", + "agent-error: existing unavailable case remains distinct", + "same-VM fresh resumption removes notice and updates values in both row modes without remount" + ], + "page_errors": [] + } + ], + "screenshots": [ + { + "path": "tmp/guest-disk-deferral-proof/desktop-retained-backup.png", + "sha256": "6c10970b4c64a784e088067e08fec003a0cf7ca5ef8617b9d91c5a2196bac7ba" + }, + { + "path": "tmp/guest-disk-deferral-proof/desktop-uncertain-timeout.png", + "sha256": "f57806ae4c631f1acdb12d466457876ab3e0c94d706bf34fb685ab1254786d1c" + }, + { + "path": "tmp/guest-disk-deferral-proof/desktop-fresh-resumption.png", + "sha256": "770cd0c083a92263ff834befc594ea4caf7c1a0849534655f3d2853b7ccd1bb6" + }, + { + "path": "tmp/guest-disk-deferral-proof/phone-retained-backup.png", + "sha256": "c79fea8c81dd71f32fcb578725e5bf9ee5ef75e9a8ca4117ab39f67f3b0a753b" + }, + { + "path": "tmp/guest-disk-deferral-proof/phone-uncertain-timeout.png", + "sha256": "b77012b0d47357f602e5ecd770437b75359944e79ac0c5fcb6930a350293de68" + }, + { + "path": "tmp/guest-disk-deferral-proof/phone-fresh-resumption.png", + "sha256": "6bc91549730fb30f64f2ff2f2a0f5d5e3fd8a29147fa50081907da78bc4fa339" + } + ], + "limitation": "Synthetic same-VM observations through production components and CSS; no native QGA/backup, History sample, installation or release acceptance.", + "base_sha": "d4da7ffefdf91db52043aa2a5b5d1c3e16d13ca9", + "changed_paths": [ + "frontend-modern/src/utils/workloadGuestPresentation.ts", + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/components/Workloads/DiskList.tsx", + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx" + ], "routes": [ - "Existing Alerts Notifications tab through production DestinationsTab, destination state, notification/alert API adapters and activity card, under synthetic read-only responses" + "Existing workload row and guest Overview filesystem evidence, shared disk breakdown tooltip/list; synthetic same-VM props through production components, not a new product route" + ], + "states": [ + "Eight fixed lock/uncertain/request/capacity/identity deferral reasons with retained and no previous disk data", + "Seven distinct permission/disabled/unavailable cases", + "Same-VM fresh 75 percent resumption removes notices without row remount" + ], + "interactions": [ + "Real desktop hover breakdown with readable freshness context", + "Desktop click and phone touch open the existing row disclosure into production Overview", + "Same-VM observation replacement, bars/sparklines and fresh resumption with no guest commands" ], "viewports": [ { @@ -29,61 +141,5 @@ "width": 390, "height": 844 } - ], - "states": [ - "Initial attempt and held-event reads pending without an empty claim", - "Successful attempts do not wait for held events; refresh remains usable", - "Both completed empty reads establish the empty window", - "Independent readable attempts and held-event rows", - "Held-event HTTP403 clears its rows and warns, without hiding attempt evidence", - "Pending held retry retains the warning until success", - "Attempt HTTP503 clears only attempt evidence, preserving readable held rows", - "Both failures warn without empty claims; successful reads clear warnings" - ], - "interactions": [ - "Desktop keyboard Enter and phone touch refresh through the real existing Notifications tab", - "Partial failure, pending retry and recovery via synthetic API replies; no mutation" - ], - "browsers": [ - { - "engine": "desktop", - "version": "141.0.7390.37" - }, - { - "engine": "phone", - "version": "26.0" - } - ], - "playwright_version": "1.56.1", - "checks": 20, - "screenshots": [ - { - "path": "tmp/notification-evidence-proof/desktop-held-pending.png", - "sha256": "58ddaf1dae1e797e6f938c3e74b705fe75276eaf560de307fe7bfe1644c2df21" - }, - { - "path": "tmp/notification-evidence-proof/desktop-held-unavailable.png", - "sha256": "fe343a2b7eb67ae1283e4eb411462073e3db26d36d226ef62d9fd49cf9a095c7" - }, - { - "path": "tmp/notification-evidence-proof/desktop-attempts-unavailable.png", - "sha256": "a4ae26cc87b0bbf91b4dc3150f375a10c6701201deed4eaa3bdfbfce08ade4de" - }, - { - "path": "tmp/notification-evidence-proof/phone-held-pending.png", - "sha256": "66262c32b745e68f32aba48db19dee2a03c7c62d763efd765ed4da154ec9e00f" - }, - { - "path": "tmp/notification-evidence-proof/phone-held-unavailable.png", - "sha256": "534cd02e8b5392bd5e73d7f4a7a3d8e2a78596a230d055daef4479ecf0d1fdbc" - }, - { - "path": "tmp/notification-evidence-proof/phone-attempts-unavailable.png", - "sha256": "09652b8023be5e527712d42384e9fee8e114575026aefaf703e62cfa30bc0884" - } - ], - "proof_script": "frontend-modern/browser-tests/notification-evidence.cjs", - "limits": [ - "Synthetic source/UI evidence only, not notification receipt, native provider delivery, release availability or dependency advisory clearance" ] } diff --git a/frontend-modern/src/components/Workloads/DiskList.tsx b/frontend-modern/src/components/Workloads/DiskList.tsx index 88afbf99c2..6d8fb6a4f2 100644 --- a/frontend-modern/src/components/Workloads/DiskList.tsx +++ b/frontend-modern/src/components/Workloads/DiskList.tsx @@ -15,6 +15,9 @@ export function DiskList(props: DiskListProps) { } >
    + +

    {diskStatusTooltip()}

    +
    {(disk) => { return ( diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index c7d95a9fae..2d843f3a35 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -13,7 +13,10 @@ import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/Availa import type { DiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { formatBytes } from '@/utils/format'; import type { MetricDisplayThresholds } from '@/utils/metricThresholds'; -import { getWorkloadsGuestProtectionPresentation } from '@/utils/workloadGuestPresentation'; +import { + getWorkloadGuestDiskStatusMessage, + getWorkloadsGuestProtectionPresentation, +} from '@/utils/workloadGuestPresentation'; import { AvailabilityProbeSuggestionCard } from './AvailabilityProbeSuggestionCard'; import { buildWorkloadsDiskPresentation } from './diskListModel'; @@ -119,6 +122,10 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { const diskRows = (props.guest.disks ?? []).map((disk, index) => buildWorkloadsDiskPresentation(disk, index, props.diskThresholds), ); + const diskStatusMessage = + isGuestDrawerVM(props.guest) && props.guest.diskStatusReason + ? getWorkloadGuestDiskStatusMessage(props.guest.diskStatusReason) + : null; return compactDetailSections([ discovery @@ -187,7 +194,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { .map((row) => makeDetailRow(row.label, row.value)), ), }, - props.hasFilesystemDetails && diskRows.length > 0 + (props.hasFilesystemDetails && diskRows.length > 0) || diskStatusMessage ? { label: 'Filesystems', rows: compactDetailRows([ @@ -212,11 +219,7 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { }, ), ), - makeDetailRow( - 'Status', - isGuestDrawerVM(props.guest) ? props.guest.diskStatusReason : null, - { wrap: true }, - ), + makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true }), ]), } : null, diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 299d9e6875..776bd256d3 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -553,7 +553,13 @@ export function GuestRow(props: GuestRowProps) { {/* Disk */} - + {renderMetricSparkline( 'disk', @@ -585,6 +591,11 @@ export function GuestRow(props: GuestRowProps) { aggregateDisk={props.guest.disk} anomaly={diskAnomaly()} thresholds={diskThresholds()} + statusMessage={ + isVM(props.guest) && props.guest.diskStatusReason + ? getDiskStatusTooltip() + : undefined + } /> } > diff --git a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx index 1ae9b1098d..74cb02dbec 100644 --- a/frontend-modern/src/components/Workloads/StackedDiskBar.tsx +++ b/frontend-modern/src/components/Workloads/StackedDiskBar.tsx @@ -255,6 +255,9 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
    {presentation().tooltipTitle}
    + +

    {props.statusMessage}

    +
    {(item, idx) => (
    = {}): Disk { return { @@ -46,7 +48,10 @@ describe('DiskList', () => { 'agent-not-running', 'Guest agent not running. Install and start qemu-guest-agent in the VM.', ], - ['agent-timeout', 'Guest agent timeout. Agent may need to be restarted.'], + [ + 'agent-timeout', + 'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.', + ], [ 'permission-denied', 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).', @@ -59,6 +64,11 @@ describe('DiskList', () => { ], ['agent-error', 'Error communicating with guest agent.'], ['no-data', 'No disk data available from Proxmox API.'], + ['vm-stopped', 'Guest filesystem stats unavailable while the VM is stopped.'], + [ + 'no-status', + 'Guest filesystem stats unavailable because Pulse could not read the VM status from Proxmox.', + ], [ 'prev-no-filesystems', 'Using last known disk stats. No filesystems found. VM may be booting or using a Live ISO.', @@ -78,6 +88,30 @@ describe('DiskList', () => { }); describe('rendering disks', () => { + it.each(guestDiskDeferrals)( + 'keeps retained values and explains %s until fresh data resumes', + (reason, message) => { + const [disks, setDisks] = createSignal([makeDisk()]); + const [status, setStatus] = createSignal(`prev-${reason}`); + render(() => ); + expect(screen.getByText(`Using last known disk stats. ${message}`)).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + setDisks([makeDisk({ used: 80530636800, usage: 75 })]); + setStatus(undefined); + expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument(); + expect(screen.getByText('75%')).toBeInTheDocument(); + }, + ); + + it.each(guestDiskDeferrals)( + 'explains %s without claiming previous values when none exist', + (reason, message) => { + render(() => ); + expect(screen.getByText('-')).toHaveAttribute('title', message); + expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument(); + }, + ); + it('renders a single disk with correct label, usage, and type', () => { const disk = makeDisk({ mountpoint: '/data', type: 'xfs' }); render(() => ); diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx index 38e5e81bce..bb27528599 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx @@ -1,7 +1,8 @@ import { cleanup, render, screen } from '@solidjs/testing-library'; -import type { ComponentProps } from 'solid-js'; +import { createSignal, type ComponentProps } from 'solid-js'; import { afterEach, describe, expect, it } from 'vitest'; import { GuestDrawerOverview } from '../GuestDrawerOverview'; +import { guestDiskDeferrals } from '../__fixtures__/guestDiskDeferrals'; const paths = ['/mnt/Plex/Media/Animation/Films', '/mnt/Plex/Media/Animation/Series']; const props: ComponentProps = { @@ -55,6 +56,47 @@ const props: ComponentProps = { describe('GuestDrawerOverview filesystem labels', () => { afterEach(cleanup); + it.each(guestDiskDeferrals)( + 'explains %s in the existing drawer even before any disk sample', + (reason, message) => { + render(() => ( + + )); + expect(screen.getByText('Filesystems')).toBeInTheDocument(); + expect(screen.getByText(message)).toBeInTheDocument(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument(); + }, + ); + + it('retains disk rows with readable status, then withdraws the notice on fresh resumption', () => { + const [guest, setGuest] = createSignal({ + ...props.guest, + type: 'qemu' as const, + diskStatusReason: 'prev-vm-locked', + }); + render(() => ); + expect( + screen.getByText(/^Using last known disk stats\. Guest reads paused/).closest('td'), + ).toHaveAttribute('colspan', '2'); + expect(screen.queryByText('prev-vm-locked')).not.toBeInTheDocument(); + for (const path of paths) expect(screen.getByText(path)).toBeInTheDocument(); + setGuest({ + ...guest(), + diskStatusReason: '', + disks: guest().disks!.map((disk) => ({ ...disk, usage: 75 })), + }); + expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument(); + expect(screen.queryByText('Status')).not.toBeInTheDocument(); + expect( + screen.getByRole('progressbar', { name: `Filesystem ${paths[0]} utilization` }), + ).toHaveAttribute('aria-valuenow', '75'); + }); + it('shows linked-agent RAID arrays beside the guest storage evidence', () => { render(() => ( ({ })); vi.mock('../StackedDiskBar', () => ({ - StackedDiskBar: () =>
    , + StackedDiskBar: (props: { statusMessage?: string }) => ( +
    + ), })); vi.mock('../StackedMemoryBar', () => ({ @@ -668,6 +671,39 @@ describe('GuestRow', () => { }); describe('lock label', () => { + it.each(guestDiskDeferrals)( + 'annotates retained row and disk tooltip for %s', + (reason, message) => { + const { container } = renderGuestRow({ + guest: makeGuest({ diskStatusReason: `prev-${reason}` }), + }); + const expected = `Using last known disk stats. ${message}`; + expect(container.querySelector('[data-workload-col="disk"]')).toHaveAttribute( + 'title', + expected, + ); + expect(screen.getByTestId('disk-bar')).toHaveAttribute('data-status-message', expected); + }, + ); + + it('clears retained status for a same-VM fresh update in sparkline mode without remounting', () => { + const [guest, setGuest] = createSignal( + makeGuest({ diskStatusReason: 'prev-agent-cooldown' }), + ); + const { container } = render(() => ( + + + + +
    + )); + const cell = container.querySelector('[data-workload-col="disk"]'); + expect(cell).toHaveAttribute('title', expect.stringContaining('Using last known')); + setGuest(makeGuest({ diskStatusReason: undefined, disk: makeDisk({ usage: 75 }) })); + expect(container.querySelector('[data-workload-col="disk"]')).toBe(cell); + expect(cell).not.toHaveAttribute('title'); + }); + it('shows lock label when guest is locked', () => { renderGuestRow({ guest: makeGuest({ lock: 'migrate' }) }); expect(screen.getByText(/Lock:.*migrate/)).toBeTruthy(); diff --git a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx index 391101ac73..9cd200c640 100644 --- a/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/StackedDiskBar.test.tsx @@ -343,6 +343,17 @@ describe('StackedDiskBar', () => { // โ”€โ”€ Tooltip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ describe('tooltip', () => { + it('includes caller-owned freshness context beside unchanged disk evidence', async () => { + const message = 'Using last known disk stats. Guest reads paused while a backup is running.'; + const { container } = render(() => ( + + )); + await fireEvent.mouseEnter(getBarTrigger(container)); + expect(screen.getByText(message)).toBeInTheDocument(); + expect(screen.getByText('/')).toBeInTheDocument(); + expect(screen.getByText('Disk Usage')).toBeInTheDocument(); + }); + it('shows tooltip on mouse enter with disk details', async () => { const disk = makeDisk({ used: 53687091200, diff --git a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts index ed72f85037..3dbf3531b6 100644 --- a/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts +++ b/frontend-modern/src/components/Workloads/stackedDiskBarModel.ts @@ -18,6 +18,7 @@ export interface StackedDiskBarProps { showDiskCount?: boolean; anomaly?: AnomalyReport | null; thresholds?: MetricDisplayThresholds | null; + statusMessage?: string; } export interface StackedDiskSegment { diff --git a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts index 7c7c0bd422..d7c8fbde94 100644 --- a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals'; import { getWorkloadsGuestBackupStatusPresentation, getWorkloadsGuestBackupTooltip, @@ -8,6 +9,18 @@ import { } from '@/utils/workloadGuestPresentation'; describe('workloadGuestPresentation', () => { + it.each(guestDiskDeferrals)( + 'explains %s with and without retained disk evidence', + (reason, message) => { + expect(getWorkloadGuestDiskStatusMessage(reason)).toBe(message); + expect(getWorkloadGuestDiskStatusMessage(`prev-${reason}`)).toBe( + `Using last known disk stats. ${message}`, + ); + expect(message).not.toContain('may not be installed'); + expect(message).not.toContain('may need to be restarted'); + }, + ); + it('returns canonical guest backup status presentation', () => { expect(getWorkloadsGuestBackupStatusPresentation('fresh')).toEqual({ color: 'text-green-600 dark:text-green-400', diff --git a/frontend-modern/src/utils/workloadGuestPresentation.ts b/frontend-modern/src/utils/workloadGuestPresentation.ts index 7b68c92052..62c1ebead9 100644 --- a/frontend-modern/src/utils/workloadGuestPresentation.ts +++ b/frontend-modern/src/utils/workloadGuestPresentation.ts @@ -103,7 +103,21 @@ export function getWorkloadGuestDiskStatusMessage(reason?: string): string { case 'agent-not-running': return 'Guest agent not running. Install and start qemu-guest-agent in the VM.'; case 'agent-timeout': - return 'Guest agent timeout. Agent may need to be restarted.'; + return 'Guest request timed out. Completion is uncertain. Do not restart the guest agent during a backup.'; + case 'vm-locked': + return 'Guest reads paused while Proxmox reports a VM operation lock, such as a backup. Pulse will check again on a later poll.'; + case 'lock-unverified': + return 'Guest reads deferred because Pulse cannot verify that the VM is unlocked. Pulse will check again on a later poll.'; + case 'agent-busy': + return 'Guest reads deferred while an earlier guest request is still in progress. Pulse will check again on a later poll.'; + case 'agent-cooldown': + return 'Guest reads paused after an earlier request did not complete reliably. Pulse will check again after the cooldown.'; + case 'agent-response-incomplete': + return 'Guest reads paused because the previous response was incomplete. Pulse will check again on a later poll.'; + case 'agent-capacity': + return 'Guest reads deferred because Pulse has reached its guest-read capacity. Pulse will check again on a later poll.'; + case 'invalid-guest-key': + return 'Guest reads unavailable because the VM identity is invalid.'; case 'permission-denied': return 'Permission denied. Check that your Pulse user/token has VM.Monitor permission (PVE 8) or VM.GuestAgent.Audit permission (PVE 9).'; case 'agent-disabled': @@ -116,6 +130,10 @@ export function getWorkloadGuestDiskStatusMessage(reason?: string): string { return 'Error communicating with guest agent.'; case 'no-data': return 'No disk data available from Proxmox API.'; + case 'vm-stopped': + return 'Guest filesystem stats unavailable while the VM is stopped.'; + case 'no-status': + return 'Guest filesystem stats unavailable because Pulse could not read the VM status from Proxmox.'; default: return 'Disk stats unavailable. Guest agent may not be installed.'; } From a355525d57f0e0ac4badfda1d014dee156313ebb Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:22:59 +0100 Subject: [PATCH 56/86] Keep fresh drawer test bytes consistent with its percentage The shared filesystem presenter derives utilisation from used/total bytes. Set the fresh control to consistent 75 percent bytes as the browser already does; changing only usage left the test fixture at 50 percent. Runtime and parent-bound browser content are unchanged. Retain the already requested predecessor source proof separately. Change-source: pulse-maintainer --- .../__tests__/GuestDrawerOverview.filesystems.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx index bb27528599..e2cb4f96fb 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestDrawerOverview.filesystems.test.tsx @@ -88,7 +88,7 @@ describe('GuestDrawerOverview filesystem labels', () => { setGuest({ ...guest(), diskStatusReason: '', - disks: guest().disks!.map((disk) => ({ ...disk, usage: 75 })), + disks: guest().disks!.map((disk) => ({ ...disk, used: (disk.total ?? 0) * 0.75, usage: 75 })), }); expect(screen.queryByText(/Using last known/)).not.toBeInTheDocument(); expect(screen.queryByText('Status')).not.toBeInTheDocument(); From 6725576c7e9c89ec1c824571e6186fea5cb80f41 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:32:23 +0100 Subject: [PATCH 57/86] Allow full-width guest freshness explanations in drawer ownership guard The full frontend run found its old global stacked-row count rejected the visually verified status explanation. Keep explicit filesystem/status placement and canonical drawer ownership, instead of coupling the guard to an unrelated global count. Mounted and browser controls still require readable status alongside retained data. No runtime or browser content changes. Change-source: pulse-maintainer --- .../WorkloadsSurface.performance.contract.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 2bd43bdb70..a621d42700 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -1661,7 +1661,11 @@ describe('Workloads performance contract', () => { ); expect(filesystemSectionSource).toContain("layout: 'stacked'"); expect(filesystemSectionSource).toContain('wrap: true'); - expect(guestDrawerOverviewSource.match(/layout: 'stacked'/g)).toHaveLength(1); + // Both filesystem paths and their longer freshness explanation need + // the card's full width. Do not couple that layout to a global count. + expect(guestDrawerOverviewSource).toContain( + "makeDetailRow('Status', diskStatusMessage, { layout: 'stacked', wrap: true })", + ); expect(guestDrawerOverviewSource).toContain('TechnicalDetailsSection'); expect(nodeDrawerOverviewSource).toContain('TechnicalDetailsSection'); From 73d2c64e0f5bce57f3a1dc6f9e055b4784e079bd Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:52:53 +0100 Subject: [PATCH 58/86] Make configuration migration warnings match the actual backup Separate configuration transfer from complete stopped-state recovery, correct omitted platform and agent-state claims, and preserve safe credential/retargeting guidance. Validate the real encrypted archive and production dialogs with scoped tests and desktop/phone browser interactions. No new backup capability or runtime contract change. Contract-Neutral: The archive, persistence, authentication and agent contracts are unchanged; help and visible warnings now describe existing scope, with regression proof for that account. Change-source: pulse-maintainer --- docs/MIGRATION.md | 174 +++++++++++------- docs/UNIFIED_AGENT.md | 10 +- .../browser-tests/configuration-migration.cjs | 140 ++++++++++++++ .../configuration-migration.html | 12 ++ .../browser-tests/configuration-migration.tsx | 32 ++++ frontend-modern/browser-verification.json | 86 +++++---- frontend-modern/public/docs/MIGRATION.md | 174 +++++++++++------- frontend-modern/public/docs/UNIFIED_AGENT.md | 10 +- .../Settings/BackupTransferDialogs.tsx | 38 +++- .../__tests__/BackupTransferDialogs.test.tsx | 87 +++++---- .../src/utils/__tests__/docsLinks.test.ts | 25 +++ internal/config/migration_scope_test.go | 118 ++++++++++++ 12 files changed, 686 insertions(+), 220 deletions(-) create mode 100644 frontend-modern/browser-tests/configuration-migration.cjs create mode 100644 frontend-modern/browser-tests/configuration-migration.html create mode 100644 frontend-modern/browser-tests/configuration-migration.tsx create mode 100644 internal/config/migration_scope_test.go diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 58de776f83..0b3398133a 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,88 +1,122 @@ -# ๐Ÿšš Migrating Pulse +# Migrating Pulse -This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow. +**Create Backup** in **Settings โ†’ System โ†’ Recovery** creates an encrypted +configuration export, not a full backup of the installation. Check its scope +before moving Pulse or retiring the old server. Do not use it alone when you +need to preserve history, agent enrolment state or settings outside that scope. -## ๐Ÿš€ Quick Migration Guide +## Choose the backup you need -### โŒ DON'T: Copy Files -Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break. +### Configuration transfer -### โœ… DO: Use Export/Import +Use export/import to move the included settings between installations. Pulse +decrypts the export with its passphrase and saves the imported credentials with +the destination's encryption key. Do not copy individual encrypted files into +a fresh installation with a different key. -#### 1. Export (Old Server) -1. Go to **Settings โ†’ System โ†’ Recovery**. -2. Click **Create Backup**. -3. Enter a strong passphrase and download the encrypted backup. +The current export format includes: -#### 2. Import (New Server) -1. Install a fresh Pulse instance. -2. Go to **Settings โ†’ System โ†’ Recovery**. -3. Click **Restore Configuration** and upload your file. -4. Enter the passphrase. - -## ๐Ÿ“ฆ What Gets Migrated - -| Included โœ… | Not Included โŒ | +| Included | Not included | | :--- | :--- | -| Nodes & credentials | Historical metrics history (`metrics.db`) | -| Alerts & overrides | Browser sessions and local cookies | -| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) | -| System settings (`system.json`) | Update history/backup folders | -| API token records | โ€” | -| OIDC config | โ€” | -| SSO / SAML config | โ€” | -| TrueNAS connections (`truenas.enc`) | โ€” | -| Guest metadata/notes | โ€” | -| โ€” | Relay config (`relay.enc`) โ€” re-enable in Settings | -| โ€” | Host metadata (notes/tags/AI command overrides) | -| โ€” | Docker metadata cache | -| โ€” | Agent profiles and assignments | -| โ€” | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) | -| โ€” | RBAC roles (`rbac_roles.json`) โ€” re-create after import | -| โ€” | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) | -| โ€” | Server sessions (`sessions.json`) | -| โ€” | Update history (`update-history.jsonl`) | +| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections | +| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history | +| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules | +| System settings | Local login credentials, environment overrides and sessions | +| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state | +| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles | +| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments | +| โ€” | AI settings, findings, usage and chat history | +| โ€” | Licence files, existing Relay configuration, binaries, service definitions and update backups | -## ๐Ÿ”„ Common Scenarios +Contents depend on the version that created the export. Older formats can omit +settings included above. Imported API-token records retain their scopes and +expiry; they do not reveal the original tokens or transfer every agent's state. +SSO configuration **is included**, even though local login credentials are not. -### Moving to New Hardware -Export from old โ†’ Install new โ†’ Import. +### Full-state recovery -### Docker โ†” Systemd โ†” Kubernetes -The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly. +If you need the same installation's history and excluded state, retain a +consistent filesystem/volume backup as well. Stop Pulse before taking that +backup; monitoring and alert delivery pause while it is stopped. Preserve every +effective data path, the matching `.encryption.key`, audit data and signing key, +environment files, external certificates, binaries and service/container +configuration together. Custom paths and organisation directories matter. +Copying a live database file alone can omit its sidecar files. -### Disaster Recovery -1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)). -2. Import your latest backup. -3. Restored in < 5 minutes. +Keep that backup private and test recovery on an isolated destination before +retiring the source. Do not mix encrypted files and keys from different +installations or restore over a running Pulse. See [configuration +paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage). +The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains) +is a third, limited backup; it is not a substitute for either procedure above. -## ๐Ÿ“‹ Post-Migration Checklist +## Transfer the included configuration -Because local login credentials are stored in `.env` (not part of exports), you must: +1. On the old server, sign in and open **Settings โ†’ System โ†’ Recovery โ†’ + Create Backup**. Use a strong, unique passphrase of at least 12 characters + and store it separately from the downloaded file. With password login, + choose **Use a custom passphrase** if you do not want to use that password. + Keep the old instance and your separate full-state backup until validation + is complete. +2. Install Pulse at the destination using the [installation guide](INSTALL.md). + Configure destination-local administrator access before importing. Keep the + destination isolated from agents and notification destinations during the + trial so two servers do not send duplicate alerts or remote actions. +3. If the destination already has useful settings, export them first. Open + **Settings โ†’ System โ†’ Recovery โ†’ Restore Configuration**, select the file + and enter its passphrase. Import replaces the included configuration and + API-token records; it does not merge them with the destination's records. +4. Check the result before changing DNS or agent addresses. A success message + establishes neither full recovery nor fresh monitoring. If import reports a + reload/apply failure, settings may already have been written: preserve both + installations and inspect the error before repeating it. -1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance. -2. **Confirm API access**: - * If you created API tokens in the UI, those token records are included in the export and should continue working. -3. **Update Agents**: - * **Unified Agent**: Update the `--token` flag in your service definition. - * **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment. - * **New server address?** Agents dial the address they were installed - with (the `--url` flag, or `PULSE_URL` for containerized agents), so - agents disappear from the new instance until that value is updated in - each agent's service definition. Fronting Pulse with a DNS name - instead of an IP address makes future moves invisible to agents. - * *Tip: Use **Settings โ†’ Infrastructure โ†’ Install on a host** to generate updated install commands.* -4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports). +Keep the archive, passphrase, configuration files and credentials out of issue +threads. Use the authenticated settings form rather than a command containing +a password, API token or session cookie. -## ๐Ÿ”’ Security +## Cut over and verify -* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM). -* **Storage**: Safe to store in cloud backups or password managers. -* **Passphrase**: Use a strong, unique passphrase (min 12 chars). +- **Login and permissions:** verify destination-local administrator access and + any imported SSO provider, group mappings and callback address. Re-create + custom roles and supply external CA/certificate files if needed. Environment + overrides can take precedence over imported settings. +- **Connections and history:** check every expected platform, guest and backup + against a fresh observation, not a cached row. Re-create excluded TrueNAS, + vSphere and Machine Availability connections. Missing historical data after + configuration-only transfer is not proof that collection has failed. +- **Agents:** preserve each agent's existing identity and private credential. + With a changed server address, use the [agent retargeting + guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host. + Do not create another token just for a server move or paste it into `--token`. + Confirm fresh reports and any enrolment/authorisation result; imported token + records alone do not prove that every agent is admitted. Re-create excluded + profiles and assignments before enabling remote actions. +- **Alerts and notifications:** avoid running both instances against the same + destinations. After cutover, check thresholds, schedules and one controlled + notification test with its actual delivery result. Importing destination + settings does not migrate pending notifications or incident history. +- **Licence and remaining settings:** re-activate Pro if required and verify + any excluded settings you rely on. Existing paired phones need their retained + configuration; configuration export alone is not a Mobile/Relay migration. + Relay is no longer sold; do not enable it as a new migration route. -## ๐Ÿ”ง Troubleshooting +Retire the old installation only after these checks succeed. Keep the private, +consistent pre-move backup for recovery. If validation fails, stop the trial +destination before returning traffic to the intact source; check agent URLs +and avoid two active writers. There is no guaranteed five-minute recovery time. -* **"Invalid passphrase"**: Ensure exact match (case-sensitive). -* **Missing Nodes**: Verify export date. -* **Connection Errors**: Update node IPs in Settings if they changed. -* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed. +## Troubleshooting + +- **Wrong passphrase or rejected file:** use the original export file and its + exact passphrase. Do not delete destination data or regenerate encryption + keys to make an import succeed. +- **A setting is missing:** compare it with the export scope above and the + exporting version. Use the old installation or full-state backup rather + than assuming import contained it. +- **Agents are absent:** check their primary URL, TLS trust and fresh admission + evidence using the linked guide. Do not uninstall, re-enrol or delete saved + identities just because the dashboard is empty. +- **Need help:** retain the source/destination versions, deployment types, + operation time and exact redacted error. Follow [safe + reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup. diff --git a/docs/UNIFIED_AGENT.md b/docs/UNIFIED_AGENT.md index adf8371792..405e50271d 100644 --- a/docs/UNIFIED_AGENT.md +++ b/docs/UNIFIED_AGENT.md @@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse. ### Moving Pulse to a new address -Configuration export/import restores the server-side agent records and API -tokens. It cannot rewrite the primary Pulse URL on remote machines because -agents initiate the connection. Prefer a stable DNS name for the primary URL -so replacing the Pulse host does not require an agent migration. +Configuration export/import restores API-token records, not the server-side +agent inventory, enrolment state, profiles or assignments. See the +[configuration-transfer scope](MIGRATION.md#configuration-transfer) before +retiring the old server. Import cannot rewrite the primary Pulse URL on remote +machines because agents initiate the connection. Prefer a stable DNS name for +the primary URL, but still verify fresh reports and agent admission after a move. After importing the configuration on a Pulse server with a different address, retarget each existing standard Linux agent from that agent machine. Download diff --git a/frontend-modern/browser-tests/configuration-migration.cjs b/frontend-modern/browser-tests/configuration-migration.cjs new file mode 100644 index 0000000000..448ff8c78e --- /dev/null +++ b/frontend-modern/browser-tests/configuration-migration.cjs @@ -0,0 +1,140 @@ +// Run from the assigned root with pulse-worker-browser. This checks the real +// dialog/flow and shipped guides, not a native migration or release delivery. +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const workspace = process.cwd(); + const root = path.join(workspace, 'frontend-modern'); + const output = path.join(workspace, 'tmp', 'configuration-migration'); + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + plugins: [{ name: 'migration-doc-harness', configureServer(server) { + server.middlewares.use((req, _res, next) => { + if (/^\/docs\/[^?]+$/.test(req.url || '') && !req.url.endsWith('.md')) + req.url = '/browser-tests/configuration-migration.html'; + next(); + }); + } }], + server: { host: '127.0.0.1', port: 5264, strictPort: true }, + }); + const observations = []; + let browser; + try { + await server.listen(); + for (const [engine, width, height] of [['chromium', 1440, 1000], ['webkit', 390, 844]]) { + browser = await (engine === 'chromium' ? chromium : webkit).launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const context = await browser.newContext({ viewport: { width, height }, acceptDownloads: true, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = [], operations = []; + await context.route('**/api/**', async (route) => { + const req = route.request(); + if (req.url().endsWith('/api/config/export')) { + operations.push({ path: '/api/config/export', method: req.method(), body: req.postDataJSON() }); + await route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ status: 'success', data: 'synthetic-archive-not-a-real-backup' }) }); + } else if (req.url().endsWith('/api/config/import')) { + operations.push({ path: '/api/config/import', method: req.method(), body: req.postDataJSON() }); + // Deliberately reject this synthetic archive: warning, error and + // cancel paths must remain available without pretending to migrate. + await route.fulfill({ status: 400, contentType: 'text/plain', body: 'Synthetic rejected archive' }); + } else await route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ hasAuthentication: false }) }); + }); + context.on('page', (p) => p.on('pageerror', (err) => errors.push(err.message))); + const page = await context.newPage(); + await page.goto('http://127.0.0.1:5264/browser-tests/configuration-migration.html'); + if (engine === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('button', { name: 'Create Backup', exact: true }).click(); + const exporting = page.getByRole('dialog', { name: 'Export configuration', exact: true }); + await exporting.waitFor(); + await exporting.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); + let text = await exporting.innerText(); + for (const phrase of ['Configuration only:', 'SSO settings', 'API-token records', + 'not history, TrueNAS/vSphere connections', 'Local login credentials and sessions are not included']) + assert.ok(text.includes(phrase), phrase); + assert.ok(await exporting.getByRole('button', { name: 'Export', exact: true }).isDisabled()); + const guide = exporting.getByRole('link', { name: 'migration guide', exact: true }); + await guide.scrollIntoViewIfNeeded(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-export.png`) }); + await guide.focus(); + const popupPromise = page.waitForEvent('popup'); + await page.keyboard.press('Enter'); + const docs = await popupPromise; + await docs.locator('article').waitFor(); + const article = await docs.locator('article').innerText(); + for (const phrase of ['not a full backup', 'TrueNAS, vSphere and Machine Availability', + 'SSO configuration is included', 'Stop Pulse', 'does not merge', 'reload/apply failure']) + assert.ok(article.includes(phrase), phrase); + assert.ok(docs.url().endsWith('/docs/MIGRATION')); + const scope = docs.locator('#configuration-transfer'); + await scope.scrollIntoViewIfNeeded(); + assert.ok(await docs.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await docs.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-scope.png`) }); + const retarget = docs.getByRole('link', { name: 'agent retargeting guide', exact: true }); + await retarget.focus(); + await docs.keyboard.press('Enter'); + await docs.waitForFunction(() => document.activeElement?.id === 'moving-pulse-to-a-new-address'); + assert.ok((await docs.locator('article').innerText()).includes('restores API-token records, not the server-side')); + assert.ok(docs.url().endsWith('/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address')); + await docs.close(); + await exporting.getByLabel('Encryption Passphrase', { exact: true }).fill('synthetic-migration-passphrase'); + const downloadPromise = page.waitForEvent('download'); + await exporting.getByRole('button', { name: 'Export', exact: true }).click(); + const download = await downloadPromise; + assert.deepEqual(JSON.parse(fs.readFileSync(await download.path(), 'utf8')), + { status: 'success', data: 'synthetic-archive-not-a-real-backup' }); + await page.getByRole('button', { name: 'Restore Configuration', exact: true }).click(); + const importing = page.getByRole('dialog', { name: 'Import configuration', exact: true }); + await importing.waitFor(); + await importing.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); + text = await importing.innerText(); + for (const phrase of ['Back up the destination first', 'not the whole installation', + 'inventory, enrolment state, profiles and assignments are not', 'verify fresh admission']) + assert.ok(text.includes(phrase), phrase); + assert.ok(!text.includes('restores server-side agent records')); + await importing.getByRole('link', { name: 'migration guide' }).scrollIntoViewIfNeeded(); + await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-import.png`) }); + await importing.getByLabel('Backup Password', { exact: true }).fill('synthetic-migration-passphrase'); + await importing.getByLabel('Configuration File', { exact: true }).setInputFiles({ name: 'synthetic-backup.json', + mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ data: 'synthetic-archive-not-a-real-backup' })) }); + const rejectedResponse = page.waitForResponse((r) => r.url().endsWith('/api/config/import')); + await importing.getByRole('button', { name: 'Import', exact: true }).click(); + await rejectedResponse; + assert.equal(operations.length, 2); + assert.deepEqual(operations[1], { path: '/api/config/import', method: 'POST', + body: { passphrase: 'synthetic-migration-passphrase', data: 'synthetic-archive-not-a-real-backup' } }); + assert.ok(await importing.isVisible(), 'failed import retains the dialog'); + await importing.getByRole('button', { name: 'Cancel', exact: true }).click(); + await importing.waitFor({ state: 'hidden' }); + assert.equal(operations.length, 2, 'copy/navigation/cancel never send another operation'); + assert.deepEqual(errors, []); + observations.push({ engine, browserVersion: browser.version(), width, height, + warningScope: true, keyboardGuideAndRetargetLinks: true, syntheticExportAndRejectedImport: true, + noPageOverflow: true, errors }); + await browser.close(); + browser = undefined; + } + const files = ['frontend-modern/src/components/Settings/BackupTransferDialogs.tsx', + 'frontend-modern/public/docs/MIGRATION.md', 'frontend-modern/public/docs/UNIFIED_AGENT.md']; + const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, + baseSha: 'b62042798c7c9b326408dd5532d7fa330d6bb089', + scope: 'Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment', + contentSha256: Object.fromEntries(files.map((file) => [file, + createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex')])), observations }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((err) => { console.error(err); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/configuration-migration.html b/frontend-modern/browser-tests/configuration-migration.html new file mode 100644 index 0000000000..b27dd2abbd --- /dev/null +++ b/frontend-modern/browser-tests/configuration-migration.html @@ -0,0 +1,12 @@ + + + + + + Configuration transfer safety + + +
    + + + diff --git a/frontend-modern/browser-tests/configuration-migration.tsx b/frontend-modern/browser-tests/configuration-migration.tsx new file mode 100644 index 0000000000..275f2a2902 --- /dev/null +++ b/frontend-modern/browser-tests/configuration-migration.tsx @@ -0,0 +1,32 @@ +// Actual transfer dialogs and flow, plus the production Docs renderer/router. +// The browser driver supplies only synthetic API responses; no server or +// operational credentials are involved in this copy/interaction proof. +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { BackupTransferDialogs } from '../src/components/Settings/BackupTransferDialogs'; +import { useBackupTransferFlow } from '../src/components/Settings/useBackupTransferFlow'; +import Docs from '../src/pages/Docs'; +import '../src/index.css'; + +function Transfer() { + const securityStatus = () => null; + const flow = useBackupTransferFlow({ securityStatus }); + return ( +
    +

    Configuration transfer

    + + + +
    + ); +} + +render( + () => ( + + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index bd2176689c..175cc2ec76 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,52 +1,72 @@ { "version": 1, "result": "passed", - "base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57", - "verified_at": "2026-10-03T04:13:25.499572Z", + "base_sha": "b62042798c7c9b326408dd5532d7fa330d6bb089", + "verified_at": "2026-10-03T14:52:51.979841Z", "changed_paths": [ - "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts" + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx" ], "content_sha256": { - "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6" + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341" }, "routes": [ - "Existing PBS/resource detail Overview change history", - "Existing resource detail Manage action history" + "/settings/system/recovery (real transfer dialog/flow harness)", + "/docs/MIGRATION", + "/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address" + ], + "states": [ + "Configuration export: includes SSO/API-token records, excludes history/TrueNAS/vSphere/agent enrolment; local login/sessions excluded", + "Import: replaces included settings, preserves agent identity and requires fresh admission", + "Desktop light Chromium and touch phone dark WebKit warnings readable; all six final screenshots inspected" + ], + "interactions": [ + "Open Create Backup and Restore Configuration dialogs", + "Keyboard-open migration guide without losing dialog state, then follow agent retargeting guide", + "Synthetic export download through actual flow; deliberately rejected synthetic import retains dialog and Cancel makes no extra operation" ], "viewports": [ { - "width": 1365, - "height": 900 + "width": 1440, + "height": 1000 }, { "width": 390, "height": 844 } ], - "states": [ - "Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence", - "Denied filtered facets do not fall back to the unfiltered bundle", - "Retry retains denial while pending; fresh successful response restores events and clears guidance", - "First action-history HTTP 503 stays visible in Manage with retry and no false empty state", - "Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads", - "All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock" - ], - "interactions": [ - "Remount full production resource drawer with denied/transient reads", - "Activate scoped Retry with keyboard Enter", - "Apply and clear Change kind filter", - "Switch between Overview and Manage" - ], "limitations": [ - "Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.", - "401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.", - "Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.", - "Independent reader authorisation remains independent; this is not a global session revocation broadcast." - ] + "Synthetic API and dedicated real-component/router harness, not native migration, licence, agent admission or shipment", + "Final capture waits for real dialog animation; preceding state checks passed but blank transition captures did not establish visual proof" + ], + "browser_versions": [ + { + "engine": "chromium", + "browserVersion": "141.0.7390.37", + "width": 1440, + "height": 1000, + "warningScope": true, + "keyboardGuideAndRetargetLinks": true, + "syntheticExportAndRejectedImport": true, + "noPageOverflow": true, + "errors": [] + }, + { + "engine": "webkit", + "browserVersion": "26.0", + "width": 390, + "height": 844, + "warningScope": true, + "keyboardGuideAndRetargetLinks": true, + "syntheticExportAndRejectedImport": true, + "noPageOverflow": true, + "errors": [] + } + ], + "playwright_version": "1.56.1", + "shipped_asset_sha256": { + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341", + "frontend-modern/public/docs/MIGRATION.md": "ad4b5a4b07af44e1657f9512ef468a578b8b5957b9b115a30816824bbd8300e9", + "frontend-modern/public/docs/UNIFIED_AGENT.md": "61a15c9651223ac26f4ca900f5731534f03abf96abfd233c47bf034c2796fc05" + }, + "browser_script": "frontend-modern/browser-tests/configuration-migration.cjs" } diff --git a/frontend-modern/public/docs/MIGRATION.md b/frontend-modern/public/docs/MIGRATION.md index 58de776f83..0b3398133a 100644 --- a/frontend-modern/public/docs/MIGRATION.md +++ b/frontend-modern/public/docs/MIGRATION.md @@ -1,88 +1,122 @@ -# ๐Ÿšš Migrating Pulse +# Migrating Pulse -This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow. +**Create Backup** in **Settings โ†’ System โ†’ Recovery** creates an encrypted +configuration export, not a full backup of the installation. Check its scope +before moving Pulse or retiring the old server. Do not use it alone when you +need to preserve history, agent enrolment state or settings outside that scope. -## ๐Ÿš€ Quick Migration Guide +## Choose the backup you need -### โŒ DON'T: Copy Files -Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break. +### Configuration transfer -### โœ… DO: Use Export/Import +Use export/import to move the included settings between installations. Pulse +decrypts the export with its passphrase and saves the imported credentials with +the destination's encryption key. Do not copy individual encrypted files into +a fresh installation with a different key. -#### 1. Export (Old Server) -1. Go to **Settings โ†’ System โ†’ Recovery**. -2. Click **Create Backup**. -3. Enter a strong passphrase and download the encrypted backup. +The current export format includes: -#### 2. Import (New Server) -1. Install a fresh Pulse instance. -2. Go to **Settings โ†’ System โ†’ Recovery**. -3. Click **Restore Configuration** and upload your file. -4. Enter the passphrase. - -## ๐Ÿ“ฆ What Gets Migrated - -| Included โœ… | Not Included โŒ | +| Included | Not included | | :--- | :--- | -| Nodes & credentials | Historical metrics history (`metrics.db`) | -| Alerts & overrides | Browser sessions and local cookies | -| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) | -| System settings (`system.json`) | Update history/backup folders | -| API token records | โ€” | -| OIDC config | โ€” | -| SSO / SAML config | โ€” | -| TrueNAS connections (`truenas.enc`) | โ€” | -| Guest metadata/notes | โ€” | -| โ€” | Relay config (`relay.enc`) โ€” re-enable in Settings | -| โ€” | Host metadata (notes/tags/AI command overrides) | -| โ€” | Docker metadata cache | -| โ€” | Agent profiles and assignments | -| โ€” | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) | -| โ€” | RBAC roles (`rbac_roles.json`) โ€” re-create after import | -| โ€” | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) | -| โ€” | Server sessions (`sessions.json`) | -| โ€” | Update history (`update-history.jsonl`) | +| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections | +| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history | +| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules | +| System settings | Local login credentials, environment overrides and sessions | +| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state | +| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles | +| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments | +| โ€” | AI settings, findings, usage and chat history | +| โ€” | Licence files, existing Relay configuration, binaries, service definitions and update backups | -## ๐Ÿ”„ Common Scenarios +Contents depend on the version that created the export. Older formats can omit +settings included above. Imported API-token records retain their scopes and +expiry; they do not reveal the original tokens or transfer every agent's state. +SSO configuration **is included**, even though local login credentials are not. -### Moving to New Hardware -Export from old โ†’ Install new โ†’ Import. +### Full-state recovery -### Docker โ†” Systemd โ†” Kubernetes -The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly. +If you need the same installation's history and excluded state, retain a +consistent filesystem/volume backup as well. Stop Pulse before taking that +backup; monitoring and alert delivery pause while it is stopped. Preserve every +effective data path, the matching `.encryption.key`, audit data and signing key, +environment files, external certificates, binaries and service/container +configuration together. Custom paths and organisation directories matter. +Copying a live database file alone can omit its sidecar files. -### Disaster Recovery -1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)). -2. Import your latest backup. -3. Restored in < 5 minutes. +Keep that backup private and test recovery on an isolated destination before +retiring the source. Do not mix encrypted files and keys from different +installations or restore over a running Pulse. See [configuration +paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage). +The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains) +is a third, limited backup; it is not a substitute for either procedure above. -## ๐Ÿ“‹ Post-Migration Checklist +## Transfer the included configuration -Because local login credentials are stored in `.env` (not part of exports), you must: +1. On the old server, sign in and open **Settings โ†’ System โ†’ Recovery โ†’ + Create Backup**. Use a strong, unique passphrase of at least 12 characters + and store it separately from the downloaded file. With password login, + choose **Use a custom passphrase** if you do not want to use that password. + Keep the old instance and your separate full-state backup until validation + is complete. +2. Install Pulse at the destination using the [installation guide](INSTALL.md). + Configure destination-local administrator access before importing. Keep the + destination isolated from agents and notification destinations during the + trial so two servers do not send duplicate alerts or remote actions. +3. If the destination already has useful settings, export them first. Open + **Settings โ†’ System โ†’ Recovery โ†’ Restore Configuration**, select the file + and enter its passphrase. Import replaces the included configuration and + API-token records; it does not merge them with the destination's records. +4. Check the result before changing DNS or agent addresses. A success message + establishes neither full recovery nor fresh monitoring. If import reports a + reload/apply failure, settings may already have been written: preserve both + installations and inspect the error before repeating it. -1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance. -2. **Confirm API access**: - * If you created API tokens in the UI, those token records are included in the export and should continue working. -3. **Update Agents**: - * **Unified Agent**: Update the `--token` flag in your service definition. - * **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment. - * **New server address?** Agents dial the address they were installed - with (the `--url` flag, or `PULSE_URL` for containerized agents), so - agents disappear from the new instance until that value is updated in - each agent's service definition. Fronting Pulse with a DNS name - instead of an IP address makes future moves invisible to agents. - * *Tip: Use **Settings โ†’ Infrastructure โ†’ Install on a host** to generate updated install commands.* -4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports). +Keep the archive, passphrase, configuration files and credentials out of issue +threads. Use the authenticated settings form rather than a command containing +a password, API token or session cookie. -## ๐Ÿ”’ Security +## Cut over and verify -* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM). -* **Storage**: Safe to store in cloud backups or password managers. -* **Passphrase**: Use a strong, unique passphrase (min 12 chars). +- **Login and permissions:** verify destination-local administrator access and + any imported SSO provider, group mappings and callback address. Re-create + custom roles and supply external CA/certificate files if needed. Environment + overrides can take precedence over imported settings. +- **Connections and history:** check every expected platform, guest and backup + against a fresh observation, not a cached row. Re-create excluded TrueNAS, + vSphere and Machine Availability connections. Missing historical data after + configuration-only transfer is not proof that collection has failed. +- **Agents:** preserve each agent's existing identity and private credential. + With a changed server address, use the [agent retargeting + guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host. + Do not create another token just for a server move or paste it into `--token`. + Confirm fresh reports and any enrolment/authorisation result; imported token + records alone do not prove that every agent is admitted. Re-create excluded + profiles and assignments before enabling remote actions. +- **Alerts and notifications:** avoid running both instances against the same + destinations. After cutover, check thresholds, schedules and one controlled + notification test with its actual delivery result. Importing destination + settings does not migrate pending notifications or incident history. +- **Licence and remaining settings:** re-activate Pro if required and verify + any excluded settings you rely on. Existing paired phones need their retained + configuration; configuration export alone is not a Mobile/Relay migration. + Relay is no longer sold; do not enable it as a new migration route. -## ๐Ÿ”ง Troubleshooting +Retire the old installation only after these checks succeed. Keep the private, +consistent pre-move backup for recovery. If validation fails, stop the trial +destination before returning traffic to the intact source; check agent URLs +and avoid two active writers. There is no guaranteed five-minute recovery time. -* **"Invalid passphrase"**: Ensure exact match (case-sensitive). -* **Missing Nodes**: Verify export date. -* **Connection Errors**: Update node IPs in Settings if they changed. -* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed. +## Troubleshooting + +- **Wrong passphrase or rejected file:** use the original export file and its + exact passphrase. Do not delete destination data or regenerate encryption + keys to make an import succeed. +- **A setting is missing:** compare it with the export scope above and the + exporting version. Use the old installation or full-state backup rather + than assuming import contained it. +- **Agents are absent:** check their primary URL, TLS trust and fresh admission + evidence using the linked guide. Do not uninstall, re-enrol or delete saved + identities just because the dashboard is empty. +- **Need help:** retain the source/destination versions, deployment types, + operation time and exact redacted error. Follow [safe + reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup. diff --git a/frontend-modern/public/docs/UNIFIED_AGENT.md b/frontend-modern/public/docs/UNIFIED_AGENT.md index adf8371792..405e50271d 100644 --- a/frontend-modern/public/docs/UNIFIED_AGENT.md +++ b/frontend-modern/public/docs/UNIFIED_AGENT.md @@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse. ### Moving Pulse to a new address -Configuration export/import restores the server-side agent records and API -tokens. It cannot rewrite the primary Pulse URL on remote machines because -agents initiate the connection. Prefer a stable DNS name for the primary URL -so replacing the Pulse host does not require an agent migration. +Configuration export/import restores API-token records, not the server-side +agent inventory, enrolment state, profiles or assignments. See the +[configuration-transfer scope](MIGRATION.md#configuration-transfer) before +retiring the old server. Import cannot rewrite the primary Pulse URL on remote +machines because agents initiate the connection. Prefer a stable DNS name for +the primary URL, but still verify fresh reports and agent admission after a move. After importing the configuration on a Pulse server with a different address, retarget each existing standard Linux agent from that agent machine. Download diff --git a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx index 352b99c689..0bd52c607e 100644 --- a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx +++ b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx @@ -5,6 +5,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader'; import { controlClass, formField, formHelpText, labelClass } from '@/components/shared/Form'; import type { SecurityStatus as SecurityStatusInfo } from '@/types/config'; import { getAPITokenManagementLocationMessage } from '@/utils/apiTokenPresentation'; +import { getShippedDocUrl } from '@/utils/docsLinks'; interface BackupTransferDialogsProps { securityStatus: Accessor; @@ -140,9 +141,19 @@ export const BackupTransferDialogs: Component = (pro />
    - Important: The backup contains node credentials but NOT - authentication settings. Each Pulse instance should configure its own login - credentials for security. Remember your{' '} + Configuration only: Includes Proxmox connections, SSO settings + and API-token records, but not history, TrueNAS/vSphere connections or agent + enrolment state. Local login credentials and sessions are not included. Check + the{' '} + + migration guide + {' '} + before relying on this for recovery. Remember your{' '} {props.useCustomPassphrase() || !props.securityStatus()?.hasAuthentication ? 'passphrase' : 'password'}{' '} @@ -282,13 +293,24 @@ export const BackupTransferDialogs: Component = (pro

    - Warning: Importing will replace all current configuration. This - action cannot be undone. + Warning: Import replaces the included settings and API-token + records, not the whole installation. Back up the destination first. History and + excluded settings are not restored; check the{' '} + + migration guide + {' '} + before retiring the old server.

    - Agent migration: The backup restores server-side agent records - and credentials, but it cannot change the Pulse URL stored on remote agents. If - this server has a different address, retarget each agent after the import. + Agent migration: API-token records are included, but agent + inventory, enrolment state, profiles and assignments are not. Import cannot + change the Pulse URL stored on remote agents. Preserve each agent's identity and + private credential, retarget if the address changes, and verify fresh admission.

    diff --git a/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx b/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx index 1a0e2eb131..4820a58ea1 100644 --- a/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/BackupTransferDialogs.test.tsx @@ -2,40 +2,65 @@ import { cleanup, render, screen } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { BackupTransferDialogs } from '../BackupTransferDialogs'; +function renderTransfer(kind: 'export' | 'import') { + return render(() => ( + null} + exportPassphrase={() => ''} + setExportPassphrase={vi.fn()} + useCustomPassphrase={() => false} + setUseCustomPassphrase={vi.fn()} + importPassphrase={() => 'secure-passphrase'} + setImportPassphrase={vi.fn()} + importFile={() => null} + setImportFile={vi.fn()} + showExportDialog={() => kind === 'export'} + showImportDialog={() => kind === 'import'} + showApiTokenModal={() => false} + apiTokenInput={() => ''} + setApiTokenInput={vi.fn()} + handleExport={vi.fn()} + handleImport={vi.fn()} + closeExportDialog={vi.fn()} + closeImportDialog={vi.fn()} + closeApiTokenModal={vi.fn()} + handleApiTokenAuthenticate={vi.fn()} + /> + )); +} + describe('BackupTransferDialogs', () => { - afterEach(() => { - cleanup(); + afterEach(cleanup); + + it('discloses the actual archive scope before exporting', () => { + renderTransfer('export'); + expect(screen.getByText('Configuration only:')).toBeInTheDocument(); + const text = screen.getByRole('dialog', { name: 'Export configuration' }).textContent; + expect(text).toContain('SSO settings'); + expect(text).toContain('API-token records'); + expect(text).toContain('not history, TrueNAS/vSphere connections'); + expect(text).toContain('Local login credentials and sessions are not included'); + expect(text).not.toContain('NOT authentication settings'); + expect(screen.getByRole('link', { name: 'migration guide' })).toHaveAttribute( + 'href', + '/docs/MIGRATION', + ); }); - it('discloses that configuration import cannot retarget remote agents', () => { - render(() => ( - null} - exportPassphrase={() => ''} - setExportPassphrase={vi.fn()} - useCustomPassphrase={() => false} - setUseCustomPassphrase={vi.fn()} - importPassphrase={() => 'secure-passphrase'} - setImportPassphrase={vi.fn()} - importFile={() => null} - setImportFile={vi.fn()} - showExportDialog={() => false} - showImportDialog={() => true} - showApiTokenModal={() => false} - apiTokenInput={() => ''} - setApiTokenInput={vi.fn()} - handleExport={vi.fn()} - handleImport={vi.fn()} - closeExportDialog={vi.fn()} - closeImportDialog={vi.fn()} - closeApiTokenModal={vi.fn()} - handleApiTokenAuthenticate={vi.fn()} - /> - )); - + it('distinguishes token transfer from agent admission before importing', () => { + renderTransfer('import'); expect(screen.getByText('Agent migration:')).toBeInTheDocument(); - expect( - screen.getByText(/it cannot change the Pulse URL stored on remote agents/i), - ).toBeInTheDocument(); + const text = screen.getByRole('dialog', { name: 'Import configuration' }).textContent; + expect(text).toContain('Back up the destination first'); + expect(text).toContain('not the whole installation'); + expect(text).toContain('inventory, enrolment state, profiles and assignments are not'); + expect(text).toContain('Import cannot change the Pulse URL stored on remote agents'); + expect(text).toContain('Preserve each agent'); + expect(text).toContain('verify fresh admission'); + expect(text).not.toContain('restores server-side agent records'); + const guide = screen.getByRole('link', { name: 'migration guide' }); + expect(guide).toHaveAttribute('href', '/docs/MIGRATION'); + expect(guide).toHaveAttribute('target', '_blank'); + expect(guide).toHaveAttribute('rel', 'noopener noreferrer'); }); }); diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index d4bac377ae..0d980ec443 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -131,6 +131,31 @@ describe('docsLinks', () => { } }); + it('keeps configuration transfer separate from full-state recovery', () => { + const migration = readFileSync(path.join(repoRoot, 'docs', 'MIGRATION.md'), 'utf8'); + const agent = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8'); + const scope = migration + .split('### Configuration transfer')[1] + .split('### Full-state recovery')[0]; + expect(scope).toContain('Proxmox VE, PBS and PMG'); + expect(scope).toContain('| TrueNAS, vSphere and Machine Availability'); + expect(scope).toContain('SSO configuration **is included**'); + expect(scope).toContain( + 'Server-side host/Docker/Kubernetes agent inventory and enrolment state', + ); + expect(migration).toContain('not a full backup of the installation'); + expect(migration).toContain('Stop Pulse before taking that'); + expect(migration).toContain('matching `.encryption.key`'); + expect(migration).toContain('reload/apply failure'); + expect(migration).toContain('it does not merge'); + expect(migration).toContain('UNIFIED_AGENT.md#moving-pulse-to-a-new-address'); + expect(migration).not.toContain('Update the `--token` flag'); + expect(migration).not.toContain('Restored in < 5 minutes'); + expect(migration).not.toContain('re-enable in Settings'); + expect(agent).toContain('restores API-token records, not the server-side'); + expect(agent).toContain('MIGRATION.md#configuration-transfer'); + }); + it('keeps lockout and incident reporting guidance safe for the affected client', () => { const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8'); const recovery = troubleshooting.split('### Recovery Mode')[1].split('\n---')[0]; diff --git a/internal/config/migration_scope_test.go b/internal/config/migration_scope_test.go new file mode 100644 index 0000000000..fc4b58377a --- /dev/null +++ b/internal/config/migration_scope_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" + "github.com/stretchr/testify/require" +) + +// Exercise the actual archive, not a fabricated ExportData value: the migration +// warnings must match what a fresh destination can recover with a different key. +func TestConfigurationMigrationArchiveScope(t *testing.T) { + t.Setenv("PULSE_DATA_DIR", t.TempDir()) + sourceDir, targetDir := t.TempDir(), t.TempDir() + source, target := NewConfigPersistence(sourceDir), NewConfigPersistence(targetDir) + require.NoError(t, source.EnsureConfigDir()) + require.NoError(t, target.EnsureConfigDir()) + + nodes := []PVEInstance{{Name: "synthetic-pve", Host: "https://pve.example:8006", TokenName: "pulse@pve!read", TokenValue: "synthetic-node-secret"}} + require.NoError(t, source.SaveNodesConfig(nodes, nil, nil)) + expires := time.Now().Add(time.Hour).UTC() + tokens := []APITokenRecord{{ID: "synthetic-agent-token", Name: "agent", Hash: auth.HashAPIToken("synthetic-agent-secret"), CreatedAt: time.Now().UTC(), ExpiresAt: &expires, Scopes: []string{ScopeAgentReport}, OrgID: "default"}} + require.NoError(t, source.SaveAPITokens(tokens)) + sso := &SSOConfig{Providers: []SSOProvider{{ID: "synthetic-sso", Name: "SSO", Type: SSOProviderTypeOIDC, Enabled: true, OIDC: &OIDCProviderConfig{IssuerURL: "https://idp.example", ClientID: "synthetic", ClientSecret: "synthetic-sso-secret"}}}} + require.NoError(t, source.SaveSSOConfig(sso)) + sourceNAS := []TrueNASInstance{{Name: "source-nas", Host: "https://nas.example", APIKey: "synthetic-nas-secret"}} + require.NoError(t, source.SaveTrueNASConfig(sourceNAS)) + targetNAS := []TrueNASInstance{{Name: "target-nas", Host: "https://target.example", APIKey: "synthetic-target-secret"}} + require.NoError(t, target.SaveTrueNASConfig(targetNAS)) + + // Excluded state exists at both ends. Import must not pretend to transfer it + // or overwrite the destination's excluded files. These are inert sentinels, + // not live databases, credentials or operational agent records. + excluded := []string{".env", "metrics.db", "audit.db", "host_agents.json", "agent_profiles.json", "rbac_roles.json", "license.enc"} + for _, name := range excluded { + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, name), []byte("synthetic-source-only"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, name), []byte("synthetic-destination-only"), 0600)) + } + sourceKey, err := os.ReadFile(filepath.Join(sourceDir, ".encryption.key")) + require.NoError(t, err) + targetKey, err := os.ReadFile(filepath.Join(targetDir, ".encryption.key")) + require.NoError(t, err) + require.False(t, bytes.Equal(sourceKey, targetKey), "fresh destinations must use a different key") + + const passphrase = "synthetic-migration-passphrase" + archive, err := source.ExportConfig(passphrase) + require.NoError(t, err) + ciphertext, err := base64.StdEncoding.DecodeString(archive) + require.NoError(t, err) + plaintext, err := decryptWithPassphrase(ciphertext, passphrase) + require.NoError(t, err) + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(plaintext, &payload)) + require.Equal(t, "4.4", stringValue(t, payload["version"])) + // Changing archive scope requires updating the actual user-facing account, + // not silently assuming every persisted store is in a configuration export. + require.ElementsMatch(t, []string{"version", "exportedAt", "nodes", "alerts", "alertIntentPolicies", "email", "webhooks", "apprise", "deadMan", "system", "sso", "apiTokens"}, rawKeys(payload)) + require.NotContains(t, string(plaintext), "synthetic-nas-secret") + require.NotContains(t, string(plaintext), "synthetic-source-only") + require.Contains(t, string(plaintext), "synthetic-sso-secret") + + baseline := []PVEInstance{{Name: "destination-before", Host: "https://before.example:8006"}} + require.NoError(t, target.SaveNodesConfig(baseline, nil, nil)) + require.Error(t, target.ImportConfig(archive, "wrong-synthetic-passphrase")) + unchanged, err := target.LoadNodesConfig() + require.NoError(t, err) + require.Equal(t, baseline[0].Name, unchanged.PVEInstances[0].Name) + + require.NoError(t, target.ImportConfig(archive, passphrase)) + reopened := NewConfigPersistence(targetDir) + restored, err := reopened.LoadNodesConfig() + require.NoError(t, err) + require.Equal(t, nodes[0].TokenValue, restored.PVEInstances[0].TokenValue) + require.Equal(t, nodes[0].Name, restored.PVEInstances[0].Name) + restoredSSO, err := reopened.LoadSSOConfig() + require.NoError(t, err) + require.Equal(t, sso.Providers[0].OIDC.ClientSecret, restoredSSO.Providers[0].OIDC.ClientSecret) + restoredTokens, err := reopened.LoadAPITokens() + require.NoError(t, err) + cfg := &Config{APITokens: restoredTokens} + record, ok := cfg.ValidateAPIToken("synthetic-agent-secret") + require.True(t, ok, "restored records validate the existing credential without replacing it") + require.Equal(t, tokens[0].ID, record.ID) + require.Equal(t, tokens[0].Scopes, record.Scopes) + require.WithinDuration(t, expires, *record.ExpiresAt, time.Millisecond) + nas, err := reopened.LoadTrueNASConfig() + require.NoError(t, err) + require.Equal(t, targetNAS[0].Name, nas[0].Name) + for _, name := range excluded { + data, err := os.ReadFile(filepath.Join(targetDir, name)) + require.NoError(t, err) + require.Equal(t, "synthetic-destination-only", string(data), name) + } + keyAfter, err := os.ReadFile(filepath.Join(targetDir, ".encryption.key")) + require.NoError(t, err) + require.True(t, bytes.Equal(targetKey, keyAfter), "configuration import keeps the destination key") +} + +func rawKeys(payload map[string]json.RawMessage) []string { + keys := make([]string, 0, len(payload)) + for key := range payload { + keys = append(keys, key) + } + return keys +} + +func stringValue(t *testing.T, raw json.RawMessage) string { + t.Helper() + var value string + require.NoError(t, json.Unmarshal(raw, &value)) + return value +} From 4e57511d1ecfbe220bf628cb78d711e9f1c9a7b1 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:05:05 +0100 Subject: [PATCH 59/86] Stabilise label-derived tags without hiding metadata changes Docker and Kubernetes label maps became ordered tag arrays in random map iteration order, forcing unchanged resources into connected-dashboard deltas (#2441, found while profiling #2199). Sort only the deduplicated label-derived list. Preserve existing label values, source maps, first-choice image references and identity ordering. Exercise all seven affected Docker adapters and twenty-six Kubernetes kinds through registry rebuilds, monitor projection, per-client queues and receiver replay, including real replace/add/remove/clear/restore changes, tenant isolation and a thousand-pod heartbeat. Contract-Neutral: Deterministic ordering of existing label-derived tag sets only; no public JSON shape, canonical ownership or ordered identity-list change. Change-source: pulse-maintainer --- internal/unifiedresources/adapters.go | 8 +- internal/unifiedresources/label_tags_test.go | 67 ++++ .../broadcast_projection_export_test.go | 36 ++ .../websocket/label_tags_connected_test.go | 329 ++++++++++++++++++ 4 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 internal/unifiedresources/label_tags_test.go create mode 100644 internal/websocket/label_tags_connected_test.go diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 9d360e2ece..2496c8eef2 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/url" + "slices" "strconv" "strings" "time" @@ -3781,7 +3782,12 @@ func labelsToTags(labels map[string]string) []string { } out = append(out, key+":"+v) } - return uniqueStrings(out) + // Labels are a set, but tags are sent as an ordered JSON array. Stabilise + // only this map-derived list so rebuilds do not create spurious socket + // deltas. uniqueStrings also serves ordered identity lists; do not sort it. + out = uniqueStrings(out) + slices.Sort(out) + return out } func convertInterfaces(interfaces []models.HostNetworkInterface) []NetworkInterface { diff --git a/internal/unifiedresources/label_tags_test.go b/internal/unifiedresources/label_tags_test.go new file mode 100644 index 0000000000..d8cf3b1fb2 --- /dev/null +++ b/internal/unifiedresources/label_tags_test.go @@ -0,0 +1,67 @@ +package unifiedresources + +import ( + "maps" + "reflect" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestLabelTagsStableAcrossMapIterations(t *testing.T) { + labels := map[string]string{ + "app": "api", "env": "production", "team": "ops", "tier": "backend", + "region": "west", "release": "stable", "managed": "yes", "version": "1", + } + before := maps.Clone(labels) + want := []string{"app:api", "env:production", "managed:yes", "region:west", "release:stable", "team:ops", "tier:backend", "version:1"} + for i := 0; i < 256; i++ { + if got := labelsToTags(labels); !reflect.DeepEqual(got, want) { + t.Fatalf("iteration %d: tags = %v, want stable %v", i, got, want) + } + } + if !reflect.DeepEqual(labels, before) { + t.Fatal("tag conversion mutated source labels") + } +} + +func TestLabelTagsRetainExistingValueSemantics(t *testing.T) { + for _, tc := range []struct { + name string + labels map[string]string + want []string + }{ + {"nil", nil, nil}, + {"empty", map[string]string{}, nil}, + {"blank-key", map[string]string{" ": "ignored"}, []string{}}, + {"bare-keys", map[string]string{" z ": "\t", "a": ""}, []string{"a", "z"}}, + {"deduplicated-trimmed-keys", map[string]string{" a": "v", "a ": "v"}, []string{"a:v"}}, + {"case-colon-and-value-spaces", map[string]string{"Z": "https://example.invalid/a:b", "a": " Mixed Case "}, []string{"Z:https://example.invalid/a:b", "a: Mixed Case"}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := labelsToTags(tc.labels); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("tags = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestLabelTagsDoNotSortOrderedIdentityInputs(t *testing.T) { + values := []string{" z ", "a", "z", "b", ""} + before := append([]string(nil), values...) + if got := uniqueStrings(values); !reflect.DeepEqual(got, []string{"z", "a", "b"}) { + t.Fatalf("first-occurrence order changed: %v", got) + } + if !reflect.DeepEqual(values, before) { + t.Fatal("deduplication changed the input list") + } + + image, identity := resourceFromDockerImage(models.DockerImage{ + ID: "sha256:example", RepoTags: []string{"z:last", "a:first"}, + Labels: map[string]string{"z": "last", "a": "first"}, + }, models.DockerHost{Hostname: "runtime-host"}) + if image.Name != "z:last" || !reflect.DeepEqual(image.Docker.RepoTags, []string{"z:last", "a:first"}) || + !reflect.DeepEqual(identity.Hostnames, []string{"z:last", "runtime-host:z:last"}) { + t.Fatalf("label sorting reordered image references or identity: %+v / %+v", image, identity) + } +} diff --git a/internal/websocket/broadcast_projection_export_test.go b/internal/websocket/broadcast_projection_export_test.go index e10bf6c790..8bb71f947e 100644 --- a/internal/websocket/broadcast_projection_export_test.go +++ b/internal/websocket/broadcast_projection_export_test.go @@ -32,3 +32,39 @@ func NewBroadcastProjectionProbeForTest(getState func(string) interface{}, recip return bytes, nil }, nil } + +// NewBroadcastProjectionCaptureForTest retains the actual per-client frames, +// including a nil frame for a quiet or unrelated tenant. It runs the production +// current-state getter, projection, delta baseline and queue path without timers +// or a network socket, and is available only in the test binary. +func NewBroadcastProjectionCaptureForTest(getState func(string) interface{}, orgIDs []string) (func(string) ([][]byte, error), error) { + hub := NewHub(getState) + clients := make([]*Client, len(orgIDs)) + for i, orgID := range orgIDs { + client := &Client{hub: hub, orgID: orgID, send: make(chan []byte, 1)} + if _, sent, err := client.queueFullState("initialState", getState(orgID)); err != nil || !sent { + return nil, fmt.Errorf("initial state: sent=%v error=%v", sent, err) + } + <-client.send + clients[i] = client + hub.clients[client] = true + if hub.clientsByTenant[orgID] == nil { + hub.clientsByTenant[orgID] = make(map[*Client]bool) + } + hub.clientsByTenant[orgID][client] = true + } + return func(orgID string) ([][]byte, error) { + hub.dispatchStateBroadcast(&Message{Type: "rawData", Data: stateBroadcastRequest{}}, orgID) + frames := make([][]byte, len(clients)) + for i, client := range clients { + if !hub.clients[client] { + return nil, fmt.Errorf("broadcast removed client %d", i) + } + select { + case frames[i] = <-client.send: + default: + } + } + return frames, nil + }, nil +} diff --git a/internal/websocket/label_tags_connected_test.go b/internal/websocket/label_tags_connected_test.go new file mode 100644 index 0000000000..f443727c6c --- /dev/null +++ b/internal/websocket/label_tags_connected_test.go @@ -0,0 +1,329 @@ +package websocket_test + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/internal/websocket" +) + +// These are source-native inventories, not pre-sorted ResourceFrontend fixtures. +// Every label-derived Docker adapter and Kubernetes kind passes through a real +// registry generation, monitor projection, wire snapshot and client delta queue. +func labelTagInventory(now time.Time, labels map[string]string) models.StateSnapshot { + cluster := models.KubernetesCluster{ID: "label-cluster", Name: "label-cluster", Status: "online", LastSeen: now} + // The Kubernetes inventory has many kinds with the same UID/name/labels + // envelope. Include each one declared by models; the explicit count below + // prevents an empty or partial fixture from silently satisfying the test. + value := reflect.ValueOf(&cluster).Elem() + for i := 0; i < value.NumField(); i++ { + field := value.Field(i) + if field.Kind() != reflect.Slice || field.Type().Elem().Kind() != reflect.Struct { + continue + } + entry := reflect.New(field.Type().Elem()).Elem() + labelField := entry.FieldByName("Labels") + if !labelField.IsValid() { + continue + } + name := "label-" + strings.ToLower(value.Type().Field(i).Name) + for key, text := range map[string]string{"UID": "uid-" + name, "Name": name, "Namespace": "workloads", "Phase": "Running"} { + if target := entry.FieldByName(key); target.IsValid() && target.Kind() == reflect.String { + target.SetString(text) + } + } + if value.Type().Field(i).Name == "Namespaces" { + entry.FieldByName("Name").SetString("workloads") + entry.FieldByName("Phase").SetString("Active") + } + labelField.Set(reflect.ValueOf(labels)) + field.Set(reflect.Append(field, entry)) + } + return models.StateSnapshot{ + LastUpdate: now, + DockerHosts: []models.DockerHost{{ + ID: "label-docker", Hostname: "label-docker", Status: "online", LastSeen: now, + Swarm: &models.DockerSwarmInfo{ClusterID: "label-swarm", ClusterName: "label-swarm"}, + Images: []models.DockerImage{{ID: "sha256:label-image", RepoTags: []string{"z:latest", "a:old"}, Labels: labels}}, + Volumes: []models.DockerVolume{{Name: "label-volume", Labels: labels}}, + Networks: []models.DockerNetwork{{ID: "label-network", Name: "label-network", Labels: labels}}, + Services: []models.DockerService{{ID: "label-service", Name: "label-service", Labels: labels}}, + Nodes: []models.DockerNode{{ID: "label-node", Hostname: "swarm-node", Labels: labels}}, + Secrets: []models.DockerSecret{{ID: "label-secret", Name: "label-secret", Labels: labels}}, + Configs: []models.DockerConfig{{ID: "label-config", Name: "label-config", Labels: labels}}, + }}, + KubernetesClusters: []models.KubernetesCluster{cluster}, + } +} + +func labelTagValues() map[string]string { + return map[string]string{"app": "api", "env": "production", "team": "ops", "tier": "backend", "region": "west", "release": "stable", "managed": "yes", "version": "1"} +} + +type labelTagProjection struct { + adapter *unifiedresources.MonitorAdapter + store *unifiedresources.MemoryStore + monitor *monitoring.Monitor +} + +func newLabelTagProjection(snapshot models.StateSnapshot) *labelTagProjection { + store := unifiedresources.NewMemoryStore() + adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(store)) + adapter.PopulateFromSnapshot(snapshot) + monitor := &monitoring.Monitor{} + // Completed ingest is the baseline: a dashboard read must not replace it + // with this test Monitor's empty live polling state. + monitor.SetResourceStore(&broadcastReadStore{adapter}) + return &labelTagProjection{adapter: adapter, store: store, monitor: monitor} +} + +func (p *labelTagProjection) state() models.StateFrontend { + return p.monitor.BuildBroadcastFrontendState() +} + +func (p *labelTagProjection) changes(t *testing.T) int { + t.Helper() + count, err := p.store.CountRecentChanges("", time.Time{}) + if err != nil { + t.Fatal(err) + } + return count +} + +type labelTagWireDelta struct { + Upserts []map[string]any `json:"upserts"` + Removed []string `json:"removed"` + Order []string `json:"order"` +} + +func labelTagFrameDelta(t *testing.T, frame []byte) labelTagWireDelta { + t.Helper() + if len(frame) == 0 { + return labelTagWireDelta{} + } + var message struct { + Type string `json:"type"` + Data map[string]json.RawMessage `json:"data"` + } + if err := json.Unmarshal(frame, &message); err != nil || message.Type != "rawData" { + t.Fatalf("invalid state frame: %s / %v", frame, err) + } + if _, full := message.Data["resources"]; full { + t.Fatal("connected client unexpectedly received a full resource array") + } + var delta labelTagWireDelta + if encoded, ok := message.Data["resourceDelta"]; ok { + if err := json.Unmarshal(encoded, &delta); err != nil { + t.Fatal(err) + } + } + return delta +} + +func labelTagResourceMaps(t *testing.T, state models.StateFrontend) map[string]map[string]any { + t.Helper() + encoded, err := json.Marshal(state.Resources) + if err != nil { + t.Fatal(err) + } + var resources []map[string]any + if err := json.Unmarshal(encoded, &resources); err != nil { + t.Fatal(err) + } + out := make(map[string]map[string]any, len(resources)) + for _, resource := range resources { + out[resource["id"].(string)] = resource + } + return out +} + +// Independent RFC 7396 receiver oracle; do not reuse the production diff builder. +func applyLabelTagMergePatch(previous map[string]any, patch map[string]any) map[string]any { + out := maps.Clone(previous) + if out == nil { + out = make(map[string]any) + } + for key, value := range patch { + if value == nil { + delete(out, key) + } else if object, ok := value.(map[string]any); ok { + old, _ := out[key].(map[string]any) + out[key] = applyLabelTagMergePatch(old, object) + } else { + out[key] = value + } + } + return out +} + +func TestLabelTagsConnectedRebuildsAreQuiet(t *testing.T) { + labels := labelTagValues() + before := maps.Clone(labels) + seed := labelTagInventory(time.Now().UTC(), labels) + projection := newLabelTagProjection(seed) + tagged := 0 + for _, resource := range projection.state().Resources { + if len(resource.Tags) == len(labels) { + tagged++ + } + } + if tagged != 33 { // Seven Docker adapters and twenty-six Kubernetes kinds. + t.Fatalf("fixture covers %d label-derived resources, want 33", tagged) + } + broadcast, err := websocket.NewBroadcastProjectionCaptureForTest(func(string) interface{} { return projection.state() }, []string{"default", "default"}) + if err != nil { + t.Fatal(err) + } + changes := projection.changes(t) + var tagPatches, upserts, wireBytes int + for i := 0; i < 32; i++ { + projection.adapter.PopulateFromSnapshot(seed) + frames, err := broadcast("default") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(frames[0], frames[1]) { + t.Fatal("equal client baselines received different frames") + } + for _, frame := range frames { + wireBytes += len(frame) + delta := labelTagFrameDelta(t, frame) + if len(delta.Removed) != 0 || len(delta.Order) != 0 { + t.Fatal("equivalent rebuild changed resource identity or order") + } + upserts += len(delta.Upserts) + for _, patch := range delta.Upserts { + if _, ok := patch["tags"]; ok { + tagPatches++ + } + } + } + } + t.Logf("33 label-derived resources, 32 rebuilds, two clients: tag patches=%d, all resource upserts=%d, queued wire bytes=%d", tagPatches, upserts, wireBytes) + if tagPatches != 0 || upserts != 0 { + t.Fatalf("unchanged label sets created %d tag patches / %d resource upserts", tagPatches, upserts) + } + if projection.changes(t) != changes || !reflect.DeepEqual(labels, before) { + t.Fatal("equivalent rebuild changed history or mutated source labels") + } +} + +func TestLabelTagsConnectedChangesAndClearsRemainVisible(t *testing.T) { + labels := labelTagValues() + now := time.Now().UTC() + projection := newLabelTagProjection(labelTagInventory(now, labels)) + otherTenant := newLabelTagProjection(labelTagInventory(now, map[string]string{"private": "other-tenant"})) + broadcast, err := websocket.NewBroadcastProjectionCaptureForTest(func(org string) interface{} { + if org == "other" { + return otherTenant.state() + } + return projection.state() + }, []string{"default", "default", "other"}) + if err != nil { + t.Fatal(err) + } + received := labelTagResourceMaps(t, projection.state()) + changes := projection.changes(t) + for _, name := range []string{"replace", "add", "remove", "clear", "restore"} { + t.Run(name, func(t *testing.T) { + labels = maps.Clone(labels) + switch name { + case "replace": + labels["env"] = "staging" + case "add": + labels["new"] = "visible" + case "remove": + delete(labels, "app") + case "clear": + labels = nil + case "restore": + labels = labelTagValues() + } + projection.adapter.PopulateFromSnapshot(labelTagInventory(now, labels)) + frames, err := broadcast("default") + if err != nil { + t.Fatal(err) + } + if len(frames[0]) == 0 || !bytes.Equal(frames[0], frames[1]) || len(frames[2]) != 0 { + t.Fatal("change was lost, differed per client or reached another tenant") + } + delta := labelTagFrameDelta(t, frames[0]) + if len(delta.Upserts) != 33 || len(delta.Removed) != 0 || len(delta.Order) != 0 { + t.Fatalf("metadata edit changed identity/order or missed a resource: %+v", delta) + } + for _, patch := range delta.Upserts { + if _, changed := patch["tags"]; !changed { + t.Fatalf("real label change omitted tags: %v", patch) + } + id := patch["id"].(string) + received[id] = applyLabelTagMergePatch(received[id], patch) + } + want := labelTagResourceMaps(t, projection.state()) + if !reflect.DeepEqual(received, want) { + t.Fatal("receiver replay differs from the full current projection (including labels/tags clears)") + } + for _, resource := range projection.state().Resources { + if !slices.IsSorted(resource.Tags) { + t.Fatalf("changed tags are not sorted for %s: %v", resource.ID, resource.Tags) + } + if name == "clear" && (len(resource.Tags) != 0 || len(resource.Labels) != 0) { + t.Fatalf("cleared metadata remained on %s", resource.ID) + } + } + changes += 33 + if got := projection.changes(t); got != changes { + t.Fatalf("history lost or duplicated real changes: %d, want %d", got, changes) + } + }) + } +} + +func TestLabelTagsFleetHeartbeatDeltas(t *testing.T) { + now := time.Now().UTC() + labels := labelTagValues() + seed := models.StateSnapshot{LastUpdate: now, KubernetesClusters: []models.KubernetesCluster{{ID: "fleet", Name: "fleet", Status: "online", LastSeen: now}}} + for i := 0; i < 1000; i++ { + seed.KubernetesClusters[0].Pods = append(seed.KubernetesClusters[0].Pods, models.KubernetesPod{ + UID: fmt.Sprintf("pod-%04d", i), Name: fmt.Sprintf("pod-%04d", i), Namespace: "workloads", Phase: "Running", Labels: labels, + }) + } + projection := newLabelTagProjection(seed) + broadcast, err := websocket.NewBroadcastProjectionCaptureForTest(func(string) interface{} { return projection.state() }, []string{"default"}) + if err != nil { + t.Fatal(err) + } + var tagPatches, wireBytes int + for i := 0; i < 8; i++ { + // Advance observed freshness as a real heartbeat does, not labels. + seed.LastUpdate = now.Add(time.Duration(i+1) * time.Second) + seed.KubernetesClusters[0].LastSeen = seed.LastUpdate + projection.adapter.PopulateFromSnapshot(seed) + frames, err := broadcast("default") + if err != nil || len(frames[0]) == 0 { + t.Fatalf("fresh heartbeat was not queued: %v", err) + } + wireBytes += len(frames[0]) + for _, patch := range labelTagFrameDelta(t, frames[0]).Upserts { + if _, changed := patch["tags"]; changed { + tagPatches++ + } + if _, changed := patch["labels"]; changed { + t.Fatal("heartbeat changed label values") + } + } + } + t.Logf("1,000 pods, eight fresh heartbeats, one client: false tag patches=%d, queued wire bytes=%d (includes real freshness deltas)", tagPatches, wireBytes) + if tagPatches != 0 { + t.Fatalf("unchanged fleet labels created %d tag patches", tagPatches) + } +} From 9a05a5d477b0c84df23e06702a9458ae1b622111 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:09:13 +0100 Subject: [PATCH 60/86] Keep connected metadata controls independent of tag ordering Canonical ordering is already exercised by the repeated map and quiet rebuild controls. Let the connected replace/add/remove/clear/restore and tenant-isolation test independently establish that real metadata still propagates on both parent and repaired adapters, rather than making this healthy control fail solely for the known parent ordering defect. Change-source: pulse-maintainer --- internal/websocket/label_tags_connected_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/websocket/label_tags_connected_test.go b/internal/websocket/label_tags_connected_test.go index f443727c6c..064adac84c 100644 --- a/internal/websocket/label_tags_connected_test.go +++ b/internal/websocket/label_tags_connected_test.go @@ -6,7 +6,6 @@ import ( "fmt" "maps" "reflect" - "slices" "strings" "testing" "time" @@ -273,9 +272,6 @@ func TestLabelTagsConnectedChangesAndClearsRemainVisible(t *testing.T) { t.Fatal("receiver replay differs from the full current projection (including labels/tags clears)") } for _, resource := range projection.state().Resources { - if !slices.IsSorted(resource.Tags) { - t.Fatalf("changed tags are not sorted for %s: %v", resource.ID, resource.Tags) - } if name == "clear" && (len(resource.Tags) != 0 || len(resource.Labels) != 0) { t.Fatalf("cleared metadata remained on %s", resource.ID) } From 26c64634f002197b6bb50165f18a2121c1eec0cc Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:12:47 +0100 Subject: [PATCH 61/86] Verify configuration scope without outbound-network assumptions Use a well-formed local-login fixture for system-settings normalisation, make the existing outbound-IP coverage deterministic through its existing dial seam, and finish the copy audit with fresh inspected browser evidence. Contract-Neutral: No runtime or archive contract changes. Existing tests now run offline without a real route, and the migration warning uses the required copy style. Change-source: pulse-maintainer --- .../browser-tests/configuration-migration.cjs | 2 +- frontend-modern/browser-verification.json | 8 ++++---- .../Settings/BackupTransferDialogs.tsx | 2 +- internal/config/config_load_extra_test.go | 17 ++++++++++++++--- internal/config/migration_scope_test.go | 12 +++++++++++- 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/frontend-modern/browser-tests/configuration-migration.cjs b/frontend-modern/browser-tests/configuration-migration.cjs index 448ff8c78e..3305fe5bdf 100644 --- a/frontend-modern/browser-tests/configuration-migration.cjs +++ b/frontend-modern/browser-tests/configuration-migration.cjs @@ -127,7 +127,7 @@ const { chromium, webkit } = require('playwright'); const files = ['frontend-modern/src/components/Settings/BackupTransferDialogs.tsx', 'frontend-modern/public/docs/MIGRATION.md', 'frontend-modern/public/docs/UNIFIED_AGENT.md']; const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, - baseSha: 'b62042798c7c9b326408dd5532d7fa330d6bb089', + baseSha: '73d2c64e0f5bce57f3a1dc6f9e055b4784e079bd', scope: 'Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment', contentSha256: Object.fromEntries(files.map((file) => [file, createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex')])), observations }; diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 175cc2ec76..2caa7213e0 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,13 +1,13 @@ { "version": 1, "result": "passed", - "base_sha": "b62042798c7c9b326408dd5532d7fa330d6bb089", - "verified_at": "2026-10-03T14:52:51.979841Z", + "base_sha": "73d2c64e0f5bce57f3a1dc6f9e055b4784e079bd", + "verified_at": "2026-10-03T15:12:03.997980Z", "changed_paths": [ "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx" ], "content_sha256": { - "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341" + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "668a53d0741316c9353848ce0b1d5fae917db9ce5053ea43c6fb100d76e32cc4" }, "routes": [ "/settings/system/recovery (real transfer dialog/flow harness)", @@ -64,7 +64,7 @@ ], "playwright_version": "1.56.1", "shipped_asset_sha256": { - "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341", + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "668a53d0741316c9353848ce0b1d5fae917db9ce5053ea43c6fb100d76e32cc4", "frontend-modern/public/docs/MIGRATION.md": "ad4b5a4b07af44e1657f9512ef468a578b8b5957b9b115a30816824bbd8300e9", "frontend-modern/public/docs/UNIFIED_AGENT.md": "61a15c9651223ac26f4ca900f5731534f03abf96abfd233c47bf034c2796fc05" }, diff --git a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx index 0bd52c607e..4ef7933c86 100644 --- a/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx +++ b/frontend-modern/src/components/Settings/BackupTransferDialogs.tsx @@ -295,7 +295,7 @@ export const BackupTransferDialogs: Component = (pro

    Warning: Import replaces the included settings and API-token records, not the whole installation. Back up the destination first. History and - excluded settings are not restored; check the{' '} + excluded settings are not restored. Check the{' '} Date: Sat, 3 Oct 2026 16:29:54 +0100 Subject: [PATCH 62/86] Format the combined Swarm wrapping regression assertion Keep the existing narrow-table behaviour control readable under the exact pinned Prettier 3.9.8 after composing Tailwind 4 wrapping classes. Test formatting only; no product, graph or receipt delta. Change-source: pulse-maintainer --- .../features/docker/__tests__/DockerNativeTables.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index b7f88cf332..15bb7087d2 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -1516,7 +1516,10 @@ describe('Docker native tables', () => { expect(screen.getByText('2')).toBeInTheDocument(); // The snake_case rollout token reads as words; the raw token stays in the title. expect(screen.getByText('Rollback started')).toBeInTheDocument(); - expect(screen.getByText('Rollback started')).toHaveClass('whitespace-normal', 'wrap-break-word'); + expect(screen.getByText('Rollback started')).toHaveClass( + 'whitespace-normal', + 'wrap-break-word', + ); expect( screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'), ).toContain('rollback_started'); From e5986b2edcef214453594707826f60bf8352d157 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:55:37 +0100 Subject: [PATCH 63/86] Remove trailing whitespace from migration browser harness Correct the two candidate-range whitespace errors identified by deterministic preflight. Application source, documentation and the own-parent browser receipt remain byte-identical to the verified candidate. Contract-Neutral: Browser harness whitespace only; no assertions, execution behaviour or product contract change. Change-source: pulse-maintainer --- frontend-modern/browser-tests/configuration-migration.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend-modern/browser-tests/configuration-migration.cjs b/frontend-modern/browser-tests/configuration-migration.cjs index 3305fe5bdf..08eace7cd1 100644 --- a/frontend-modern/browser-tests/configuration-migration.cjs +++ b/frontend-modern/browser-tests/configuration-migration.cjs @@ -56,7 +56,7 @@ const { chromium, webkit } = require('playwright'); await page.getByRole('button', { name: 'Create Backup', exact: true }).click(); const exporting = page.getByRole('dialog', { name: 'Export configuration', exact: true }); await exporting.waitFor(); - await exporting.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); + await exporting.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); let text = await exporting.innerText(); for (const phrase of ['Configuration only:', 'SSO settings', 'API-token records', 'not history, TrueNAS/vSphere connections', 'Local login credentials and sessions are not included']) @@ -96,7 +96,7 @@ const { chromium, webkit } = require('playwright'); await page.getByRole('button', { name: 'Restore Configuration', exact: true }).click(); const importing = page.getByRole('dialog', { name: 'Import configuration', exact: true }); await importing.waitFor(); - await importing.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); + await importing.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished))); text = await importing.innerText(); for (const phrase of ['Back up the destination first', 'not the whole installation', 'inventory, enrolment state, profiles and assignments are not', 'verify fresh admission']) From 1e9c2bdae6cc6c7a88785136f8320530e1ec0663 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:52:34 +0100 Subject: [PATCH 64/86] Bind migration browser checks to the composed source graph Keep the original proof record unchanged. Require exact final content and dependency identity in a new browser run, including the migrated warning icon styling. Contract-Neutral: Browser verification harness only; no runtime or archive contract delta. Change-source: pulse-maintainer --- .../browser-tests/configuration-migration.cjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend-modern/browser-tests/configuration-migration.cjs b/frontend-modern/browser-tests/configuration-migration.cjs index 08eace7cd1..2a6fe5b658 100644 --- a/frontend-modern/browser-tests/configuration-migration.cjs +++ b/frontend-modern/browser-tests/configuration-migration.cjs @@ -11,6 +11,11 @@ const { chromium, webkit } = require('playwright'); const root = path.join(workspace, 'frontend-modern'); const output = path.join(workspace, 'tmp', 'configuration-migration'); fs.mkdirSync(output, { recursive: true }); + const binding = JSON.parse(fs.readFileSync( + path.join(workspace, 'tmp', 'configuration-migration-binding.json'), 'utf8')); + assert.match(binding.sourceSha, /^[0-9a-f]{40}$/); + for (const [file, digest] of Object.entries(binding.contentSha256)) + assert.equal(createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex'), digest, file); process.chdir(root); const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); const server = await createServer({ @@ -65,6 +70,10 @@ const { chromium, webkit } = require('playwright'); const guide = exporting.getByRole('link', { name: 'migration guide', exact: true }); await guide.scrollIntoViewIfNeeded(); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + const warning = exporting.locator('strong', { hasText: 'Configuration only:' }).locator('..'); + assert.ok(await warning.isVisible(), 'configuration scope warning is visible'); + const iconWidth = await exporting.locator('svg').first().evaluate((el) => el.getBoundingClientRect().width); + assert.ok(iconWidth >= 15, 'Tailwind4 warning icon does not shrink'); await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-export.png`) }); await guide.focus(); const popupPromise = page.waitForEvent('popup'); @@ -120,14 +129,16 @@ const { chromium, webkit } = require('playwright'); assert.deepEqual(errors, []); observations.push({ engine, browserVersion: browser.version(), width, height, warningScope: true, keyboardGuideAndRetargetLinks: true, syntheticExportAndRejectedImport: true, - noPageOverflow: true, errors }); + noPageOverflow: true, warningIconWidth: iconWidth, errors }); await browser.close(); browser = undefined; } const files = ['frontend-modern/src/components/Settings/BackupTransferDialogs.tsx', 'frontend-modern/public/docs/MIGRATION.md', 'frontend-modern/public/docs/UNIFIED_AGENT.md']; const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version, - baseSha: '73d2c64e0f5bce57f3a1dc6f9e055b4784e079bd', + sourceSha: binding.sourceSha, baseSha: binding.sourceSha, + dependencySnapshot: binding.dependencySnapshot, + manifestSha256: binding.manifestSha256, verifiedAt: new Date().toISOString(), scope: 'Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment', contentSha256: Object.fromEntries(files.map((file) => [file, createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex')])), observations }; From b8eaed71c824d0ec13a1199fb916c86cfdb31c81 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:57:36 +0100 Subject: [PATCH 65/86] Record final-content browser proof for the composed frontend Verify the 26 missing final contents across inherited and newly composed PBS/NAS, guest backup, notification, Docker and History surfaces. Preserve valid old receipts; add one content-fingerprinted own-parent receipt without changing application source. Keep a production backup-page harness with measured desktop/phone CSS viewports, independent backup versus snapshot attribution, location filtering and rendered recovery waits. Change-source: pulse-maintainer Contract-Neutral: Browser proof and verification-harness changes only; no application, API, graph, safety or subsystem-contract delta. --- frontend-modern/browser-tests/backups.html | 1 + .../browser-tests/composed-backups.cjs | 333 ++++++++++++++++++ .../86320370b56dc686.json | 116 ++++++ 3 files changed, 450 insertions(+) create mode 100644 frontend-modern/browser-tests/composed-backups.cjs create mode 100644 frontend-modern/browser-verification/86320370b56dc686.json diff --git a/frontend-modern/browser-tests/backups.html b/frontend-modern/browser-tests/backups.html index f22750f1cb..66a352fb93 100644 --- a/frontend-modern/browser-tests/backups.html +++ b/frontend-modern/browser-tests/backups.html @@ -2,6 +2,7 @@ +

    diff --git a/frontend-modern/browser-tests/composed-backups.cjs b/frontend-modern/browser-tests/composed-backups.cjs new file mode 100644 index 0000000000..fd000eb8e1 --- /dev/null +++ b/frontend-modern/browser-tests/composed-backups.cjs @@ -0,0 +1,333 @@ +// Production orchestration/table/CSS with synthetic read-only HTTP evidence. +// Confirms combined Tailwind rendering and source attribution, not native backup recovery. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +(async () => { + const root = '/workspace/frontend-modern'; + const output = '/workspace/tmp/composed-backups-proof'; + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwright = require('playwright/package.json').version; + assert.equal( + playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5295, strictPort: true }, + }); + const runtime = [ + 'frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx', + 'frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx', + ]; + const content_sha256 = Object.fromEntries( + runtime.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join('/workspace', p))) + .digest('hex'), + ]), + ); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: phone, + isMobile: phone, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(20000); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + requests = [], + offOrigin = [], + checks = [], + screenshots = []; + const now = '2026-10-03T12:00:00Z'; + const workload = { + id: 'ct-310', + type: 'system-container', + name: 'artifact-cache-310', + displayName: 'artifact-cache-310', + platformId: 'fixture-estate', + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'running', + lastSeen: now, + proxmox: { vmid: 310, node: 'pve-edge', instance: 'homelab' }, + }; + const archive = { + id: 'file-310', + storage: 'local', + node: 'pve-edge', + instance: 'homelab', + type: 'ct', + vmid: 310, + time: '2026-09-20T02:00:00Z', + ctime: Date.parse('2026-09-20T02:00:00Z') / 1000, + size: 1048576, + format: 'tar.zst', + protected: false, + volid: 'local:backup/vzdump-lxc-310-2026_09_20-02_00_00.tar.zst', + isPBS: false, + verified: false, + }; + const snapshot = { + id: 'snap-310', + name: 'fresh-local-snapshot', + node: 'pve-edge', + instance: 'homelab', + type: 'ct', + vmid: 310, + time: '2026-10-03T11:30:00Z', + vmstate: false, + }; + page.on('pageerror', (error) => errors.push(error.message)); + await page.route('**/*', (route) => { + const request = route.request(), + url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5295') { + offOrigin.push(url.origin); + return route.abort(); + } + if (url.pathname === '/fixture/workloads') return route.fulfill({ json: [workload] }); + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, method: request.method() }); + assert.equal(request.method(), 'GET'); + if (url.pathname === '/api/backups/pve') + return route.fulfill({ + json: { + data: { backupTasks: [], storageBackups: [archive], guestSnapshots: [snapshot] }, + }, + }); + if (url.pathname === '/api/backups/pbs') + return route.fulfill({ json: { data: { backups: [] } } }); + if (url.pathname === '/api/recovery/postures') + return route.fulfill({ + json: { + data: [], + policy: { + freshnessWindowSeconds: 0, + verificationWindowSeconds: 0, + requireVerification: false, + }, + meta: { page: 1, limit: 200, total: 0, totalPages: 0 }, + }, + }); + return route.fulfill({ + json: + url.pathname === '/api/license/runtime-capabilities' + ? { + capabilities: [], + limits: [], + max_history_days: 7, + hosted_mode: false, + runtime: { build: 'community' }, + blocked_capabilities: [], + } + : { data: [], enabled: false }, + }); + }); + try { + await page.goto('http://127.0.0.1:5295/browser-tests/backups.html', { + waitUntil: 'domcontentloaded', + timeout: 120000, + }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + const capture = async (state) => { + await page.waitForTimeout(100); + const dimensions = await page.evaluate(() => ({ + width: innerWidth, + scroll: document.documentElement.scrollWidth, + })); + assert.equal( + dimensions.width, + phone ? 390 : 1365, + 'actual CSS viewport, not only Playwright emulation', + ); + assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions)); + const file = `${name}-${state}.png`; + await page.screenshot({ path: path.join(output, file), fullPage: true }); + screenshots.push({ file, dimensions }); + }; + await page.getByRole('link', { name: 'By date', exact: true }).click(); + const byDate = page.locator('[data-proxmox-backups-table="recoverable"]'); + await byDate.getByText('artifact-cache-310', { exact: true }).first().waitFor(); + await capture('by-date-diagnostic'); + fs.writeFileSync( + path.join(output, `${name}-by-date-diagnostic.json`), + JSON.stringify({ text: await byDate.innerText(), requests, errors }, null, 2), + ); + if (!phone) { + await byDate.getByText('tar.zst', { exact: true }).waitFor(); + assert.ok(await byDate.locator(`[title="${archive.volid}"]`).count()); + } + checks.push( + 'Explicit by-date navigation retains source, archive format and complete wide identifier', + ); + await capture('by-date'); + await page.getByRole('link', { name: 'Coverage', exact: true }).click(); + const coverage = page.locator('[data-proxmox-backups-table="coverage"]'); + await coverage.getByText('artifact-cache-310', { exact: true }).waitFor(); + const columns = await coverage + .locator('colgroup col') + .evaluateAll((elements) => elements.map((e) => e.dataset.proxmoxBackupsColumn)); + const row = coverage.locator('tr[data-proxmox-backup-row="coverage"]').first(); + const backupAge = await row.locator('td').nth(columns.indexOf('latest')).innerText(); + assert.ok(backupAge.includes('13d'), backupAge); + assert.ok(!backupAge.includes('30m'), backupAge); + if (columns.includes('snapshot')) + assert.ok( + (await row.locator('td').nth(columns.indexOf('snapshot')).innerText()).includes('30m'), + ); + checks.push('Fresh local guest snapshot does not masquerade as a recent backup'); + await capture('coverage'); + const search = page.getByPlaceholder('Search backups by workload, node, source, or status'); + await search.fill('non-existent-fixture'); + await page + .getByText('No workload coverage rows match current filters', { exact: true }) + .waitFor(); + await search.fill('artifact-cache'); + await coverage.getByText('artifact-cache-310', { exact: true }).waitFor(); + checks.push('Coverage route query filters and recovery preserve the target'); + if (phone && !(await page.getByRole('combobox', { name: 'Filter', exact: true }).count())) + await page.getByRole('button', { name: /^Filters/ }).click(); + await page + .getByRole('combobox', { name: 'Filter', exact: true }) + .selectOption({ label: 'Backup location: homelab / pve-edge' }); + await page.waitForFunction( + () => new URL(location.href).searchParams.get('location') === 'snapshot:homelab:pve-edge', + ); + const scopedColumns = await coverage + .locator('colgroup col') + .evaluateAll((elements) => elements.map((e) => e.dataset.proxmoxBackupsColumn)); + const scopedAge = row.locator('td').nth(scopedColumns.indexOf('latest')); + await scopedAge.getByText('None', { exact: true }).waitFor(); + assert.ok( + (await scopedAge.locator('span[title]').getAttribute('title')).includes( + 'Guest snapshots alone do not prove independent recovery.', + ), + ); + checks.push( + 'Snapshot-only location scope reports no independent backup, not a fresh restore promise', + ); + await capture('snapshot-only-location'); + if ( + phone && + !(await page + .getByRole('button', { name: 'Remove Backup location filter', exact: true }) + .count()) + ) + await page.getByRole('button', { name: /^Filters/ }).click(); + await page + .getByRole('button', { name: 'Remove Backup location filter', exact: true }) + .click(); + await page.waitForFunction(() => !new URL(location.href).searchParams.has('location')); + await row + .locator('td') + .nth(columns.indexOf('latest')) + .getByText(phone ? '13d' : '13d ago', { exact: true }) + .waitFor(); + checks.push('Removing location scope restores the independently dated archive'); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert.ok( + requests.some((r) => r.path === '/api/backups/pve') && + requests.some((r) => r.path === '/api/backups/pbs'), + ); + observations.push({ + name, + browser: browser.version(), + viewport: page.viewportSize(), + checks, + requests, + errors, + offOrigin, + screenshots, + }); + } catch (error) { + fs.writeFileSync( + path.join(output, `${name}-failure.json`), + JSON.stringify( + { + error: error.message, + text: await page.locator('body').innerText(), + url: page.url(), + requests, + errors, + offOrigin, + }, + null, + 2, + ), + ); + await page.screenshot({ path: path.join(output, `${name}-failure.png`), fullPage: true }); + throw error; + } + await browser.close(); + browser = null; + } + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + passed: true, + playwright, + content_sha256, + harness_sha256: crypto + .createHash('sha256') + .update(fs.readFileSync(__filename)) + .digest('hex'), + fixture_sha256: Object.fromEntries( + ['backups.html', 'backups.tsx'].map((file) => [ + file, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join(root, 'browser-tests', file))) + .digest('hex'), + ]), + ), + observations, + limitation: + 'Synthetic production component rendering, not native backup, QGA/thaw, installation or shipment.', + }, + null, + 2, + ), + ); + console.log( + JSON.stringify({ + passed: true, + browsers: observations.length, + checks: observations.reduce((n, o) => n + o.checks.length, 0), + }), + ); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification/86320370b56dc686.json b/frontend-modern/browser-verification/86320370b56dc686.json new file mode 100644 index 0000000000..4d88950e96 --- /dev/null +++ b/frontend-modern/browser-verification/86320370b56dc686.json @@ -0,0 +1,116 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "50756d745ea7ae62d3e1bbdccd03a03ce8963a1a", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx", + "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx", + "frontend-modern/src/components/Storage/StoragePoolsTable.tsx", + "frontend-modern/src/components/Workloads/DiskList.tsx", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx", + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx", + "frontend-modern/src/features/docker/DockerServicesTable.tsx", + "frontend-modern/src/features/docker/DockerTasksTable.tsx", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts", + "frontend-modern/src/features/storageBackups/diskPresentation.ts", + "frontend-modern/src/features/storageBackups/storagePagePresentation.ts", + "frontend-modern/src/utils/workloadGuestPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "4fc443b465a7fe78df9f2b3faad6337bdddd118f9cfd33faa9217aaeb2d43c1b", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "b922b64a4388855c29296b178df553c60ba9395bd63d1433f79c6112030c8399", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "aca82b1f7a6f330d1f6fd7bdb6301420d3a7bb4f7f22fa5666e013a8a656ed26", + "frontend-modern/src/components/Infrastructure/ResourceDetailSummary.tsx": "ddad24a010a2a194f37977f3c3aa3fa647b431bd65d1d75e9f5aea6d42f72692", + "frontend-modern/src/components/Storage/EnhancedStorageBar.tsx": "8021e55b50564fc9dfa29ebb83a824541b3ba34a1a6fc0fcc3793af5e5e4f701", + "frontend-modern/src/components/Storage/StoragePoolsTable.tsx": "abab4dcd8a67c39a846a97e46d3ba4e910b577f84318ed1e9c6a2c1cb6dc81fa", + "frontend-modern/src/components/Workloads/DiskList.tsx": "b9828bd9a9593093cfaeee5816a644b22177c0e91146c1592e01149a1c833793", + "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "83a0d0c00786ab3a9115d0534a6d5a08f3c428109469a932c7d4f7fd9accc2b4", + "frontend-modern/src/components/Workloads/GuestRow.tsx": "33ce83ef5a529cbab740326de6c6b04ac62ca2024f07f3414262f68c69727186", + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "ff67b81bc5722f27f49820aeed76e5cc20b770cbd2fae4dbd1f267b10519995d", + "frontend-modern/src/components/shared/HistoryChartOverlay.tsx": "a41425ff8dc2cd0768c7a9bf2f1192a3a058498d002ba3e8723979c5aaa60950", + "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "75530d5d16a579d415a5bc2c9f86e507555de1446d06529315b6c31919b03e03", + "frontend-modern/src/features/docker/DockerServicesTable.tsx": "a6d61e02ed31912abe9d1a33776a923412bef9aaa03b44d84facee723e0cc190", + "frontend-modern/src/features/docker/DockerTasksTable.tsx": "6cc99405c7bdb2b2edfd992f6b17b2636bb9285a53e949e41609dfa8b339cacf", + "frontend-modern/src/features/kubernetes/KubernetesControllersTable.tsx": "5d7362905632cf4e69c4a878f972ac40544bda2959db2822a431bcc74ba35089", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b791a50845befbdcfdca979355188fb1e977878ce69e8a72cbbdf90fda5e8cbe", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "97a95f97c94795005300fe55fdca81756b6ce9f11e977d1a3f683775eb0dc88d", + "frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "f85558ab1092078b9a3d160c986450d2167552b10b2f76c10087fceef5af0689", + "frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx": "1dc95dd84f499e38a8fca77650ae72ff6c903b7c03c8fdc3cf366b309817e488", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "80079e8de47bbaa368ff511445209fe61fdbcd7ac0ce8003ef7d90ab338df2de", + "frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts": "908af893071386d1a11a7191694b9fef399e4b134e78188bee90faa80f758e91", + "frontend-modern/src/features/standalone/AvailabilityFleetView.tsx": "426d43f1635b659949e2317740ca09b54dfff89b1cd93c4dc6035a6b82fc2d13", + "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "90670aaa66ce08fb90570b757029e10ea232c21f2e64ea4ce5e6cc3daa829d41", + "frontend-modern/src/features/storageBackups/diskPresentation.ts": "6e1faea3be9f1c7bacc8bc74ee03b2849c0e254bdee29cf6fb912c90d0ae6623", + "frontend-modern/src/features/storageBackups/storagePagePresentation.ts": "51a917ac758ce2611f444d6c37240de1e706282adad81f1feaa621aee939d115", + "frontend-modern/src/utils/workloadGuestPresentation.ts": "c51181ed80bc33907d331f2fd646b5aa209ebde622fb1ec1b6b70b8dcc2fd7d8" + }, + "routes": [ + "/browser-tests/guest-disk-deferral.html", + "/browser-tests/proxmox-docker-rows.html", + "/browser-tests/operator-tables-consent.html", + "/browser-tests/notification-evidence.html", + "/browser-tests/resource-evidence-access.html", + "/browser-tests/history-access.html", + "/browser-tests/history-keyboard.html", + "/browser-tests/drawer-snapshot.html", + "/browser-tests/backups.html" + ], + "states": [ + "Eight guest disk deferral/uncertainty families with and without retained observations; seven distinct unavailable states; fresh same-VM resumption", + "PBS/NAS pool health and capacity, full SMART reason, Kubernetes absolute started/completed and last-run context", + "REST-mapped Docker/PMG/storage rows and disclosures; fresh and missing facets; Swarm lifecycle details", + "Backup age, source and archive detail remain independent of guest snapshots, including snapshot-only repository scope", + "Notification initial/held pending, denied/unavailable and retained-transient read states", + "History/action denial withdraws prior samples, cache and fallback; transient failures retain truthful last-known evidence; pending retry and remount", + "Same-ID drawer replacement, target withdrawal, explicit selection and removed/restored availability checks", + "History overlay keyboard focus, arrow/Home/End bounds, grouped tooltips, Escape and empty data", + "Rollback consent reports unknown restore scope; rejection keeps consent and pending submissions cannot be duplicated or cancelled" + ], + "interactions": [ + "Open/close real rows and detail disclosures; hover disk bars on desktop; use phone touch controls", + "Select By date and Coverage using real router links; query filter to empty and back; scope to snapshot-only location and remove scope", + "Retry History and action/facet reads; select target and range; retain denial during pending refresh; read fresh successful result", + "Keyboard activation, Tab, Enter, arrow keys, Home, End and Escape; cancel restore before mutation and observe synthetic rejection/pending acceptance", + "Inspect actual desktop and phone screenshots and assert rendered bounds and final data, not just route or HTTP success" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 1440, + "height": 900 + }, + { + "width": 768, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "verified_at": "2026-10-03T16:56:27Z", + "evidence": [ + "Current invocation OUTCOME/RECEIPTS.md and hash-verified browser artifacts (nine production-component fixture families), synthetic production-component/CSS observations only" + ], + "limitations": [ + "Not native appliance/QGA/thaw, full-app/server, installation, signed persistent-state recovery or released acceptance.", + "Fresh additive receipt covers the 26 missing final contents against published main. Existing valid own-parent proofs are retained rather than rewritten." + ] +} From 76484e4e8cf9a17925fd5091d399eda4235ee668 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:00:03 +0100 Subject: [PATCH 66/86] Verify combined migration warnings and guides on Tailwind 4 Chromium desktop and WebKit touch-phone checks exercise the real transfer components, synthetic export/rejected-import flow and production Docs rendering. Record only this fresh final-content run on its own parent, leaving old receipts in history. Contract-Neutral: Fresh browser receipt only; no runtime or archive contract delta. Change-source: pulse-maintainer --- .../c37ec2e0f5b10ec9.json | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 frontend-modern/browser-verification/c37ec2e0f5b10ec9.json diff --git a/frontend-modern/browser-verification/c37ec2e0f5b10ec9.json b/frontend-modern/browser-verification/c37ec2e0f5b10ec9.json new file mode 100644 index 0000000000..9785a19690 --- /dev/null +++ b/frontend-modern/browser-verification/c37ec2e0f5b10ec9.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "result": "passed", + "base_sha": "1e9c2bdae6cc6c7a88785136f8320530e1ec0663", + "changed_paths": [ + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "7bc815d6a324bea1823600029dd8c36decf724bce9b725a4c2124df7bb3b7706" + }, + "routes": [ + "/browser-tests/configuration-migration.html (actual transfer dialogs and flow)", + "/docs/MIGRATION", + "/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address" + ], + "states": [ + "Chromium desktop/light export scope with disabled empty-passphrase export", + "WebKit touch phone/dark transfer warnings with Tailwind4 styling", + "Production Docs rendering of archive scope, recovery precautions and agent retargeting", + "Synthetic exported archive and deliberately rejected import; dialog remains available" + ], + "interactions": [ + "Keyboard open migration guide from export dialog", + "Keyboard navigate agent retargeting link and focused destination heading", + "Enter synthetic passphrase and verify exported synthetic download", + "Choose synthetic import file and verify only the intended POST payload", + "Rejected import then Cancel; no duplicate export/import operations", + "Check no horizontal page overflow, no page errors and 16px non-shrinking warning icon" + ], + "viewports": [ + { + "width": 1440, + "height": 1000 + }, + { + "width": 390, + "height": 844 + } + ], + "verified_at": "2026-10-03T16:56:47.149Z", + "browser_versions": [ + { + "engine": "chromium", + "browserVersion": "141.0.7390.37", + "width": 1440, + "height": 1000, + "warningScope": true, + "keyboardGuideAndRetargetLinks": true, + "syntheticExportAndRejectedImport": true, + "noPageOverflow": true, + "warningIconWidth": 16, + "errors": [] + }, + { + "engine": "webkit", + "browserVersion": "26.0", + "width": 390, + "height": 844, + "warningScope": true, + "keyboardGuideAndRetargetLinks": true, + "syntheticExportAndRejectedImport": true, + "noPageOverflow": true, + "warningIconWidth": 16, + "errors": [] + } + ], + "playwright_version": "1.56.1", + "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82", + "manifest_sha256": { + "frontend-modern/package.json": "ed07ff9bc11ef047d04dbbdf025eb3e0c56e2fe388cfeb83e41fe6234b8af76a", + "frontend-modern/package-lock.json": "636405b905f4f90d9f5e38f729ecdb76f528121476ada657e084e6eccea8fa1b" + }, + "guide_content_sha256": { + "frontend-modern/public/docs/MIGRATION.md": "ad4b5a4b07af44e1657f9512ef468a578b8b5957b9b115a30816824bbd8300e9", + "frontend-modern/public/docs/UNIFIED_AGENT.md": "61a15c9651223ac26f4ca900f5731534f03abf96abfd233c47bf034c2796fc05" + }, + "scope": "Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment" +} From 971d4e9f776e3642712676da8472cfc086c2237d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:16:20 +0100 Subject: [PATCH 67/86] Keep auth context tests independent of tenant runtime writers The containing-source API suite passed its auth assertions but failed temporary-directory cleanup in the tenant token test. It unnecessarily started a complete mock monitoring runtime for a request-context check. Use the existing cached-monitor fixture pattern instead; retain global fallback and add real tenant-token and invalid-token controls. This changes no production authentication or monitor lifecycle behaviour. Change-source: pulse-maintainer --- internal/api/auth_context_tenant_test.go | 107 ++++++++++++++--------- 1 file changed, 67 insertions(+), 40 deletions(-) diff --git a/internal/api/auth_context_tenant_test.go b/internal/api/auth_context_tenant_test.go index 2c132fa31f..b29d9ba89a 100644 --- a/internal/api/auth_context_tenant_test.go +++ b/internal/api/auth_context_tenant_test.go @@ -11,49 +11,76 @@ import ( ) func TestExtractAndStoreAuthContext_UsesTenantConfigForToken(t *testing.T) { - setMockModeForTest(t, true) + const globalToken = "global-token-123.12345678" + const tenantToken = "tenant-token-123.12345678" + for _, tc := range []struct { + name string + global bool + tenant bool + provided string + wantRecord string + }{ + {"global fallback with empty tenant tokens", true, false, globalToken, "global"}, + {"tenant token with different global tokens", true, true, tenantToken, "tenant"}, + {"unknown token is rejected", true, true, "unknown-token-123.12345678", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + baseCfg := &config.Config{} + tenantCfg := &config.Config{} + for _, token := range []struct { + enabled bool + cfg *config.Config + raw string + name string + }{ + {tc.global, baseCfg, globalToken, "global"}, + {tc.tenant, tenantCfg, tenantToken, "tenant"}, + } { + if !token.enabled { + continue + } + record, err := config.NewAPITokenRecord(token.raw, token.name, []string{config.ScopeMonitoringRead}) + if err != nil { + t.Fatalf("new %s token record: %v", token.name, err) + } + token.cfg.APITokens = []config.APITokenRecord{*record} + token.cfg.SortAPITokens() + } - baseCfg := &config.Config{ - DataPath: t.TempDir(), - ConfigPath: t.TempDir(), - } + const tenantID = "org-1" + mtPersistence := config.NewMultiTenantPersistence(t.TempDir()) + if _, err := mtPersistence.GetPersistence(tenantID); err != nil { + t.Fatalf("tenant persistence: %v", err) + } + mtm := monitoring.NewMultiTenantMonitor(baseCfg, mtPersistence, nil) + t.Cleanup(mtm.Stop) - mtPersistence := config.NewMultiTenantPersistence(t.TempDir()) - mtm := monitoring.NewMultiTenantMonitor(baseCfg, mtPersistence, nil) - t.Cleanup(mtm.Stop) + // Authentication needs a cached tenant configuration, not a running + // demo monitor whose background writers can outlive fixture cleanup. + tenantMonitor := &monitoring.Monitor{} + setUnexportedField(t, tenantMonitor, "config", tenantCfg) + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{tenantID: tenantMonitor}) - tenantID := "org-1" - _, err := mtPersistence.GetPersistence(tenantID) - if err != nil { - t.Fatalf("tenant persistence: %v", err) - } + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + req.Header.Set("X-Pulse-Org-ID", tenantID) + req.Header.Set("X-API-Token", tc.provided) + req = extractAndStoreAuthContext(baseCfg, mtm, req) - rawToken := "tenant-token-123.12345678" - record, err := config.NewAPITokenRecord(rawToken, "tenant", []string{config.ScopeMonitoringRead}) - if err != nil { - t.Fatalf("new token record: %v", err) - } - baseCfg.APITokens = []config.APITokenRecord{*record} - baseCfg.SortAPITokens() - - // Ensure tenant monitor/config is initialized - tenantMonitor, err := mtm.GetMonitor(tenantID) - if err != nil { - t.Fatalf("get tenant monitor: %v", err) - } - tenantMonitor.GetConfig().APITokens = nil - - req := httptest.NewRequest(http.MethodGet, "/api/test", nil) - req.Header.Set("X-Pulse-Org-ID", tenantID) - req.Header.Set("X-API-Token", rawToken) - - req = extractAndStoreAuthContext(baseCfg, mtm, req) - - user := internalauth.GetUser(req.Context()) - if user == "" { - t.Fatalf("expected user context to be set") - } - if internalauth.GetAPIToken(req.Context()) == nil { - t.Fatalf("expected API token record in context") + user := internalauth.GetUser(req.Context()) + token := internalauth.GetAPIToken(req.Context()) + if tc.wantRecord == "" { + if user != "" || token != nil { + t.Fatal("unknown token must not receive user or API token context") + } + return + } + if user == "" { + t.Fatal("expected authenticated user context") + } + record, ok := token.(*config.APITokenRecord) + if !ok || record.Name != tc.wantRecord || !record.HasScope(config.ScopeMonitoringRead) { + t.Fatalf("expected %s monitoring token in context, got %#v", tc.wantRecord, token) + } + }) } } From cbbed83addc84478655da8789df29f24f764b996 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:28:18 +0100 Subject: [PATCH 68/86] Make reverse-proxy help match the forwarded-header trust boundary Explain immediate-peer trust before proxy setup, constrain copyable HTTPS examples and preserve authentication during diagnostics. Keep both shipped guides in sync and cover the missing guidance in the documentation regression checks. Contract-Neutral: Documentation and regression checks only; proxy trust, authentication, routing and runtime behaviour are unchanged. Change-source: pulse-maintainer --- docs/CONFIGURATION.md | 2 +- docs/REVERSE_PROXY.md | 123 +++++++++++++----- frontend-modern/public/docs/CONFIGURATION.md | 2 +- frontend-modern/public/docs/REVERSE_PROXY.md | 123 +++++++++++++----- .../src/utils/__tests__/docsLinks.test.ts | 28 ++++ 5 files changed, 218 insertions(+), 60 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f95747ce41..190e1d6b93 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -307,7 +307,7 @@ Environment variables take precedence over `system.json`. | `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` | | `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` | | `DEMO_MODE` | Enable read-only demo mode | `false` | -| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* | +| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated immediate proxy IPs/CIDRs trusted for forwarded client IP, scheme, host and port. Use the peer seen by Pulse; wildcard ranges are rejected. See [Reverse Proxy](REVERSE_PROXY.md#before-configuring-the-proxy). | *(unset)* | | `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* | | `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` | diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md index d66fbe8f79..f6b19278d1 100644 --- a/docs/REVERSE_PROXY.md +++ b/docs/REVERSE_PROXY.md @@ -2,49 +2,118 @@ Pulse uses WebSockets for real-time updates. Your proxy **MUST** support WebSockets. +## Before configuring the proxy + +Use HTTPS at the public proxy and keep Pulse's backend private. The examples +below are configuration fragments, not complete TLS or authentication setups. +Keep Pulse authentication enabled; TLS termination does not authenticate users. +For identity supplied by an IdP/proxy, follow the separate +[proxy authentication guide](PROXY_AUTH.md#-header-trust-boundary). + +**Forwarded HTTPS headers are trusted only from a configured immediate peer.** +Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy +address **as seen by Pulse**, not the browser's address or the public hostname. +For a proxy on the same host connecting to `127.0.0.1:7655`, use: + +```dotenv +PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32 +``` + +Use `::1/128` instead if that connection actually uses IPv6 loopback. A Docker +proxy normally reaches Pulse from its private container/network address, not +loopback; trust only that proxy's fixed address or a narrowly isolated proxy +network. Do not trust a whole LAN or all Docker networks to make a warning go +away. Wildcard ranges `0.0.0.0/0` and `::/0` are rejected at startup. + +At the public edge, replace client-supplied forwarded scheme, host and client-IP +headers with values established by the proxy; do not append an untrusted value. +With multiple proxy hops, configure the upstream trust boundary on each hop +and trust only the final peer in Pulse. `PULSE_TRUSTED_NETWORKS` is not a +substitute for proxy trust, and proxy trust is not an authentication bypass. +See [configuration overrides](CONFIGURATION.md#common-overrides-environment-variables). + ## โšก Quick Configs ### Nginx + +Inside your HTTPS `server` block, for a single public-edge proxy: + ```nginx location / { - proxy_pass http://localhost:7655; + proxy_pass http://127.0.0.1:7655; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; - + proxy_set_header X-Forwarded-Scheme ""; + proxy_set_header Forwarded ""; + # Critical for WebSockets proxy_read_timeout 86400; # 24h } ``` ### Caddy + +Caddy handles the WebSocket upgrade and sets forwarded client-IP, host and +scheme headers automatically. The removals below discard other client-supplied +forwarding metadata. Pulse still needs the matching trusted-peer setting above: + ```caddy pulse.example.com { - reverse_proxy localhost:7655 + reverse_proxy 127.0.0.1:7655 { + header_up -Forwarded + header_up -X-Forwarded-Scheme + header_up -X-Forwarded-Port + } } ``` ### Traefik (Docker Compose) + +These labels assume an existing HTTPS entrypoint named `websecure` and a +configured certificate. Keep Pulse on the private proxy network; do not publish +its backend port publicly. Apply trusted-peer configuration to Pulse separately. +Keep Traefik's `forwardedHeaders.insecure` disabled; upstream forwarded headers +need their own narrow trusted-IP configuration, not blanket client trust. + ```yaml labels: - "traefik.enable=true" - "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)" + - "traefik.http.routers.pulse.entrypoints=websecure" + - "traefik.http.routers.pulse.tls=true" + - "traefik.http.routers.pulse.service=pulse" - "traefik.http.services.pulse.loadbalancer.server.port=7655" ``` ### Apache + +Inside a TLS-enabled virtual host, with the required proxy, rewrite and headers +modules enabled: + ```apache +ProxyPreserveHost On +ProxyAddHeaders On +RequestHeader unset X-Forwarded-For +RequestHeader unset X-Forwarded-Host +RequestHeader unset X-Forwarded-Port +RequestHeader unset X-Real-IP +RequestHeader set X-Forwarded-Proto "https" +RequestHeader unset X-Forwarded-Scheme +RequestHeader unset Forwarded RewriteEngine On RewriteCond %{HTTP:Upgrade} websocket [NC] RewriteCond %{HTTP:Connection} upgrade [NC] -RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L] +RewriteRule ^/?(.*) "ws://127.0.0.1:7655/$1" [P,L] -ProxyPass / http://localhost:7655/ -ProxyPassReverse / http://localhost:7655/ +ProxyPass / http://127.0.0.1:7655/ +ProxyPassReverse / http://127.0.0.1:7655/ ``` --- @@ -55,33 +124,29 @@ ProxyPassReverse / http://localhost:7655/ If your reverse proxy terminates SSL but Pulse shows "HTTPS: HTTP only" in Settings โ†’ Security: -**Cause**: Pulse detects HTTPS in two ways: -1. Direct TLS connection (`req.TLS != nil`) -2. The `X-Forwarded-Proto: https` header +Pulse recognises direct TLS, or `X-Forwarded-Proto: https` from an immediate +peer in `PULSE_TRUSTED_PROXY_CIDRS`. An untrusted peer's header is ignored even +when the browser used HTTPS. Adding the header alone is not sufficient. -If your proxy terminates SSL but doesn't forward this header, Pulse sees plain HTTP. +Check the proxy's actual backend connection address and the effective Pulse +setting first, then check that the TLS proxy sends exactly `https`. For Nginx, +keep `proxy_set_header X-Forwarded-Proto $scheme` inside the HTTPS server's +location; do not label a plain-HTTP request as HTTPS. Caddy normally needs no +header override. Any Caddy `header_up` override belongs **inside** its +`reverse_proxy` block, not directly in the site block. -**Fix**: Add the `X-Forwarded-Proto` header in your proxy config: - -```nginx -# Nginx -proxy_set_header X-Forwarded-Proto $scheme; -``` - -```caddy -# Caddy (automatic, but explicit override if needed) -header_up X-Forwarded-Proto {scheme} -``` - -```apache -# Apache -RequestHeader set X-Forwarded-Proto "https" -``` +After applying configuration through your normal deployment path, sign in +through the real HTTPS URL and recheck Security Posture and live dashboard +updates. If using SSO, check its redirect/callback on that same URL. A successful +`/api/health` request alone does not verify authentication, WebSockets or proxy +trust. Do not disable authentication, certificate verification or origin checks, +or expose the backend, to test this. Share only redacted configuration and +HTTP/error status, not cookies, proxy secrets or a full network export. ### Other Issues - **"Connection Lost"**: WebSocket upgrade failed. Check `Upgrade` and `Connection` headers. -- **502 Bad Gateway**: Pulse is not running on port 7655. +- **502 Bad Gateway**: Check Pulse's actual listener and the proxy's upstream address, port and network reachability; this does not by itself prove Pulse is stopped. Container `localhost` refers to that container, not another service. - **CORS Errors**: Do not add CORS headers in the proxy; Pulse handles them. Set **Settings โ†’ System โ†’ Network โ†’ Allowed Origins** or use `ALLOWED_ORIGINS` if needed. - **OIDC redirects fail**: Ensure `X-Forwarded-Proto` is set (see above). -- **Wrong client IPs**: Set `PULSE_TRUSTED_PROXY_CIDRS` to your proxy IP/CIDR so `X-Forwarded-For` is trusted. +- **Wrong client IPs**: Check the immediate peer and header replacement above. Do not broaden proxy trust or change authentication to repair attribution. diff --git a/frontend-modern/public/docs/CONFIGURATION.md b/frontend-modern/public/docs/CONFIGURATION.md index f95747ce41..190e1d6b93 100644 --- a/frontend-modern/public/docs/CONFIGURATION.md +++ b/frontend-modern/public/docs/CONFIGURATION.md @@ -307,7 +307,7 @@ Environment variables take precedence over `system.json`. | `DISCOVERY_HTTP_TIMEOUT_MS` | HTTP probe timeout (ms) | `2000` | | `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form | `false` | | `DEMO_MODE` | Enable read-only demo mode | `false` | -| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated IPs/CIDRs trusted to supply `X-Forwarded-For`/`X-Real-IP` | *(unset)* | +| `PULSE_TRUSTED_PROXY_CIDRS` | Comma-separated immediate proxy IPs/CIDRs trusted for forwarded client IP, scheme, host and port. Use the peer seen by Pulse; wildcard ranges are rejected. See [Reverse Proxy](REVERSE_PROXY.md#before-configuring-the-proxy). | *(unset)* | | `PULSE_TRUSTED_NETWORKS` | Comma-separated CIDRs treated as trusted local networks (does not bypass auth) | *(unset)* | | `ALLOW_UNPROTECTED_EXPORT` | Allow unauthenticated config export on public networks when no auth is configured (use with caution) | `false` | diff --git a/frontend-modern/public/docs/REVERSE_PROXY.md b/frontend-modern/public/docs/REVERSE_PROXY.md index d66fbe8f79..f6b19278d1 100644 --- a/frontend-modern/public/docs/REVERSE_PROXY.md +++ b/frontend-modern/public/docs/REVERSE_PROXY.md @@ -2,49 +2,118 @@ Pulse uses WebSockets for real-time updates. Your proxy **MUST** support WebSockets. +## Before configuring the proxy + +Use HTTPS at the public proxy and keep Pulse's backend private. The examples +below are configuration fragments, not complete TLS or authentication setups. +Keep Pulse authentication enabled; TLS termination does not authenticate users. +For identity supplied by an IdP/proxy, follow the separate +[proxy authentication guide](PROXY_AUTH.md#-header-trust-boundary). + +**Forwarded HTTPS headers are trusted only from a configured immediate peer.** +Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy +address **as seen by Pulse**, not the browser's address or the public hostname. +For a proxy on the same host connecting to `127.0.0.1:7655`, use: + +```dotenv +PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32 +``` + +Use `::1/128` instead if that connection actually uses IPv6 loopback. A Docker +proxy normally reaches Pulse from its private container/network address, not +loopback; trust only that proxy's fixed address or a narrowly isolated proxy +network. Do not trust a whole LAN or all Docker networks to make a warning go +away. Wildcard ranges `0.0.0.0/0` and `::/0` are rejected at startup. + +At the public edge, replace client-supplied forwarded scheme, host and client-IP +headers with values established by the proxy; do not append an untrusted value. +With multiple proxy hops, configure the upstream trust boundary on each hop +and trust only the final peer in Pulse. `PULSE_TRUSTED_NETWORKS` is not a +substitute for proxy trust, and proxy trust is not an authentication bypass. +See [configuration overrides](CONFIGURATION.md#common-overrides-environment-variables). + ## โšก Quick Configs ### Nginx + +Inside your HTTPS `server` block, for a single public-edge proxy: + ```nginx location / { - proxy_pass http://localhost:7655; + proxy_pass http://127.0.0.1:7655; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; - + proxy_set_header X-Forwarded-Scheme ""; + proxy_set_header Forwarded ""; + # Critical for WebSockets proxy_read_timeout 86400; # 24h } ``` ### Caddy + +Caddy handles the WebSocket upgrade and sets forwarded client-IP, host and +scheme headers automatically. The removals below discard other client-supplied +forwarding metadata. Pulse still needs the matching trusted-peer setting above: + ```caddy pulse.example.com { - reverse_proxy localhost:7655 + reverse_proxy 127.0.0.1:7655 { + header_up -Forwarded + header_up -X-Forwarded-Scheme + header_up -X-Forwarded-Port + } } ``` ### Traefik (Docker Compose) + +These labels assume an existing HTTPS entrypoint named `websecure` and a +configured certificate. Keep Pulse on the private proxy network; do not publish +its backend port publicly. Apply trusted-peer configuration to Pulse separately. +Keep Traefik's `forwardedHeaders.insecure` disabled; upstream forwarded headers +need their own narrow trusted-IP configuration, not blanket client trust. + ```yaml labels: - "traefik.enable=true" - "traefik.http.routers.pulse.rule=Host(`pulse.example.com`)" + - "traefik.http.routers.pulse.entrypoints=websecure" + - "traefik.http.routers.pulse.tls=true" + - "traefik.http.routers.pulse.service=pulse" - "traefik.http.services.pulse.loadbalancer.server.port=7655" ``` ### Apache + +Inside a TLS-enabled virtual host, with the required proxy, rewrite and headers +modules enabled: + ```apache +ProxyPreserveHost On +ProxyAddHeaders On +RequestHeader unset X-Forwarded-For +RequestHeader unset X-Forwarded-Host +RequestHeader unset X-Forwarded-Port +RequestHeader unset X-Real-IP +RequestHeader set X-Forwarded-Proto "https" +RequestHeader unset X-Forwarded-Scheme +RequestHeader unset Forwarded RewriteEngine On RewriteCond %{HTTP:Upgrade} websocket [NC] RewriteCond %{HTTP:Connection} upgrade [NC] -RewriteRule ^/?(.*) "ws://localhost:7655/$1" [P,L] +RewriteRule ^/?(.*) "ws://127.0.0.1:7655/$1" [P,L] -ProxyPass / http://localhost:7655/ -ProxyPassReverse / http://localhost:7655/ +ProxyPass / http://127.0.0.1:7655/ +ProxyPassReverse / http://127.0.0.1:7655/ ``` --- @@ -55,33 +124,29 @@ ProxyPassReverse / http://localhost:7655/ If your reverse proxy terminates SSL but Pulse shows "HTTPS: HTTP only" in Settings โ†’ Security: -**Cause**: Pulse detects HTTPS in two ways: -1. Direct TLS connection (`req.TLS != nil`) -2. The `X-Forwarded-Proto: https` header +Pulse recognises direct TLS, or `X-Forwarded-Proto: https` from an immediate +peer in `PULSE_TRUSTED_PROXY_CIDRS`. An untrusted peer's header is ignored even +when the browser used HTTPS. Adding the header alone is not sufficient. -If your proxy terminates SSL but doesn't forward this header, Pulse sees plain HTTP. +Check the proxy's actual backend connection address and the effective Pulse +setting first, then check that the TLS proxy sends exactly `https`. For Nginx, +keep `proxy_set_header X-Forwarded-Proto $scheme` inside the HTTPS server's +location; do not label a plain-HTTP request as HTTPS. Caddy normally needs no +header override. Any Caddy `header_up` override belongs **inside** its +`reverse_proxy` block, not directly in the site block. -**Fix**: Add the `X-Forwarded-Proto` header in your proxy config: - -```nginx -# Nginx -proxy_set_header X-Forwarded-Proto $scheme; -``` - -```caddy -# Caddy (automatic, but explicit override if needed) -header_up X-Forwarded-Proto {scheme} -``` - -```apache -# Apache -RequestHeader set X-Forwarded-Proto "https" -``` +After applying configuration through your normal deployment path, sign in +through the real HTTPS URL and recheck Security Posture and live dashboard +updates. If using SSO, check its redirect/callback on that same URL. A successful +`/api/health` request alone does not verify authentication, WebSockets or proxy +trust. Do not disable authentication, certificate verification or origin checks, +or expose the backend, to test this. Share only redacted configuration and +HTTP/error status, not cookies, proxy secrets or a full network export. ### Other Issues - **"Connection Lost"**: WebSocket upgrade failed. Check `Upgrade` and `Connection` headers. -- **502 Bad Gateway**: Pulse is not running on port 7655. +- **502 Bad Gateway**: Check Pulse's actual listener and the proxy's upstream address, port and network reachability; this does not by itself prove Pulse is stopped. Container `localhost` refers to that container, not another service. - **CORS Errors**: Do not add CORS headers in the proxy; Pulse handles them. Set **Settings โ†’ System โ†’ Network โ†’ Allowed Origins** or use `ALLOWED_ORIGINS` if needed. - **OIDC redirects fail**: Ensure `X-Forwarded-Proto` is set (see above). -- **Wrong client IPs**: Set `PULSE_TRUSTED_PROXY_CIDRS` to your proxy IP/CIDR so `X-Forwarded-For` is trusted. +- **Wrong client IPs**: Check the immediate peer and header replacement above. Do not broaden proxy trust or change authentication to repair attribution. diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 0d980ec443..2988806241 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -131,6 +131,34 @@ describe('docsLinks', () => { } }); + it('keeps reverse-proxy help explicit about forwarded HTTPS and peer trust', () => { + const proxy = readFileSync(path.join(repoRoot, 'docs', 'REVERSE_PROXY.md'), 'utf8'); + const configuration = readFileSync(path.join(repoRoot, 'docs', 'CONFIGURATION.md'), 'utf8'); + const setup = proxy + .split('## Before configuring the proxy')[1] + ?.split('## โšก Quick Configs')[0]; + expect(setup).toBeDefined(); + expect(setup).toContain('configured immediate peer'); + expect(setup).toContain('PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32'); + expect(setup).toContain('::1/128'); + expect(setup).toContain('Wildcard ranges `0.0.0.0/0` and `::/0` are rejected'); + expect(setup).toContain('not an authentication bypass'); + expect(setup).toContain('PROXY_AUTH.md#-header-trust-boundary'); + const nginx = proxy.split('### Nginx')[1].split('### Caddy')[0]; + expect(nginx).toContain('HTTPS `server` block'); + expect(nginx).toContain('proxy_set_header X-Forwarded-Proto $scheme;'); + expect(nginx).toContain('proxy_set_header X-Forwarded-For $remote_addr;'); + expect(nginx).toContain('proxy_set_header X-Forwarded-Host $host;'); + expect(nginx).toContain('proxy_set_header Forwarded "";'); + expect(proxy).not.toContain('$proxy_add_x_forwarded_for'); + const troubleshooting = proxy.split('### "HTTPS: HTTP only"')[1].split('### Other Issues')[0]; + expect(troubleshooting).toContain('Adding the header alone is not sufficient'); + expect(troubleshooting).toContain('inside** its\n`reverse_proxy` block'); + expect(troubleshooting).toContain('does not verify authentication, WebSockets or proxy'); + expect(configuration).toContain('REVERSE_PROXY.md#before-configuring-the-proxy'); + expect(configuration).toContain('forwarded client IP, scheme, host and port'); + }); + it('keeps configuration transfer separate from full-state recovery', () => { const migration = readFileSync(path.join(repoRoot, 'docs', 'MIGRATION.md'), 'utf8'); const agent = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8'); From d36dba5dffe33cbbdc0d9c4022ab676eb660000f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:35:12 +0100 Subject: [PATCH 69/86] Verify trusted-peer proxy help in the shipped Docs viewer Exercise the real guide, safe copied Nginx block and keyboard links on desktop Chromium and narrow phone WebKit. This renders guidance only; it is not native proxy, TLS or SSO acceptance. Contract-Neutral: Browser test harness only; no frontend runtime, authentication or proxy contract change. Change-source: pulse-maintainer --- .../browser-tests/reverse-proxy-help.cjs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 frontend-modern/browser-tests/reverse-proxy-help.cjs diff --git a/frontend-modern/browser-tests/reverse-proxy-help.cjs b/frontend-modern/browser-tests/reverse-proxy-help.cjs new file mode 100644 index 0000000000..cfd6292286 --- /dev/null +++ b/frontend-modern/browser-tests/reverse-proxy-help.cjs @@ -0,0 +1,120 @@ +// Real shipped Docs/router/CSS, with no backend, proxy or real credential. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash } = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const workspace = process.cwd(); + const root = path.join(workspace, 'frontend-modern'); + const output = path.join(workspace, 'tmp', 'reverse-proxy-help'); + fs.mkdirSync(output, { recursive: true }); + const binding = JSON.parse(fs.readFileSync(path.join(output, 'binding.json'), 'utf8')); + assert.match(binding.sourceSha, /^[0-9a-f]{40}$/); + for (const [file, digest] of Object.entries(binding.contentSha256)) { + assert.equal( + createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex'), + digest, + file, + ); + } + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-reverse-proxy-help'), + server: { host: '127.0.0.1', port: 5268, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const observations = []; + let browser; + const inViewport = (id) => { + const heading = document.getElementById(id); + return heading && heading.getBoundingClientRect().top >= 0 && + heading.getBoundingClientRect().top < innerHeight; + }; + try { + await server.listen(); + for (const [engine, width, height, tone] of [ + ['chromium', 1280, 1000, 'light'], ['webkit', 390, 844, 'dark'], + ]) { + browser = await { chromium, webkit }[engine].launch(engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ + viewport: { width, height }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto('http://127.0.0.1:5268/browser-tests/docs-fragment-navigation.html'); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + await page.getByRole('link', { name: 'โ† All documentation' }).click(); + const entry = page.locator('a[data-doc-link][href="/docs/REVERSE_PROXY"]').first(); + await entry.focus(); + await page.keyboard.press('Enter'); + const heading = page.locator('#before-configuring-the-proxy'); + await heading.waitFor(); + await heading.scrollIntoViewIfNeeded(); + let text = await page.locator('article').innerText(); + for (const phrase of [ + 'configured immediate peer', 'PULSE_TRUSTED_PROXY_CIDRS=127.0.0.1/32', + 'Wildcard ranges', 'rejected at startup', 'not an authentication bypass', + 'Adding the header alone is not sufficient', + ]) assert.ok(text.includes(phrase), phrase); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(output, `${engine}-trust.png`) }); + const nginx = page.locator('pre').filter({ hasText: 'proxy_set_header X-Forwarded-For $remote_addr;' }); + await nginx.scrollIntoViewIfNeeded(); + const copied = await nginx.innerText(); + assert.ok(copied.includes('proxy_set_header X-Forwarded-Proto $scheme;')); + assert.ok(copied.includes('proxy_set_header Forwarded "";')); + assert.ok(!copied.includes('$proxy_add_x_forwarded_for')); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(output, `${engine}-nginx.png`) }); + const configuration = page.getByRole('link', { name: 'configuration overrides', exact: true }); + await configuration.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(inViewport, 'common-overrides-environment-variables'); + text = await page.locator('article').innerText(); + assert.ok(text.includes('forwarded client IP, scheme, host and port')); + const back = page.getByRole('link', { name: 'Reverse Proxy', exact: true }); + await back.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(inViewport, 'before-configuring-the-proxy'); + assert.ok(page.url().endsWith('/docs/REVERSE_PROXY#before-configuring-the-proxy')); + const authentication = page.getByRole('link', { name: 'proxy authentication guide', exact: true }); + await authentication.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(inViewport, '-header-trust-boundary'); + assert.ok(page.url().endsWith('/docs/PROXY_AUTH#-header-trust-boundary')); + assert.ok((await page.locator('article').innerText()).includes('never append to them')); + assert.deepEqual(errors, []); + observations.push({ + engine, width, height, tone, browserVersion: browser.version(), + trustWarningRendered: true, safeNginxBlockRendered: true, + keyboardConfigurationRoundTrip: true, keyboardAuthenticationBoundary: true, + noPageOverflow: true, errors, + }); + await browser.close(); + browser = undefined; + } + const result = { + result: 'passed', playwrightVersion: require('playwright/package.json').version, + sourceSha: binding.sourceSha, verifiedAt: new Date().toISOString(), + contentSha256: binding.contentSha256, dependencySnapshot: binding.dependencySnapshot, + scope: 'Production Docs rendering/navigation; not native proxy configuration, TLS or SSO acceptance', + observations, + }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); From bbf839a26194204fbe025415f67630f7b0afeece Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:36:44 +0100 Subject: [PATCH 70/86] Keep completed guest backup evidence visible during activity The real guest drawer replaced missing or old completed protection with a green running state. Preserve the completed age and tone, and show current activity separately as not completed yet. Ending activity without a new timestamp must not imply protection. Mounted and full-drawer VM/CT browser regressions preserve that distinction and the independent disk-lock guidance. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 28 +- .../subsystems/performance-and-scalability.md | 8 + .../browser-tests/guest-backup-protection.cjs | 255 ++++++++++++++++++ .../guest-backup-protection.html | 12 + .../browser-tests/guest-backup-protection.tsx | 82 ++++++ .../98ed2bca9322b776.json | 56 ++++ .../components/Workloads/GuestDrawer.test.tsx | 65 ++++- .../Workloads/GuestDrawerOverview.tsx | 11 +- .../workloadGuestPresentation.test.ts | 12 +- .../src/utils/workloadGuestPresentation.ts | 6 +- 10 files changed, 516 insertions(+), 19 deletions(-) create mode 100644 frontend-modern/browser-tests/guest-backup-protection.cjs create mode 100644 frontend-modern/browser-tests/guest-backup-protection.html create mode 100644 frontend-modern/browser-tests/guest-backup-protection.tsx create mode 100644 frontend-modern/browser-verification/98ed2bca9322b776.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 23f91dd104..9f64f9576c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -22,7 +22,7 @@ ### Guest disk deferral and retained observations -The existing workload row, disk breakdown tooltip/list and guest Overview use +The existing workload row, disk breakdown tooltip and guest Overview use one presentation for the fixed guest-read reason vocabulary. Operation locks, unverified lock clearance, an in-flight request, cooldown, incomplete response and capacity deferrals are not evidence that QGA is missing. Invalid VM identity @@ -38,12 +38,36 @@ updates values without remounting the row. Permission, disabled-agent and actual unavailable cases retain their own explanations. Verification: `workloadGuestPresentation.test.ts`, the mounted `GuestRow`, -`DiskList`, `StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions, +`StackedDiskBar` and `GuestDrawerOverview.filesystems` regressions, and `browser-tests/guest-disk-deferral.cjs` cover the fixed reasons, retained/no previous data and fresh resumption with production components and CSS on desktop Chromium and touch WebKit. Synthetic observations do not establish the native command-ID cause, guest thaw, fresh History collection or containing release. +The legacy Workloads `DiskList` is exercised only by tests/fixtures, not a +shipped caller. Its empty-state title is not evidence of readable touch guidance; +the shipped guest Overview supplies the readable pre-sample explanation. + +### Completed guest backup evidence versus current activity + +The guest Overview keeps the last completed backup age and its existing +threshold-derived tone visible independently of current backup activity. +Starting a backup must not hide absent or old completed evidence or turn it +green. A separate cautionary activity row reports a running backup as not yet +completed. Ending that activity without a new completed timestamp does not +manufacture protection. A new completed timestamp updates the existing drawer +without remounting it. Both values wrap at phone widths, rather than requiring +hover to distinguish them. + +Verification: the mounted `GuestDrawer.test.tsx` backup regressions, +`workloadGuestPresentation.test.ts` and production full-drawer fixture +`browser-tests/guest-backup-protection.cjs` cover VM/CT missing, stale and fresh +completion evidence, concurrent activity, stopped activity with no completion, +and later completed observations. They preserve independent disk-lock/timeout +guidance and existing touch/keyboard collapse. Synthetic observations establish +presentation, not native task success, archive restorability, QGA/thaw or release +availability; those require their own containing native and delivery evidence. + ### Composed operator tables and conservative rollback consent diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 9d9e0731fd..931c3191b5 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -25,6 +25,14 @@ and `browser-tests/guest-disk-deferral.cjs` verify same-row ownership in bar and sparkline modes with desktop and phone CSS. This is client behaviour proof, not native backup coordination, new History samples or a fleet performance result. +Completed backup presentation follows the existing same-guest snapshot owner. +Running activity and completed protection are independent derived detail rows; +neither starts a command, adds a read/timer nor remounts the drawer. Stopping +activity cannot synthesize a completed timestamp. `GuestDrawer.test.tsx` and +`browser-tests/guest-backup-protection.cjs` verify in-place missing/old/new +completion transitions through the actual drawer. This is ownership/presentation +proof, not an allocation benchmark, native backup or fleet performance claim. + ### Phone workload identity weights The existing phone guest projection allocates 38 percent to the name cell, diff --git a/frontend-modern/browser-tests/guest-backup-protection.cjs b/frontend-modern/browser-tests/guest-backup-protection.cjs new file mode 100644 index 0000000000..e220a85cd1 --- /dev/null +++ b/frontend-modern/browser-tests/guest-backup-protection.cjs @@ -0,0 +1,255 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +const baseline = false; +const root = '/workspace/frontend-modern'; +const output = `/workspace/tmp/guest-backup-protection-${baseline ? 'parent' : 'final'}`; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx', + 'frontend-modern/src/utils/workloadGuestPresentation.ts', + 'frontend-modern/src/components/Workloads/GuestDrawer.tsx', + 'frontend-modern/src/components/Workloads/useGuestDrawerState.ts', + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', +]; +const sha256 = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const hashes = Object.fromEntries(runtime.map((p) => [p, sha256(path.join('/workspace', p))])); + process.chdir(root); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5296, strictPort: true }, + }); + const results = [], + screenshots = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark] of [ + ['desktop', chromium, 1365, false], + ['phone', webkit, 390, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + for (const kind of ['qemu', 'lxc']) { + const page = await browser.newPage({ + viewport: { width, height: width === 390 ? 844 : 900 }, + isMobile: width === 390, + hasTouch: width === 390, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(15000); + page.setDefaultNavigationTimeout(60000); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + writes = [], + checks = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.route('**/*', (route) => { + const request = route.request(); + const url = new URL(request.url()); + assert.equal( + url.origin, + 'http://127.0.0.1:5296', + 'only guest-local fixture requests are allowed', + ); + if (url.pathname.startsWith('/api/')) { + if (request.method() !== 'GET') writes.push(`${request.method()} ${url.pathname}`); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: [], + alerts: [], + anomalies: [], + config: null, + }), + }); + } + return route.continue(); + }); + await page.goto( + `http://127.0.0.1:5296/browser-tests/guest-backup-protection.html?kind=${kind}`, + { waitUntil: 'domcontentloaded' }, + ); + await page.waitForFunction(() => window.__backupProtection); + if (dark) await page.evaluate(() => document.documentElement.classList.add('dark')); + assert.equal(await page.evaluate(() => window.innerWidth), width, 'actual CSS viewport'); + const details = page.getByTestId('guest-technical-details'); + await details.waitFor({ state: 'visible' }); + await page.evaluate(() => { + window.__originalGuestDetails = document.querySelector( + '[data-testid="guest-technical-details"]', + ); + window.__originalGuestHeading = document.querySelector('h3'); + }); + const update = async (lastBackup, running, reason = '') => { + await page.evaluate((value) => window.__backupProtection.update(value), { + lastBackup, + running, + reason, + }); + assert.equal( + await page.evaluate( + () => + document.querySelector('[data-testid="guest-technical-details"]') === + window.__originalGuestDetails, + ), + true, + ); + assert.equal( + await page.evaluate(() => window.__backupProtection.identity()), + 'fixture-pve:pve-a:101', + ); + }; + const capture = async (suffix) => { + const p = path.join(output, `${name}-${kind}-${suffix}.png`); + await page.screenshot({ path: p, fullPage: true }); + screenshots.push({ path: path.relative('/workspace', p), sha256: sha256(p) }); + }; + const oldBackup = new Date('2026-09-23T12:00:00Z').getTime(); + if (baseline) { + await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : ''); + const activity = details.getByText('Backup running', { exact: true }); + await activity.waitFor({ state: 'visible' }); + assert((await activity.locator('..').getAttribute('class')).includes('text-emerald-700')); + assert.equal(await details.getByText('No backup found', { exact: true }).count(), 0); + checks.push( + 'parent: running backup hides missing completed protection and appears green', + ); + if (kind === 'qemu') await capture('running-with-none'); + await update(oldBackup, true); + assert.equal(await details.getByText('10d ago', { exact: true }).count(), 0); + await update(oldBackup, false); + await details.getByText('10d ago', { exact: true }).waitFor({ state: 'visible' }); + checks.push('parent: running backup hides existing ten-day-old completed evidence'); + } else { + const missing = details.getByText('No completed backup found', { exact: true }); + const activity = details.getByText('Running ยท not completed yet', { exact: true }); + await missing.waitFor({ state: 'visible' }); + assert((await missing.locator('..').getAttribute('class')).includes('text-rose-700')); + assert.equal(await details.getByText('Backup activity', { exact: true }).count(), 0); + await update(0, true, kind === 'qemu' ? 'prev-vm-locked' : ''); + await activity.waitFor({ state: 'visible' }); + await missing.waitFor({ state: 'visible' }); + assert((await activity.locator('..').getAttribute('class')).includes('text-amber-700')); + assert.equal(await details.getByText('Backup running', { exact: true }).count(), 0); + if (kind === 'qemu') + await details + .getByText(/Using last known disk stats\. Guest reads paused/) + .waitFor({ state: 'visible' }); + checks.push( + 'absent completion stays missing/danger while separate activity is cautionary; VM lock notice survives', + ); + if (kind === 'qemu') await capture('running-with-none'); + await update(0, false); + await missing.waitFor({ state: 'visible' }); + assert.equal(await activity.count(), 0); + checks.push( + 'activity stopping without new completion evidence never manufactures protection', + ); + await update(oldBackup, true); + const age = details.getByText('10d ago', { exact: true }); + await age.waitFor({ state: 'visible' }); + assert((await age.locator('..').getAttribute('class')).includes('text-amber-700')); + await activity.waitFor({ state: 'visible' }); + checks.push('stale completed age and caution tone remain visible during a new backup'); + if (kind === 'qemu') await capture('running-with-old'); + await update(oldBackup, false, kind === 'qemu' ? 'prev-agent-timeout' : ''); + await age.waitFor({ state: 'visible' }); + assert.equal(await activity.count(), 0); + if (kind === 'qemu') + await details + .getByText(/Do not restart the guest agent during a backup/) + .waitFor({ state: 'visible' }); + checks.push('ended activity preserves old completion and independent timeout safety'); + const freshBackup = new Date('2026-10-03T10:00:00Z').getTime(); + await update(freshBackup, false); + const today = details.getByText('Today', { exact: true }); + await today.waitFor({ state: 'visible' }); + assert((await today.locator('..').getAttribute('class')).includes('text-emerald-700')); + assert.equal(await missing.count(), 0); + await update(freshBackup, true); + await today.waitFor({ state: 'visible' }); + await activity.waitFor({ state: 'visible' }); + checks.push( + 'only new completed timestamp updates protection; fresh age stays separate from further activity', + ); + await update(freshBackup, false); + const close = page.getByRole('button', { name: 'Collapse backup-guest details' }); + if (name === 'phone') await close.tap(); + else { + await close.focus(); + await page.keyboard.press('Enter'); + } + assert.equal(await page.evaluate(() => window.__backupProtection.closeCount()), 1); + checks.push( + 'existing collapse remains touch/keyboard usable and observations trigger no write', + ); + assert.equal( + await details.getByText('Last completed backup', { exact: true }).count(), + 1, + ); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + true, + ); + if (kind === 'qemu') await capture('fresh-completion'); + } + assert.deepEqual(writes, []); + assert.deepEqual(errors, []); + results.push({ + name, + kind, + browser_version: browser.version(), + viewport: { width, height: width === 390 ? 844 : 900 }, + checks, + page_errors: errors, + writes, + }); + await page.close(); + } + await browser.close(); + browser = null; + } + const receipt = { + version: 1, + result: baseline ? 'defect-reproduced' : 'passed', + verified_at: new Date().toISOString(), + content_sha256: hashes, + playwright_version: playwrightVersion, + results, + screenshots, + limitation: + 'Actual GuestDrawer and production CSS with synthetic VM/CT observations. Not a native backup, usable archive, QGA/thaw, installation or release result.', + }; + fs.writeFileSync( + path.join(output, 'browser-result.json'), + JSON.stringify(receipt, null, 2) + '\n', + ); + process.stdout.write(JSON.stringify(receipt) + '\n'); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((e) => { + process.stderr.write(e.stack + '\n'); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/guest-backup-protection.html b/frontend-modern/browser-tests/guest-backup-protection.html new file mode 100644 index 0000000000..ff871fcf3c --- /dev/null +++ b/frontend-modern/browser-tests/guest-backup-protection.html @@ -0,0 +1,12 @@ + + + + + + Guest backup protection verification + + +
    + + + diff --git a/frontend-modern/browser-tests/guest-backup-protection.tsx b/frontend-modern/browser-tests/guest-backup-protection.tsx new file mode 100644 index 0000000000..37e9887ae7 --- /dev/null +++ b/frontend-modern/browser-tests/guest-backup-protection.tsx @@ -0,0 +1,82 @@ +// Synthetic observations through the actual GuestDrawer, its state model and +// production CSS. This fixture neither starts a backup nor establishes thaw. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Router, Route } from '@solidjs/router'; +import { GuestDrawer } from '../src/components/Workloads/GuestDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { WorkloadGuest } from '../src/types/workloads'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: false }); + +function Fixture() { + const [observation, setObservation] = createSignal({ + lastBackup: 0, + running: false, + reason: '', + }); + const [closeCount, setCloseCount] = createSignal(0); + const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu'; + const guest = (): WorkloadGuest => ({ + id: 'fixture-pve:pve-a:101', + vmid: 101, + name: 'backup-guest', + node: 'pve-a', + instance: 'fixture-pve', + status: 'running', + type, + cpu: 0.1, + cpus: 2, + memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 }, + disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 }, + disks: [ + { mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 }, + ], + diskStatusReason: observation().reason, + networkIn: 0, + networkOut: 0, + diskRead: 0, + diskWrite: 0, + uptime: 3600, + template: false, + lastBackup: observation().lastBackup, + backupInProgress: observation().running, + tags: [], + lock: observation().running ? 'backup' : '', + lastSeen: '2026-10-03T12:00:00Z', + }); + (window as any).__backupProtection = { + update: setObservation, + closeCount, + identity: () => guest().id, + }; + return ( +
    +

    Guest backup protection

    +

    Synthetic observations; no backup command is sent.

    +
    + setCloseCount((count) => count + 1)} /> +
    +
    + ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/98ed2bca9322b776.json b/frontend-modern/browser-verification/98ed2bca9322b776.json new file mode 100644 index 0000000000..e3bc81dd66 --- /dev/null +++ b/frontend-modern/browser-verification/98ed2bca9322b776.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "base_sha": "7a5635b2c0b76e13f510cc7a27b7ca0cd55091ba", + "verified_at": "2026-10-03T18:34:44.680Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", + "frontend-modern/src/utils/workloadGuestPresentation.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "3c19f8214611369807c48715a4429f4913c346b687ba3fe40b43c8ce094e6bd9", + "frontend-modern/src/utils/workloadGuestPresentation.ts": "8b648129348725216f14f6b06931222daa69439e4bee76add81324699b6266bf" + }, + "routes": [ + "/browser-tests/guest-backup-protection.html?kind=qemu", + "/browser-tests/guest-backup-protection.html?kind=lxc" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Missing completed backup, idle and running; missing protection stays danger, running activity is cautionary", + "Existing ten-day-old completed backup remains visible and cautionary during a new backup", + "Activity ends without a new completed timestamp: missing or stale protection is unchanged", + "Fresh completed timestamp alone updates protection; later concurrent activity remains separate", + "VM disk-lock and uncertain-timeout guidance remain independent of backup activity" + ], + "interactions": [ + "Reactive same-ID guest observations through actual GuestDrawer, state model and production CSS; no technical-details wrapper remount", + "Desktop keyboard focus and Enter collapse, phone touch collapse; no API writes or page errors", + "Measured 390px phone CSS viewport, no page overflow and six candidate screenshots visually inspected" + ], + "playwright_version": "1.56.1", + "browser_versions": [ + { + "engine": "chromium", + "version": "141.0.7390.37" + }, + { + "engine": "webkit", + "version": "26.0" + } + ], + "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82", + "limitations": [ + "Actual GuestDrawer and production CSS with synthetic VM/CT observations. Not a native backup, usable archive, QGA/thaw, installation or release result.", + "Synthetic completion evidence does not prove the reported freeze cause or native thaw; no released-availability claim." + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx index adb3254ccb..47d2960bba 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawer.test.tsx @@ -7,7 +7,7 @@ import { waitFor, within, } from '@solidjs/testing-library'; -import { Suspense } from 'solid-js'; +import { createSignal, Suspense } from 'solid-js'; import type { WorkloadGuest } from '@/types/workloads'; import type { Memory, Disk, GuestNetworkInterface } from '@/types/api'; import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; @@ -922,7 +922,7 @@ describe('GuestDrawer', () => { it('shows "Today" for a backup from today', () => { const now = new Date('2026-03-02T10:00:00Z').getTime(); render(() => ); - expect(screen.getByText('Protection')).toBeInTheDocument(); + expect(screen.getByText('Last completed backup')).toBeInTheDocument(); expect(technicalDetails().getByText('Today')).toBeInTheDocument(); }); @@ -966,9 +966,64 @@ describe('GuestDrawer', () => { expect(ageCell).toHaveClass('text-emerald-700'); }); - it('hides Backup card when lastBackup is 0 (falsy)', () => { - render(() => ); - expect(screen.queryByText('Backup')).not.toBeInTheDocument(); + it.each(['qemu', 'lxc'] as const)( + 'keeps absent completed protection visible while a %s backup runs', + (type) => { + solidRender(() => ( + + )); + expect(technicalDetails().getByText('Last completed backup')).toBeInTheDocument(); + expect(technicalDetails().getByText('No completed backup found').closest('td')).toHaveClass( + 'text-rose-700', + ); + expect(technicalDetails().getByText('Backup activity')).toBeInTheDocument(); + expect( + technicalDetails().getByText('Running ยท not completed yet').closest('td'), + ).toHaveClass('text-amber-700'); + }, + ); + + it.each([ + ['2026-02-20T12:00:00Z', '10d ago', 'text-amber-700'], + ['2026-03-02T10:00:00Z', 'Today', 'text-emerald-700'], + ])('preserves completed backup age and tone during a new backup (%s)', (date, age, tone) => { + solidRender(() => ( + + )); + expect(technicalDetails().getByText(age).closest('td')).toHaveClass(tone); + expect(technicalDetails().getByText('Running ยท not completed yet').closest('td')).toHaveClass( + 'text-amber-700', + ); + }); + + it('never treats activity stopping as a completed backup and updates only on completion evidence', () => { + const [guest, setGuest] = createSignal(makeGuest({ lastBackup: 0 })); + const { container } = solidRender(() => ); + const drawer = container.querySelector('[data-testid="guest-technical-details"]'); + expect(drawer).not.toBeNull(); + expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument(); + setGuest({ ...guest(), backupInProgress: true }); + expect(technicalDetails().getByText('Running ยท not completed yet')).toBeInTheDocument(); + expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument(); + setGuest({ ...guest(), backupInProgress: false }); + expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument(); + expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument(); + setGuest({ ...guest(), lastBackup: new Date('2026-03-02T10:00:00Z').getTime() }); + expect(technicalDetails().queryByText('No completed backup found')).not.toBeInTheDocument(); + expect(technicalDetails().getByText('Today').closest('td')).toHaveClass('text-emerald-700'); + expect(container.querySelector('[data-testid="guest-technical-details"]')).toBe(drawer); + }); + + it('reports missing completed protection rather than hiding it when lastBackup is 0', () => { + solidRender(() => ); + expect(technicalDetails().getByText('No completed backup found')).toBeInTheDocument(); + expect(technicalDetails().queryByText('Backup activity')).not.toBeInTheDocument(); }); }); diff --git a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx index 2d843f3a35..fd5d76f240 100644 --- a/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx +++ b/frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx @@ -64,7 +64,6 @@ interface GuestDrawerOverviewProps { export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { const protectionPresentation = () => getWorkloadsGuestProtectionPresentation({ - backupInProgress: props.guest.backupInProgress, ageLabel: props.backupPresentation?.ageLabel, ageClass: props.backupPresentation?.ageClass, }); @@ -88,9 +87,17 @@ export function GuestDrawerOverview(props: GuestDrawerOverviewProps) { : undefined, }), makeDetailRow('Primary IP', props.ipAddresses[0]), - makeDetailRow('Protection', protectionPresentation().label, { + // Starting or ending an operation is not evidence of a completed + // backup. Keep its activity separate from the last completed age. + makeDetailRow('Last completed backup', protectionPresentation().label, { tone: protectionPresentation().tone, + wrap: true, }), + makeDetailRow( + 'Backup activity', + props.guest.backupInProgress ? 'Running ยท not completed yet' : null, + { tone: 'warning', wrap: true }, + ), makeDetailRow( 'Identified service', props.discoveryIdentifiedSummary diff --git a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts index e1194c4f5e..28dd0c08ed 100644 --- a/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/workloadGuestPresentation.test.ts @@ -45,12 +45,8 @@ describe('workloadGuestPresentation', () => { }); it('returns canonical compact protection context for object drawers', () => { - expect(getWorkloadsGuestProtectionPresentation({ backupInProgress: true })).toEqual({ - label: 'Backup running', - tone: 'success', - }); expect(getWorkloadsGuestProtectionPresentation({})).toEqual({ - label: 'No backup found', + label: 'No completed backup found', tone: 'danger', }); expect( @@ -59,6 +55,12 @@ describe('workloadGuestPresentation', () => { ageClass: 'text-yellow-600', }), ).toEqual({ label: '3d ago', tone: 'warning' }); + expect( + getWorkloadsGuestProtectionPresentation({ + ageLabel: 'Today', + ageClass: 'text-green-600', + }), + ).toEqual({ label: 'Today', tone: 'success' }); }); it('presents a running backup as its own state, keeping the completed age', () => { diff --git a/frontend-modern/src/utils/workloadGuestPresentation.ts b/frontend-modern/src/utils/workloadGuestPresentation.ts index 00d1fe68cf..41a08077c5 100644 --- a/frontend-modern/src/utils/workloadGuestPresentation.ts +++ b/frontend-modern/src/utils/workloadGuestPresentation.ts @@ -70,15 +70,11 @@ export function getWorkloadsGuestBackupTooltip( } export function getWorkloadsGuestProtectionPresentation(options: { - backupInProgress?: boolean; ageLabel?: string | null; ageClass?: string | null; }): WorkloadsGuestProtectionPresentation { - if (options.backupInProgress) { - return { label: 'Backup running', tone: 'success' }; - } if (!options.ageLabel) { - return { label: 'No backup found', tone: 'danger' }; + return { label: 'No completed backup found', tone: 'danger' }; } return { label: options.ageLabel, From ee069f21444c3792f98724daad28c1adef5941a3 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:44:31 +0100 Subject: [PATCH 71/86] Add independent bounded guest-write evidence for backup thaw An OK PVE task and HTTP admission controls cannot establish thaw. Supply a credential-free guest-local fsync/readback witness and bounded offline record checks for the real #2439 job, without QGA probes, recovery actions or a replacement native platform. Require actual overlap, every covered filesystem and truthful monitoring resumption; retain native provenance and execution with Delivery. Contract-Neutral: Measurement support for the existing guest-agent backup safety obligation only; production APIs, polling, access boundaries and release gates are unchanged. Change-source: pulse-maintainer --- .../v6/internal/subsystems/monitoring.md | 9 + .../guest-agent-backup/README.md | 160 +++++++++++ .../qualification/guest-agent-backup/check.py | 230 +++++++++++++++ .../guest-agent-backup/guest-writer/main.go | 206 ++++++++++++++ .../guest-writer/main_test.go | 220 +++++++++++++++ .../guest-agent-backup/test_check.py | 264 ++++++++++++++++++ 6 files changed, 1089 insertions(+) create mode 100644 tests/qualification/guest-agent-backup/README.md create mode 100644 tests/qualification/guest-agent-backup/check.py create mode 100644 tests/qualification/guest-agent-backup/guest-writer/main.go create mode 100644 tests/qualification/guest-agent-backup/guest-writer/main_test.go create mode 100644 tests/qualification/guest-agent-backup/test_check.py diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 24cf2bb889..26ce03a228 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -58,6 +58,15 @@ on a containing build remains required. Until then, stopping Pulse before a freeze-enabled backup and restarting only after verified thaw remains the safe workaround, with monitoring and alerts unavailable during that window. +The [bounded backup observation tools](../../../../../tests/qualification/guest-agent-backup/README.md) +provide an independent guest filesystem writer (file/directory fsync and +synthetic readback over non-QGA stdout) and a fail-closed checker for native +overlap, truthful deferred readings/History and post-task writes/resumption. +An OK task without fresh writes on every covered filesystem cannot pass those +record checks. Local tests are not native freeze/thaw evidence; supplied +platform/artifact provenance and mount completeness still need independent +review. The tools add no runtime interface, recovery action or release gate. + ### Exact TrueNAS subscription termination โ€” issue #2396 JSON-RPC stream readers and the subscription-acknowledgement wait recognise diff --git a/tests/qualification/guest-agent-backup/README.md b/tests/qualification/guest-agent-backup/README.md new file mode 100644 index 0000000000..ca0eff9002 --- /dev/null +++ b/tests/qualification/guest-agent-backup/README.md @@ -0,0 +1,160 @@ +# Guest-agent backup observation โ€” #2439 + +This is a **measurement tool**, not a new release gate or a backup hook. It +does not start Pulse, a backup, PVE or QGA. It never sends a guest-agent command, +freezes/thaws a filesystem, changes ACLs, disables freezing or restarts/resets +a guest. Local tests and an OK backup task do not establish native acceptance. + +The reported job is PVE **9.2.21**, **HAOS with QGA**, and Pulse **6.4.5** using +the same effective **Audit + FileRead** access: a backup task reported OK while +the guest remained frozen. Pulse's containing-source mitigation starts at +`3c57c2d20728f9e711ae2930e282b853eb218730` (with its ancestors). Source controls +cover admission, lock checks, in-flight payload withdrawal, no replay and cache +truth. They cannot atomically reserve the native PVE/QGA channel or prove thaw. + +## Smallest useful native route + +Delivery owns provisioning and native execution. Core supplies the independent +guest writer and record checks here. The existing private CI VM can build these +tools and judge returned records; guest sudo does not supply a PVE hypervisor, +HAOS, QGA's real channel or permission to reach an existing host. An ordinary +QGA subprocess, simulated PVE API or observation guest with PVE/QGA disabled +cannot meet this job. Do not build a replacement platform just to call it native. + +The missing resource is **one owned disposable PVE target with a real HAOS/QGA +guest and backup storage**, isolated from production, plus verified official +image/package inputs and a supported way for its owner to observe it. Use an +already provisioned suitable target if available; otherwise Delivery returns +the exact provisioning/input gap through its normal reviewed infrastructure +route. This file is not permission to use a host, account or credential. + +Before native execution the owner retains, privately: + +- The exact inspected Pulse binary digest, source/tree, required guard ancestry, + build/artifact provenance and process identity. Use a containing reviewed + build, not a version string or an unverified rebuild. Preserve 6.4.5 as the + reported source, not proof of the candidate. No new-source acceptance follows + from old app/source receipts. +- Actual PVE manager/kernel/package identities (reconcile what the reported + `9.2.21` identifies), HAOS image and QGA executable/version/configuration. + Record filesystem-freeze enablement and every writable filesystem covered by + the backup. Map opaque filesystem IDs to these mounts; exclude tmpfs and + container overlays that are not actually frozen. Stage the writer through + the owner's supported guest-console/image route **before** the backup. +- Effective privileges of the *same* Pulse token: Audit and FileRead on the + target VM, QGA enabled, ordinary TLS verification, one Pulse process, no other + manual QGA probes. A refusal stops the attempt; do not substitute root or + broaden access. Never record the credential, full config or service environment. +- Preselected observation window (at most ten minutes), backup task identity, + Pulse polling/cache intervals, native command-trace coverage and guest/host + clock calibration with at most one second relative error. Hash/link original + provenance, clock/mount crosswalk and raw task/command traces separately. + +Run a normal freeze-enabled backup with Pulse stopped first as the fixture +control, observing the guest independently. Then, on a fresh equivalent +disposable fixture, observe normal containing-build polling across a backup. +For in-flight acceptance, require a **genuinely observed** Pulse read spanning +lock acquisition, not a sleep or a claimed overlap. Never force overlap on a +customer guest or queue manual QGA probes. If no overlap occurs within the +chosen window, retain that inconclusive attempt; it is not an acceptance pass. +No repeated run is prescribed merely to obtain a favourable result. + +## Independent guest filesystem writer + +Build with the declared Go compiler; stage the resulting Linux binary for the +actual guest architecture. Python is **not** required in HAOS: + +```sh +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/guest-writer \ + ./tests/qualification/guest-agent-backup/guest-writer +``` + +The native owner runs one writer on each mapped, freeze-covered filesystem: + +```sh +guest-writer --directory /owner-provisioned/scratch-on-covered-mount \ + --run-id run-01 --filesystem-id data --duration-seconds 600 --interval-ms 1000 +``` + +The directory must exist, be absolute and have no symlink components. Each run +creates a new private child; repeated IDs refuse reuse. Each write uses an +exclusive new 0600 file, writes 512 synthetic bytes, fsyncs both file and +directory, reads back and checks the bytes, then emits their SHA-256. At most +600 such files are left per filesystem; it deletes nothing. Only opaque IDs, +fixed outcomes and timestamps reach stdout. Collect stdout/exit through an +**independent console channel**, not QGA or the filesystem being frozen. + +One write can be in flight. Heartbeats continue while it is blocked; no further +write is queued. Interruption, output failure, readback/write error or a write +pending at the deadline cannot pass. The program does not unblock a frozen +filesystem. Preserve failed evidence and leave disposal/recovery to the fixture +owner; do not turn the failure into a guest restart/reset workaround. + +A successful write witnesses only that mapped filesystem at that instant, not +all filesystems, application integrity or a successful backup restore. Require +at least two completed baseline writes, liveness during the frozen interval and +two **newly started** fsync/readback writes after task completion on every mapped +filesystem. A pre-freeze write that finally returns is insufficient on its own. + +## Bounded record checks + +`check.py` reads a redacted JSON record and adjacent hash-bound writer JSONL +files. It has no network, subprocess or guest-control capability. The shape is +exercised by `record()` and `witness_events()` in `test_check.py`; those are +**synthetic checker fixtures, never native evidence**. Fields are exact and +extra fields (including private names/raw logs) are rejected. + +The native owner normalises its actual observations into that shape, retaining +underlying receipts separately. `identity` binds Pulse/PVE/QGA/HAOS digests; +`preflight` and its mount-crosswalk hash cover all declared filesystem IDs. +`backup` contains native start, lock, freeze, thaw-*request*, unlock and completed +times. A thaw request is not a thaw result. `coverage` binds the complete +bounded native command trace with zero dropped events. `commands` are **actual +dispatched** reads, not planned or deferred calls; preserve adverse outcomes. +The checker requires real in-flight overlap, no concurrent/duplicate dispatch +and no dispatch inside the observed lock/clock-uncertainty interval. + +`snapshots` are baseline, at least two locked polls and resumed readbacks of the +same VM. Retain metadata/memory/disk payload hashes, guest-agent state, memory +source, disk reason, latest memory/disk History times and CPU observation time. +Obtain them from the existing read-state, memory diagnostics and History APIs; +normalise only the target guest's fixed fields, without raw snapshots in public +evidence. Locked values/History cannot be renewed as fresh; CPU must progress. +Resumption needs available/agent memory, no disk deferral and fresh post-task +memory/disk History. **Internal cache timestamp preservation is source-tested; +these public readbacks do not directly inspect those internal timestamps.** + +```sh +python3 tests/qualification/guest-agent-backup/check.py \ + --record /private/new-native-record/record.json --expected-source EXACT_40_HEX_SHA +``` + +Files are bounded, hash-bound, single-name regular files; symlinks, traversal, +duplicate JSON keys, wrong identities, truncation, impossible order, replay, +incomplete coverage and adverse witness exits fail closed. Evidence-class and +provenance fields are owner observations, **not access grants or independent +artifact verification by the checker**. Exit 0 means the supplied record's +checks passed. Output deliberately retains `native_acceptance_complete: false`: +review original native provenance, mount completeness and command/task traces +before judging the job. It does not establish the reported command-ID cause, +fleet-wide safety, shipment, restore integrity or release qualification. + +## Source checks and next result + +```sh +python3 -m unittest discover -s tests/qualification/guest-agent-backup -v +go test -race ./tests/qualification/guest-agent-backup/guest-writer -count=1 +``` + +Use the assigned source-proof VM for worker execution. The real local writer +smoke is ordinary synthetic fsync/readback, **not** a filesystem-freeze test. +Do not replay unchanged guard/app suites to disguise the missing native target. + +Delivery's next material result is verified native inputs and a completed (or +adverse/inconclusive) bounded run: independent writes/liveness, actual command +overlap, truthful lock-time readings/History and normal cache resumption on the +exact containing build. Coordinator/reviewer/publisher own protected landing; +Delivery/steward own its containing all-main release. Community owns useful +reporter contact after material evidence. Until native acceptance, preserve +stop-Pulse-before-backup and restart-only-after-verified-thaw advice, explicitly +including the monitoring/alert outage. An OK task or cleared lock is not enough. diff --git a/tests/qualification/guest-agent-backup/check.py b/tests/qualification/guest-agent-backup/check.py new file mode 100644 index 0000000000..076e86262c --- /dev/null +++ b/tests/qualification/guest-agent-backup/check.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Check a bounded, redacted native backup record. No network or guest commands. + +Supplied provenance is evidence for independent review, not authority or an +independently verified artifact. A successful check is not release qualification. +""" + +import argparse +import hashlib +import json +from pathlib import Path +import re +import sys + +MAX_BYTES = 2 * 1024 * 1024 +ID = re.compile(r"[a-z0-9][a-z0-9-]{0,31}\Z") +HEX40 = re.compile(r"[0-9a-f]{40}\Z") +HEX64 = re.compile(r"[0-9a-f]{64}\Z") +METHODS = {"get-fsinfo", "file-read", "get-osinfo", "info", "network-get-interfaces"} + + +class InvalidRecord(Exception): + pass + + +def require(ok, message): + if not ok: + raise InvalidRecord(message) + + +def shape(value, keys, label): + require(type(value) is dict and set(value) == set(keys.split()), label + " shape") + + +def integer(value): + return type(value) is int and 0 <= value < 2**53 + + +def digest(value, pattern=HEX64): + return type(value) is str and pattern.fullmatch(value) is not None + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, "duplicate JSON field") + result[key] = value + return result + + +def decode(raw): + try: + return json.loads(raw, object_pairs_hook=unique_object) + except (ValueError, UnicodeError, RecursionError): + raise InvalidRecord("invalid JSON") from None + + +def read_bounded(path): + require(not path.is_symlink() and path.is_file(), "regular evidence file required") + with path.open("rb") as stream: + raw = stream.read(MAX_BYTES + 1) + require(0 < len(raw) <= MAX_BYTES, "evidence size bound") + return raw + + +def witness_events(root, entry, run_id, skew): + shape(entry, "filesystem_id file sha256 exit_code", "witness") + require(type(entry["filesystem_id"]) is str and ID.fullmatch(entry["filesystem_id"]), "filesystem ID") + name = entry["file"] + require(type(name) is str and re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,95}\.jsonl", name), "witness filename") + require(digest(entry["sha256"]), "witness digest") + require(type(entry["exit_code"]) is int and entry["exit_code"] == 0, "witness did not exit successfully") + raw = read_bounded(root / name) + require(hashlib.sha256(raw).hexdigest() == entry["sha256"], "witness content mismatch") + require(raw.endswith(b"\n"), "incomplete witness line") + lines = raw.splitlines() + require(4 <= len(lines) <= 2000 and all(0 < len(line) <= 2048 for line in lines), "witness event bound") + events = [decode(line) for line in lines] + previous_ms, previous_elapsed, sequence, previous_write_ms = -1, -1, 0, -1 + for i, event in enumerate(events): + kind = event.get("kind") if type(event) is dict else None + require(type(kind) is str, "witness event kind") + extra = {"start": "", "heartbeat": "", "write": " sequence started_unix_ms sha256", "stop": " result"}.get(kind) + require(extra is not None, "witness event kind") + shape(event, "schema_version kind run_id filesystem_id unix_ms elapsed_ms" + extra, "witness event") + require(type(event["schema_version"]) is int and event["schema_version"] == 1, "witness schema") + require(event["run_id"] == run_id and event["filesystem_id"] == entry["filesystem_id"], "witness identity mismatch") + at, elapsed = event["unix_ms"], event["elapsed_ms"] + require(integer(at) and integer(elapsed) and at >= previous_ms and elapsed >= previous_elapsed, "witness time order") + require(elapsed <= 601000, "witness duration bound") + if i: + drift = (at - events[0]["unix_ms"]) - (elapsed - events[0]["elapsed_ms"]) + require(abs(drift) <= skew, "witness clock discontinuity") + require((kind == "start") == (i == 0) and (kind == "stop") == (i == len(events) - 1), "witness lifecycle") + if kind == "write": + sequence += 1 + require(type(event["sequence"]) is int and event["sequence"] == sequence, "witness sequence") + require(integer(event["started_unix_ms"]) and + max(previous_write_ms, events[0]["unix_ms"]) <= event["started_unix_ms"] <= at, "witness write time") + payload = f"Pulse disposable backup witness\n{run_id}\n{entry['filesystem_id']}\n{sequence}\n".encode() + payload += b"x" * (512 - len(payload)) + require(digest(event["sha256"]) and event["sha256"] == hashlib.sha256(payload).hexdigest(), "witness readback digest") + previous_write_ms = at + if kind == "stop": + require(event["result"] == "complete", "witness incomplete or failed") + previous_ms, previous_elapsed = at, elapsed + return events + + +def check_record(record, root, expected_source): + require(digest(expected_source, HEX40), "expected source SHA") + shape(record, "schema_version run_id evidence_class identity preflight window backup coverage commands snapshots witnesses", "record") + require(type(record["schema_version"]) is int and record["schema_version"] == 1, "record schema") + require(type(record["run_id"]) is str and ID.fullmatch(record["run_id"]), "run ID") + require(record["evidence_class"] == "native-disposable", "not a native disposable record") + identity = record["identity"] + shape(identity, "pulse_source pulse_binary_sha256 pve_packages_sha256 qga_binary_sha256 haos_image_sha256 provenance_receipt_sha256", "identity") + require(identity["pulse_source"] == expected_source, "installed source mismatch") + require(all(digest(identity[k]) for k in identity if k != "pulse_source"), "input identity digest") + preflight = record["preflight"] + shape(preflight, "pve_version guest_type audit file_read tls_verified qga_enabled freeze_enabled single_pulse_process filesystem_map_sha256 filesystem_ids", "preflight") + require(preflight["pve_version"] == "9.2.21" and preflight["guest_type"] == "HAOS", "reported platform mismatch") + require(all(preflight[k] is True for k in ("audit", "file_read", "tls_verified", "qga_enabled", "freeze_enabled", "single_pulse_process")), "preflight incomplete") + require(digest(preflight["filesystem_map_sha256"]), "filesystem crosswalk digest") + expected_filesystems = preflight["filesystem_ids"] + require(type(expected_filesystems) is list and 1 <= len(expected_filesystems) <= 16 and + all(type(v) is str and ID.fullmatch(v) for v in expected_filesystems) and + len(set(expected_filesystems)) == len(expected_filesystems), "covered filesystem IDs") + window = record["window"] + shape(window, "start_ms end_ms clock_error_ms", "window") + require(all(integer(v) for v in window.values()), "observation bounds") + start, end, skew = window["start_ms"], window["end_ms"], window["clock_error_ms"] + require(3000 <= end - start <= 600000 and skew <= 1000, "observation duration or clock bound") + backup = record["backup"] + shape(backup, "start_ms lock_start_ms freeze_ms thaw_request_ms lock_end_ms completed_ms status trace_sha256", "backup") + times = [backup[k] for k in ("start_ms", "lock_start_ms", "freeze_ms", "thaw_request_ms", "lock_end_ms", "completed_ms")] + require(all(integer(v) for v in times) and times == sorted(times) and start < times[0] < times[-1] < end, "backup time order") + require(backup["lock_start_ms"] < backup["lock_end_ms"], "backup lock not observed") + require(backup["status"] == "OK" and digest(backup["trace_sha256"]), "backup did not complete normally") + coverage = record["coverage"] + shape(coverage, "start_ms end_ms dropped_events trace_sha256", "coverage") + require(all(integer(coverage[k]) for k in ("start_ms", "end_ms", "dropped_events")) and + coverage["start_ms"] <= start and coverage["end_ms"] >= end and coverage["dropped_events"] == 0 and + digest(coverage["trace_sha256"]), "native command coverage incomplete") + + commands = record["commands"] + require(type(commands) is list and 1 <= len(commands) <= 512, "native command bound") + ids, intervals = set(), [] + overlap = False + for command in commands: + shape(command, "id method start_ms end_ms result", "command") + require(type(command["id"]) is str and ID.fullmatch(command["id"]) and command["id"] not in ids, "duplicate or invalid command ID") + ids.add(command["id"]) + require(type(command["method"]) is str and command["method"] in METHODS and command["result"] == "ok", "native command adverse or unsupported") + began, finished = command["start_ms"], command["end_ms"] + require(integer(began) and integer(finished) and start <= began < finished <= end, "command time order") + require(not backup["lock_start_ms"] - skew <= began <= backup["lock_end_ms"] + skew, "command dispatched during lock or uncertainty") + overlap |= began < backup["lock_start_ms"] - skew and finished > backup["lock_start_ms"] + skew + intervals.append((began, finished)) + require(overlap, "no observed in-flight overlap") + intervals.sort() + require(all(a[1] <= b[0] for a, b in zip(intervals, intervals[1:])), "concurrent guest commands") + + snapshots = record["snapshots"] + require(type(snapshots) is list and 4 <= len(snapshots) <= 64, "monitoring snapshot bound") + phases = [s.get("phase") if type(s) is dict else None for s in snapshots] + require(phases == ["before"] + ["locked"] * (len(snapshots) - 2) + ["resumed"], "monitoring phases") + for snapshot in snapshots: + shape(snapshot, "phase at_ms guest_identity_sha256 guest_status metadata_sha256 memory_sha256 disk_sha256 memory_source disk_reason disk_history_latest_ms memory_history_latest_ms cpu_latest_ms", "snapshot") + require(integer(snapshot["at_ms"]), "snapshot time") + require(all(digest(snapshot[k]) for k in ("guest_identity_sha256", "metadata_sha256", "memory_sha256", "disk_sha256")), "snapshot digest") + require(all(integer(snapshot[k]) and start <= snapshot[k] <= snapshot["at_ms"] <= end for k in + ("at_ms", "disk_history_latest_ms", "memory_history_latest_ms", "cpu_latest_ms")), "snapshot times") + require(all(a["at_ms"] < b["at_ms"] for a, b in zip(snapshots, snapshots[1:])), "snapshot time order") + before, resumed = snapshots[0], snapshots[-1] + require(before["at_ms"] < backup["start_ms"] - skew and before["guest_status"] == "available" and + before["memory_source"] == "guest-agent-meminfo" and before["disk_reason"] == "", "baseline guest truth missing") + stable = ("guest_identity_sha256", "metadata_sha256", "memory_sha256", "disk_sha256", "disk_history_latest_ms", "memory_history_latest_ms") + for locked in snapshots[1:-1]: + require(backup["lock_start_ms"] + skew < locked["at_ms"] < backup["lock_end_ms"] - skew, + "locked snapshot outside lock") + require(locked["guest_status"] == "deferred" and locked["memory_source"] == "previous-snapshot" and + locked["disk_reason"] == "prev-vm-locked", "deferral not visible") + require(all(locked[k] == before[k] for k in stable), "retained evidence renewed or changed during lock") + require(locked["cpu_latest_ms"] > before["cpu_latest_ms"], "non-QGA monitoring did not progress") + require(resumed["guest_identity_sha256"] == before["guest_identity_sha256"] and resumed["guest_status"] == "available" and + resumed["memory_source"] == "guest-agent-meminfo" and resumed["disk_reason"] == "", "guest did not resume") + require(all(resumed[k] > backup["completed_ms"] + skew for k in ("disk_history_latest_ms", "memory_history_latest_ms", "cpu_latest_ms")), + "fresh post-backup History absent") + + witnesses = record["witnesses"] + require(type(witnesses) is list and 1 <= len(witnesses) <= 16, "filesystem witness bound") + filesystems = set() + for witness in witnesses: + events = witness_events(root, witness, record["run_id"], skew) + require(witness["filesystem_id"] not in filesystems, "duplicate filesystem witness") + filesystems.add(witness["filesystem_id"]) + require(events[0]["unix_ms"] < backup["start_ms"] - skew and end - skew <= events[-1]["unix_ms"] <= end + skew, + "witness did not cover the observation window") + writes = [e for e in events if e["kind"] == "write"] + require(sum(e["unix_ms"] < backup["start_ms"] - skew for e in writes) >= 2, "baseline filesystem writes missing") + require(sum(backup["completed_ms"] + skew < e["started_unix_ms"] <= e["unix_ms"] <= end + skew for e in writes) >= 2, + "independent post-task fsync/readback missing") + require(any(e["kind"] == "heartbeat" and backup["freeze_ms"] + skew < e["unix_ms"] < backup["thaw_request_ms"] - skew for e in events), + "guest liveness during freeze missing") + require(filesystems == set(expected_filesystems), "covered filesystem witness missing") + return {"record_checks_passed": True, "native_acceptance_complete": False, + "source": expected_source, "filesystem_witnesses": len(filesystems), + "limits": "Review native provenance, covered-filesystem completeness and raw traces independently. This record does not reproduce the reported cause, certify all guests, establish delivery or qualify a release."} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--record", type=Path, required=True) + parser.add_argument("--expected-source", required=True) + args = parser.parse_args() + try: + result = check_record(decode(read_bounded(args.record)), args.record.parent, args.expected_source) + except (InvalidRecord, OSError, TypeError, ValueError, RecursionError) as error: + # The record may contain private or malformed input. Do not echo it or + # exception paths. Detailed receipts remain with the native owner. + failure = str(error) if isinstance(error, InvalidRecord) else "evidence read or schema failure" + print(json.dumps({"record_checks_passed": False, "native_acceptance_complete": False, "failure": failure}), file=sys.stderr) + return 1 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/qualification/guest-agent-backup/guest-writer/main.go b/tests/qualification/guest-agent-backup/guest-writer/main.go new file mode 100644 index 0000000000..82ec54ee56 --- /dev/null +++ b/tests/qualification/guest-agent-backup/guest-writer/main.go @@ -0,0 +1,206 @@ +// guest-writer observes a disposable guest filesystem without using QGA. +// It never starts a backup, freezes/thaws a filesystem or restarts a service. +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "regexp" + "syscall" + "time" +) + +var opaqueID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + +type options struct { + directory, runID, filesystemID string + duration, interval time.Duration +} + +func (o options) validate() error { + if !opaqueID.MatchString(o.runID) || !opaqueID.MatchString(o.filesystemID) { + return errors.New("opaque run and filesystem IDs required") + } + if o.duration < 3*time.Second || o.duration > 10*time.Minute || o.interval < time.Second || o.interval > 5*time.Second { + return errors.New("duration must be 3..600 seconds and interval 1000..5000 milliseconds") + } + if !filepath.IsAbs(o.directory) || filepath.Clean(o.directory) != o.directory { + return errors.New("absolute clean scratch directory required") + } + return nil +} + +// The owner must map this directory to a filesystem actually covered by the +// native backup. A tmpfs or container overlay is not an equivalent witness. +func prepareWriter(o options) (func(int) (string, error), error) { + if err := o.validate(); err != nil { + return nil, err + } + resolved, err := filepath.EvalSymlinks(o.directory) + if err != nil || resolved != o.directory { + return nil, errors.New("scratch directory unavailable or symlinked") + } + info, err := os.Stat(o.directory) + if err != nil || !info.IsDir() { + return nil, errors.New("scratch directory unavailable") + } + dir := filepath.Join(o.directory, "pulse-backup-witness-"+o.runID+"-"+o.filesystemID) + // Never reuse a run or overwrite any pre-existing file. Retain synthetic + // files on both success and failure; fixture teardown belongs to its owner. + if err := os.Mkdir(dir, 0700); err != nil { + return nil, errors.New("new witness directory could not be created") + } + return func(seq int) (string, error) { + payload := []byte(fmt.Sprintf("Pulse disposable backup witness\n%s\n%s\n%d\n", o.runID, o.filesystemID, seq)) + payload = append(payload, bytes.Repeat([]byte{'x'}, 512-len(payload))...) + path := filepath.Join(dir, fmt.Sprintf("witness-%06d", seq)) + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return "", errors.New("witness write failed") + } + _, writeErr := f.Write(payload) + syncErr := f.Sync() + closeErr := f.Close() + if errors.Join(writeErr, syncErr, closeErr) != nil { + return "", errors.New("witness write failed") + } + d, err := os.Open(dir) + if err != nil { + return "", errors.New("witness directory sync failed") + } + syncErr, closeErr = d.Sync(), d.Close() + if errors.Join(syncErr, closeErr) != nil { + return "", errors.New("witness directory sync failed") + } + readback, err := os.ReadFile(path) + if err != nil || !bytes.Equal(readback, payload) { + return "", errors.New("witness readback failed") + } + digest := sha256.Sum256(readback) + return hex.EncodeToString(digest[:]), nil + }, nil +} + +type event struct { + SchemaVersion int `json:"schema_version"` + Kind string `json:"kind"` + RunID string `json:"run_id"` + FilesystemID string `json:"filesystem_id"` + UnixMS int64 `json:"unix_ms"` + ElapsedMS int64 `json:"elapsed_ms"` + Sequence int `json:"sequence,omitempty"` + StartedUnixMS int64 `json:"started_unix_ms,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Result string `json:"result,omitempty"` +} + +type writeResult struct { + sequence int + started time.Time + digest string + err error +} + +func observe(ctx context.Context, o options, output io.Writer, write func(int) (string, error)) error { + start := time.Now() + deadline := start.Add(o.duration) + encoder := json.NewEncoder(output) + emit := func(e event) error { + now := time.Now() + e.SchemaVersion, e.RunID, e.FilesystemID = 1, o.runID, o.filesystemID + e.UnixMS, e.ElapsedMS = now.UnixMilli(), now.Sub(start).Milliseconds() + return encoder.Encode(e) + } + if err := emit(event{Kind: "start"}); err != nil { + return err + } + timer := time.NewTimer(o.duration) + defer timer.Stop() + ticker := time.NewTicker(o.interval) + defer ticker.Stop() + results := make(chan writeResult, 1) + sequence, pending := 0, false + begin := func() { + sequence++ + pending = true + seq, began := sequence, time.Now() + go func() { + digest, err := write(seq) + results <- writeResult{seq, began, digest, err} + }() + } + stop := func(result string) error { return emit(event{Kind: "stop", Result: result}) } + if ctx.Err() == nil { + begin() + } + for { + select { + case <-ctx.Done(): + _ = stop("interrupted") + return errors.New("witness interrupted") + case <-timer.C: + if pending { + _ = stop("write-pending") + return errors.New("witness deadline with write pending") + } + return stop("complete") + case <-ticker.C: + if err := emit(event{Kind: "heartbeat"}); err != nil { + return err + } + // A blocked filesystem write must not queue more work or prevent + // independent guest liveness observations on stdout. + if !pending && time.Now().Before(deadline) { + begin() + } + case result := <-results: + pending = false + if result.err != nil { + _ = stop("write-failed") + return errors.New("witness write failed") + } + if err := emit(event{Kind: "write", Sequence: result.sequence, StartedUnixMS: result.started.UnixMilli(), SHA256: result.digest}); err != nil { + return err + } + } + } +} + +func main() { + var o options + var durationSeconds, intervalMS int + flag.StringVar(&o.directory, "directory", "", "owner-provisioned scratch directory on the covered filesystem") + flag.StringVar(&o.runID, "run-id", "", "opaque run ID; no names or paths") + flag.StringVar(&o.filesystemID, "filesystem-id", "", "opaque filesystem ID from the private mount crosswalk") + flag.IntVar(&durationSeconds, "duration-seconds", 600, "preselected observation duration, 3..600") + flag.IntVar(&intervalMS, "interval-ms", 1000, "synthetic write/heartbeat interval, 1000..5000") + flag.Parse() + if flag.NArg() != 0 || durationSeconds < 3 || durationSeconds > 600 || intervalMS < 1000 || intervalMS > 5000 { + fmt.Fprintln(os.Stderr, "invalid witness arguments") + os.Exit(2) + } + o.duration, o.interval = time.Duration(durationSeconds)*time.Second, time.Duration(intervalMS)*time.Millisecond + write, err := prepareWriter(o) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if err := observe(ctx, o, os.Stdout, write); err != nil { + // Fixed errors only: never emit the private filesystem path or a + // provider/service environment. An output error is still a failure. + fmt.Fprintln(os.Stderr, "witness observation failed") + os.Exit(1) + } +} diff --git a/tests/qualification/guest-agent-backup/guest-writer/main_test.go b/tests/qualification/guest-agent-backup/guest-writer/main_test.go new file mode 100644 index 0000000000..e4b6e37186 --- /dev/null +++ b/tests/qualification/guest-agent-backup/guest-writer/main_test.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func testOptions(t *testing.T) options { + t.Helper() + return options{directory: t.TempDir(), runID: "run-01", filesystemID: "data", duration: 3 * time.Second, interval: time.Second} +} + +func events(t *testing.T, output *bytes.Buffer) []event { + t.Helper() + var result []event + decoder := json.NewDecoder(output) + for { + var e event + if err := decoder.Decode(&e); err == io.EOF { + break + } else if err != nil { + t.Fatal(err) + } + result = append(result, e) + } + return result +} + +func TestWitnessWritesSyncsAndReadsOnlyNewSyntheticFiles(t *testing.T) { + o := testOptions(t) + untouched := filepath.Join(o.directory, "existing") + if err := os.WriteFile(untouched, []byte("do not overwrite"), 0600); err != nil { + t.Fatal(err) + } + write, err := prepareWriter(o) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(o.directory, "pulse-backup-witness-run-01-data") + for seq := 1; seq <= 2; seq++ { + digest, err := write(seq) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, fmt.Sprintf("witness-%06d", seq)) + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + hash := sha256.Sum256(payload) + if len(payload) != 512 || digest != hex.EncodeToString(hash[:]) || !bytes.Contains(payload, []byte("\nrun-01\ndata\n")) { + t.Fatal("witness did not read back the new synthetic payload") + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("witness file permissions") + } + } + if _, err := write(1); err == nil { + t.Fatal("repeated sequence overwrote existing evidence") + } + if _, err := prepareWriter(o); err == nil { + t.Fatal("repeated run reused existing evidence") + } + data, err := os.ReadFile(untouched) + if err != nil || string(data) != "do not overwrite" { + t.Fatal("unrelated file changed") + } + info, err := os.Stat(dir) + if err != nil || info.Mode().Perm() != 0700 { + t.Fatal("witness directory permissions") + } +} + +func TestWitnessRejectsInvalidInputsBeforeCreatingFiles(t *testing.T) { + changes := map[string]func(*options){ + "relative": func(o *options) { o.directory = "relative" }, + "run traversal": func(o *options) { o.runID = "../elsewhere" }, + "filesystem traversal": func(o *options) { o.filesystemID = "../elsewhere" }, + "oversize ID": func(o *options) { o.runID = strings.Repeat("a", 33) }, + "duration small": func(o *options) { o.duration = time.Second }, + "duration large": func(o *options) { o.duration = 11 * time.Minute }, + "interval small": func(o *options) { o.interval = time.Millisecond }, + "interval large": func(o *options) { o.interval = 6 * time.Second }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + o := testOptions(t) + original := o.directory + change(&o) + if _, err := prepareWriter(o); err == nil { + t.Fatal("unsafe or unbounded input accepted") + } + entries, err := os.ReadDir(original) + if err != nil || len(entries) != 0 { + t.Fatal("invalid input created files") + } + }) + } + for _, parent := range []bool{false, true} { + t.Run("symlink", func(t *testing.T) { + o := testOptions(t) + real := t.TempDir() + link := filepath.Join(o.directory, "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + o.directory = link + if parent { + if err := os.Mkdir(filepath.Join(real, "child"), 0700); err != nil { + t.Fatal(err) + } + o.directory = filepath.Join(link, "child") + } + if _, err := prepareWriter(o); err == nil { + t.Fatal("symlinked scratch path accepted") + } + }) + } +} + +func TestObserverCompletesWithOrderedWriteAndLivenessEvidence(t *testing.T) { + o := testOptions(t) + // Short timers exercise the event loop, not the CLI's native bounds. + o.duration, o.interval = 250*time.Millisecond, 30*time.Millisecond + var output bytes.Buffer + if err := observe(context.Background(), o, &output, func(int) (string, error) { return strings.Repeat("a", 64), nil }); err != nil { + t.Fatal(err) + } + seen := events(t, &output) + if len(seen) < 4 || seen[0].Kind != "start" || seen[len(seen)-1].Result != "complete" { + t.Fatalf("incomplete lifecycle: %#v", seen) + } + sequence := 0 + for i, event := range seen { + if event.SchemaVersion != 1 || event.RunID != o.runID || event.FilesystemID != o.filesystemID || (i > 0 && event.ElapsedMS < seen[i-1].ElapsedMS) { + t.Fatal("event identity or order") + } + if event.Kind == "write" { + sequence++ + if event.Sequence != sequence || event.StartedUnixMS > event.UnixMS || event.SHA256 == "" { + t.Fatal("write evidence incomplete") + } + } + } + if sequence == 0 { + t.Fatal("no writes observed") + } +} + +func TestObserverBlockedWriteDoesNotQueueMoreAndCannotPass(t *testing.T) { + o := testOptions(t) + o.duration, o.interval = 250*time.Millisecond, 30*time.Millisecond + var output bytes.Buffer + var calls atomic.Int32 + release := make(chan struct{}) + defer close(release) + err := observe(context.Background(), o, &output, func(int) (string, error) { + calls.Add(1) + <-release + return "", nil + }) + if err == nil || calls.Load() != 1 { + t.Fatal("blocked write passed or queued more work") + } + seen := events(t, &output) + if len(seen) < 3 || seen[len(seen)-1].Result != "write-pending" { + t.Fatal("blocked write erased liveness or deadline failure") + } + for _, event := range seen { + if event.Kind == "write" { + t.Fatal("uncompleted write reported successful") + } + } +} + +func TestObserverFailureAndInterruptionRemainAdverse(t *testing.T) { + o := testOptions(t) + var output bytes.Buffer + if err := observe(context.Background(), o, &output, func(int) (string, error) { return "", errors.New("private path") }); err == nil { + t.Fatal("failed write passed") + } + if strings.Contains(output.String(), "private path") || events(t, &output)[1].Result != "write-failed" { + t.Fatal("failure missing or private error disclosed") + } + output.Reset() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + called := false + if err := observe(ctx, o, &output, func(int) (string, error) { called = true; return "", nil }); err == nil || called { + t.Fatal("cancelled run started writes or passed") + } + if events(t, &output)[1].Result != "interrupted" { + t.Fatal("interruption not retained") + } +} + +type failedOutput struct{} + +func (failedOutput) Write([]byte) (int, error) { return 0, errors.New("private output path") } + +func TestObserverUncollectedOutputFailsBeforeStartingWrites(t *testing.T) { + called := false + err := observe(context.Background(), testOptions(t), failedOutput{}, func(int) (string, error) { called = true; return "", nil }) + if err == nil || called { + t.Fatal("missing output passed or began writes") + } +} diff --git a/tests/qualification/guest-agent-backup/test_check.py b/tests/qualification/guest-agent-backup/test_check.py new file mode 100644 index 0000000000..8dc9a98fd6 --- /dev/null +++ b/tests/qualification/guest-agent-backup/test_check.py @@ -0,0 +1,264 @@ +"""Synthetic checker controls; none is native PVE/QGA acceptance.""" + +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +SPEC = importlib.util.spec_from_file_location("backup_check", Path(__file__).with_name("check.py")) +CHECK = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECK) +SOURCE = "a" * 40 +HASH = "b" * 64 + + +def witness_events(filesystem="data"): + events = [] + for kind, at, extra in [ + ("start", 100000, {}), + ("write", 100020, {"sequence": 1, "started_unix_ms": 100010}), + ("heartbeat", 101000, {}), + ("write", 101020, {"sequence": 2, "started_unix_ms": 101010}), + ("heartbeat", 105000, {}), + ("write", 108020, {"sequence": 3, "started_unix_ms": 108010}), + ("write", 109020, {"sequence": 4, "started_unix_ms": 109010}), + ("stop", 120000, {"result": "complete"}), + ]: + event = {"schema_version": 1, "kind": kind, "run_id": "run-01", "filesystem_id": filesystem, + "unix_ms": at, "elapsed_ms": at - 100000, **extra} + if kind == "write": + payload = f"Pulse disposable backup witness\nrun-01\n{filesystem}\n{event['sequence']}\n".encode() + payload += b"x" * (512 - len(payload)) + event["sha256"] = hashlib.sha256(payload).hexdigest() + events.append(event) + return events + + +def record(): + baseline = {"phase": "before", "at_ms": 102000, "guest_identity_sha256": HASH, "guest_status": "available", + "metadata_sha256": HASH, "memory_sha256": HASH, "disk_sha256": HASH, + "memory_source": "guest-agent-meminfo", "disk_reason": "", "disk_history_latest_ms": 100020, + "memory_history_latest_ms": 100040, "cpu_latest_ms": 100010} + locked = {**baseline, "phase": "locked", "at_ms": 104500, "cpu_latest_ms": 104000, + "guest_status": "deferred", "memory_source": "previous-snapshot", "disk_reason": "prev-vm-locked"} + resumed = {**baseline, "phase": "resumed", "at_ms": 110000, "disk_history_latest_ms": 109500, + "memory_history_latest_ms": 109500, "cpu_latest_ms": 109500} + return {"schema_version": 1, "run_id": "run-01", "evidence_class": "native-disposable", + "identity": {"pulse_source": SOURCE, "pulse_binary_sha256": HASH, "pve_packages_sha256": HASH, + "qga_binary_sha256": HASH, "haos_image_sha256": HASH, "provenance_receipt_sha256": HASH}, + "preflight": {"pve_version": "9.2.21", "guest_type": "HAOS", "audit": True, "file_read": True, + "tls_verified": True, "qga_enabled": True, "freeze_enabled": True, + "single_pulse_process": True, "filesystem_map_sha256": HASH, "filesystem_ids": ["data"]}, + "window": {"start_ms": 100000, "end_ms": 120000, "clock_error_ms": 10}, + "backup": {"start_ms": 103000, "lock_start_ms": 103200, "freeze_ms": 104000, "thaw_request_ms": 106000, + "lock_end_ms": 106200, "completed_ms": 107000, "status": "OK", "trace_sha256": HASH}, + "coverage": {"start_ms": 100000, "end_ms": 120000, "dropped_events": 0, "trace_sha256": HASH}, + "commands": [{"id": "request-01", "method": "get-fsinfo", "start_ms": 103100, + "end_ms": 103500, "result": "ok"}], + "snapshots": [baseline, locked, {**locked, "at_ms": 105500, "cpu_latest_ms": 105000}, resumed], + "witnesses": []} + + +class BackupRecordTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.record = record() + self.add_witness() + + def add_witness(self, filesystem="data", events=None): + events = witness_events(filesystem) if events is None else events + raw = b"".join(json.dumps(e).encode() + b"\n" for e in events) + filename = filesystem + ".jsonl" + (self.root / filename).write_bytes(raw) + entry = {"filesystem_id": filesystem, "file": filename, "sha256": hashlib.sha256(raw).hexdigest(), "exit_code": 0} + self.record["witnesses"] = [w for w in self.record["witnesses"] if w["filesystem_id"] != filesystem] + [entry] + + def check(self): + return CHECK.check_record(self.record, self.root, SOURCE) + + def test_complete_record_checks_do_not_claim_native_acceptance(self): + result = self.check() + self.assertTrue(result["record_checks_passed"]) + self.assertFalse(result["native_acceptance_complete"]) + + def test_every_covered_filesystem_needs_an_independent_witness(self): + self.record["preflight"]["filesystem_ids"].append("root") + with self.assertRaisesRegex(CHECK.InvalidRecord, "witness missing"): + self.check() + self.add_witness("root") + self.assertEqual(self.check()["filesystem_witnesses"], 2) + + def test_ok_task_without_post_task_writes_is_not_thaw(self): + self.add_witness(events=[e for e in witness_events() if e.get("sequence", 0) < 3]) + with self.assertRaisesRegex(CHECK.InvalidRecord, "post-task fsync"): + self.check() + + def test_outstanding_prefreeze_write_is_not_two_fresh_post_task_writes(self): + events = witness_events() + for event in events: + if event.get("sequence") == 3: + event["started_unix_ms"] = 102000 + self.add_witness(events=events) + with self.assertRaisesRegex(CHECK.InvalidRecord, "post-task fsync"): + self.check() + + def test_wrong_id_timeout_and_incomplete_native_commands_stay_adverse(self): + for result in ("wrong-command-id", "timeout", "response-incomplete", "permission-denied"): + with self.subTest(result=result): + self.record["commands"][0]["result"] = result + with self.assertRaisesRegex(CHECK.InvalidRecord, "adverse"): + self.check() + + def test_lock_dispatch_concurrency_replay_and_absent_overlap_fail(self): + original = copy.deepcopy(self.record) + changes = [ + lambda r: r["commands"][0].update(start_ms=104000, end_ms=104500), + lambda r: r["commands"][0].update(start_ms=103200), + lambda r: r["commands"][0].update(end_ms=103150), + lambda r: r["commands"].append({**r["commands"][0], "id": "another"}), + lambda r: r["commands"].append(copy.deepcopy(r["commands"][0])), + ] + for change in changes: + self.record = copy.deepcopy(original) + change(self.record) + with self.subTest(commands=self.record["commands"]): + with self.assertRaises(CHECK.InvalidRecord): + self.check() + + def test_cache_history_cpu_and_resume_controls(self): + original = copy.deepcopy(self.record) + changes = [ + lambda r: r["snapshots"][1].update(disk_history_latest_ms=104000), + lambda r: r["snapshots"][1].update(memory_history_latest_ms=104000), + lambda r: r["snapshots"][1].update(metadata_sha256="c" * 64), + lambda r: r["snapshots"][1].update(memory_sha256="c" * 64), + lambda r: r["snapshots"][1].update(disk_sha256="c" * 64), + lambda r: r["snapshots"][1].update(cpu_latest_ms=100010), + lambda r: r["snapshots"][1].update(guest_status="available"), + lambda r: r["snapshots"][1].update(disk_reason=""), + lambda r: r["snapshots"][1].update(memory_source="guest-agent-meminfo"), + lambda r: r["snapshots"][-1].update(guest_identity_sha256="c" * 64), + lambda r: r["snapshots"][-1].update(disk_history_latest_ms=100020), + lambda r: r["snapshots"][-1].update(memory_history_latest_ms=100040), + lambda r: r["snapshots"][-1].update(guest_status="deferred"), + ] + for change in changes: + self.record = copy.deepcopy(original) + change(self.record) + with self.subTest(snapshots=self.record["snapshots"]): + with self.assertRaises(CHECK.InvalidRecord): + self.check() + + def test_missing_permissions_identity_trace_and_clock_are_not_acceptance(self): + original = copy.deepcopy(self.record) + changes = [ + lambda r: r["identity"].update(pulse_source="c" * 40), + lambda r: r["identity"].update(haos_image_sha256="missing"), + lambda r: r.update(evidence_class="source-control"), + lambda r: r["preflight"].update(pve_version="9.0"), + lambda r: r["preflight"].update(guest_type="ordinary-qga-subprocess"), + lambda r: r["preflight"].update(audit=False), + lambda r: r["preflight"].update(file_read=False), + lambda r: r["preflight"].update(tls_verified=False), + lambda r: r["preflight"].update(freeze_enabled=False), + lambda r: r["preflight"].update(single_pulse_process=False), + lambda r: r["coverage"].update(dropped_events=1), + lambda r: r["coverage"].update(start_ms=103500), + lambda r: r["coverage"].update(end_ms=107000), + lambda r: r["window"].update(end_ms=800000), + lambda r: r["window"].update(clock_error_ms=2000), + lambda r: r["backup"].update(status="failed"), + ] + for change in changes: + self.record = copy.deepcopy(original) + change(self.record) + with self.subTest(record=self.record): + with self.assertRaises(CHECK.InvalidRecord): + self.check() + + def test_witness_corruption_replay_missing_liveness_and_early_exit_fail(self): + original = witness_events() + changes = [ + lambda events: events[-1].update(result="write-pending"), + lambda events: events[-1].update(result="interrupted"), + lambda events: events[-1].update(unix_ms=110000, elapsed_ms=10000), + lambda events: events[1].update(sha256=HASH), + lambda events: events[5].update(sequence=1), + lambda events: events[1].update(run_id="other-run"), + lambda events: events[1].update(filesystem_id="other-fs"), + lambda events: events[4].update(unix_ms=104000, elapsed_ms=4000), + lambda events: events[4].update(elapsed_ms=4000), + lambda events: events[1].update(kind=[]), + lambda events: events[1].update(private_value="must not be accepted"), + ] + for change in changes: + events = copy.deepcopy(original) + change(events) + self.add_witness(events=events) + with self.subTest(events=events): + with self.assertRaises(CHECK.InvalidRecord): + self.check() + + def test_missing_symlinked_oversized_truncated_and_changed_witness_fail(self): + path = self.root / "data.jsonl" + original = path.read_bytes() + for raw in (original[:-1], original + b"corrupted\n", b"x" * (CHECK.MAX_BYTES + 1)): + path.write_bytes(raw) + self.record["witnesses"][0]["sha256"] = hashlib.sha256(raw).hexdigest() + with self.assertRaises(CHECK.InvalidRecord): + self.check() + self.add_witness() + self.record["witnesses"][0]["sha256"] = HASH + with self.assertRaisesRegex(CHECK.InvalidRecord, "mismatch"): + self.check() + self.add_witness() + target = self.root / "retained.jsonl" + path.rename(target) + path.symlink_to(target.name) + with self.assertRaisesRegex(CHECK.InvalidRecord, "regular"): + self.check() + path.unlink() + with self.assertRaisesRegex(CHECK.InvalidRecord, "regular"): + self.check() + + def test_traversal_duplicate_filesystem_boolean_times_and_extra_fields_fail(self): + original = copy.deepcopy(self.record) + changes = [ + lambda r: r["witnesses"][0].update(file="../data.jsonl"), + lambda r: r["witnesses"][0].update(exit_code=True), + lambda r: r["witnesses"].append(copy.deepcopy(r["witnesses"][0])), + lambda r: r["window"].update(start_ms=True), + lambda r: r["snapshots"][0].update(at_ms=False), + lambda r: r["commands"][0].update(method=[]), + lambda r: r.update(private_names="do not accept private fields"), + ] + for change in changes: + self.record = copy.deepcopy(original) + change(self.record) + with self.assertRaises(CHECK.InvalidRecord): + self.check() + + def test_duplicate_json_keys_fail(self): + with self.assertRaisesRegex(CHECK.InvalidRecord, "duplicate"): + CHECK.decode(b'{"status":"OK","status":"failed"}') + + def test_cli_returns_sanitised_failures_without_echoing_private_input(self): + path = self.root / "record.json" + self.record["snapshots"][0]["at_ms"] = "private-value-must-not-escape" + path.write_text(json.dumps(self.record)) + result = subprocess.run([sys.executable, str(Path(__file__).with_name("check.py")), + "--record", str(path), "--expected-source", SOURCE], capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertNotIn("private-value-must-not-escape", result.stdout + result.stderr) + self.assertEqual(json.loads(result.stderr)["failure"], "snapshot time") + + +if __name__ == "__main__": + unittest.main() From cd20663a7e55cc324d3965e3aa32d42ebf939125 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:45:48 +0100 Subject: [PATCH 72/86] Keep demo recovery fixtures portable across temporary path aliases Both macOS installer jobs failed the connected recovery controls because their temporary estate retained the /var symlink prefix. Resolve only the fixture root and exercise both paths under a real alias; keep production symlink rejection and complete observation windows unchanged. Contract-Neutral: Test fixture path canonicalisation and a connected portability control only; no demo runtime, workflow, release or installation contract changes. Change-source: pulse-maintainer --- .../tests/test_demo_runtime_transaction.py | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 3900e32a01..487e845fd9 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -145,7 +145,10 @@ class TransactionTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) - root = Path(self.tmp.name) + # Model an owned estate, not the OS alias leading to its temporary + # directory (for example macOS /var -> /private/var). The production + # history guard still rejects symlinks introduced inside the estate. + root = Path(self.tmp.name).resolve() self.paths = {name: root / "estate" / name for name in engine.PATHS} for name, path in self.paths.items(): path.parent.mkdir(parents=True, exist_ok=True) @@ -456,7 +459,7 @@ class TransactionTest(unittest.TestCase): def test_unhealthy_recovery_retains_original_generated_history(self): self.host.unhealthy = True code, receipt = self.run_transaction() - self.assertEqual(code, 0) + self.assertEqual(code, 0, receipt.get("failure")) self.assertFalse(receipt["healthy_baseline"]) self.assertFalse((self.paths["data"] / "alerts/events.db").exists()) self.assertEqual((self.attempt / "snapshot/data/alerts/events.db").read_bytes(), b"synthetic opaque persistent file") @@ -489,7 +492,7 @@ class TransactionTest(unittest.TestCase): def test_listener_readiness_does_not_spend_the_watch_window(self): self.host.ready_until = 30 code, receipt = self.run_transaction() - self.assertEqual(code, 0) + self.assertEqual(code, 0, receipt.get("failure")) self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) self.assertGreaterEqual(self.host.clock, 330) self.assertEqual(self.host.starts, 1) @@ -822,6 +825,29 @@ class RuntimeSelectionTest(unittest.TestCase): engine.runtime_members(self.archive(directory, entries), 'v6.4.6') +class PortableTransactionFixtureTest(unittest.TestCase): + def test_recovery_controls_accept_a_symlinked_temporary_parent(self): + # macOS commonly exposes its temporary directory through /var -> + # /private/var. Exercise both failing recovery paths with a real + # equivalent alias, without changing the production symlink guard. + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + real = root / "real" + real.mkdir() + alias = root / "alias" + alias.symlink_to(real, target_is_directory=True) + cases = unittest.TestSuite([ + TransactionTest("test_unhealthy_recovery_retains_original_generated_history"), + TransactionTest("test_listener_readiness_does_not_spend_the_watch_window"), + ]) + result = unittest.TestResult() + with patch.object(tempfile, "tempdir", str(alias)): + cases.run(result) + self.assertEqual(result.testsRun, 2) + self.assertEqual(result.errors, []) + self.assertEqual(result.failures, []) + + class NativeGateTest(unittest.TestCase): def row(self, run_id=10, **fields): return {'id': run_id, 'head_sha': 'a' * 40, 'run_attempt': 1, From c381995d24382f31d33873cf60770b027111f21f Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:47:00 +0100 Subject: [PATCH 73/86] Keep proxy-auth help navigation independent of emoji fragments The real Docs browser check reached the guide but could not resolve the guessed emoji heading fragment. Link the existing guide directly and verify its trust-boundary heading and text without inventing an anchor. Contract-Neutral: Documentation and browser regression checks only; rendering, proxy trust and authentication runtime contracts are unchanged. Change-source: pulse-maintainer --- docs/REVERSE_PROXY.md | 2 +- frontend-modern/browser-tests/reverse-proxy-help.cjs | 6 ++++-- frontend-modern/public/docs/REVERSE_PROXY.md | 2 +- frontend-modern/src/utils/__tests__/docsLinks.test.ts | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md index f6b19278d1..d0fdf9bd98 100644 --- a/docs/REVERSE_PROXY.md +++ b/docs/REVERSE_PROXY.md @@ -8,7 +8,7 @@ Use HTTPS at the public proxy and keep Pulse's backend private. The examples below are configuration fragments, not complete TLS or authentication setups. Keep Pulse authentication enabled; TLS termination does not authenticate users. For identity supplied by an IdP/proxy, follow the separate -[proxy authentication guide](PROXY_AUTH.md#-header-trust-boundary). +[proxy authentication guide](PROXY_AUTH.md), including its Header Trust Boundary. **Forwarded HTTPS headers are trusted only from a configured immediate peer.** Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy diff --git a/frontend-modern/browser-tests/reverse-proxy-help.cjs b/frontend-modern/browser-tests/reverse-proxy-help.cjs index cfd6292286..db82926d91 100644 --- a/frontend-modern/browser-tests/reverse-proxy-help.cjs +++ b/frontend-modern/browser-tests/reverse-proxy-help.cjs @@ -91,8 +91,10 @@ const { chromium, webkit } = require('playwright'); const authentication = page.getByRole('link', { name: 'proxy authentication guide', exact: true }); await authentication.focus(); await page.keyboard.press('Enter'); - await page.waitForFunction(inViewport, '-header-trust-boundary'); - assert.ok(page.url().endsWith('/docs/PROXY_AUTH#-header-trust-boundary')); + const boundary = page.getByRole('heading', { name: 'โš ๏ธ Header Trust Boundary', exact: true }); + await boundary.waitFor(); + assert.ok(page.url().endsWith('/docs/PROXY_AUTH')); + await boundary.scrollIntoViewIfNeeded(); assert.ok((await page.locator('article').innerText()).includes('never append to them')); assert.deepEqual(errors, []); observations.push({ diff --git a/frontend-modern/public/docs/REVERSE_PROXY.md b/frontend-modern/public/docs/REVERSE_PROXY.md index f6b19278d1..d0fdf9bd98 100644 --- a/frontend-modern/public/docs/REVERSE_PROXY.md +++ b/frontend-modern/public/docs/REVERSE_PROXY.md @@ -8,7 +8,7 @@ Use HTTPS at the public proxy and keep Pulse's backend private. The examples below are configuration fragments, not complete TLS or authentication setups. Keep Pulse authentication enabled; TLS termination does not authenticate users. For identity supplied by an IdP/proxy, follow the separate -[proxy authentication guide](PROXY_AUTH.md#-header-trust-boundary). +[proxy authentication guide](PROXY_AUTH.md), including its Header Trust Boundary. **Forwarded HTTPS headers are trusted only from a configured immediate peer.** Set `PULSE_TRUSTED_PROXY_CIDRS` in Pulse's deployment configuration to the proxy diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 2988806241..bfe282cb05 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -143,7 +143,8 @@ describe('docsLinks', () => { expect(setup).toContain('::1/128'); expect(setup).toContain('Wildcard ranges `0.0.0.0/0` and `::/0` are rejected'); expect(setup).toContain('not an authentication bypass'); - expect(setup).toContain('PROXY_AUTH.md#-header-trust-boundary'); + expect(setup).toContain('[proxy authentication guide](PROXY_AUTH.md)'); + expect(setup).toContain('including its Header Trust Boundary'); const nginx = proxy.split('### Nginx')[1].split('### Caddy')[0]; expect(nginx).toContain('HTTPS `server` block'); expect(nginx).toContain('proxy_set_header X-Forwarded-Proto $scheme;'); From d9a2c78612b21dd2e46de0f75b71c0ddaddb9f86 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:04:41 +0100 Subject: [PATCH 74/86] Assert held delivery rows separately from unreadable evidence The case-insensitive Deferred substring also matches the held-read warning after a 503. Scope the exact badge and source warning to delivery activity, prove pending retention and independent attempts, withdraw visible rows on 503 and 403, and recover only after a successful held read. Production runtime semantics are unchanged. Contract-Neutral: Verification-only correction for existing alert evidence semantics; no runtime, alert policy or subsystem contract delta. Change-source: pulse-maintainer --- .../browser-tests/notification-evidence.cjs | 28 ++++- .../93-alert-operator-qualification.spec.ts | 103 ++++++++++++++---- 2 files changed, 110 insertions(+), 21 deletions(-) diff --git a/frontend-modern/browser-tests/notification-evidence.cjs b/frontend-modern/browser-tests/notification-evidence.cjs index 8a6fef7319..ad14362270 100644 --- a/frontend-modern/browser-tests/notification-evidence.cjs +++ b/frontend-modern/browser-tests/notification-evidence.cjs @@ -117,9 +117,9 @@ const held = [ requests.push({ path: url.pathname, query: url.search, mode }); if (mode.startsWith('hold-')) await new Promise((resolve) => releases.push({ isHeld, resolve })); - if (mode.endsWith('denied')) + if (mode.endsWith('denied') || mode.endsWith('unavailable')) return route.fulfill({ - status: isHeld ? 403 : 503, + status: isHeld && mode.endsWith('denied') ? 403 : 503, json: { error: 'Synthetic unavailable evidence' }, }); return route.fulfill({ @@ -240,6 +240,30 @@ const held = [ assert.equal(await empty.count(), 0); }, ); + await check( + 'The CI locator matches the 503 warning after the held row is withdrawn', + async () => { + heldMode = 'unavailable'; + await refresh.focus(); + await page.keyboard.press('Enter'); + await heldWarning.waitFor(); + const broadTexts = await page.getByText('Deferred').allTextContents(); + const exactBadgeCount = await activity.getByText('Deferred', { exact: true }).count(); + assert.deepEqual(broadTexts, [ + 'Pulse could not read held or deferred notifications. Refresh to try again.', + ]); + assert.equal(exactBadgeCount, 0); + assert.equal(await activity.getByText('Delivered', { exact: true }).count(), 1); + checks.push({ label: 'Observed broad versus exact status match', broadTexts, exactBadgeCount }); + }, + ); + await capture('held-503-ci-locator'); + await check('A healthy refresh restores the held row before permission withdrawal', async () => { + heldMode = 'known'; + await refresh.click(); + await activity.getByText('Deferred', { exact: true }).waitFor(); + await heldWarning.waitFor({ state: 'hidden' }); + }); await check( 'Held-event 403 withdraws held rows and warns without hiding delivered evidence', async () => { diff --git a/tests/integration/tests/93-alert-operator-qualification.spec.ts b/tests/integration/tests/93-alert-operator-qualification.spec.ts index 9268223c13..e8d2d7bfe8 100644 --- a/tests/integration/tests/93-alert-operator-qualification.spec.ts +++ b/tests/integration/tests/93-alert-operator-qualification.spec.ts @@ -367,12 +367,16 @@ test.describe('Alert operator qualification', () => { }), }), ); - let heldEventsAvailable = true; - await page.route('**/api/alerts/events**', (route) => - route.fulfill({ - status: heldEventsAvailable ? 200 : 503, + let heldEventsStatus = 200; + let heldEventsBarrier: Promise | null = null; + let releaseHeldEvents: (() => void) | undefined; + await page.route('**/api/alerts/events**', async (route) => { + const status = heldEventsStatus; + if (heldEventsBarrier) await heldEventsBarrier; + await route.fulfill({ + status, contentType: 'application/json', - body: heldEventsAvailable + body: status === 200 ? JSON.stringify([ { id: 'held-1', @@ -386,8 +390,8 @@ test.describe('Alert operator qualification', () => { }, ]) : JSON.stringify({ error: 'event log unavailable' }), - }), - ); + }); + }); await page.goto('/alerts/overview', { waitUntil: 'domcontentloaded' }); await expect(page.getByText('Delivery Node').first()).toBeVisible(); @@ -407,21 +411,82 @@ test.describe('Alert operator qualification', () => { { exact: false }, ), ).toBeVisible(); - await expect(page.getByText('Deferred')).toBeVisible(); - await expect(page.getByText('Quiet hours')).toBeVisible(); - await expect(page.getByText('Delivered', { exact: true })).toBeVisible(); + const activity = page.locator('#notification-delivery-activity'); + const deferred = activity.getByText('Deferred', { exact: true }); + const delivered = activity.getByText('Delivered', { exact: true }); + const heldWarning = activity.getByRole('alert').filter({ + hasText: 'Pulse could not read held or deferred notifications. Refresh to try again.', + }); + const heldLoading = activity.getByText('Loading held and deferred notifications...', { + exact: true, + }); + const refresh = activity.getByRole('button', { name: 'Refresh delivery status' }); + await expect(deferred).toBeVisible(); + await expect(activity.getByText('Quiet hours', { exact: true })).toBeVisible(); + await expect(delivered).toBeVisible(); + await expect(heldLoading).toHaveCount(0); + await expect(heldWarning).toHaveCount(0); - heldEventsAvailable = false; - const refresh = page - .getByRole('heading', { name: 'Recent delivery activity' }) - .locator('..') - .locator('..') - .getByRole('button', { name: 'Refresh delivery status' }); + // A pending refresh retains known evidence until its result arrives. The + // independently successful attempt read must keep Refresh usable. + heldEventsStatus = 503; + heldEventsBarrier = new Promise((resolve) => { releaseHeldEvents = resolve; }); await refresh.focus(); await page.keyboard.press('Enter'); - await expect(page.getByText('Deferred')).toHaveCount(0); - await expect(page.getByText('Delivered', { exact: true })).toBeVisible(); - await expect(page.getByText(/Delivery history is unavailable/i)).toHaveCount(0); + await expect(heldLoading).toBeVisible(); + await expect(deferred).toBeVisible(); + await expect(delivered).toBeVisible(); + await expect(refresh).toBeEnabled(); + releaseHeldEvents!(); + heldEventsBarrier = null; + await expect(heldWarning).toBeVisible(); + await expect(heldLoading).toHaveCount(0); + // Substring text lookup is case-insensitive and also matches the warning's + // "deferred notifications". Assert the exact status badge, not useful copy. + await expect(deferred).toHaveCount(0); + await expect(activity.getByText('Delivery Node (cpu)', { exact: true })).toHaveCount(0); + await expect(delivered).toBeVisible(); + await expect(activity.getByText(/could not read the delivery log/i)).toHaveCount(0); + await expect(activity.getByText(/No alert deliveries were attempted/)).toHaveCount(0); + + heldEventsStatus = 200; + await refresh.focus(); + await page.keyboard.press('Enter'); + await expect(deferred).toBeVisible(); + await expect(heldWarning).toHaveCount(0); + + // Permission withdrawal removes previously readable held evidence, not + // independent delivery attempts. + heldEventsStatus = 403; + const denied = page.waitForResponse((response) => + new URL(response.url()).pathname === '/api/alerts/events' && response.status() === 403, + ); + await refresh.focus(); + await page.keyboard.press('Enter'); + await denied; + await expect(heldLoading).toHaveCount(0); + await expect(heldWarning).toBeVisible(); + await expect(deferred).toHaveCount(0); + await expect(delivered).toBeVisible(); + + // A pending retry keeps the failure visible; only a healthy held read + // restores that source's rows and clears its warning. + heldEventsStatus = 200; + heldEventsBarrier = new Promise((resolve) => { releaseHeldEvents = resolve; }); + await refresh.focus(); + await page.keyboard.press('Enter'); + await expect(heldLoading).toBeVisible(); + await expect(heldWarning).toBeVisible(); + await expect(deferred).toHaveCount(0); + await expect(delivered).toBeVisible(); + await expect(refresh).toBeEnabled(); + releaseHeldEvents!(); + heldEventsBarrier = null; + await expect(deferred).toBeVisible(); + await expect(heldLoading).toHaveCount(0); + await expect(heldWarning).toHaveCount(0); + await expect(delivered).toBeVisible(); + }); test('mounts a bounded runway and renders 10,000 History records within budget', async ({ page }, testInfo) => { From 091f987798caa2a9cf7a21fcfe92d207d1da1808 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:24:47 +0100 Subject: [PATCH 75/86] Exercise authoritative guest-lock admission in VM diagnostics fixtures The required API shard failed because its healthy filesystem fixture omitted VM config. Supply a verified unlocked config instead of bypassing the guard, and cover backup/snapshot locks, absent or malformed config, permission refusals and locks appearing in flight through the real diagnostic client. Contract-Neutral: Diagnostic fixtures and connected safety assertions only; production guest admission, APIs and polling behavior are unchanged. Change-source: pulse-maintainer --- internal/api/diagnostics_additional_test.go | 79 ++++++++++++++++++++- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/internal/api/diagnostics_additional_test.go b/internal/api/diagnostics_additional_test.go index e5fead6201..ddafea8a7e 100644 --- a/internal/api/diagnostics_additional_test.go +++ b/internal/api/diagnostics_additional_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -668,9 +669,11 @@ func TestBuildAIChatDiagnostic_WithService(t *testing.T) { func TestCheckVMDiskMonitoring_Success(t *testing.T) { responses := map[string]proxmoxTestResponse{ - "/api2/json/nodes": {body: `{"data":[{"node":"pve1","status":"online"}]}`}, - "/api2/json/nodes/pve1/qemu": {body: `{"data":[{"vmid":100,"name":"vm-100","node":"pve1","status":"running","template":0}]}`}, - "/api2/json/nodes/pve1/qemu/100/status/current": {body: `{"data":{"agent":1}}`}, + "/api2/json/nodes": {body: `{"data":[{"node":"pve1","status":"online"}]}`}, + "/api2/json/nodes/pve1/qemu": {body: `{"data":[{"vmid":100,"name":"vm-100","node":"pve1","status":"running","template":0}]}`}, + "/api2/json/nodes/pve1/qemu/100/status/current": {body: `{"data":{"agent":1}}`}, + // Guest admission must independently verify the authoritative config lock. + "/api2/json/nodes/pve1/qemu/100/config": {body: `{"data":{}}`}, "/api2/json/nodes/pve1/qemu/100/agent/get-fsinfo": {body: `{"data":{"result":[{"name":"root","type":"ext4","mountpoint":"/","total-bytes":100,"used-bytes":50}]}}`}, } server := newProxmoxTestServer(t, responses) @@ -752,3 +755,73 @@ func TestCheckPhysicalDisks_PermissionDenied(t *testing.T) { t.Fatalf("expected permissions recommendation") } } + +func TestCheckVMDiskMonitoring_BackupAdmission(t *testing.T) { + for _, tc := range []struct { + name string + config proxmoxTestResponse + agentStatus int + lockInFlight bool + wantAgentCalls int32 + wantPermission bool + }{ + {name: "backup-only-in-config", config: proxmoxTestResponse{body: `{"data":{"lock":"backup"}}`}}, + {name: "snapshot-lock", config: proxmoxTestResponse{body: `{"data":{"lock":"snapshot"}}`}}, + {name: "lock-permission-denied", config: proxmoxTestResponse{status: http.StatusForbidden}}, + {name: "lock-unavailable", config: proxmoxTestResponse{status: http.StatusNotFound}}, + {name: "lock-null", config: proxmoxTestResponse{body: `{"data":null}`}}, + {name: "lock-malformed", config: proxmoxTestResponse{body: `{"data":{"lock":true}}`}}, + {name: "lock-starts-in-flight", config: proxmoxTestResponse{body: `{"data":{}}`}, lockInFlight: true, wantAgentCalls: 1}, + {name: "completed-agent-permission-refusal", config: proxmoxTestResponse{body: `{"data":{}}`}, agentStatus: http.StatusForbidden, wantAgentCalls: 2, wantPermission: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + var locked atomic.Bool + server := newIPv4HTTPServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api2/json/nodes": + _, _ = w.Write([]byte(`{"data":[{"node":"pve1","status":"online"}]}`)) + case "/api2/json/nodes/pve1/qemu": + _, _ = w.Write([]byte(`{"data":[{"vmid":100,"name":"vm-100","node":"pve1","status":"running","template":0}]}`)) + case "/api2/json/nodes/pve1/qemu/100/status/current": + // As on the failed CI fixture, current status alone has no lock field. + _, _ = w.Write([]byte(`{"data":{"agent":1}}`)) + case "/api2/json/nodes/pve1/qemu/100/config": + if locked.Load() { + _, _ = w.Write([]byte(`{"data":{"lock":"backup"}}`)) + return + } + if tc.config.status != 0 { + w.WriteHeader(tc.config.status) + } + _, _ = w.Write([]byte(tc.config.body)) + case "/api2/json/nodes/pve1/qemu/100/agent/get-fsinfo": + calls.Add(1) + if tc.lockInFlight { + locked.Store(true) + } + if tc.agentStatus != 0 { + w.WriteHeader(tc.agentStatus) + return + } + _, _ = w.Write([]byte(`{"data":{"result":[{"name":"root","type":"ext4","mountpoint":"/","total-bytes":100,"used-bytes":50}]}}`)) + default: + t.Errorf("unexpected diagnostic request: %s", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + result := (&Router{}).checkVMDiskMonitoring(context.Background(), newProxmoxClient(t, server.URL), "") + if result.VMsFound != 1 || result.VMsWithAgent != 1 || result.VMsWithDiskData != 0 || strings.Contains(result.TestResult, "SUCCESS") { + t.Fatalf("locked/unverified/refused data must not become successful diagnostics: %+v", result) + } + if calls.Load() != tc.wantAgentCalls { + t.Fatalf("guest requests = %d, want %d", calls.Load(), tc.wantAgentCalls) + } + if tc.wantPermission && result.TestResult != "Permission denied accessing guest agent" { + t.Fatalf("completed permission refusal lost: %q", result.TestResult) + } + }) + } +} From 7d7396988207aec7a3c2134d42670dbc805435d4 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:25:40 +0100 Subject: [PATCH 76/86] Keep demo runtime profile changes out of signed billing state The helper could repersist legacy entitlement secrets and clear billing integrity while granting demo fixtures. Require the existing demo capability before mutation, leave all billing/key material untouched, and keep verification and encrypted legacy migration with the canonical application. Preserve full-state recovery, service ownership and the existing native route; seed only non-secret admitted fresh-runner fixtures. Add real canonical encrypted/legacy/HMAC connected controls and malformed or unprovisioned refusal-before-mutation coverage. Change-source: pulse-maintainer --- .github/scripts/demo-runtime-transaction.py | 67 ++++++++--- .github/scripts/tests/demo_runtime_native.py | 2 +- .../tests/test_demo_runtime_transaction.py | 101 ++++++++++++++++- .../v6/internal/DEMO_RUNTIME_TRANSACTION.md | 21 ++++ .../subsystems/deployment-installability.md | 14 +++ internal/config/billing_demo_profile_test.go | 107 ++++++++++++++++++ 6 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 internal/config/billing_demo_profile_test.go diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 3cd8824742..2bfaa27376 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -349,17 +349,61 @@ class Host: values["PULSE_MOCK_" + key.upper()] = value return values - def profile_matches(self, request): - text = (PATHS["data"] / ".env").read_text().splitlines() - values = self.profile_values(request) - return (all(text.count(key + "=" + value) == 1 for key, value in values.items()) - and "demo_fixtures" in json.loads((PATHS["data"] / "billing.json").read_text()).get("capabilities", [])) + def profile_admission(self): + """Read-only prerequisite; this transaction never grants entitlements. - def profile(self, request, unhealthy): + The running application owns billing HMAC verification, encrypted + secrets and legacy migration. Do not decrypt, clear integrity, re-sign + or rewrite billing/key material to enable a demo profile. + """ env_file = PATHS["data"] / ".env" billing_file = PATHS["data"] / "billing.json" if env_file.is_symlink() or billing_file.is_symlink(): raise Failure("configuration-symlink") + if not env_file.is_file(): + raise Failure("configuration-shape") + + def unique_fields(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Failure("billing-shape") + result[key] = value + return result + + def invalid_constant(_value): + raise Failure("billing-shape") + + try: + fd = os.open(billing_file, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise Failure("billing-shape") + raw = stream.read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: + raise Failure("billing-shape") + billing = json.loads(raw, object_pairs_hook=unique_fields, + parse_constant=invalid_constant) + except (OSError, ValueError, UnicodeError, RecursionError) as exc: + raise Failure("billing-observation") from exc + if (not isinstance(billing, dict) + or not isinstance(billing.get("capabilities"), list) + or not all(isinstance(capability, str) for capability in billing["capabilities"])): + raise Failure("billing-shape") + if "demo_fixtures" not in billing["capabilities"]: + raise Failure("billing-demo-capability-required") + + def profile_matches(self, request): + self.profile_admission() + text = (PATHS["data"] / ".env").read_text().splitlines() + values = self.profile_values(request) + return all(text.count(key + "=" + value) == 1 for key, value in values.items()) + + def profile(self, request, unhealthy): + env_file = PATHS["data"] / ".env" + # Recheck after quiescent capture too; a failed prerequisite must not + # partially change the profile or bypass the application's licence gate. + self.profile_admission() values = self.profile_values(request) text = env_file.read_text() for key, value in values.items(): @@ -369,14 +413,6 @@ class Host: data_owner = PATHS["data"].stat() os.chown(env_file, data_owner.st_uid, data_owner.st_gid) os.chmod(env_file, 0o600) - billing = json.loads(billing_file.read_text()) - if not isinstance(billing, dict) or not isinstance(billing.get("capabilities", []), list): - raise Failure("billing-shape") - billing["capabilities"] = sorted(set(billing.get("capabilities", []) + ["demo_fixtures"])) - billing.pop("integrity", None) - billing_file.write_text(json.dumps(billing)) - os.chown(billing_file, data_owner.st_uid, data_owner.st_gid) - os.chmod(billing_file, 0o600) if unhealthy: for relative in DEMO_HISTORY: path = PATHS["data"] / relative @@ -546,6 +582,9 @@ class Transaction: if (prior.get("status") not in TERMINAL or (prior.get("child_result_required") and not self.host.prior_closed(entry, prior["status"]))): raise Failure("prior-recovery-required") + # Refuse absent/malformed or unprovisioned billing before stopping + # a service, installing a binary or changing any estate file. + self.host.profile_admission() states = self.host.state() if not all(active(states[name]) for name in ("pulse-relay", "caddy")) or self.host.status(RELAY_HEALTH) != "200": raise Failure("cohost-unhealthy") diff --git a/.github/scripts/tests/demo_runtime_native.py b/.github/scripts/tests/demo_runtime_native.py index e381e2e435..a9cf4e3bf5 100644 --- a/.github/scripts/tests/demo_runtime_native.py +++ b/.github/scripts/tests/demo_runtime_native.py @@ -92,7 +92,7 @@ def main(): Path("/etc/pulse").mkdir(mode=0o755) os.chown("/etc/pulse", owner.pw_uid, owner.pw_gid) Path("/etc/pulse/.env").write_text("DEMO_MODE=true\n") - Path("/etc/pulse/billing.json").write_text('{"capabilities":[]}') + Path("/etc/pulse/billing.json").write_text('{"capabilities":["demo_fixtures"],"limits":{},"meters_enabled":[],"plan_version":"community","subscription_state":"active"}') Path("/etc/pulse/persistent-marker").write_text("original data") for path in Path("/etc/pulse").iterdir(): os.chown(path, owner.pw_uid, owner.pw_gid) diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 3900e32a01..24f146b657 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -156,7 +156,7 @@ class TransactionTest(unittest.TestCase): path.chmod(0o755 if name == "binary" else 0o644) (self.paths["dropins"] / "limits.conf").write_bytes(b"old limits") (self.paths["data"] / ".env").write_text("PRIVATE_TOKEN=not-logged\n") - (self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"private"}') + (self.paths["data"] / "billing.json").write_text('{"capabilities":["demo_fixtures"],"integrity":"private"}') (self.paths["data"] / "persisted-history").write_bytes(b"original history") (self.paths["data"] / "alerts").mkdir() (self.paths["data"] / "alerts/events.db").write_bytes(b"synthetic opaque persistent file") @@ -188,6 +188,105 @@ class TransactionTest(unittest.TestCase): self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text())) return result, tx.receipt + def test_profile_preserves_encrypted_and_legacy_billing_bytes_key_and_metadata(self): + billing_path = self.paths["data"] / "billing.json" + key_path = self.paths["data"] / ".encryption.key" + key_path.write_bytes(b"synthetic-original-key") + key_path.chmod(0o600) + for representation in ("synthetic-legacy-plaintext", "synthetic-encrypted-ciphertext"): + with self.subTest(representation=representation): + billing = {"capabilities": ["other-feature", "demo_fixtures"], + "integrity": "original-synthetic-signature", + "entitlement_jwt": representation + "-jwt", + "entitlement_refresh_token": representation + "-refresh", + "limits": {"resources": 100}, "subscription_state": "active", + "other_field": {"preserve": [1, 2]}} + billing_path.write_text(json.dumps(billing, indent=2) + "\n") + billing_path.chmod(0o640) + before = engine.estate_hash({"billing": billing_path, "key": key_path}) + self.host.profile(request(), False) + self.host.profile(request(), False) + self.assertEqual(engine.estate_hash({"billing": billing_path, "key": key_path}), before) + self.assertTrue(self.host.profile_matches(request())) + + def test_missing_demo_capability_refuses_before_any_service_or_estate_mutation(self): + (self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"unchanged"}') + before = engine.estate_hash(self.paths) + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"], receipt["failure"]), + (1, "refused", "billing-demo-capability-required")) + self.assertFalse(receipt["mutated"]) + self.assertEqual((self.host.stops, self.host.starts, self.host.install_count), (0, 0, 0)) + self.assertEqual(engine.estate_hash(self.paths), before) + self.assertFalse((self.attempt / "snapshot").exists()) + + def test_malformed_billing_refuses_before_mutation_with_fixed_diagnostics(self): + path = self.paths["data"] / "billing.json" + cases = (b"private-malformed-input", b"[]", b"null", b"{}", + b'{"capabilities":"demo_fixtures"}', + b'{"capabilities":["demo_fixtures",{}]}', + b'{"capabilities":[],"capabilities":["demo_fixtures"]}', + b'{"capabilities":["demo_fixtures"],"other":NaN}', + b'{"capabilities":["demo_fixtures"],"other":"\xff"}', + b" " * (1024 * 1024 + 1)) + for value in cases: + with self.subTest(length=len(value)): + path.write_bytes(value) + before = engine.estate_hash(self.paths) + code, receipt = self.run_transaction() + self.assertEqual((code, receipt["status"]), (1, "refused")) + self.assertIn(receipt["failure"], {"billing-shape", "billing-observation"}) + self.assertEqual(self.host.stops, 0) + self.assertFalse(receipt["mutated"]) + self.assertEqual(engine.estate_hash(self.paths), before) + self.assertNotIn("private-malformed-input", json.dumps(receipt)) + + def test_missing_symlink_and_nonregular_billing_refuse_without_touching_target(self): + path = self.paths["data"] / "billing.json" + target = self.paths["data"] / "other-billing" + target.write_text('{"capabilities":["demo_fixtures"]}') + path.unlink() + for kind in ("missing", "symlink", "directory"): + with self.subTest(kind=kind): + if kind == "symlink": + path.symlink_to(target) + elif kind == "directory": + path.mkdir() + before = engine.estate_hash(self.paths) + code, receipt = self.run_transaction() + self.assertEqual((code, receipt["status"]), (1, "refused")) + self.assertEqual(self.host.stops, 0) + self.assertEqual(engine.estate_hash(self.paths), before) + if kind == "symlink": + path.unlink() + elif kind == "directory": + path.rmdir() + + def test_profile_rechecks_capability_before_changing_environment(self): + path = self.paths["data"] / "billing.json" + self.host.profile_admission() + path.write_text('{"capabilities":[]}') + before = engine.estate_hash(self.paths) + with self.assertRaisesRegex(engine.Failure, "^billing-demo-capability-required$"): + self.host.profile(request(), True) + self.assertEqual(engine.estate_hash(self.paths), before) + + def test_late_update_failure_preserves_full_billing_and_key_through_recovery(self): + path = self.paths["data"] / "billing.json" + path.write_text(json.dumps({"capabilities": ["demo_fixtures"], + "integrity": "synthetic-original-signature", + "entitlement_jwt": "synthetic-original-secret", + "other": {"keep": True}})) + (self.paths["data"] / ".encryption.key").write_text("synthetic-original-key") + before = engine.estate_hash(self.paths) + self.host.fail_at = 55 + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (1, "rolled_back")) + self.assertEqual(engine.estate_hash(self.paths), before) + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + for secret in ("synthetic-original-signature", "synthetic-original-secret", "synthetic-original-key"): + self.assertNotIn(secret, json.dumps(receipt)) + def test_healthy_profile_change_keeps_data_and_watches_complete_window(self): code, receipt = self.run_transaction() self.assertEqual(code, 0) diff --git a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md index cf638ea766..7c1fb4eb34 100644 --- a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md +++ b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md @@ -18,6 +18,17 @@ workflow queue does not exclude another repository's host deployment. file lock through capture, atomic runtime/profile work, forward observation, complete restoration and recovery observation. It does not restart Relay or Caddy, upgrade a package, lift the Caddy hold or activate frozen commercial copy. +- Billing and `.encryption.key` are read-only to the profile helper. Before the + first service stop or estate mutation, it requires regular, bounded, + unambiguous billing JSON with an already provisioned `demo_fixtures` + capability. Missing capability or unavailable/malformed state refuses the + transaction with fixed diagnostics; it never grants the capability, drops + integrity or rewrites secret-bearing JSON. It rechecks after quiescent capture + before changing the environment. The application still owns HMAC/entitlement + verification, encryption and legacy plaintext migration; this shape check + does not authenticate a licence. Full-state snapshots/recovery preserve the + original private billing/key estate. Native fixtures seed non-secret demo + capability state only while provisioning their fresh empty disposable estate. - The complete quiescent **chosen footprint**: Pulse executable, VERSION, unit/drop-in identity and demo data estate is retained privately before mutation. No backup or database is deleted for space. The @@ -69,6 +80,16 @@ unchanged-parent control retains the adverse skipped restoration. Collection controls wait for writer closure and reject favourable stale terminals. The production window is not shortened. This is **not native acceptance**. +Connected billing controls also pass actual canonical encrypted and legacy +secret state through the real profile helper, retain every billing/key byte and +metadata, then use the canonical loader to verify encryption/migration and HMAC. +A tampered signature remains rejected by that loader, not laundered by the +helper. Malformed, missing, duplicate-key, nonregular and unprovisioned billing +refuse before service/binary/data mutation. Late failures retain full-state +recovery and sanitised evidence. These are synthetic source tests; they do not +establish production capability provisioning, a green CodeQL check or native +forward/reverse acceptance. + `demo-runtime-native.yml` now carries the next executable acceptance rather than requiring a worker to obtain system mode: exact PR/main source, a fresh secret-free public hosted runner, actual systemd/Caddy and disposable trusted diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 39ff236c60..d5e098d282 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -34,6 +34,20 @@ browser fixture exercise those states; neither proves a binary/data rollback. ### Demo transactions share the service host, not just a workflow queue +The demo runtime profile does not grant or rewrite billing entitlements. Before +the first stop/install/profile mutation, the transaction requires regular, +bounded, unambiguous existing billing state containing `demo_fixtures`; missing +capability or malformed/unavailable state refuses without estate mutation. The +helper rechecks before profile writes and leaves all billing/key bytes, +ownership and modes unchanged, including encrypted or legacy plaintext secrets +and integrity. Only the canonical application verifies HMAC/entitlements and +performs encrypted-at-rest migration; the read-only shape check cannot make a +tampered signature valid. Recovery retains the full original private estate. +Synthetic native fixtures provision their non-secret capability before starting +the transaction on their admitted fresh empty runner, not by granting it during +recovery. Connected Python and `TestBillingState_DemoProfilePreservesCanonicalState` +controls cover these boundaries without asserting installed acceptance. + Stable demo update and recovery keep their existing publication/activation, exact-tag installer signing key, Tailscale, pinned SSH and environment boundaries. The mutating paths now share the Relay host's existing diff --git a/internal/config/billing_demo_profile_test.go b/internal/config/billing_demo_profile_test.go new file mode 100644 index 0000000000..ba1543c3b8 --- /dev/null +++ b/internal/config/billing_demo_profile_test.go @@ -0,0 +1,107 @@ +package config + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + "github.com/stretchr/testify/require" +) + +// Use the real billing encryption/HMAC implementation and the real Python +// profile writer. This is a synthetic, private filesystem test, not a native +// service, production entitlement or deployment acceptance. +func TestBillingState_DemoProfilePreservesCanonicalState(t *testing.T) { + for _, mode := range []string{"encrypted", "legacy-plaintext", "tampered-signature"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("PULSE_LEGACY_KEY_PATH", filepath.Join(t.TempDir(), ".encryption.key")) + writeTestEncryptionKey(t, dir) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("DEMO_MODE=true\nPRIVATE_SYNTHETIC=unchanged\n"), 0o600)) + store := NewFileBillingStore(dir) + state := &pkglicensing.BillingState{ + Capabilities: []string{pkglicensing.FeatureDemoFixtures}, + SubscriptionState: pkglicensing.SubStateActive, + EntitlementJWT: "synthetic-lease.jwt.value", + EntitlementRefreshToken: "synthetic-refresh-token", + } + require.NoError(t, store.SaveBillingState("default", state)) + billingPath := filepath.Join(dir, "billing.json") + if mode != "encrypted" { + data, err := os.ReadFile(billingPath) + require.NoError(t, err) + var raw pkglicensing.BillingState + require.NoError(t, json.Unmarshal(data, &raw)) + if mode == "legacy-plaintext" { + // The same valid signature covers the unencrypted historical values. + raw.EntitlementJWT = state.EntitlementJWT + raw.EntitlementRefreshToken = state.EntitlementRefreshToken + } else { + raw.Integrity = "invalid-synthetic-signature" + } + data, err = json.Marshal(&raw) + require.NoError(t, err) + require.NoError(t, os.WriteFile(billingPath, data, 0o600)) + } + beforeBilling, err := os.ReadFile(billingPath) + require.NoError(t, err) + keyPath := filepath.Join(dir, ".encryption.key") + beforeKey, err := os.ReadFile(keyPath) + require.NoError(t, err) + info, err := os.Stat(billingPath) + require.NoError(t, err) + helper, err := filepath.Abs(filepath.Join("..", "..", ".github", "scripts", "demo-runtime-transaction.py")) + require.NoError(t, err) + script := `import importlib.util,pathlib,sys +s=importlib.util.spec_from_file_location("demo",sys.argv[1]); m=importlib.util.module_from_spec(s); s.loader.exec_module(m) +m.PATHS={**m.PATHS,"data":pathlib.Path(sys.argv[2])} +request={"profile":{**{name:"2" for name in m.COUNT_KEYS},"seed_duration":"2h","sample_interval":"5m","update_interval":"15s"}} +m.Host().profile(request,False) +assert m.Host().profile_matches(request) +` + cmd := exec.Command("python3", "-c", script, helper, dir) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "synthetic demo profile failed: %s", output) + require.Empty(t, output, "helper must not disclose any billing or key material") + afterBilling, err := os.ReadFile(billingPath) + require.NoError(t, err) + require.Equal(t, beforeBilling, afterBilling, "billing bytes, including all fields and integrity, must be untouched") + afterKey, err := os.ReadFile(keyPath) + require.NoError(t, err) + require.Equal(t, beforeKey, afterKey, "profile must not rotate or rewrite the key") + afterInfo, err := os.Stat(billingPath) + require.NoError(t, err) + require.Equal(t, info.Mode(), afterInfo.Mode()) + require.Equal(t, info.ModTime(), afterInfo.ModTime()) + // The canonical loader still owns both migration and tamper detection. + loaded, err := store.GetBillingState("default") + require.NoError(t, err) + if mode == "tampered-signature" { + require.Nil(t, loaded, "profile must not launder invalid signed billing into accepted state") + return + } + require.NotNil(t, loaded) + require.Equal(t, state.EntitlementJWT, loaded.EntitlementJWT) + require.Equal(t, state.EntitlementRefreshToken, loaded.EntitlementRefreshToken) + afterLoad, err := os.ReadFile(billingPath) + require.NoError(t, err) + require.False(t, bytes.Contains(afterLoad, []byte(state.EntitlementJWT))) + require.False(t, bytes.Contains(afterLoad, []byte(state.EntitlementRefreshToken))) + var persisted pkglicensing.BillingState + require.NoError(t, json.Unmarshal(afterLoad, &persisted)) + manager, err := store.billingCryptoManager() + require.NoError(t, err) + persisted.EntitlementJWT, err = manager.DecryptString(persisted.EntitlementJWT) + require.NoError(t, err) + persisted.EntitlementRefreshToken, err = manager.DecryptString(persisted.EntitlementRefreshToken) + require.NoError(t, err) + hmacKey, err := store.loadHMACKey() + require.NoError(t, err) + require.True(t, verifyBillingIntegrity(&persisted, hmacKey), "preserved state/migration must retain a valid canonical HMAC") + }) + } +} From 5355a41c082363e45b34234e6a7b869e5be3b528 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:32:25 +0100 Subject: [PATCH 77/86] Record final browser proof for composed disk and backup presentation Fresh desktop Chromium and phone WebKit preserve the retained maximum-label structure with the restored hover ring and Unknown coverage dot. Read-only backup scope controls and the exact revised alert qualification spec pass. Record only exercised viewports and retain earlier valid receipts rather than rebinding them. Contract-Neutral: Browser verification of existing composed frontend contracts and test-only alert qualification; no runtime or subsystem contract delta. Change-source: pulse-maintainer --- .../a562ef365082becb.json | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 frontend-modern/browser-verification/a562ef365082becb.json diff --git a/frontend-modern/browser-verification/a562ef365082becb.json b/frontend-modern/browser-verification/a562ef365082becb.json new file mode 100644 index 0000000000..28ebd92908 --- /dev/null +++ b/frontend-modern/browser-verification/a562ef365082becb.json @@ -0,0 +1,66 @@ +{ + "version": 1, + "base_sha": "d9a2c78612b21dd2e46de0f75b71c0ddaddb9f86", + "verified_at": "2026-10-03T19:32:24.556252Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/StackedDiskBar.tsx": "39f4000617cb62621c0abf5749a46eb26e769bb437ded70730f6d9727e358a55", + "frontend-modern/src/features/proxmox/proxmoxBackupsTableShared.tsx": "41202d1ae6095d882b124a55aed4110c74f38047d51b83b07bf52a6e95ca6b01" + }, + "routes": [ + "/combined-disk.html", + "/browser-tests/backups.html", + "/proxmox/backups/coverage", + "/alerts/overview", + "/alerts/notifications" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 1440, + "height": 1000 + } + ], + "states": [ + "Retained fullest-filesystem 90% label and equal-weight micro-bars, missing observations and fresh 40% resumption", + "Restored 30% desktop hover ring with retained-observation explanation", + "Restored Unknown coverage dot at 40% rendered opacity on desktop; intentional dot-free compact phone filter stays readable", + "Dated independent archive remains distinct from a fresh local snapshot, including snapshot-only scope and restored full scope", + "Exact committed alert qualification spec: pending retention, 503 and 403 withdrawal of held rows only, warning through pending retry and healthy recovery" + ], + "interactions": [ + "Desktop hover and inspected hovered/unhovered disk screenshots; phone label remains readable without hover", + "By date and Coverage navigation, route-backed search, location selection/removal, phone Filters disclosure and inspected screenshots", + "Keyboard Refresh with independently successful attempts and deliberately pending held responses; exact row badges and unavailable warnings are asserted separately in Chromium, mobile Chromium and phone WebKit" + ], + "playwright_version": "1.56.1", + "browser_versions": [ + { + "engine": "chromium", + "version": "141.0.7390.37" + }, + { + "engine": "webkit", + "version": "26.0" + } + ], + "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82", + "evidence": [ + "Current Web outcome RECEIPTS.md retains raw browser results, the three-project exact-spec report and inspected screenshots." + ], + "limitations": [ + "Production component/CSS and full application entry with synthetic observations and HTTP/WebSocket data only; not native appliance, backup/thaw, authentication, signed recovery or release acceptance.", + "Fresh proof covers the two runtime files changed by merge composition. Earlier valid own-parent receipts remain preserved; this is not a new whole-range browser run." + ] +} From 2201fd59b4727012d8931d299b2d9b569465d60a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:21:04 +0100 Subject: [PATCH 78/86] Make recovery help distinguish backup evidence from safe restore The existing guide points to a retired page and documents ignored time and subject filters. Explain current platform views, provider verification limits, independent thaw and isolated restore acceptance; mirror the guide and bind examples to the read-only handlers. Contract-Neutral: Recovery runtime, APIs, trust policy, frontend behaviour and dependency graph are unchanged; this corrects existing help and its regression checks. Change-source: pulse-maintainer --- docs/RECOVERY.md | 317 +++++++++--------- frontend-modern/public/docs/RECOVERY.md | 317 +++++++++--------- .../src/utils/__tests__/recoveryHelp.test.ts | 108 ++++++ 3 files changed, 440 insertions(+), 302 deletions(-) create mode 100644 frontend-modern/src/utils/__tests__/recoveryHelp.test.ts diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 2d90ee95ed..1ffc4b15b2 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -1,173 +1,188 @@ # Recovery -Pulse v6 includes a **provider-neutral recovery view** that aggregates backup, snapshot, and replication artifacts across all connected platforms into a single interface. +Pulse collects backup, snapshot and replication evidence from connected +providers. Use it to find recorded artifacts, identify gaps in protection and +check the source of a backup before using the provider's own recovery tools. +Pulse does not restore workloads from these views. -## Overview +## Where to look -Recovery is event-first and answers two questions: +There is no top-level Recovery page. Open the existing platform view: -1. **"What happened?"** โ†’ The **Recovery events** table shows individual recovery points (artifacts) with timestamps, outcomes, and sizes. -2. **"What can I actually recover?"** โ†’ **Protection coverage** shows the canonical posture for each resource: protected, attention, unprotected, or unknown. +- **Proxmox โ†’ Backups** (`/proxmox/backups`): **By date** lists recorded + artifacts; **Coverage** groups them by workload. Expand a coverage row to + inspect its posture reason and individual artifacts. Backup servers have + their own rows on the same page. +- **TrueNAS โ†’ Protection** (`/truenas/protection`): snapshots and replication + activity reported by the TrueNAS connection. +- **Kubernetes**: recovery records depend on the agent reporting VolumeSnapshot + or Velero inventory. An installed agent or a connected cluster does not + establish that either is available. -## Supported Providers +Provider collection is separate from presentation. Not every platform displays +the same columns or filters, and a missing reading is not proof that no backup +exists. [Check missing evidence](#missing-or-inconsistent-evidence) before +changing the provider or Pulse's stored data. -| Provider | Recovery Point Types | -|---|---| -| **Proxmox Backup Server (PBS)** | Full and incremental backups, sync jobs, verify tasks | -| **Proxmox VE (PVE)** | Local dump-style backups (`vzdump`) | -| **TrueNAS** | ZFS snapshots, replication tasks | -| **Kubernetes** | VolumeSnapshots, Velero backups (when available) | +## What the evidence means -## Concepts +Keep these observations separate: -### Subject (What Was Protected) - -A subject is the thing being protected: - -- A Proxmox VM or container -- A TrueNAS dataset (e.g., `tank/apps/postgres`) -- A Kubernetes PVC (e.g., `monitoring/prometheus-pvc`) - -Subjects link to unified resources via `subjectResourceId` when possible. - -### Recovery Point (An Artifact / Event) - -A recovery point is a single concrete artifact: - -- A PBS backup snapshot -- A local `vzdump` backup file -- A ZFS snapshot -- A replication run result - -### Rollup (A Subject Summary) - -A rollup groups recovery points for a subject to show: - -- **Protection status** โ€” is this subject actively protected? -- **Latest point** โ€” when was the most recent successful backup/snapshot? -- **Health** โ€” are there recent failures or warnings? - -### Protection Posture (A Trust Decision) - -A protection posture combines subject-linked recovery points with the latest -provider collection evidence. It deliberately keeps four operator-facing -states: - -- **Protected** โ€” a qualifying current recovery point is linked to the resource - and complete provider evidence does not invalidate the claim. -- **Attention** โ€” evidence exists, but it is stale, failing, incomplete, or - unverified when verification is expected. -- **Unprotected** โ€” complete evidence confirms that no qualifying protection - exists. -- **Unknown** โ€” identity, permissions, provider history, or collection - completeness cannot support a stronger claim. - -A backup or snapshot artifact may still be shown while posture is unknown. -Artifacts answer what Pulse found; posture answers what Pulse can safely claim. -Snapshot presence alone is never presented as independent recovery. - -## Navigating Recovery - -### Recovery Events - -Shows individual recovery points. Key columns: - -| Column | Description | -|---|---| -| Time | When the point was created (started/completed) | -| Subject | What was backed up | -| Method | Kind + mode of the backup | -| Outcome | success / warning / failed / running | -| Size | Size of the artifact (when available) | -| Verified | Whether the backup has been verified (tri-state) | - -### Protection Coverage - -The Proxmox **Backups โ†’ Coverage** view shows one row per workload. The default -table stays compact; expanding a row reveals the plain-language posture reason, -provider evidence quality, and individual restore artifacts. - -| Column | Description | -|---|---| -| Item | The protected resource (VM name, dataset path, etc.) | -| Item Type | Canonical resource category | -| Posture | Protected, attention, unprotected, or unknown | -| Restore | Most recent successful recovery point timestamp | -| Provider columns | Latest PBS, PVE, or guest-snapshot artifact where available | - -### Filtering - -Both workspaces support: - -- **Platform filter** โ€” show only points from a specific platform -- **Outcome filter** โ€” show only failed, successful, or running points -- **Time range** โ€” filter to a specific time window -- **Search** โ€” full-text search across items and details - -## API Reference - -| Method | Endpoint | Description | +| Observation | What it establishes | What it does not establish | |---|---|---| -| `GET` | `/api/recovery/points` | List individual recovery points | -| `GET` | `/api/recovery/rollups` | List subject rollups (protection coverage) | -| `GET` | `/api/recovery/postures` | List canonical per-resource protection postures | -| `GET` | `/api/recovery/series` | Time-series data for recovery charts | -| `GET` | `/api/recovery/facets` | Available filter facets (providers, kinds, outcomes) | +| Successful task or `success` outcome | A provider-reported result or collected artifact | Guest thaw, application health or a tested restore | +| Running task or partial artifact | Work or an incomplete artifact is observed | A completed recovery point, even if a size is shown | +| Verified backup | The provider's verification result for that artifact | A successful application restore or guest thaw | +| Recent snapshot | A point on the source system | An independent copy that survives loss of that system | +| Protected posture | Current, linked evidence meets Pulse's protection policy | A recovery guarantee or permission to discard other backups | -### Query Parameters +**A backup task's OK status is not confirmation that the guest has thawed.** +For freeze-enabled Proxmox backups, stop Pulse before the backup and restart it +only after independently confirming guest thaw. Pulse monitoring and alert +delivery are unavailable while it is stopped. Do not send additional +guest-agent commands during freeze/thaw to diagnose the problem, or disable +filesystem freezing merely to make a task look successful. Follow the +[backup safety precaution](VM_DISK_MONITORING.md#backup-safety); it is not a +claim that Pulse has repaired or reproduced a native thaw failure. -All recovery endpoints support: +Before relying on an artifact, confirm its provider, repository, workload +identity and time in the provider's own tools. For recovery assurance, test a +restore to an isolated destination using that provider's procedure and check +the restored application's data and operation. Do not overwrite the live +workload as a diagnostic test, delete older backups, or change retention to +hide a Pulse display problem. -| Parameter | Description | +### Artifacts and subjects + +A **recovery point** is a collected backup, snapshot or replication result. +Its **subject** is the protected workload, dataset or PVC; its **repository** +is the storage location. Pulse links subjects to monitored resources where it +can, and retains provider-local references when it cannot. + +Independent Proxmox installations can reuse a VMID. Compare the connection, +node, guest type/ID, datastore, namespace and artifact time; matching names or +VMIDs alone are not sufficient to identify a duplicate or the right restore. +Direct PBS observations and PBS-through-PVE observations can differ in detail. +See [PBS data sources](PBS.md#data-source-indicator). + +### Outcomes and verification + +Recorded outcomes are `success`, `warning`, `failed`, `running` or `unknown`. +An unknown outcome is not success; an error fetching records is not an empty +history. Where available, the API returns `startedAt`, `completedAt`, +`sizeBytes` and provider-specific `details`. Missing times and sizes are not +zero-valued measurements. A source backup timestamp is not necessarily the +completion time of a later copy or sync task. + +The `verified` field is optional: `true` is a positive provider verification +observation; `false` supplies no positive verification, so inspect the +provider's detailed state; an omitted value supplies no verification result. +Do not interpret an omitted field as a failed verification. Rollups may also +report `verifyIntent` (`verified`, `stale` or `unknown`) and `lastVerifiedAt`; +these summarise observed verification recency, not an end-to-end restore test. + +### Protection posture + +Pulse combines subject-linked artifacts with current provider collection +evidence: + +- **Protected**: a qualifying current recovery point is linked to the resource + and complete provider evidence does not invalidate the claim. +- **Attention**: evidence is stale, failing, incomplete or unverified when + verification is expected. +- **Unprotected**: complete evidence confirms that no qualifying protection + exists. +- **Unknown**: identity, permissions or collection completeness cannot support + a stronger claim. + +A retained artifact can remain visible while posture is unknown. A running +backup is not a new completed recovery point; inspect any earlier completed +point separately rather than treating current activity as protection. + +## Missing or inconsistent evidence + +1. Open the relevant platform view, clear its search and filters, and check the + selected connection and organisation. Record the route that is affected. +2. Inspect connection health, the latest observation time and any collection + error. Wait for that provider's configured polling interval, not a fixed + 30-second assumption. A green connection badge does not prove that every + history or backup read succeeded. +3. Check the expected artifact in the provider's own inventory using an + existing authorised session. Verify its identity, location and permissions; + do not grant write or restore permissions merely to fill a monitoring row. +4. For an **Unknown** posture, expand the coverage row and use the specific + limiting evidence. Resolve that collection or identity gap; do not turn + retained artifacts into a protection claim by clearing Pulse history. + +If the guest stopped responding during backup, preserve the task's warning or +thaw error and the observations already available. Do not repeat the backup, +reset the guest, or run active guest-agent probes just to reproduce it. Use +the provider's recovery procedure and a separate trusted guest console to +assess guest liveness and thaw, not Pulse's backup badge. + +When reporting missing records, share the failing view, running Pulse version, +affected provider, time range, expected reading and relevant redacted error. +Use consistent placeholders for private hostnames, datasets and IDs. Do not +share full backup inventories, request headers, tokens, cookies or recovery +keys. See [safe issue reporting](TROUBLESHOOTING.md#getting-help). + +## API reference + +These are read-only monitoring endpoints. Use an existing authenticated browser +session or a `monitoring:read` token supplied through a private header file, +never a token in a URL or diagnostic command argument. See [API access](API.md). + +| Method | Endpoint | Result | +|---|---|---| +| `GET` | `/api/recovery/points` | Paged recovery points | +| `GET` | `/api/recovery/rollups` | Paged subject summaries | +| `GET` | `/api/recovery/series` | Completed-point counts grouped by day | +| `GET` | `/api/recovery/facets` | Filter values from matching points | +| `GET` | `/api/recovery/postures` | Canonical resource postures and limiting evidence | + +### Point and rollup filters + +Points, rollups, series and facets accept these filters: + +| Parameter | Values or meaning | |---|---| -| `provider` | Filter by provider (`pve`, `pbs`, `truenas`, `k8s`) | -| `kind` | Filter by kind (`backup`, `snapshot`, `replication`) | -| `outcome` | Filter by outcome (`success`, `failed`, `warning`, `running`) | -| `since` | ISO 8601 timestamp โ€” only points after this time | -| `until` | ISO 8601 timestamp โ€” only points before this time | -| `subject` | Filter by subject reference | -| `limit` | Max results (default: 500) | +| `platform` | `proxmox-pve`, `proxmox-pbs`, `truenas` or `kubernetes`; `provider` is an alias | +| `kind` | `backup`, `snapshot` or `other`; TrueNAS replication results use `backup` | +| `mode` | `local`, `remote` or `snapshot` | +| `outcome` | `success`, `warning`, `failed`, `running` or `unknown` | +| `from` | Lower time bound in RFC3339, for example `2026-10-01T00:00:00Z` | +| `to` | Upper time bound in RFC3339 | +| `subjectResourceId` | Exact linked resource identity; `itemResourceId` is an alias | +| `rollupId` | Exact subject summary identity | +| `q` | Text search; `query` is an alias | -`/api/recovery/postures` has a deliberately bounded table contract. Supply one -or more repeated `resourceId` parameters for a resource or batch lookup (at -most 200), or omit them for a paged list. It also accepts `state`, `page`, and -`limit`; `state=attention` returns the actionable attention list. The response -includes the posture policy and provider evidence states so clients do not -re-derive trust from raw artifacts. +For example, this relative browser request selects PBS backup records within +the stated time bounds without putting a credential in the URL: -## Troubleshooting +```text +/api/recovery/points?platform=proxmox-pbs&kind=backup&from=2026-10-01T00:00:00Z&to=2026-10-02T00:00:00Z&page=1&limit=100 +``` -### No recovery data showing +Use `from`/`to`, not `since`/`until`, and `subjectResourceId`, not `subject`. +Unrecognised query names do not apply those filters. Invalid `from` or `to` +values return an error rather than an empty result. -1. Verify at least one data source provides backup/snapshot data: - - **PBS**: Ensure a PBS connection exists in Settings โ†’ Infrastructure. - - **TrueNAS**: Ensure a TrueNAS connection exists in Settings โ†’ TrueNAS. - - **PVE**: Local backups from PVE are included automatically. -2. Wait one polling cycle (~30 seconds) for data to appear. -3. Check the source filter โ€” make sure you're not filtering to an empty source. +Points and rollups accept `page` (starting at 1) and `limit` (default **100**, +maximum **500**). Their response has a `data` array and `meta` pagination; +inspect every required page rather than assuming the first is the inventory. +Series and facets return aggregate data, not that paged-list contract. -### PBS backups showing but not TrueNAS snapshots (or vice versa) +### Posture lookup -Check the **Source** filter on the Recovery page. Each provider surfaces its recovery points independently. Clear all filters to see everything. +`/api/recovery/postures` uses a separate filter contract. Repeat `resourceId` +for a batch of at most **200** resource IDs, or omit it for a paged list. +`state=attention` selects attention postures; `page` and `limit` control list +pagination. Its response includes policy and provider evidence states. Do not +apply point filters to this endpoint or re-derive posture from artifact count. -### Recovery points showing as "failed" +## See also -Click the row to expand the details drawer, which shows the provider-specific error message. Common causes: - -- **PBS**: Datastore unreachable, verification failed, prune job errors -- **TrueNAS**: Replication target unreachable, dataset locked, insufficient space -- **PVE**: Backup storage full, vzdump process error - -### Current backups show an unknown posture - -Expand the workload row and inspect the limiting evidence. Pulse uses unknown -when the current poll cannot prove provider-history completeness, permission -scope, or subject identity. Fix the reported collection or access gap and wait -for the next provider poll; Pulse does not promote retained backup artifacts to -protected while that uncertainty remains. - -## See Also - -- [PBS Integration](PBS.md) โ€” Proxmox Backup Server monitoring -- [TrueNAS Integration](TRUENAS.md) โ€” TrueNAS snapshot and replication monitoring -- [Unified Resource Model](UNIFIED_RESOURCES.md) โ€” how recovery integrates with the unified model +- [PBS integration](PBS.md) โ€” access, data sources and host History +- [TrueNAS integration](TRUENAS.md) โ€” snapshots and replication monitoring +- [VM disk monitoring](VM_DISK_MONITORING.md) โ€” guest-agent and backup safety +- [Migrating Pulse](MIGRATION.md) โ€” configuration transfer versus a full-state backup of Pulse itself diff --git a/frontend-modern/public/docs/RECOVERY.md b/frontend-modern/public/docs/RECOVERY.md index 2d90ee95ed..1ffc4b15b2 100644 --- a/frontend-modern/public/docs/RECOVERY.md +++ b/frontend-modern/public/docs/RECOVERY.md @@ -1,173 +1,188 @@ # Recovery -Pulse v6 includes a **provider-neutral recovery view** that aggregates backup, snapshot, and replication artifacts across all connected platforms into a single interface. +Pulse collects backup, snapshot and replication evidence from connected +providers. Use it to find recorded artifacts, identify gaps in protection and +check the source of a backup before using the provider's own recovery tools. +Pulse does not restore workloads from these views. -## Overview +## Where to look -Recovery is event-first and answers two questions: +There is no top-level Recovery page. Open the existing platform view: -1. **"What happened?"** โ†’ The **Recovery events** table shows individual recovery points (artifacts) with timestamps, outcomes, and sizes. -2. **"What can I actually recover?"** โ†’ **Protection coverage** shows the canonical posture for each resource: protected, attention, unprotected, or unknown. +- **Proxmox โ†’ Backups** (`/proxmox/backups`): **By date** lists recorded + artifacts; **Coverage** groups them by workload. Expand a coverage row to + inspect its posture reason and individual artifacts. Backup servers have + their own rows on the same page. +- **TrueNAS โ†’ Protection** (`/truenas/protection`): snapshots and replication + activity reported by the TrueNAS connection. +- **Kubernetes**: recovery records depend on the agent reporting VolumeSnapshot + or Velero inventory. An installed agent or a connected cluster does not + establish that either is available. -## Supported Providers +Provider collection is separate from presentation. Not every platform displays +the same columns or filters, and a missing reading is not proof that no backup +exists. [Check missing evidence](#missing-or-inconsistent-evidence) before +changing the provider or Pulse's stored data. -| Provider | Recovery Point Types | -|---|---| -| **Proxmox Backup Server (PBS)** | Full and incremental backups, sync jobs, verify tasks | -| **Proxmox VE (PVE)** | Local dump-style backups (`vzdump`) | -| **TrueNAS** | ZFS snapshots, replication tasks | -| **Kubernetes** | VolumeSnapshots, Velero backups (when available) | +## What the evidence means -## Concepts +Keep these observations separate: -### Subject (What Was Protected) - -A subject is the thing being protected: - -- A Proxmox VM or container -- A TrueNAS dataset (e.g., `tank/apps/postgres`) -- A Kubernetes PVC (e.g., `monitoring/prometheus-pvc`) - -Subjects link to unified resources via `subjectResourceId` when possible. - -### Recovery Point (An Artifact / Event) - -A recovery point is a single concrete artifact: - -- A PBS backup snapshot -- A local `vzdump` backup file -- A ZFS snapshot -- A replication run result - -### Rollup (A Subject Summary) - -A rollup groups recovery points for a subject to show: - -- **Protection status** โ€” is this subject actively protected? -- **Latest point** โ€” when was the most recent successful backup/snapshot? -- **Health** โ€” are there recent failures or warnings? - -### Protection Posture (A Trust Decision) - -A protection posture combines subject-linked recovery points with the latest -provider collection evidence. It deliberately keeps four operator-facing -states: - -- **Protected** โ€” a qualifying current recovery point is linked to the resource - and complete provider evidence does not invalidate the claim. -- **Attention** โ€” evidence exists, but it is stale, failing, incomplete, or - unverified when verification is expected. -- **Unprotected** โ€” complete evidence confirms that no qualifying protection - exists. -- **Unknown** โ€” identity, permissions, provider history, or collection - completeness cannot support a stronger claim. - -A backup or snapshot artifact may still be shown while posture is unknown. -Artifacts answer what Pulse found; posture answers what Pulse can safely claim. -Snapshot presence alone is never presented as independent recovery. - -## Navigating Recovery - -### Recovery Events - -Shows individual recovery points. Key columns: - -| Column | Description | -|---|---| -| Time | When the point was created (started/completed) | -| Subject | What was backed up | -| Method | Kind + mode of the backup | -| Outcome | success / warning / failed / running | -| Size | Size of the artifact (when available) | -| Verified | Whether the backup has been verified (tri-state) | - -### Protection Coverage - -The Proxmox **Backups โ†’ Coverage** view shows one row per workload. The default -table stays compact; expanding a row reveals the plain-language posture reason, -provider evidence quality, and individual restore artifacts. - -| Column | Description | -|---|---| -| Item | The protected resource (VM name, dataset path, etc.) | -| Item Type | Canonical resource category | -| Posture | Protected, attention, unprotected, or unknown | -| Restore | Most recent successful recovery point timestamp | -| Provider columns | Latest PBS, PVE, or guest-snapshot artifact where available | - -### Filtering - -Both workspaces support: - -- **Platform filter** โ€” show only points from a specific platform -- **Outcome filter** โ€” show only failed, successful, or running points -- **Time range** โ€” filter to a specific time window -- **Search** โ€” full-text search across items and details - -## API Reference - -| Method | Endpoint | Description | +| Observation | What it establishes | What it does not establish | |---|---|---| -| `GET` | `/api/recovery/points` | List individual recovery points | -| `GET` | `/api/recovery/rollups` | List subject rollups (protection coverage) | -| `GET` | `/api/recovery/postures` | List canonical per-resource protection postures | -| `GET` | `/api/recovery/series` | Time-series data for recovery charts | -| `GET` | `/api/recovery/facets` | Available filter facets (providers, kinds, outcomes) | +| Successful task or `success` outcome | A provider-reported result or collected artifact | Guest thaw, application health or a tested restore | +| Running task or partial artifact | Work or an incomplete artifact is observed | A completed recovery point, even if a size is shown | +| Verified backup | The provider's verification result for that artifact | A successful application restore or guest thaw | +| Recent snapshot | A point on the source system | An independent copy that survives loss of that system | +| Protected posture | Current, linked evidence meets Pulse's protection policy | A recovery guarantee or permission to discard other backups | -### Query Parameters +**A backup task's OK status is not confirmation that the guest has thawed.** +For freeze-enabled Proxmox backups, stop Pulse before the backup and restart it +only after independently confirming guest thaw. Pulse monitoring and alert +delivery are unavailable while it is stopped. Do not send additional +guest-agent commands during freeze/thaw to diagnose the problem, or disable +filesystem freezing merely to make a task look successful. Follow the +[backup safety precaution](VM_DISK_MONITORING.md#backup-safety); it is not a +claim that Pulse has repaired or reproduced a native thaw failure. -All recovery endpoints support: +Before relying on an artifact, confirm its provider, repository, workload +identity and time in the provider's own tools. For recovery assurance, test a +restore to an isolated destination using that provider's procedure and check +the restored application's data and operation. Do not overwrite the live +workload as a diagnostic test, delete older backups, or change retention to +hide a Pulse display problem. -| Parameter | Description | +### Artifacts and subjects + +A **recovery point** is a collected backup, snapshot or replication result. +Its **subject** is the protected workload, dataset or PVC; its **repository** +is the storage location. Pulse links subjects to monitored resources where it +can, and retains provider-local references when it cannot. + +Independent Proxmox installations can reuse a VMID. Compare the connection, +node, guest type/ID, datastore, namespace and artifact time; matching names or +VMIDs alone are not sufficient to identify a duplicate or the right restore. +Direct PBS observations and PBS-through-PVE observations can differ in detail. +See [PBS data sources](PBS.md#data-source-indicator). + +### Outcomes and verification + +Recorded outcomes are `success`, `warning`, `failed`, `running` or `unknown`. +An unknown outcome is not success; an error fetching records is not an empty +history. Where available, the API returns `startedAt`, `completedAt`, +`sizeBytes` and provider-specific `details`. Missing times and sizes are not +zero-valued measurements. A source backup timestamp is not necessarily the +completion time of a later copy or sync task. + +The `verified` field is optional: `true` is a positive provider verification +observation; `false` supplies no positive verification, so inspect the +provider's detailed state; an omitted value supplies no verification result. +Do not interpret an omitted field as a failed verification. Rollups may also +report `verifyIntent` (`verified`, `stale` or `unknown`) and `lastVerifiedAt`; +these summarise observed verification recency, not an end-to-end restore test. + +### Protection posture + +Pulse combines subject-linked artifacts with current provider collection +evidence: + +- **Protected**: a qualifying current recovery point is linked to the resource + and complete provider evidence does not invalidate the claim. +- **Attention**: evidence is stale, failing, incomplete or unverified when + verification is expected. +- **Unprotected**: complete evidence confirms that no qualifying protection + exists. +- **Unknown**: identity, permissions or collection completeness cannot support + a stronger claim. + +A retained artifact can remain visible while posture is unknown. A running +backup is not a new completed recovery point; inspect any earlier completed +point separately rather than treating current activity as protection. + +## Missing or inconsistent evidence + +1. Open the relevant platform view, clear its search and filters, and check the + selected connection and organisation. Record the route that is affected. +2. Inspect connection health, the latest observation time and any collection + error. Wait for that provider's configured polling interval, not a fixed + 30-second assumption. A green connection badge does not prove that every + history or backup read succeeded. +3. Check the expected artifact in the provider's own inventory using an + existing authorised session. Verify its identity, location and permissions; + do not grant write or restore permissions merely to fill a monitoring row. +4. For an **Unknown** posture, expand the coverage row and use the specific + limiting evidence. Resolve that collection or identity gap; do not turn + retained artifacts into a protection claim by clearing Pulse history. + +If the guest stopped responding during backup, preserve the task's warning or +thaw error and the observations already available. Do not repeat the backup, +reset the guest, or run active guest-agent probes just to reproduce it. Use +the provider's recovery procedure and a separate trusted guest console to +assess guest liveness and thaw, not Pulse's backup badge. + +When reporting missing records, share the failing view, running Pulse version, +affected provider, time range, expected reading and relevant redacted error. +Use consistent placeholders for private hostnames, datasets and IDs. Do not +share full backup inventories, request headers, tokens, cookies or recovery +keys. See [safe issue reporting](TROUBLESHOOTING.md#getting-help). + +## API reference + +These are read-only monitoring endpoints. Use an existing authenticated browser +session or a `monitoring:read` token supplied through a private header file, +never a token in a URL or diagnostic command argument. See [API access](API.md). + +| Method | Endpoint | Result | +|---|---|---| +| `GET` | `/api/recovery/points` | Paged recovery points | +| `GET` | `/api/recovery/rollups` | Paged subject summaries | +| `GET` | `/api/recovery/series` | Completed-point counts grouped by day | +| `GET` | `/api/recovery/facets` | Filter values from matching points | +| `GET` | `/api/recovery/postures` | Canonical resource postures and limiting evidence | + +### Point and rollup filters + +Points, rollups, series and facets accept these filters: + +| Parameter | Values or meaning | |---|---| -| `provider` | Filter by provider (`pve`, `pbs`, `truenas`, `k8s`) | -| `kind` | Filter by kind (`backup`, `snapshot`, `replication`) | -| `outcome` | Filter by outcome (`success`, `failed`, `warning`, `running`) | -| `since` | ISO 8601 timestamp โ€” only points after this time | -| `until` | ISO 8601 timestamp โ€” only points before this time | -| `subject` | Filter by subject reference | -| `limit` | Max results (default: 500) | +| `platform` | `proxmox-pve`, `proxmox-pbs`, `truenas` or `kubernetes`; `provider` is an alias | +| `kind` | `backup`, `snapshot` or `other`; TrueNAS replication results use `backup` | +| `mode` | `local`, `remote` or `snapshot` | +| `outcome` | `success`, `warning`, `failed`, `running` or `unknown` | +| `from` | Lower time bound in RFC3339, for example `2026-10-01T00:00:00Z` | +| `to` | Upper time bound in RFC3339 | +| `subjectResourceId` | Exact linked resource identity; `itemResourceId` is an alias | +| `rollupId` | Exact subject summary identity | +| `q` | Text search; `query` is an alias | -`/api/recovery/postures` has a deliberately bounded table contract. Supply one -or more repeated `resourceId` parameters for a resource or batch lookup (at -most 200), or omit them for a paged list. It also accepts `state`, `page`, and -`limit`; `state=attention` returns the actionable attention list. The response -includes the posture policy and provider evidence states so clients do not -re-derive trust from raw artifacts. +For example, this relative browser request selects PBS backup records within +the stated time bounds without putting a credential in the URL: -## Troubleshooting +```text +/api/recovery/points?platform=proxmox-pbs&kind=backup&from=2026-10-01T00:00:00Z&to=2026-10-02T00:00:00Z&page=1&limit=100 +``` -### No recovery data showing +Use `from`/`to`, not `since`/`until`, and `subjectResourceId`, not `subject`. +Unrecognised query names do not apply those filters. Invalid `from` or `to` +values return an error rather than an empty result. -1. Verify at least one data source provides backup/snapshot data: - - **PBS**: Ensure a PBS connection exists in Settings โ†’ Infrastructure. - - **TrueNAS**: Ensure a TrueNAS connection exists in Settings โ†’ TrueNAS. - - **PVE**: Local backups from PVE are included automatically. -2. Wait one polling cycle (~30 seconds) for data to appear. -3. Check the source filter โ€” make sure you're not filtering to an empty source. +Points and rollups accept `page` (starting at 1) and `limit` (default **100**, +maximum **500**). Their response has a `data` array and `meta` pagination; +inspect every required page rather than assuming the first is the inventory. +Series and facets return aggregate data, not that paged-list contract. -### PBS backups showing but not TrueNAS snapshots (or vice versa) +### Posture lookup -Check the **Source** filter on the Recovery page. Each provider surfaces its recovery points independently. Clear all filters to see everything. +`/api/recovery/postures` uses a separate filter contract. Repeat `resourceId` +for a batch of at most **200** resource IDs, or omit it for a paged list. +`state=attention` selects attention postures; `page` and `limit` control list +pagination. Its response includes policy and provider evidence states. Do not +apply point filters to this endpoint or re-derive posture from artifact count. -### Recovery points showing as "failed" +## See also -Click the row to expand the details drawer, which shows the provider-specific error message. Common causes: - -- **PBS**: Datastore unreachable, verification failed, prune job errors -- **TrueNAS**: Replication target unreachable, dataset locked, insufficient space -- **PVE**: Backup storage full, vzdump process error - -### Current backups show an unknown posture - -Expand the workload row and inspect the limiting evidence. Pulse uses unknown -when the current poll cannot prove provider-history completeness, permission -scope, or subject identity. Fix the reported collection or access gap and wait -for the next provider poll; Pulse does not promote retained backup artifacts to -protected while that uncertainty remains. - -## See Also - -- [PBS Integration](PBS.md) โ€” Proxmox Backup Server monitoring -- [TrueNAS Integration](TRUENAS.md) โ€” TrueNAS snapshot and replication monitoring -- [Unified Resource Model](UNIFIED_RESOURCES.md) โ€” how recovery integrates with the unified model +- [PBS integration](PBS.md) โ€” access, data sources and host History +- [TrueNAS integration](TRUENAS.md) โ€” snapshots and replication monitoring +- [VM disk monitoring](VM_DISK_MONITORING.md) โ€” guest-agent and backup safety +- [Migrating Pulse](MIGRATION.md) โ€” configuration transfer versus a full-state backup of Pulse itself diff --git a/frontend-modern/src/utils/__tests__/recoveryHelp.test.ts b/frontend-modern/src/utils/__tests__/recoveryHelp.test.ts new file mode 100644 index 0000000000..6af454d0ad --- /dev/null +++ b/frontend-modern/src/utils/__tests__/recoveryHelp.test.ts @@ -0,0 +1,108 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { renderDocMarkdown } from '@/features/docs/docMarkdown'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const read = (relative: string): string => readFileSync(path.join(repoRoot, relative), 'utf8'); +const guide = read('docs/RECOVERY.md'); + +describe('recovery help', () => { + it('separates backup evidence from thaw and tested recovery in the shipped guide', () => { + expect(read('frontend-modern/public/docs/RECOVERY.md')).toBe(guide); + expect(guide).toContain('Pulse does not restore workloads from these views.'); + expect(guide).toContain('OK status is not confirmation that the guest has thawed.'); + expect(guide).toContain('only after independently confirming guest thaw'); + expect(guide).toContain('Pulse monitoring and alert\ndelivery are unavailable'); + expect(guide).toContain('restore to an isolated destination'); + expect(guide).toContain('Do not overwrite the live\nworkload'); + expect(guide).toContain('an omitted value supplies no verification result'); + expect(guide).toContain('An unknown outcome is not success'); + expect(guide).not.toContain('What can I actually recover?'); + }); + + it('points to existing platform views and a usable backup-safety heading', () => { + expect(guide).toContain('There is no top-level Recovery page.'); + expect(guide).toContain('**Proxmox โ†’ Backups** (`/proxmox/backups`)'); + expect(guide).toContain('**TrueNAS โ†’ Protection** (`/truenas/protection`)'); + const proxmox = read('frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx'); + expect(proxmox).toContain("label: 'By date'"); + expect(proxmox).toContain("label: 'Coverage'"); + expect(read('frontend-modern/src/features/truenas/truenasPageModel.ts')).toContain( + "path: '/truenas/protection'", + ); + const rendered = document.createElement('article'); + rendered.innerHTML = renderDocMarkdown(guide, 'RECOVERY'); + const safety = rendered.querySelector('a[href="/docs/VM_DISK_MONITORING#backup-safety"]'); + expect(safety?.textContent).toBe('backup safety precaution'); + const target = document.createElement('article'); + target.innerHTML = renderDocMarkdown(read('docs/VM_DISK_MONITORING.md'), 'VM_DISK_MONITORING'); + expect(target.querySelector('#backup-safety')?.textContent).toBe('Backup safety'); + }); + + it('documents actual point filters and an example the handlers consume', () => { + const handlers = read('internal/api/recovery_handlers.go'); + const model = read('internal/recovery/model/types.go'); + const section = guide.split('### Point and rollup filters')[1].split('### Posture lookup')[0]; + const table = section.split('For example,')[0]; + const parameters = [...table.matchAll(/^\| `([^`]+)` \|/gm)].map((match) => match[1]); + expect(parameters).toEqual([ + 'platform', + 'kind', + 'mode', + 'outcome', + 'from', + 'to', + 'subjectResourceId', + 'rollupId', + 'q', + ]); + for (const parameter of parameters) { + expect(handlers).toContain(`qs.Get("${parameter}")`); + } + const example = section.match(/```text\n([^\n]+)\n```/)?.[1]; + expect(example).toBeDefined(); + const request = new URL(example!, 'https://pulse.example.invalid'); + expect(request.pathname).toBe('/api/recovery/points'); + expect([...request.searchParams.keys()]).toEqual([ + 'platform', + 'kind', + 'from', + 'to', + 'page', + 'limit', + ]); + expect(request.searchParams.get('platform')).toBe('proxmox-pbs'); + expect(model).toContain('ProviderProxmoxPBS Provider = "proxmox-pbs"'); + expect(model).toContain('DefaultListPageLimit = 100'); + expect(model).toContain('MaxListPageLimit = 500'); + expect(section).toContain('default **100**'); + expect(section).toContain('maximum **500**'); + expect(section).toContain('Unrecognised query names do not apply those filters'); + expect(request.searchParams.has('token')).toBe(false); + }); + + it('keeps read-only endpoints and posture batch lookup distinct from point filters', () => { + const routes = read('internal/api/router_routes_monitoring.go'); + const handlers = read('internal/api/recovery_handlers.go'); + const endpoints = [...guide.matchAll(/^\| `GET` \| `([^`]+)` \|/gm)].map((match) => match[1]); + expect(endpoints).toEqual([ + '/api/recovery/points', + '/api/recovery/rollups', + '/api/recovery/series', + '/api/recovery/facets', + '/api/recovery/postures', + ]); + for (const endpoint of endpoints) { + expect(routes).toContain(`HandleFunc("${endpoint}", RequireAuth`); + } + expect(guide).toContain('`monitoring:read` token supplied through a private header file'); + const posture = guide.split('### Posture lookup')[1].split('## See also')[0]; + expect(posture).toContain('Repeat `resourceId`'); + expect(posture).toContain('at most **200**'); + expect(handlers).toContain('const maxProtectionPostureResourceIDs = 200'); + expect(posture).toContain('Do not\napply point filters to this endpoint'); + }); +}); From d9292ef38cc728837e6d21b890f3fe6ae23b23ba Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:22:30 +0100 Subject: [PATCH 79/86] Keep backup inventory failures source-local Distinguish PVE and PBS loading, failed and fulfilled inventories. Keep independent evidence usable, provide isolated retry, and withdraw prior inventory on organisation changes without new polling or caching. Preserve server health and canonical protection posture while marking incomplete counts. Cover lifecycle and recovery with mounted regressions, production-component browser proof and subsystem contracts. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 29 ++ .../internal/subsystems/storage-recovery.md | 20 ++ .../internal/subsystems/unified-resources.md | 12 + .../browser-tests/backup-inventory.cjs | 318 ++++++++++++++++++ .../browser-tests/backup-inventory.html | 12 + .../browser-tests/backup-inventory.tsx | 64 ++++ .../7da9cf49e169b9f3.json | 58 ++++ .../proxmox/ProxmoxBackupServersTable.tsx | 26 +- .../features/proxmox/ProxmoxBackupsTable.tsx | 157 +++++++-- .../__tests__/ProxmoxBackupsTable.test.tsx | 199 ++++++++++- 10 files changed, 856 insertions(+), 39 deletions(-) create mode 100644 frontend-modern/browser-tests/backup-inventory.cjs create mode 100644 frontend-modern/browser-tests/backup-inventory.html create mode 100644 frontend-modern/browser-tests/backup-inventory.tsx create mode 100644 frontend-modern/browser-verification/7da9cf49e169b9f3.json diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9f64f9576c..b42308955a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -68,6 +68,35 @@ guidance and existing touch/keyboard collapse. Synthetic observations establish presentation, not native task success, archive restorability, QGA/thaw or release availability; those require their own containing native and delivery evidence. +### Independent backup inventory read ownership + +PVE files/snapshots/tasks and PBS inventory have independent, non-suspending +read owners on the existing Backups page. A pending or failed source is not an +empty successful inventory. Keep the other source's readable artifacts, filters +and server health usable; name the missing source beside the evidence, mark +restore-point totals as incomplete and retry only that source. If neither source +can be read, the shared error action retries both. An initial pending read must +not show โ€œNo backups yetโ€ or a measured zero PBS count. Only a fulfilled empty +PBS inventory establishes a zero count. + +Pending retries retain independently readable evidence. A settled failed read +withdraws that source's artifacts and counts until it recovers, without clearing +the other source or inferring server-owned protection posture. A retry remains +disabled and its previous warning visible while pending. Organisation switches +withdraw prior inventory, replace/abort the read owners and refetch in the new +API context; late responses cannot repopulate old evidence. Disposal aborts +reads and removes subscriptions. This uses the existing query lifecycle, without +new inventory polling or a remount cache. + +Verification: mounted `ProxmoxBackupsTable.test.tsx` covers source pending, +partial/both-source failure, isolated retry, fulfilled empty counts, 401/403, +organisation replacement, late response and disposal. Production component, +router, API adapter and stylesheet fixture `browser-tests/backup-inventory.cjs` +checks desktop Chromium and touch WebKit recovery, warning readability, keyboard +or touch retry, source isolation and actual new organisation headers. Synthetic +HTTP results establish frontend truthfulness/lifecycle, not native PBS/PVE +permission, archive restorability, guest thaw or released availability. + ### Composed operator tables and conservative rollback consent diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ef5654ebc6..0d7f50f36c 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -21,6 +21,26 @@ ## Purpose +### Partial backup inventory is not empty recovery evidence + +The existing Proxmox Backups view treats PVE and PBS inventories as independent +reads. Fulfilled artifacts remain usable when the other source is loading or +unavailable. The page identifies that source, marks totals as incomplete and +does not claim no backups exist until both inventories have fulfilled. PBS server +backup counts show loading/unavailable rather than a fabricated zero; current +server health and server-owned protection posture remain independent facts. +Isolated retry preserves filters and the other source, while settled failures +withdraw the failed source's artifacts. Both failed reads have a shared retry. +Organisation changes and disposal cancel/replace reads and prevent late responses +from restoring former inventory. No polling, recovery operation, native identity +rule, protection-policy or provider contract is changed. + +Verification: `ProxmoxBackupsTable.test.tsx` exercises pending/partial/both-source +failure, retries, measured empty counts, access denial, org-switch races and +disposal. `browser-tests/backup-inventory.cjs` uses production components, +API adapters, router and CSS in Chromium/WebKit with synthetic HTTP responses; +these controls are not native backup, permission, thaw or release acceptance. + ### Backup identity, phone storage and History consent composition The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index cf80b93be1..19287752f7 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,18 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Backup server counts retain their inventory availability + +The existing PBS server/datastore row takes backup-inventory read availability +separately from the current canonical resource's health, capacity and identity. +A pending or failed PBS read renders loading/unavailable count text, not zero +backups on an otherwise healthy server. Only a fulfilled inventory establishes +its observed count. Independently readable resources and the existing correlated +drawer target are unchanged. `ProxmoxBackupsTable.test.tsx` covers the combined +count lifecycle; `browser-tests/backup-inventory.cjs` verifies production rows +and source notices on desktop/phone. This changes no resource identity, native +provider assessment or permission policy. + ### Guest disk freshness presentation Existing workload disk evidence retains its source reason through the row, diff --git a/frontend-modern/browser-tests/backup-inventory.cjs b/frontend-modern/browser-tests/backup-inventory.cjs new file mode 100644 index 0000000000..8ef2dd06b1 --- /dev/null +++ b/frontend-modern/browser-tests/backup-inventory.cjs @@ -0,0 +1,318 @@ +// Production backup page, router, API adapters and CSS; synthetic HTTP only. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const sha256 = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); +const root = '/workspace/frontend-modern'; +const output = '/workspace/tmp/backup-inventory-proof'; +const emptyPVE = { data: { storageBackups: [], guestSnapshots: [], backupTasks: [] } }; +const emptyPBS = { data: { backups: [] } }; +const pvePayload = { + data: { + ...emptyPVE.data, + storageBackups: [ + { + id: 'archive-112', + instance: 'pve-a', + node: 'pve-a', + storage: 'local', + type: 'ct', + vmid: 112, + time: '2026-10-03T01:00:00Z', + format: 'zst', + volid: 'local:backup/vzdump-lxc-112.tar.zst', + }, + ], + }, +}; +const pbsPayload = { + data: { + backups: [ + { + id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z', + instance: 'pbs-main', + datastore: 'main', + backupType: 'ct', + vmid: '112', + backupTime: '2026-10-03T02:00:00Z', + files: ['index.json.blob'], + }, + ], + }, +}; +const reply = (body, status = 200) => ({ body, status }); +const held = (body, status = 200) => { + let release; + const gate = new Promise((resolve) => (release = resolve)); + return { ...reply(body, status), gate, release }; +}; +(async () => { + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5297, strictPort: true }, + }); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }; + const page = await browser.newPage({ + viewport, + isMobile: phone, + hasTouch: phone, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(20000); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + requests = [], + offOrigin = [], + screenshots = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + const missing = held({}, 503); + const sources = { pve: reply(emptyPVE), pbs: missing }; + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5297') { + offOrigin.push(url.origin); + return route.abort(); + } + // Do not intercept Vite's source modules under /src/api/. + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ + path: url.pathname, + method: request.method(), + org: request.headers()['x-pulse-org-id'] ?? null, + }); + const key = + url.pathname === '/api/backups/pbs' + ? 'pbs' + : url.pathname === '/api/backups/pve' + ? 'pve' + : null; + const selected = key ? sources[key] : reply({ data: [], policy: {}, meta: {} }); + if (selected.gate) await selected.gate; + await route.fulfill({ + status: selected.status, + contentType: 'application/json', + body: JSON.stringify(selected.body), + }); + }); + const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]'); + const notice = (source, status) => + page.getByRole('status').filter({ hasText: `${source} backup inventory is ${status}` }); + const capture = async (suffix) => { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((animation) => animation.effect?.getTiming().iterations !== Infinity) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const file = path.join(output, `${name}-${suffix}.png`); + await page.screenshot({ path: file, fullPage: true }); + screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) }); + }; + const activate = async (button) => { + if (phone) await button.tap(); + else { + await button.focus(); + await page.keyboard.press('Enter'); + } + }; + const switchOrg = (org) => page.evaluate((value) => window.__switchBackupOrg(value), org); + await page.goto('http://127.0.0.1:5297/browser-tests/backup-inventory.html?view=date', { + waitUntil: 'domcontentloaded', + }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + await notice('PBS', 'loading').waitFor(); + assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0); + await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor(); + if (!phone) await page.locator('[title="PBS backup inventory is loading"]').waitFor(); + await capture('pending'); + missing.release(); + await notice('PBS', 'unavailable').waitFor(); + assert.equal(await page.getByRole('alert').count(), 0); + const style = await notice('PBS', 'unavailable').evaluate((node) => { + const rect = node.getBoundingClientRect(); + return { + color: getComputedStyle(node).color, + background: getComputedStyle(node).backgroundColor, + width: rect.width, + scrollWidth: node.scrollWidth, + clientWidth: node.clientWidth, + }; + }); + assert.equal(style.scrollWidth <= style.clientWidth + 1, true); + assert( + !style.color.startsWith('rgba') || style.color.endsWith(', 1)'), + 'warning text must be opaque', + ); + const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true }); + const recovering = held(pbsPayload); + sources.pbs = recovering; + await activate(retry); + assert.equal(await retry.isDisabled(), true); + await notice('PBS', 'unavailable').waitFor(); + await capture('retrying'); + recovering.release(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + assert.equal(await notice('PBS', 'unavailable').count(), 0); + assert.equal( + requests.filter((r) => r.path === '/api/backups/pve').length, + 1, + 'isolated PBS retry does not reread PVE', + ); + await capture('recovered'); + checks.push( + 'PBS loading never reads empty/zero; 503 remains contained, warning persists through disabled keyboard/touch retry, and success restores its point', + ); + + const newPVE = held(pvePayload); + sources.pve = newPVE; + sources.pbs = reply({}, 403); + await switchOrg('fixture-other-org'); + await notice('PBS', 'unavailable').waitFor(); + assert.equal(await rows().count(), 0, 'org switch withdraws previous inventory'); + assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0); + await page.getByText(/Access denied\. Check your permissions/).waitFor(); + newPVE.release(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + assert.match(await rows().first().innerText(), /PVE file/); + if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor(); + const pveRow = await rows().first().elementHandle(); + sources.pbs = held(pbsPayload); + await activate(page.getByRole('button', { name: 'Retry PBS inventory', exact: true })); + await notice('PBS', 'unavailable').waitFor(); + assert.equal( + await page.evaluate( + (node) => node === document.querySelector('[data-proxmox-backup-row="recoverable"]'), + pveRow, + ), + true, + 'isolated retry preserves readable row DOM', + ); + sources.pbs.release(); + await page.waitForFunction( + () => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2, + ); + checks.push( + '403 removes old PBS points/counts while PVE evidence survives; only success restores PBS, without remounting the independent row', + ); + + sources.pve = reply({}, 500); + sources.pbs = reply({}, 503); + await switchOrg('fixture-final-org'); + await page.getByText('Could not load Proxmox backup inventory', { exact: true }).waitFor(); + assert.equal(await rows().count(), 0); + const finalPVE = held(pvePayload); + sources.pve = finalPVE; + sources.pbs = reply(pbsPayload); + await activate(page.getByRole('button', { name: 'Refresh', exact: true })); + await notice('PVE', 'unavailable').waitFor(); + assert.equal(await rows().count(), 1, 'PBS recovery is independent of a pending PVE retry'); + finalPVE.release(); + await page.waitForFunction( + () => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2, + ); + assert.equal( + await page.getByRole('status').filter({ hasText: 'Counts are incomplete' }).count(), + 0, + ); + await activate(page.getByRole('link', { name: 'Coverage', exact: true })); + await page.locator('[data-proxmox-backups-table="coverage"]').waitFor(); + assert.equal( + await page + .getByRole('link', { name: 'Coverage', exact: true }) + .getAttribute('aria-current'), + 'page', + ); + await capture('coverage'); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), + true, + ); + assert.equal(await page.evaluate(() => window.innerWidth), viewport.width); + for (const org of ['fixture-other-org', 'fixture-final-org']) { + assert(requests.some((r) => r.path === '/api/backups/pbs' && r.org === org)); + assert(requests.some((r) => r.path === '/api/backups/pve' && r.org === org)); + } + assert(requests.every((r) => r.method === 'GET')); + assert.deepEqual(offOrigin, []); + assert.deepEqual(errors, []); + checks.push( + 'both failed inventories retry, first recovered source renders immediately, healthy Coverage navigation remains usable, GET-only new organisation headers and no page overflow/errors', + ); + observations.push({ + name, + viewport, + browser_version: browser.version(), + style, + requests, + checks, + screenshots, + }); + await page.close(); + await browser.close(); + browser = null; + } + const paths = [ + 'frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx', + 'frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx', + ]; + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + playwright_version: playwrightVersion, + content_sha256: Object.fromEntries( + paths.map((p) => [p, sha256(path.join('/workspace', p))]), + ), + observations, + limits: [ + 'Synthetic responses and organisation contexts, not native permissions, PBS/PVE monitoring, guest thaw, restore verification or release acceptance.', + 'Browser uses production components and stylesheet through offline Vite; exact production/embed build is separately source-proved.', + ], + }, + null, + 2, + ), + ); + console.log( + 'PASS: independent backup read states, source retry, 403/org withdrawal, recovered Coverage and desktop/phone warning layout.', + ); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/backup-inventory.html b/frontend-modern/browser-tests/backup-inventory.html new file mode 100644 index 0000000000..8832c7896b --- /dev/null +++ b/frontend-modern/browser-tests/backup-inventory.html @@ -0,0 +1,12 @@ + + + + + + Backup inventory read verification + + +
    + + + diff --git a/frontend-modern/browser-tests/backup-inventory.tsx b/frontend-modern/browser-tests/backup-inventory.tsx new file mode 100644 index 0000000000..3171373954 --- /dev/null +++ b/frontend-modern/browser-tests/backup-inventory.tsx @@ -0,0 +1,64 @@ +// Real router/table/API reader/styles; all HTTP evidence is synthetic. +import { ErrorBoundary, onCleanup, onMount } from 'solid-js'; +import { render } from 'solid-js/web'; +import { Route, Router } from '@solidjs/router'; +import { ProxmoxBackupsTable } from '../src/features/proxmox/ProxmoxBackupsTable'; +import { eventBus } from '../src/stores/events'; +import { setOrgID } from '../src/utils/apiClient'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +const guest = { + id: 'ct-112', + type: 'system-container', + name: 'backup-guest', + platformId: 'pve-a', + platformType: 'proxmox-pve', + sourceType: 'api', + status: 'running', + lastSeen: Date.parse('2026-10-03T12:00:00Z'), + proxmox: { vmid: 112, node: 'pve-a', instance: 'pve-a' }, +} as Resource; +const server = { + id: 'pbs-main', + type: 'pbs', + name: 'pbs-main', + platformId: 'pbs-main', + platformType: 'proxmox-pbs', + sourceType: 'api', + status: 'online', + lastSeen: Date.parse('2026-10-03T12:00:00Z'), + pbs: { + instanceId: 'pbs-main', + connectionHealth: 'healthy', + datastores: [{ name: 'main', total: 10_000, used: 4_000, available: 6_000 }], + }, +} as Resource; + +function Fixture() { + onMount(() => { + Object.assign(window, { + __switchBackupOrg: (orgID: string) => { + setOrgID(orgID); + eventBus.emit('org_switched', orgID); + }, + }); + onCleanup(() => Reflect.deleteProperty(window, '__switchBackupOrg')); + }); + return ( +
    + Backup view failed
    }> + } workloads={[guest]} servers={[server]} /> + + + ); +} + +render( + () => ( + + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/7da9cf49e169b9f3.json b/frontend-modern/browser-verification/7da9cf49e169b9f3.json new file mode 100644 index 0000000000..0f459588c4 --- /dev/null +++ b/frontend-modern/browser-verification/7da9cf49e169b9f3.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "base_sha": "6140b24bc2bc6e0f272f77bc445758107ab48f45", + "verified_at": "2026-10-03T20:20:56.516012Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx", + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "3aba53c4eb3019fd47eed67cb1556644fba45b0d7909f30b03c3039806fa2f38", + "frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "b7ab96c153e4b2c84c3afef4c913265b2598bcbb32dd44a5cf9cb612499a2355" + }, + "routes": [ + "/browser-tests/backup-inventory.html?view=date", + "/proxmox/backups/coverage" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "PBS loading with fulfilled empty PVE: incomplete inventory, not no backups or zero PBS count", + "PBS 503 isolated with visible warning during disabled retry and recovered PBS evidence", + "Organisation replacement: previous artifacts withdrawn before new reads; PBS 403 does not hide independent PVE evidence", + "Both inventories fail then recover independently; canonical Coverage navigation and current server health preserved" + ], + "interactions": [ + "Desktop keyboard Enter and phone touch retry only the failed source", + "Both-source Refresh and new organisation GET headers; partial results usable without waiting for the other source", + "Inspect eight settled-animation screenshots at desktop/light and phone/dark widths; warning/count/empty explanation readable, no page overflow" + ], + "playwright_version": "1.56.1", + "browser_versions": [ + { + "engine": "chromium-desktop", + "version": "141.0.7390.37" + }, + { + "engine": "webkit-phone", + "version": "26.0" + } + ], + "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82", + "evidence": [ + "Current Web outcome retains complete semantic observations, settled-animation capture subset, baseline, inspected screenshots and exact-source receipts." + ], + "limitations": [ + "Synthetic responses and organisation contexts, not native permissions, PBS/PVE monitoring, guest thaw, restore verification or release acceptance.", + "Browser uses production components and stylesheet through offline Vite; exact production/embed build is separately source-proved." + ] +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx index ec407b7b8c..44997db7e3 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -380,6 +380,7 @@ export function buildBackupServerRows( export function ProxmoxBackupServersTable(props: { servers: readonly Resource[]; backups?: readonly PBSBackup[]; + backupInventoryState?: 'available' | 'loading' | 'unavailable'; emptyIcon?: JSX.Element; layoutWidth?: Accessor; }) { @@ -615,10 +616,27 @@ export function ProxmoxBackupServersTable(props: { class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`} > โ€”}> - + + {props.backupInventoryState === 'loading' + ? 'Loading' + : 'Unavailable'} + + } + > + + diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index f90874b0d5..e3d51a93b9 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -1,9 +1,10 @@ -import { Show, createMemo, createResource, createSignal, type Component, type JSX } from 'solid-js'; +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; import { useLocation, useSearchParams } from '@solidjs/router'; import { FilterBar, type FilterDef, type FilterSelectOption } from '@/components/shared/FilterBar'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { useProtectionPostures } from '@/hooks/useProtectionPostures'; +import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { buildProxmoxBackupsPath, PROXMOX_BACKUPS_DEFAULT_VIEW, @@ -12,6 +13,7 @@ import { type ProxmoxBackupsView, } from '@/routing/resourceLinks'; import { apiFetch } from '@/utils/apiClient'; +import { getAPIReadAccessErrorMessage } from '@/utils/apiAccessError'; import { PlatformErrorState, PlatformResourceCounter, @@ -79,28 +81,35 @@ const BACKUP_VIEW_TABS: readonly { id: BackupView; label: string }[] = [ { id: 'coverage', label: 'Coverage' }, ]; -async function fetchPVEBackups(): Promise { - const response = await apiFetch('/api/backups/pve'); +const EMPTY_PVE_BACKUPS: PVEBackupsPayload = { + backupTasks: [], + storageBackups: [], + guestSnapshots: [], +}; +const EMPTY_PBS_BACKUPS: PBSBackupsPayload = { backups: [] }; +const BACKUP_READ_NOTICE_CLASS = + 'rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-100'; + +async function fetchPVEBackups(signal: AbortSignal): Promise { + const response = await apiFetch('/api/backups/pve', { signal }); if (!response.ok) { - throw new Error(`Failed to load PVE backups (${response.status})`); + throw Object.assign(new Error(`Failed to load PVE backups (${response.status})`), { + status: response.status, + }); } const payload = (await response.json()) as PVEBackupsResponse; - return ( - payload?.data ?? { - backupTasks: [], - storageBackups: [], - guestSnapshots: [], - } - ); + return payload?.data ?? EMPTY_PVE_BACKUPS; } -async function fetchPBSBackups(): Promise { - const response = await apiFetch('/api/backups/pbs'); +async function fetchPBSBackups(signal: AbortSignal): Promise { + const response = await apiFetch('/api/backups/pbs', { signal }); if (!response.ok) { - throw new Error(`Failed to load PBS backups (${response.status})`); + throw Object.assign(new Error(`Failed to load PBS backups (${response.status})`), { + status: response.status, + }); } const payload = (await response.json()) as PBSBackupsResponse; - return payload?.data ?? { backups: [] }; + return payload?.data ?? EMPTY_PBS_BACKUPS; } export const ProxmoxBackupsTable: Component<{ @@ -108,8 +117,31 @@ export const ProxmoxBackupsTable: Component<{ workloads?: readonly Resource[]; servers?: readonly Resource[]; }> = (props) => { - const [backups, { refetch }] = createResource(fetchPVEBackups); - const [pbsBackups] = createResource(fetchPBSBackups); + // Independent read owners contain source failures and replace pending reads + // on org changes/disposal. No new polling or cross-route inventory cache. + const backups = createNonSuspendingQuery({ + source: () => 'pve', + fetcher: (_source, signal) => fetchPVEBackups(signal), + initialValue: EMPTY_PVE_BACKUPS, + }); + const pbsBackups = createNonSuspendingQuery({ + source: () => 'pbs', + fetcher: (_source, signal) => fetchPBSBackups(signal), + initialValue: EMPTY_PBS_BACKUPS, + }); + const inventorySources = [ + { label: 'PVE', query: backups }, + { label: 'PBS', query: pbsBackups }, + ]; + const inventoryIncomplete = () => + inventorySources.some(({ query }) => !query.resolvedOnce() || Boolean(query.error())); + const hasReadableInventory = () => + inventorySources.some(({ query }) => query.resolvedOnce() && !query.error()); + const allInventoriesFailed = () => inventorySources.every(({ query }) => Boolean(query.error())); + const retryAllInventories = () => { + if (inventorySources.some(({ query }) => query.loading())) return; + void Promise.all([backups.refetch(), pbsBackups.refetch(), protectionPostures.refetch()]); + }; const { isMobile } = useBreakpoint(); const location = useLocation(); const protectionPostures = useProtectionPostures(() => @@ -261,10 +293,20 @@ export const ProxmoxBackupsTable: Component<{ }); const toggleDay = (key: string) => setSelectedDateKey(selectedDateKey() === key ? null : key); - const pbsArtifacts = createMemo(() => pbsBackups()?.backups ?? []); - const snapshots = createMemo(() => backups()?.guestSnapshots ?? []); - const archives = createMemo(() => backups()?.storageBackups ?? []); - const tasks = createMemo(() => backups()?.backupTasks ?? []); + // Retain readable observations while a retry is pending, but never treat a + // settled failed read as a complete inventory or expose its old artifacts. + const pbsArtifacts = createMemo(() => + pbsBackups.error() ? [] : (pbsBackups.value().backups ?? []), + ); + const snapshots = createMemo(() => + backups.error() ? [] : (backups.value().guestSnapshots ?? []), + ); + const archives = createMemo(() => + backups.error() ? [] : (backups.value().storageBackups ?? []), + ); + const tasks = createMemo(() => + backups.error() ? [] : (backups.value().backupTasks ?? []), + ); // Render-time `now` snapshot so all age comparisons within a render share a // reference moment; not reactive to ticking time (fine for sysadmin grouping). @@ -628,17 +670,19 @@ export const ProxmoxBackupsTable: Component<{ return ( void refetch()} + description="PVE and PBS backup inventories are unavailable. Counts are incomplete. Refresh to retry both sources." + onRefresh={retryAllInventories} /> } > Boolean(query.error())) + } fallback={
    + + {({ label, query }) => ( + +
    + + {label} backup inventory is {query.error() ? 'unavailable' : 'loading'}. Counts + are incomplete. {getAPIReadAccessErrorMessage(query.error())} + + + + +
    +
    + )} +
    -
    +
    Protection posture is unavailable. Pulse is showing restore evidence without guessing whether workloads are protected.
    0}> - + {/* The health strip renders identically in both route-backed views. */} @@ -667,7 +744,7 @@ export const ProxmoxBackupsTable: Component<{ tail={ {liveTotalCount()} targets ยท {recoveryModel().coverageSummary.recoverableArtifacts}{' '} - restore points + restore points{inventoryIncomplete() ? ' read' : ''} 0}> {' ยท '} {recoveryModel().coverageSummary.withPBS} with{' '} @@ -800,7 +877,13 @@ export const ProxmoxBackupsTable: Component<{ } /> @@ -810,8 +893,14 @@ export const ProxmoxBackupsTable: Component<{ artifacts={filteredRecoverableArtifacts()} hasAnyArtifacts={recoveryModel().recoverableArtifacts.length > 0} emptyIcon={props.emptyIcon} - emptyTitle="No backups yet" - emptyDescription="PBS snapshots, PVE backup files, and guest snapshots will appear here once backups run." + emptyTitle={ + inventoryIncomplete() ? 'Backup inventory is incomplete' : 'No backups yet' + } + emptyDescription={ + inventoryIncomplete() + ? 'Some backup sources are still loading or unavailable. Missing restore points do not mean no backups exist.' + : 'PBS snapshots, PVE backup files, and guest snapshots will appear here once backups run.' + } sortKey={recoverableSortKey} sortDirection={recoverableSortDirection} onSort={handleRecoverableSort} diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index 6011425fef..ece278c3d6 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; import { Route, Router } from '@solidjs/router'; import { createSignal, type JSX } from 'solid-js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -15,6 +15,7 @@ import { import { TABLE_CARD_FRAME_CLASS } from '@/components/shared/TableCard'; import type { Resource } from '@/types/resource'; import { getRecoveryFullDateLabel } from '@/utils/recoveryDatePresentation'; +import { eventBus } from '@/stores/events'; import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; // ProxmoxBackupsTable reads URL search params (node/type scope filters), so it @@ -703,3 +704,199 @@ describe('ProxmoxBackupsTable', () => { expect(proxmoxBackupsTableSource).not.toContain('Proxmox Patrol coverage'); }); }); + +const emptyPVE = { data: { backupTasks: [], storageBackups: [], guestSnapshots: [] } }; +const emptyPBS = { data: { backups: [] } }; +const readPvePayload = { data: { ...pvePayload.data, guestSnapshots: [] } }; +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => (resolve = res)); + return { promise, resolve }; +}; +const mountBackupReadState = () => + renderInRouter(() => ( + } + workloads={[workloadResource]} + servers={[pbsServerResource]} + /> + )); +const recoverableRows = () => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]'); +const serverTable = () => document.querySelector('[data-proxmox-backups-table="servers"]')!; +const sourceCalls = (source: 'pve' | 'pbs') => + apiFetchMock.mock.calls.filter(([url]) => url === `/api/backups/${source}`); + +describe('independent Proxmox backup inventory reads', () => { + beforeEach(() => { + window.history.replaceState({}, '', '/proxmox/backups/date'); + apiFetchJSONMock.mockResolvedValue({ data: [], policy: {}, meta: {} }); + }); + it('does not present an unread PBS inventory as empty or a measured zero', async () => { + const pending = deferred(); + apiFetchMock.mockImplementation((url) => + url.endsWith('/pbs') ? pending.promise : Promise.resolve(jsonResponse(emptyPVE)), + ); + mountBackupReadState(); + await screen.findByText(/PBS backup inventory is loading/); + expect(screen.queryByText('No backups yet')).not.toBeInTheDocument(); + expect(screen.getByText('Backup inventory is incomplete')).toBeInTheDocument(); + expect(serverTable()).toHaveTextContent('Loading'); + pending.resolve(jsonResponse(emptyPBS)); + await screen.findByText('No backups yet'); + expect(serverTable()).not.toHaveTextContent('Loading'); + expect( + within(serverTable() as HTMLElement).getByText('0', { exact: true }), + ).toBeInTheDocument(); + }); + + it('shows fulfilled PBS evidence without waiting for PVE', async () => { + const pending = deferred(); + apiFetchMock.mockImplementation((url) => + url.endsWith('/pve') ? pending.promise : Promise.resolve(jsonResponse(pbsPayload)), + ); + mountBackupReadState(); + await screen.findByText(/PVE backup inventory is loading/); + expect(recoverableRows()).toHaveLength(1); + expect(screen.getByText(/1 restore points read/)).toBeInTheDocument(); + pending.resolve(jsonResponse(readPvePayload)); + await waitFor(() => expect(recoverableRows()).toHaveLength(2)); + expect(screen.queryByText(/inventory is loading/)).not.toBeInTheDocument(); + }); + + it('keeps a source failure actionable even before the other read settles', async () => { + const pending = deferred(); + apiFetchMock.mockImplementation((url) => + url.endsWith('/pve') ? pending.promise : Promise.resolve(new Response('{}', { status: 503 })), + ); + mountBackupReadState(); + await screen.findByText(/PBS backup inventory is unavailable/); + expect(screen.getByText(/PVE backup inventory is loading/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry PBS inventory' })).toBeEnabled(); + expect(screen.getByText('Backup inventory is incomplete')).toBeInTheDocument(); + expect(screen.queryByText('No backups yet')).not.toBeInTheDocument(); + pending.resolve(jsonResponse(readPvePayload)); + await waitFor(() => expect(recoverableRows()).toHaveLength(1)); + }); + + it.each(['pve', 'pbs'] as const)( + 'contains a %s failure, preserves the other source and retries only the failed source', + async (source) => { + let response = Promise.resolve(new Response('{}', { status: 503 })); + apiFetchMock.mockImplementation((url) => + url.endsWith(`/${source}`) + ? response + : Promise.resolve(jsonResponse(source === 'pve' ? pbsPayload : readPvePayload)), + ); + mountBackupReadState(); + await screen.findByText( + new RegExp(`${source.toUpperCase()} backup inventory is unavailable`), + ); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(recoverableRows()).toHaveLength(1); + if (source === 'pbs') expect(serverTable()).toHaveTextContent('Unavailable'); + const retry = screen.getByRole('button', { name: `Retry ${source.toUpperCase()} inventory` }); + const pending = deferred(); + response = pending.promise; + fireEvent.click(retry); + expect(retry).toBeDisabled(); + fireEvent.click(retry); + expect(sourceCalls(source)).toHaveLength(2); + expect(recoverableRows()).toHaveLength(1); + expect(screen.getByText(/inventory is unavailable/)).toBeInTheDocument(); + pending.resolve(jsonResponse(source === 'pbs' ? pbsPayload : readPvePayload)); + await waitFor(() => expect(recoverableRows()).toHaveLength(2)); + expect(screen.queryByText(/inventory is unavailable/)).not.toBeInTheDocument(); + expect(sourceCalls(source === 'pbs' ? 'pve' : 'pbs')).toHaveLength(1); + }, + ); + + it('retries both failed inventories and allows the first recovered source to render', async () => { + apiFetchMock.mockResolvedValue(new Response('{}', { status: 500 })); + mountBackupReadState(); + await screen.findByText('Could not load Proxmox backup inventory'); + expect(screen.queryByText('No backups yet')).not.toBeInTheDocument(); + const pending = deferred(); + apiFetchMock.mockImplementation((url) => + url.endsWith('/pve') ? pending.promise : Promise.resolve(jsonResponse(pbsPayload)), + ); + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByText(/PVE backup inventory is unavailable/); + expect(recoverableRows()).toHaveLength(1); + pending.resolve(jsonResponse(readPvePayload)); + await waitFor(() => expect(recoverableRows()).toHaveLength(2)); + expect(sourceCalls('pve')).toHaveLength(2); + expect(sourceCalls('pbs')).toHaveLength(2); + }); + + it('does not turn a rejected empty source into proof that no backups exist', async () => { + apiFetchMock.mockImplementation((url) => + Promise.resolve( + url.endsWith('/pbs') ? new Response('{}', { status: 403 }) : jsonResponse(emptyPVE), + ), + ); + mountBackupReadState(); + await screen.findByText(/PBS backup inventory is unavailable/); + expect(screen.getByText('Backup inventory is incomplete')).toBeInTheDocument(); + expect(screen.queryByText('No backups yet')).not.toBeInTheDocument(); + expect(serverTable()).toHaveTextContent('Unavailable'); + expect(screen.getByText(/Access denied/)).toBeInTheDocument(); + }); + + it.each([401, 403])( + 'withdraws the old organisation before a new %s PBS denial', + async (status) => { + apiFetchMock.mockImplementation((url) => + Promise.resolve(jsonResponse(url.endsWith('/pbs') ? pbsPayload : readPvePayload)), + ); + mountBackupReadState(); + await waitFor(() => expect(recoverableRows()).toHaveLength(2)); + const pending = deferred(); + apiFetchMock.mockImplementation((url) => + url.endsWith('/pbs') ? pending.promise : Promise.resolve(jsonResponse(emptyPVE)), + ); + eventBus.emit('org_switched', 'other-fixture-org'); + expect(recoverableRows()).toHaveLength(0); + await screen.findByText(/PBS backup inventory is loading/); + expect(screen.queryByText('No backups yet')).not.toBeInTheDocument(); + pending.resolve(new Response('{}', { status })); + await screen.findByText(/PBS backup inventory is unavailable/); + expect(recoverableRows()).toHaveLength(0); + expect(serverTable()).toHaveTextContent('Unavailable'); + }, + ); + + it('aborts replaced org reads and ignores a late earlier response', async () => { + const old = deferred(); + apiFetchMock.mockImplementation((url) => + url.endsWith('/pbs') ? old.promise : Promise.resolve(jsonResponse(emptyPVE)), + ); + mountBackupReadState(); + await waitFor(() => expect(sourceCalls('pbs')).toHaveLength(1)); + const oldSignal = sourceCalls('pbs')[0][1].signal as AbortSignal; + apiFetchMock.mockImplementation((url) => + Promise.resolve(jsonResponse(url.endsWith('/pbs') ? emptyPBS : emptyPVE)), + ); + eventBus.emit('org_switched', 'other-fixture-org'); + expect(oldSignal.aborted).toBe(true); + await screen.findByText('No backups yet'); + old.resolve(jsonResponse(pbsPayload)); + await old.promise; + await waitFor(() => expect(recoverableRows()).toHaveLength(0)); + expect( + within(serverTable() as HTMLElement).getByText('0', { exact: true }), + ).toBeInTheDocument(); + }); + + it('aborts pending reads on disposal and leaves no org-switch readers', async () => { + const pending = deferred(); + apiFetchMock.mockReturnValue(pending.promise); + const view = mountBackupReadState(); + await waitFor(() => expect(apiFetchMock).toHaveBeenCalledTimes(2)); + const signals = apiFetchMock.mock.calls.map(([, options]) => options.signal as AbortSignal); + view.unmount(); + expect(signals.every((signal) => signal.aborted)).toBe(true); + eventBus.emit('org_switched', 'after-disposal'); + expect(apiFetchMock).toHaveBeenCalledTimes(2); + pending.resolve(jsonResponse(emptyPVE)); + }); +}); From d203eeb17a5c340be708692f3110be403bd5ff8e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:25:39 +0100 Subject: [PATCH 80/86] Bind backup read tests to cancellation and independent settlement The first exact-source proof passed 507 cases and rejected three stale test assumptions: the API now receives an AbortSignal, and a source failure notice may render before the other read has fulfilled. Assert the actual cancellation arguments and await independently readable rows without changing timeouts, runtime code or browser receipts. Change-source: pulse-maintainer --- .../proxmox/__tests__/ProxmoxBackupsTable.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx index ece278c3d6..a1b06924c8 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx @@ -343,8 +343,12 @@ describe('ProxmoxBackupsTable', () => { for (const table of tables) { expectCanonicalPlatformTableShell(table); } - expect(apiFetchMock).toHaveBeenCalledWith('/api/backups/pbs'); - expect(apiFetchMock).toHaveBeenCalledWith('/api/backups/pve'); + expect(apiFetchMock).toHaveBeenCalledWith('/api/backups/pbs', { + signal: expect.any(AbortSignal), + }); + expect(apiFetchMock).toHaveBeenCalledWith('/api/backups/pve', { + signal: expect.any(AbortSignal), + }); expect(apiFetchJSONMock).toHaveBeenCalledTimes(1); const postureURL = new URL(apiFetchJSONMock.mock.calls[0][0], 'https://pulse.invalid'); expect(postureURL.pathname).toBe('/api/recovery/postures'); @@ -792,7 +796,9 @@ describe('independent Proxmox backup inventory reads', () => { new RegExp(`${source.toUpperCase()} backup inventory is unavailable`), ); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(recoverableRows()).toHaveLength(1); + // A failed source can now be actionable before the independent read + // settles. Wait for that read's evidence, not the failure notice. + await waitFor(() => expect(recoverableRows()).toHaveLength(1)); if (source === 'pbs') expect(serverTable()).toHaveTextContent('Unavailable'); const retry = screen.getByRole('button', { name: `Retry ${source.toUpperCase()} inventory` }); const pending = deferred(); From 509c12f1ae7c27fd004890c4db9e6d12312c874c Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:53:22 +0100 Subject: [PATCH 81/86] Isolate route allocation checks from API fixture workers AllocsPerRun observes process-wide allocations even with GOMAXPROCS=1. Run both zero-allocation checks in the same instrumented test binary with only the selected measurement enabled, bound execution to its marker and retain strict failure propagation. A synchronised background-allocation control demonstrates the old attribution error while the child checks still demand zero. Root results now escape as real metric labels do. Production normalisation and thresholds are unchanged. Contract-Neutral: Test-process isolation and connected allocation controls only; no runtime, API or performance contract delta. Change-source: pulse-maintainer --- internal/api/http_metrics_allocations_test.go | 125 ++++++++++++++++++ internal/api/http_metrics_test.go | 34 +++-- 2 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 internal/api/http_metrics_allocations_test.go diff --git a/internal/api/http_metrics_allocations_test.go b/internal/api/http_metrics_allocations_test.go new file mode 100644 index 0000000000..ddc01b2ffe --- /dev/null +++ b/internal/api/http_metrics_allocations_test.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "flag" + "os" + "os/exec" + "regexp" + "strings" + "sync/atomic" + "testing" + "time" +) + +var routeAllocationChild = flag.Bool("test.routeAllocationChild", false, "measure route allocations in an isolated test process") + +// AllocsPerRun reads process-wide MemStats, even with GOMAXPROCS=1. Router, +// alert and monitoring fixtures may have background workers in this package; +// their allocations must not be attributed to a constant-return fast path. +// Reuse the actual test binary (including race instrumentation), but select +// only the requested measurement. No allocation floor, retries or skips. +func isolatedRouteAllocationCheck(t *testing.T, measure func()) { + t.Helper() + if *routeAllocationChild { + measure() + t.Log("ROUTE_ALLOCATION_MEASURED " + t.Name()) + return + } + runRouteAllocationTest(t, t.Name()) +} + +func runRouteAllocationTest(t *testing.T, name string) { + t.Helper() + switch name { + case "TestNormalizeSegment_DoesNotAllocate", "TestNormalizeRoute_RootFastPathDoesNotAllocate": + default: + t.Fatalf("unknown route allocation measurement %q", name) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, executable, + "-test.run=^"+regexp.QuoteMeta(name)+"$", "-test.count=1", "-test.v", + "-test.timeout=25s", "-test.routeAllocationChild=true") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("isolated %s failed: %v\n%s", name, err, output) + } + if !strings.Contains(string(output), "ROUTE_ALLOCATION_MEASURED "+name) { + t.Fatalf("isolated %s did not execute its measurement:\n%s", name, output) + } +} + +func TestRouteAllocationMeasurementExcludesBackgroundFixtures(t *testing.T) { + requests := make(chan struct{}) + acknowledged := make(chan struct{}) + stop := make(chan struct{}) + done := make(chan struct{}) + var sink []byte + var calls atomic.Uint64 + go func() { + defer close(done) + for { + select { + case <-stop: + return + case <-requests: + sink = make([]byte, 256) + calls.Add(1) + acknowledged <- struct{}{} + } + } + }() + t.Cleanup(func() { + close(stop) + <-done + if len(sink) != 256 { + t.Error("background fixture did not retain its allocation") + } + }) + allocateElsewhere := func() { + requests <- struct{}{} + <-acknowledged + } + + // Synchronise an unrelated allocation inside each measured call. The + // normaliser still returns a literal, but the old process-wide assertion + // necessarily attributes this other goroutine's allocation to it. + contaminated := testing.AllocsPerRun(100, func() { + normalizeRouteSink = normalizeRoute("/") + allocateElsewhere() + }) + if contaminated < 1 { + t.Fatal("control failed to demonstrate process-wide allocation accounting") + } + t.Logf("unrelated fixture contaminates root measurement: %v allocations/call", contaminated) + + // Keep the parent fixture active while the same binary's isolated root + // and segment checks execute. Both must still enforce exactly zero. + pumpStop := make(chan struct{}) + pumpDone := make(chan struct{}) + go func() { + defer close(pumpDone) + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + select { + case <-pumpStop: + return + case <-ticker.C: + allocateElsewhere() + } + } + }() + t.Cleanup(func() { close(pumpStop); <-pumpDone }) + before := calls.Load() + runRouteAllocationTest(t, "TestNormalizeRoute_RootFastPathDoesNotAllocate") + runRouteAllocationTest(t, "TestNormalizeSegment_DoesNotAllocate") + if calls.Load() <= before { + t.Fatal("background fixture was not active during isolated checks") + } +} diff --git a/internal/api/http_metrics_test.go b/internal/api/http_metrics_test.go index 9c68ab9038..8a32be88b4 100644 --- a/internal/api/http_metrics_test.go +++ b/internal/api/http_metrics_test.go @@ -199,17 +199,19 @@ func TestNormalizeSegment(t *testing.T) { var normalizeSegmentSink string func TestNormalizeSegment_DoesNotAllocate(t *testing.T) { - for _, seg := range []string{ - "12345", - "550e8400-e29b-41d4-a716-446655440000", - "abcdefghijklmnopqrstuvwxyz1234567890abcdef", - "resources", - "metrics-store", - } { - if allocs := testing.AllocsPerRun(100, func() { normalizeSegmentSink = normalizeSegment(seg) }); allocs != 0 { - t.Errorf("normalizeSegment(%q) allocated %v times per call, want 0", seg, allocs) + isolatedRouteAllocationCheck(t, func() { + for _, seg := range []string{ + "12345", + "550e8400-e29b-41d4-a716-446655440000", + "abcdefghijklmnopqrstuvwxyz1234567890abcdef", + "resources", + "metrics-store", + } { + if allocs := testing.AllocsPerRun(100, func() { normalizeSegmentSink = normalizeSegment(seg) }); allocs != 0 { + t.Errorf("normalizeSegment(%q) allocated %v times per call, want 0", seg, allocs) + } } - } + }) } func TestNormalizeRoute(t *testing.T) { @@ -268,12 +270,16 @@ func TestNormalizeRoute(t *testing.T) { // The root and empty paths must return before splitting. Without the fast // path, "/" still normalizes to "/" but allocates a split slice on every call. +var normalizeRouteSink string + func TestNormalizeRoute_RootFastPathDoesNotAllocate(t *testing.T) { - for _, path := range []string{"/", ""} { - if allocs := testing.AllocsPerRun(100, func() { _ = normalizeRoute(path) }); allocs != 0 { - t.Errorf("normalizeRoute(%q) allocated %v times per call, want 0", path, allocs) + isolatedRouteAllocationCheck(t, func() { + for _, path := range []string{"/", ""} { + if allocs := testing.AllocsPerRun(100, func() { normalizeRouteSink = normalizeRoute(path) }); allocs != 0 { + t.Errorf("normalizeRoute(%q) allocated %v times per call, want 0", path, allocs) + } } - } + }) } // --------------------------------------------------------------------------- From c54aa9e3e7f309e18c2b250959497295e1cb5608 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:03:13 +0100 Subject: [PATCH 82/86] Reject unreadable backup responses before publishing inventory Validate backup envelopes, source collections and consumed row fields without coercing completion flags, dropping bad rows or treating missing data as empty. Preserve independent evidence and explicit nil PVE collections, keep format errors private and retry only the failed source. Include parser and mounted regressions, subsystem contracts and inspected Chromium/WebKit final-content proof. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 11 + .../internal/subsystems/storage-recovery.md | 11 + .../internal/subsystems/unified-resources.md | 7 + .../browser-tests/backup-inventory-format.cjs | 278 ++++++++++++++++++ .../78c9d8427bdfa307.json | 53 ++++ .../features/proxmox/ProxmoxBackupsTable.tsx | 19 +- .../__tests__/ProxmoxBackupsTable.test.tsx | 73 +++++ .../__tests__/proxmoxBackupInventory.test.ts | 153 ++++++++++ .../proxmox/proxmoxBackupInventory.ts | 131 +++++++++ 9 files changed, 730 insertions(+), 6 deletions(-) create mode 100644 frontend-modern/browser-tests/backup-inventory-format.cjs create mode 100644 frontend-modern/browser-verification/78c9d8427bdfa307.json create mode 100644 frontend-modern/src/features/proxmox/__tests__/proxmoxBackupInventory.test.ts create mode 100644 frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index dd225b090f..0bf42b1a70 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -88,6 +88,17 @@ API context; late responses cannot repopulate old evidence. Disposal aborts reads and removes subscriptions. This uses the existing query lifecycle, without new inventory polling or a remount cache. +Successful HTTP responses still need a readable backup envelope, collections +and consumed row fields. Invalid JSON, missing data and malformed rows produce +a source-local unavailable state with fixed format guidance, never raw response +content, a fabricated empty inventory or a page-level rendering failure that +hides another source. No invalid rows are silently dropped, no completion flags +are coerced, and only a validated response clears the warning. Explicitly nil +PVE collections remain compatible with the existing Go encoding. +`proxmoxBackupInventory.test.ts`, mounted backup-table controls and +`browser-tests/backup-inventory-format.cjs` verify these boundaries and isolated +keyboard/touch recovery, not native backup or released availability. + Verification: mounted `ProxmoxBackupsTable.test.tsx` covers source pending, partial/both-source failure, isolated retry, fulfilled empty counts, 401/403, organisation replacement, late response and disposal. Production component, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 0d7f50f36c..3711fff084 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -41,6 +41,17 @@ disposal. `browser-tests/backup-inventory.cjs` uses production components, API adapters, router and CSS in Chromium/WebKit with synthetic HTTP responses; these controls are not native backup, permission, thaw or release acceptance. +An HTTP success alone does not establish readable recovery evidence. The client +validates the data envelope, source collections and consumed row fields before +publishing that inventory. Malformed or undecodable responses invalidate that +source atomically, not a silently truncated list or confirmed empty inventory. +PVE's explicitly nil Go collections remain valid empty observations; absent +fields are not nil collections. Optional unknown facts and additional fields +retain their existing meaning, and completion flags are never coerced. +`proxmoxBackupInventory.test.ts`, the mounted backup-table tests and +`browser-tests/backup-inventory-format.cjs` cover this response boundary, source +isolation and recovery without extending native protection or thaw claims. + ### Backup identity, phone storage and History consent composition The Proxmox Coverage last-backup pointer selects completed PBS snapshots or PVE diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 19287752f7..98e6481a30 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -35,6 +35,13 @@ count lifecycle; `browser-tests/backup-inventory.cjs` verifies production rows and source notices on desktop/phone. This changes no resource identity, native provider assessment or permission policy. +The backup inventory count also remains unavailable when an HTTP200 response +fails the client backup-envelope/row validation; transport success is not an +observed zero. A malformed source cannot remove independently readable canonical +server health or the other source's artifacts. Fixed diagnostics expose no +response body. The inventory parser, mounted table and +`browser-tests/backup-inventory-format.cjs` exercise that combined boundary. + ### Guest disk freshness presentation Existing workload disk evidence retains its source reason through the row, diff --git a/frontend-modern/browser-tests/backup-inventory-format.cjs b/frontend-modern/browser-tests/backup-inventory-format.cjs new file mode 100644 index 0000000000..1d0fce2be9 --- /dev/null +++ b/frontend-modern/browser-tests/backup-inventory-format.cjs @@ -0,0 +1,278 @@ +// Production backup readers/table/router/CSS, synthetic HTTP only. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); +const phase = 'final'; +const root = '/workspace/frontend-modern'; +const output = `/workspace/tmp/backup-format-${phase}`; +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const emptyPVE = { data: { backupTasks: [], storageBackups: [], guestSnapshots: [] } }; +const emptyPBS = { data: { backups: [] } }; +const archive = { + id: 'archive-112', + instance: 'pve-a', + node: 'pve-a', + storage: 'local', + type: 'ct', + vmid: 112, + time: '2026-10-03T01:00:00Z', + format: 'zst', + volid: 'local:backup/vzdump-lxc-112.tar.zst', +}; +const snapshot = { + id: 'pbs-main/main/ct/112/2026-10-03T02:00:00Z', + instance: 'pbs-main', + datastore: 'main', + backupType: 'ct', + vmid: '112', + backupTime: '2026-10-03T02:00:00Z', + files: ['index.json.blob'], +}; +const pvePayload = { data: { ...emptyPVE.data, storageBackups: [archive] } }; +const pbsPayload = { data: { backups: [snapshot] } }; + +(async () => { + fs.mkdirSync(output, { recursive: true }); + process.chdir(root); + const playwrightVersion = require('playwright/package.json').version; + assert.equal( + playwrightVersion, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + cacheDir: path.join(output, 'vite-cache'), + server: { host: '127.0.0.1', port: 5298, strictPort: true }, + }); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, phone] of [ + ['chromium-desktop', chromium, false], + ['webkit-phone', webkit, true], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const viewport = phone ? { width: 390, height: 844 } : { width: 1365, height: 900 }; + const page = await browser.newPage({ + viewport, + isMobile: phone, + hasTouch: phone, + locale: 'en-GB', + timezoneId: 'UTC', + }); + page.setDefaultTimeout(20000); + await page.clock.setFixedTime(new Date('2026-10-03T12:00:00Z')); + const errors = [], + requests = [], + offOrigin = [], + screenshots = [], + checks = []; + page.on('pageerror', (error) => errors.push(error.message)); + const sources = { pve: emptyPVE, pbs: {} }; + let retryGate; + await page.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin !== 'http://127.0.0.1:5298') { + offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + requests.push({ path: url.pathname, method: request.method() }); + const key = + url.pathname === '/api/backups/pbs' + ? 'pbs' + : url.pathname === '/api/backups/pve' + ? 'pve' + : null; + const payload = key ? sources[key] : { data: [], policy: {}, meta: {} }; + if (key === 'pbs' && retryGate) await retryGate; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(payload), + }); + }); + const rows = () => page.locator('[data-proxmox-backup-row="recoverable"]'); + const notice = (source) => + page.getByRole('status').filter({ + hasText: `${source} backup inventory is unavailable`, + }); + const load = async () => { + await page.goto('http://127.0.0.1:5298/browser-tests/backup-inventory.html?view=date', { + waitUntil: 'domcontentloaded', + }); + if (phone) await page.evaluate(() => document.documentElement.classList.add('dark')); + }; + const capture = async (suffix) => { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((animation) => animation.effect?.getTiming().iterations !== Infinity) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const file = path.join(output, `${name}-${suffix}.png`); + await page.screenshot({ path: file, fullPage: true }); + screenshots.push({ path: path.relative('/workspace', file), sha256: sha256(file) }); + }; + + await load(); + if (phase === 'baseline') { + await page.getByText('No backups yet', { exact: true }).waitFor(); + assert.equal(await notice('PBS').count(), 0); + checks.push('HTTP200 missing PBS data is incorrectly shown as No backups yet'); + } else { + await notice('PBS').waitFor(); + await page.getByText('Backup inventory is incomplete', { exact: true }).waitFor(); + assert.equal(await page.getByText('No backups yet', { exact: true }).count(), 0); + await page.getByText(/The response format is invalid/).waitFor(); + if (!phone) await page.locator('[title="PBS backup inventory is unavailable"]').waitFor(); + checks.push( + 'HTTP200 missing data remains incomplete with a fixed format explanation, not empty/zero', + ); + } + await capture('missing-data'); + + sources.pve = pvePayload; + sources.pbs = { data: { backups: {} }, message: 'SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY' }; + await load(); + if (phase === 'baseline') { + await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor(); + assert.equal(await rows().count(), 0); + checks.push( + 'HTTP200 non-array PBS inventory removes independently readable PVE evidence at ErrorBoundary', + ); + } else { + await notice('PBS').waitFor(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + assert.match(await rows().first().innerText(), /PVE file/); + assert.equal(await page.getByRole('alert').count(), 0); + assert.equal(await page.getByText('SYNTHETIC_PRIVATE_BODY_DO_NOT_DISPLAY').count(), 0); + checks.push( + 'Malformed PBS is source-local; readable PVE remains and raw response content is not displayed', + ); + } + await capture('pbs-invalid'); + + sources.pbs = pbsPayload; + sources.pve = { data: { ...emptyPVE.data, backupTasks: {} } }; + await load(); + if (phase === 'baseline') { + await page.getByRole('alert').filter({ hasText: 'Backup view failed' }).waitFor(); + assert.equal(await rows().count(), 0); + checks.push( + 'HTTP200 non-array PVE tasks removes independently readable PBS evidence at ErrorBoundary', + ); + } else { + await notice('PVE').waitFor(); + await rows().first().waitFor(); + assert.equal(await rows().count(), 1); + await rows().first().getByText('PBS', { exact: true }).waitFor(); + await rows().first().getByText('Snapshot', { exact: true }).waitFor(); + assert.equal(await page.getByRole('alert').count(), 0); + checks.push('Malformed PVE is source-local and independent PBS evidence remains usable'); + } + await capture('pve-invalid'); + + if (phase === 'final') { + sources.pve = pvePayload; + sources.pbs = { data: { backups: [{ ...snapshot, inProgress: 'true' }] } }; + await load(); + await notice('PBS').waitFor(); + await rows().first().waitFor(); + assert.equal( + await rows().count(), + 1, + 'a mistyped completion flag must not become recovery evidence', + ); + const before = requests.filter((r) => r.path === '/api/backups/pve').length; + sources.pbs = pbsPayload; + let release; + retryGate = new Promise((resolve) => (release = resolve)); + const retry = page.getByRole('button', { name: 'Retry PBS inventory', exact: true }); + if (phone) await retry.tap(); + else { + await retry.focus(); + await page.keyboard.press('Enter'); + } + assert.equal(await retry.isDisabled(), true); + await notice('PBS').waitFor(); + assert.equal(await rows().count(), 1); + release(); + await page.waitForFunction( + () => document.querySelectorAll('[data-proxmox-backup-row="recoverable"]').length === 2, + ); + retryGate = null; + assert.equal(await notice('PBS').count(), 0); + assert.equal(requests.filter((r) => r.path === '/api/backups/pve').length, before); + await capture('recovered'); + checks.push( + 'Mistyped incomplete flag is rejected; warning persists through keyboard/touch isolated retry until valid recovery', + ); + + sources.pve = { data: { backupTasks: null, storageBackups: null, guestSnapshots: null } }; + sources.pbs = emptyPBS; + await load(); + await page.getByText('No backups yet', { exact: true }).waitFor(); + assert.equal(await page.getByRole('status').filter({ hasText: 'inventory' }).count(), 0); + checks.push('Explicit Go nil PVE collections remain a valid empty observation'); + } + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > window.innerWidth + 1, + ); + assert.equal(overflow, false); + assert.deepEqual(errors, []); + assert.deepEqual(offOrigin, []); + assert(requests.every((r) => r.method === 'GET')); + observations.push({ + name, + browserVersion: browser.version(), + viewport, + checks, + requests, + errors, + offOrigin, + overflow, + screenshots, + }); + await browser.close(); + browser = null; + } + const runtime = [ + 'src/features/proxmox/ProxmoxBackupsTable.tsx', + 'src/features/proxmox/proxmoxBackupInventory.ts', + ].filter((p) => fs.existsSync(path.join(root, p))); + fs.writeFileSync( + path.join(output, 'result.json'), + JSON.stringify( + { + phase, + playwrightVersion, + runtimeSha256: Object.fromEntries(runtime.map((p) => [p, sha256(path.join(root, p))])), + observations, + }, + null, + 2, + ) + '\n', + ); + console.log(`${phase}: ${observations.length} browser contexts passed; receipts at ${output}`); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-verification/78c9d8427bdfa307.json b/frontend-modern/browser-verification/78c9d8427bdfa307.json new file mode 100644 index 0000000000..099102e7a1 --- /dev/null +++ b/frontend-modern/browser-verification/78c9d8427bdfa307.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "base_sha": "f5925942f49b088689695678b98e703af9ead9ef", + "verified_at": "2026-10-03T21:03:10.375831Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx", + "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts" + ], + "content_sha256": { + "frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx": "8e1113c7c9e263d284c86e4c3373381b3d71f6a0161897d9dcc782ce225478d5", + "frontend-modern/src/features/proxmox/proxmoxBackupInventory.ts": "deccbf62dcdbafd447cca0f33b516bf98fddb8c726515a30b85b614d26a37095" + }, + "routes": [ + "/browser-tests/backup-inventory.html?view=date" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "HTTP200 missing PBS envelope stays incomplete, never no-backups/zero", + "Malformed PBS/PVE collections preserve independent source and server health without a rendering failure", + "Mistyped incomplete flags are unavailable, not completed restore points", + "Explicit Go nil PVE collections remain valid empty observations" + ], + "interactions": [ + "Desktop Enter and phone touch isolated retry keep warnings and independent rows while pending, clear only after validated success", + "Eight settled final screenshots and two baseline screenshots opened and inspected; light desktop/dark phone warnings and rows readable, no page overflow" + ], + "playwright_version": "1.56.1", + "browser_versions": [ + { + "engine": "chromium-desktop", + "version": "141.0.7390.37" + }, + { + "engine": "webkit-phone", + "version": "26.0" + } + ], + "dependency_snapshot": "8aaf02a3db8e1c245174d04d132398d1716a69525064975470ff738d168abb82", + "limitations": [ + "Synthetic source responses through production table/router/API/CSS, not native provider collection, permission, restore, QGA/thaw or installed/released acceptance.", + "Production/embed build is separately source-proved; no new route, polling, source cache or authority." + ] +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index e3d51a93b9..c0bf4513ea 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -29,9 +29,7 @@ import type { GuestSnapshot, PBSBackup, PBSBackupsPayload, - PBSBackupsResponse, PVEBackupsPayload, - PVEBackupsResponse, StorageBackup, } from '@/types/api'; import type { Resource } from '@/types/resource'; @@ -70,6 +68,12 @@ import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip'; import { ProxmoxBackupServersTable } from './ProxmoxBackupServersTable'; import { ProxmoxCoverageTable } from './ProxmoxCoverageTable'; import { ProxmoxRecoverableTable } from './ProxmoxRecoverableTable'; +import { + BackupInventoryFormatError, + parsePBSBackupInventory, + parsePVEBackupInventory, + readBackupInventoryJSON, +} from './proxmoxBackupInventory'; // One backups surface, two operator views: a chronological recoverable-artifact // feed for "what ran when", and a guest coverage table for "what is protected". @@ -97,8 +101,7 @@ async function fetchPVEBackups(signal: AbortSignal): Promise status: response.status, }); } - const payload = (await response.json()) as PVEBackupsResponse; - return payload?.data ?? EMPTY_PVE_BACKUPS; + return parsePVEBackupInventory(await readBackupInventoryJSON(response)); } async function fetchPBSBackups(signal: AbortSignal): Promise { @@ -108,8 +111,7 @@ async function fetchPBSBackups(signal: AbortSignal): Promise status: response.status, }); } - const payload = (await response.json()) as PBSBackupsResponse; - return payload?.data ?? EMPTY_PBS_BACKUPS; + return parsePBSBackupInventory(await readBackupInventoryJSON(response)); } export const ProxmoxBackupsTable: Component<{ @@ -702,6 +704,11 @@ export const ProxmoxBackupsTable: Component<{ {label} backup inventory is {query.error() ? 'unavailable' : 'loading'}. Counts are incomplete. {getAPIReadAccessErrorMessage(query.error())} + + {' '} + The response format is invalid. Missing restore points do not mean no backups + exist. + + + +
    + + {(item) => ( +
    +

    {item.label}

    +
    + +
    +
    + )} +
    + + ); +} + +render(() => , document.getElementById('root')!); diff --git a/frontend-modern/browser-verification/e3b706f9d975b05a.json b/frontend-modern/browser-verification/e3b706f9d975b05a.json new file mode 100644 index 0000000000..463b1880c3 --- /dev/null +++ b/frontend-modern/browser-verification/e3b706f9d975b05a.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "base_sha": "ffac59b6dcccaf03f52fb4ba747b6f99a79ba9b0", + "verified_at": "2026-10-03T22:38:46.141Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/stackedDiskBarModel.ts": "9f50f630df8379673032052970e7cc6d287c02ecd212b3df5d16f1032be42413" + }, + "routes": [ + "/browser-tests/composed-disk-labels.html", + "/browser-tests/guest-disk-deferral.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Exact combined deferred-read statusMessage and measured-glyph disk model; production StackedDiskBar, ResizeObserver, GuestRow, Overview, disk list and stylesheet, synthetic observations only. Playwright 1.56.1 matches the integration lock; Chromium 141.0.7390.37 light desktop and WebKit 26.0 dark 390px phone.", + "100px bars retain percentages and omit optional capacity detail; equal inline slots retain iiii 67% but drop the wider MMMM label. 320px bars show whole capacity, anomaly, fullest-disk/count and inline labels without ellipsis. The micro-bars keep a truthful 67% highest-disk label.", + "Paused guest evidence retains last-known disk values and caller-owned VM-operation-lock context in row, Overview, disk list and desktop tooltip. Same-VM fresh observations clear stale context without remount; row, Overview and disk list settle at 75%. All eight final screenshots inspected.", + "Phone drawer rendering is verified using an emulated mouse click on the guest name. Separate trusted WebKit row/name taps emitted no click and did not open the drawer; this pass does not establish touch acceptance or clear that adverse observation. No native collection, QGA/thaw, installation or release claim." + ], + "interactions": [ + "Chromium keyboard focus and Enter / WebKit tap on the native Roomy button drive ResizeObserver from 100px to 320px. Independent DOM scrollWidth/clientWidth checks verify rendered label fit.", + "Desktop hover reads paused provenance beside unchanged /data evidence, then confirms fresh same-disk update removes only that status. Production GuestRow/Overview/list callers are exercised at both viewports; the phone guest is opened by mouse click, not a claimed tap success.", + "Fresh source update preserves the same row node, clears stale status, updates the Overview progressbar and waits for AnimatedNumber to settle at 75% before capture. No page error, off-origin request, non-GET request or horizontal page overflow." + ] +} diff --git a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx index b7f88cf332..15bb7087d2 100644 --- a/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx +++ b/frontend-modern/src/features/docker/__tests__/DockerNativeTables.test.tsx @@ -1516,7 +1516,10 @@ describe('Docker native tables', () => { expect(screen.getByText('2')).toBeInTheDocument(); // The snake_case rollout token reads as words; the raw token stays in the title. expect(screen.getByText('Rollback started')).toBeInTheDocument(); - expect(screen.getByText('Rollback started')).toHaveClass('whitespace-normal', 'wrap-break-word'); + expect(screen.getByText('Rollback started')).toHaveClass( + 'whitespace-normal', + 'wrap-break-word', + ); expect( screen.getByText('Rollback started').closest('[title]')?.getAttribute('title'), ).toContain('rollback_started'); From e597bb2a4beba8679782a453e0653bf53d3f743d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:04:36 +0100 Subject: [PATCH 86/86] Keep release governance aligned with billing-safe demo profiles PR2429 governance run 37161775883 still required the superseded entitlement rewrite after reviewed repair 7d739698 made profile admission read-only. Require the existing demo capability and reject the former integrity removal and billing rewrite instead. Retain runtime-setting, release and recovery guards unchanged. Contract-Neutral: Test-only integration reconciliation; production billing and release admission are unchanged. Change-source: pulse-maintainer --- .../release_control/release_promotion_policy_test.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 351a017957..f68fbb09d2 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -2643,15 +2643,20 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("MOCK_SEED_DURATION=2h", demo_profile) self.assertIn("MOCK_SAMPLE_INTERVAL=5m", demo_profile) self.assertIn("MOCK_UPDATE_INTERVAL=15s", demo_profile) - # Profile application moved into the owned transaction; keep its - # runtime settings and entitlement, not the superseded shell snippets. + # Profile application moved into the owned transaction; retain runtime + # settings but require an existing capability without rewriting billing. transaction = read(".github/scripts/demo-runtime-transaction.py") for required in ('"DEMO_MODE": "true"', '"PULSE_MOCK_MODE": "true"', '"PULSE_MOCK_SEED_METRICS_STORE": "false"', '"seed_duration": "trends_seed_duration"', '"sample_interval": "trends_sample_interval"', - '"PULSE_MOCK_" + key.upper()', '["demo_fixtures"]', 'billing.pop("integrity", None)', + '"PULSE_MOCK_" + key.upper()', 'self.profile_admission()', + 'if "demo_fixtures" not in billing["capabilities"]:', + 'raise Failure("billing-demo-capability-required")', 'self.host.profile(self.request, not healthy_baseline)', 'self.host.start()'): self.assertIn(required, transaction) + self.assertNotIn('billing.pop("integrity", None)', transaction) + self.assertNotIn('billing["capabilities"] = ["demo_fixtures"]', transaction) + self.assertNotIn('atomic_json(billing_file, billing)', transaction) self.assertIn("MOCK_NODES: ${{ needs.resolve.outputs.mock_nodes }}", demo) self.assertIn("MOCK_UPDATE_INTERVAL: ${{ needs.resolve.outputs.mock_update_interval }}", demo) self.assertIn("/api/license/runtime-capabilities", demo)