From ee618fb99366bb76c798f8ada028de85079d4dfa Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:16:03 +0100 Subject: [PATCH] Guard canonical VM Discovery during guest-read deferrals Use the existing guest safety signals in the canonical resource drawer, including typed and legacy PVE facets. Keep saved Discovery evidence readable and the backup precaution visible without admitting a manual rescan while paused. Contract-Neutral: performance-and-scalability; the workload helper input is narrowed without changing its decisions, metrics, render path or performance contract. Change-source: pulse-maintainer --- .../internal/subsystems/unified-resources.md | 24 ++ .../browser-tests/resource-guest-safety.cjs | 312 ++++++++++++++++++ .../browser-tests/resource-guest-safety.html | 12 + .../browser-tests/resource-guest-safety.tsx | 64 ++++ .../cd3f858e636899f4.json | 45 +++ .../Infrastructure/ResourceDetailDrawer.tsx | 19 ++ .../ResourceDetailDrawerOverviewTab.tsx | 1 + .../ResourceDetailDrawer.discovery.test.ts | 75 ++++- ...ResourceDetailDrawer.guest-safety.test.tsx | 135 ++++++++ .../resourceDetailDiscoveryModel.ts | 28 ++ .../useResourceDetailDrawerDerivedState.ts | 7 +- .../components/Workloads/guestDrawerModel.ts | 24 +- 12 files changed, 740 insertions(+), 6 deletions(-) create mode 100644 frontend-modern/browser-tests/resource-guest-safety.cjs create mode 100644 frontend-modern/browser-tests/resource-guest-safety.html create mode 100644 frontend-modern/browser-tests/resource-guest-safety.tsx create mode 100644 frontend-modern/browser-verification/cd3f858e636899f4.json create mode 100644 frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.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 15c3f338e7..94a3586bfc 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -23,6 +23,30 @@ and sort the complete canonical change table while startup and ingestion wait. ## Purpose +### Canonical Discovery honours VM guest-read deferrals + +The canonical resource drawer uses the same fixed PVE VM lock, backup and +read-deferral signals as the Workloads drawer. Typed and legacy PVE facets are +checked independently: an available flag in one cannot cancel a pause in the +other. Only the VM's own provider evidence is used; parent-node locks, an agent +version and a Discovery target are not guest-liveness evidence. Containers, +ordinary agents and unrelated provider facets retain their existing behaviour. + +Both embedded Analysis and the dedicated Discovery entry pass this pause to the +existing manual-run guard. Saved results remain readable; same-ID updates block +or release the existing control without an automatic scan, invented timestamp +or cancellation claim for already-dispatched work. The wrapping precaution and +existing safety-guide link remain visible across drawer tabs. Clearance is not +independent thaw, covered-filesystem writes or safe monitoring resumption. + +`ResourceDetailDrawer.discovery.test.ts` covers both facets, every fixed reason, +precedence and platform isolation. `ResourceDetailDrawer.guest-safety.test.tsx` +mounts the real Discovery client/control, checks same-ID updates and no dispatch +while paused, and preserves normal agent actions. The production browser fixture +`browser-tests/resource-guest-safety.cjs` checks desktop keyboard and narrow touch +with synthetic local responses. No backend, permission, collector or native +recovery guarantee is added. + ### Broadcast bulk reads capture resources and history coordinates together `ListWithMetricsTargets` and the monitor adapter's matching optional read return diff --git a/frontend-modern/browser-tests/resource-guest-safety.cjs b/frontend-modern/browser-tests/resource-guest-safety.cjs new file mode 100644 index 0000000000..d1d4a4a2b8 --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.cjs @@ -0,0 +1,312 @@ +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/resource-guest-safety-settled-proof'; +const paths = [ + 'frontend-modern/src/components/Workloads/guestDrawerModel.ts', + 'frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts', + 'frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts', + 'frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx', + 'frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx', +]; +const reasons = [ + 'vm-locked', + 'lock-unverified', + 'agent-busy', + 'agent-cooldown', + 'agent-response-incomplete', + 'agent-capacity', + 'invalid-guest-key', + 'agent-timeout', +]; +const saved = { + id: 'vm:fixture-node-agent:101', + resource_type: 'vm', + resource_id: '101', + target_id: 'fixture-node-agent', + hostname: 'Backup guest', + service_type: 'home-assistant', + service_name: 'Saved Home Assistant', + service_version: 'test', + category: 'home_automation', + cli_access: '', + facts: [], + config_paths: [], + data_paths: [], + log_paths: [], + ports: [], + user_notes: '', + user_secrets: {}, + confidence: 0.9, + ai_reasoning: 'Synthetic saved evidence', + discovered_at: '2026-10-04T12:00:00Z', + updated_at: '2026-10-04T12:00:00Z', + discovery_engine_version: 1, +}; +const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +(async () => { + fs.mkdirSync(output, { recursive: true }); + const report = { + runtime_sha256: Object.fromEntries(paths.map((p) => [p, hash(path.join('/workspace', p))])), + playwright: require('playwright/package.json').version, + groups: [], + cleanup: {}, + limits: + 'Synthetic local snapshots and HTTP only. No native QGA, diagnostics, backup, thaw, filesystem write, service resumption, physical phone, publication or release acceptance.', + }; + assert.equal( + report.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + const { createServer } = + await import('/workspace/frontend-modern/node_modules/vite/dist/node/index.js'); + let server, browser, phase; + try { + for (const [name, engine, width, dark] of [ + ['final-chromium', chromium, 1365, false], + ['final-webkit-phone', webkit, 320, true], + ]) { + phase = name; + const module = '/workspace/frontend-modern'; + process.chdir(module); + server = await createServer({ + root: module, + configFile: path.join(module, 'vite.config.ts'), + cacheDir: path.join(output, name, 'vite'), + server: { + host: '127.0.0.1', + port: 5299, + strictPort: true, + watch: null, + fs: { allow: ['/workspace'] }, + }, + }); + await server.listen(); + const origin = 'http://127.0.0.1:5299'; + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: width < 500 ? 844 : 900 }, + isMobile: width < 500, + hasTouch: width < 500, + locale: 'en-GB', + }); + page.setDefaultTimeout(10000); + const group = { + name, + width, + dark, + browser: browser.version(), + requests: [], + errors: [], + offOrigin: [], + checks: [], + screenshots: [], + }; + report.groups.push(group); + const check = (label) => group.checks.push(label); + const capture = async (label) => { + const file = path.join(output, `${name}-${label}.png`); + await page.screenshot({ path: file, fullPage: true }); + group.screenshots.push({ + path: file.replace('/workspace/', ''), + sha256: hash(file), + bytes: fs.statSync(file).size, + }); + }; + page.on('pageerror', (e) => group.errors.push(e.message)); + await page.routeWebSocket('**/ws*', () => {}); + await page.route('**/*', async (route) => { + const req = route.request(), + url = new URL(req.url()); + if (url.origin !== origin) { + group.offOrigin.push(url.origin); + return route.abort(); + } + if (!url.pathname.startsWith('/api/')) return route.continue(); + group.requests.push({ + path: url.pathname, + query: url.search, + method: req.method(), + body: req.postData(), + }); + if ( + url.pathname === '/api/discovery/vm/fixture-node-agent/101' || + url.pathname === '/api/discovery/agent/fixture-node-agent/101' + ) + return route.fulfill({ json: saved }); + assert.equal(req.method(), 'GET'); + if (url.pathname.startsWith('/api/discovery/info/')) + return route.fulfill({ + json: { + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + }, + }); + if (url.pathname === '/api/ai/agents') + return route.fulfill({ + json: { + count: 1, + agents: [ + { + agent_id: 'fixture-node-agent', + hostname: 'pve-a', + commands_enabled: true, + connected: 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: [], + }, + }); + return route.fulfill({ + json: { + capabilities: [], + relationships: [], + recentChanges: [], + counts: {}, + audits: [], + count: 0, + data: [], + enabled: false, + }, + }); + }); + await page.goto(`${origin}/browser-tests/resource-guest-safety.html`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await page.evaluate((d) => document.documentElement.classList.toggle('dark', d), dark); + const activate = async (locator) => (width < 500 ? locator.tap() : locator.press('Enter')); + await activate(page.getByRole('tab', { name: 'Manage', exact: true })); + const analysis = page.getByTestId('resource-access-analysis'); + await activate(analysis.getByRole('button', { name: 'Open analysis' })); + await analysis.getByText('Saved Home Assistant', { exact: true }).waitFor(); + const button = analysis.getByRole('button', { name: 'Update Discovery' }); + await button.waitFor(); + const posts = () => group.requests.filter((r) => r.method === 'POST'); + const notice = page.getByTestId('resource-guest-read-precaution'); + await notice.waitFor(); + assert(await button.isDisabled()); + assert.match(await notice.innerText(), /An OK backup or a running VM does not prove thaw/); + assert.equal( + await notice.getByRole('link', { name: 'Backup safety guidance' }).getAttribute('href'), + '/docs/VM_DISK_MONITORING', + ); + const control = await button.elementHandle(); + const update = (evidence, legacy = false, type = 'vm') => + page.evaluate( + ({ evidence, legacy, type }) => + window.__resourceGuestSafety.update(evidence, legacy, type), + { evidence, legacy, type }, + ); + for (const legacy of [false, true]) { + for (const reason of reasons) { + for (const diskStatusReason of [reason, `prev-${reason}`]) { + await update({ diskStatusReason, guestAgentStatus: 'available' }, legacy); + assert(await button.isDisabled()); + assert(await notice.isVisible()); + assert.equal(posts().length, 0); + } + } + for (const evidence of [ + { lock: 'backup' }, + { backupInProgress: true }, + { guestAgentStatus: 'deferred' }, + ]) { + await update(evidence, legacy); + assert(await button.isDisabled()); + } + } + assert(await button.evaluate((node, old) => node === old, control)); + assert.equal(await analysis.getByText('Saved Home Assistant', { exact: true }).count(), 1); + check( + 'All 32 reason/facet variants and six operation variants block the same mounted rescan; saved evidence remains readable; zero POST.', + ); + await update({ diskStatusReason: 'prev-agent-timeout' }, true); + const geometry = await notice.boundingBox(); + assert(geometry && geometry.x >= 0 && geometry.x + geometry.width <= width); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)); + const describedBy = await button.getAttribute('aria-describedby'); + assert(describedBy && (await page.locator(`#${describedBy}`).isVisible())); + await capture('paused-saved'); + check('Disabled rescan is described and narrow geometry has no horizontal overflow.'); + await update({ guestAgentStatus: 'available' }); + assert(await button.isEnabled()); + assert.equal(await notice.count(), 0); + assert(await button.evaluate((node, old) => node === old, control)); + assert.equal(posts().length, 0); + check( + 'Healthy same-VM evidence clears the guard without an automatic scan or remount; no thaw claim.', + ); + await capture('cleared'); + const response = page.waitForResponse( + (r) => + r.url() === `${origin}/api/discovery/vm/fixture-node-agent/101` && + r.request().method() === 'POST', + ); + await activate(button); + await response; + assert.equal(posts().length, 1); + assert.equal(posts()[0].path, '/api/discovery/vm/fixture-node-agent/101'); + check( + 'An explicit permitted keyboard/touch action sends exactly one synthetic local request to the unchanged VM target.', + ); + await update({ lock: 'backup' }); + assert(await button.isDisabled()); + await activate(page.getByRole('tab', { name: 'Overview', exact: true })); + assert(await notice.isVisible()); + check('Precaution and guide stay visible on Overview after leaving Analysis.'); + await update({ lock: 'backup' }, false, 'agent'); + await activate(page.getByRole('tab', { name: 'Discovery', exact: true })); + const agentButton = page.getByRole('button', { name: 'Run Discovery', exact: true }); + await agentButton.waitFor(); + assert(await agentButton.isEnabled()); + assert.equal(await notice.count(), 0); + check('Ordinary agent Discovery tab is not paused by parent-side PVE evidence.'); + assert.deepEqual(group.errors, []); + assert.deepEqual(group.offOrigin, []); + check('No page error, off-origin request, native target or background POST.'); + await browser.close(); + browser = null; + await server.close(); + server = null; + } + } catch (error) { + report.failure = { phase, message: error.message }; + throw error; + } finally { + if (browser) await browser.close(); + if (server) await server.close(); + report.cleanup = { browser_closed: true, server_closed: true }; + fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(report, null, 2)); + } + console.log( + JSON.stringify({ + groups: report.groups.length, + checks: report.groups.reduce((n, g) => n + g.checks.length, 0), + screenshots: report.groups.reduce((n, g) => n + g.screenshots.length, 0), + cleanup: report.cleanup, + }), + ); +})().catch((e) => { + console.error(e.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/resource-guest-safety.html b/frontend-modern/browser-tests/resource-guest-safety.html new file mode 100644 index 0000000000..b641a56e4b --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.html @@ -0,0 +1,12 @@ + + + + + + Canonical guest safety evidence + + +
+ + + diff --git a/frontend-modern/browser-tests/resource-guest-safety.tsx b/frontend-modern/browser-tests/resource-guest-safety.tsx new file mode 100644 index 0000000000..dc048daf34 --- /dev/null +++ b/frontend-modern/browser-tests/resource-guest-safety.tsx @@ -0,0 +1,64 @@ +// Real canonical drawer/state/Discovery clients; synthetic provider snapshots. +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer'; +import { DarkModeContext } from '../src/contexts/appRuntime'; +import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState'; +import type { Resource, ResourceProxmoxMeta } from '../src/types/resource'; +import '../src/index.css'; + +syncAIRuntimeSettings({ discovery_enabled: true }); +const healthy: ResourceProxmoxMeta = { + nodeName: 'pve-a', + vmid: 101, + guestAgentStatus: 'available', + lock: '', + diskStatusReason: '', +}; +const resource = ( + evidence: ResourceProxmoxMeta, + legacy = false, + type: Resource['type'] = 'vm', +): Resource => ({ + id: 'fixture:pve-a:101', + type, + name: 'Backup guest', + displayName: 'Backup guest', + platformType: type === 'agent' ? 'agent' : 'proxmox-pve', + platformScopes: type === 'agent' ? ['agent'] : ['proxmox-pve'], + sources: type === 'agent' ? ['agent'] : ['proxmox'], + sourceType: type === 'agent' ? 'agent' : 'api', + status: 'online', + lastSeen: Date.now(), + cpu: { current: 10 }, + memory: { current: 25, used: 1024, total: 4096 }, + discoveryTarget: { + resourceType: type === 'agent' ? 'agent' : 'vm', + agentId: 'fixture-node-agent', + resourceId: '101', + }, + proxmox: legacy ? healthy : { ...healthy, ...evidence }, + platformData: legacy ? { proxmox: evidence } : undefined, +}); +function Fixture() { + const [value, setValue] = createSignal(resource({ lock: 'backup' })); + (window as any).__resourceGuestSafety = { + update: (evidence: ResourceProxmoxMeta, legacy = false, type: Resource['type'] = 'vm') => + setValue(resource(evidence, legacy, type)), + }; + return ( +
+

Canonical guest safety evidence

+

Synthetic local responses. No guest check or backup is sent.

+ +
+ ); +} +render( + () => ( + document.documentElement.classList.contains('dark')}> + + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/cd3f858e636899f4.json b/frontend-modern/browser-verification/cd3f858e636899f4.json new file mode 100644 index 0000000000..77cb78f9e7 --- /dev/null +++ b/frontend-modern/browser-verification/cd3f858e636899f4.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "90a7d2e0ad08109132bed4747690868861f67e23", + "verified_at": "2026-10-05T16:15:22.197327Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx", + "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts" + ], + "content_sha256": { + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "3d89b12c95fc35bb93fa703a7d10beb06296fb4ed9b9f0c313f22afc0ff0b6e5", + "frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "969e4051af443b50e45443241a8b4e0f9e66a8f7b51c43d64ab4fd9e5c3f9a84", + "frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts": "7958a719302571e07dde329231554c59f2fa2d3d49852486b54e929df2856af8", + "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "8abfb347ac32bdd4cb88676999b523f69d8c8f1ea5febd7736a64ba7b57159b5", + "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "53dc922a8cc70d3e39f51554c581707cd496fa1086290c8ebd6f20fc226933ba" + }, + "routes": [ + "/browser-tests/resource-guest-safety.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 320, + "height": 844 + } + ], + "states": [ + "Real canonical resource drawer/state/Discovery client and CSS, synthetic local snapshots and HTTP only. Parent at the receipt base exposes enabled Update Discovery under backup lock; no parent POST was sent.", + "Desktop Chromium and dark 320px touch WebKit cover every eight fixed reasons, retained variants and typed/legacy facets (32 each), plus backup/lock/deferred signals. A healthy alternate facet never cancels the pause. Same-ID updates retain the mounted control and saved service evidence.", + "First browser attempt exited one on an early dispatch-count assertion after desktop pause/clearance checks; it did not establish dispatch or phone acceptance. Remaining dispatch checks synchronise on the actual POST response; the complete phone matrix and both explicit synthetic actions pass with zero page errors/off-origin requests and closed browser/server.", + "All five captures inspected: parent gap, desktop paused/cleared and phone paused/cleared. Wrapping precaution/guide and saved results fit without horizontal overflow; the disabled rescan has a visible description. Agent Discovery remains usable outside the VM safety condition.", + "This is not native QGA, diagnostics, backup, thaw, covered-filesystem writes, liveness, safe resumption, physical-phone, installation, publication or release acceptance." + ], + "interactions": [ + "Desktop keyboard activation and phone taps use the actual Manage -> Access -> Analysis controls. All paused variants send zero POST; same-ID clearance neither remounts the control nor sends an automatic scan.", + "An explicit healthy VM action sends exactly one synthetic local POST per engine to the existing VM/agent/101 identity. Pause remains visible when leaving Analysis for Overview; ordinary agent Discovery is not paused by host-side PVE evidence.", + "Original desktop matrix/parent observations are retained rather than repeated in the remaining-proof run. Exact executed scripts, results, client exits and screenshot hashes are retained in the lane dossier; production content matches this receipt." + ] +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx index 23ea1a0de7..243092e9dd 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx @@ -31,6 +31,9 @@ import { ResourceOperatorStateSection } from './ResourceOperatorStateSection'; import { MaintenanceVerificationSection } from './MaintenanceVerificationSection'; import { ResourceActionHistory } from './ResourceActionHistory'; import { CONTAINER_CPU_CAPACITY_DESCRIPTION } from '@/features/docker/dockerCpuPresentation'; +import { InlineNotice } from '@/components/shared/InlineNotice'; +import { GUEST_DRAWER_BACKUP_PRECAUTION } from '@/components/Workloads/guestDrawerModel'; +import { getShippedDocUrl } from '@/utils/docsLinks'; interface ResourceDetailDrawerProps { resource: Resource; @@ -159,6 +162,21 @@ const DrawerContent: Component = (props) => {

+ + {(message) => ( + +

{message()}

+

{GUEST_DRAWER_BACKUP_PRECAUTION}

+
+ )} +
+ = (props) => { canonicalResourceId={props.resource.id} commandsEnabled={drawer.agentMeta()?.commandsEnabled} showManualRunAction + runBlockReason={drawer.guestReadPrecaution()} /> )} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index d58b0d7cb3..75ac3bb823 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -337,6 +337,7 @@ export const ResourceAccessDisclosure: Component<{ hostname={config().hostname} canonicalResourceId={props.resource.id} commandsEnabled={props.drawer.agentMeta()?.commandsEnabled} + runBlockReason={props.drawer.guestReadPrecaution()} /> diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index bfa830187a..f13a976d3e 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -5,7 +5,11 @@ import type { ResourceDiscovery } from '@/types/discovery'; import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw'; import discoveryReadinessSource from '@/components/Discovery/discoveryReadiness.ts?raw'; import discoveryTabStateSource from '@/components/Discovery/useDiscoveryTabState.ts?raw'; -import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; +import { + getResourceGuestReadPrecaution, + toDiscoveryConfig, +} from '@/components/Infrastructure/resourceDetailDiscoveryModel'; +import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals'; import { hasMeaningfulDiscoveryContext } from '@/utils/discoveryPresentation'; const baseResource = (): Resource => ({ @@ -28,6 +32,75 @@ const baseResource = (): Resource => ({ }, }); +describe('canonical VM guest-read safety', () => { + const vm = (): Resource => ({ ...baseResource(), type: 'vm' }); + + it.each(guestDiskDeferrals.flatMap(([reason]) => [reason, `prev-${reason}`]))( + 'honours %s from typed and legacy PVE evidence independently', + (diskStatusReason) => { + for (const facet of ['typed', 'legacy']) { + const evidence = { diskStatusReason, guestAgentStatus: 'available' }; + const value = vm(); + if (facet === 'typed') value.proxmox = evidence; + else value.platformData = { proxmox: evidence }; + expect(getResourceGuestReadPrecaution(value)).toBeTruthy(); + expect(getResourceGuestReadPrecaution(value)).not.toContain(diskStatusReason); + } + }, + ); + + it.each([{ lock: 'backup' }, { backupInProgress: true }, { guestAgentStatus: 'deferred' }])( + 'does not let a healthy alternate facet cancel %j', + (paused) => { + const healthy = { guestAgentStatus: 'available', diskStatusReason: '', lock: '' }; + expect( + getResourceGuestReadPrecaution({ + ...vm(), + proxmox: healthy, + platformData: { proxmox: paused }, + }), + ).toBeTruthy(); + expect( + getResourceGuestReadPrecaution({ + ...vm(), + proxmox: paused, + platformData: { proxmox: healthy }, + }), + ).toBeTruthy(); + }, + ); + + it.each(['agent', 'system-container', 'app-container'] as const)( + 'does not turn parent or container PVE evidence into a %s QGA pause', + (type) => { + expect( + getResourceGuestReadPrecaution({ ...vm(), type, proxmox: { lock: 'backup' } }), + ).toBeNull(); + }, + ); + + it('leaves healthy/unrelated VMs alone and withholds unknown raw provider text', () => { + expect(getResourceGuestReadPrecaution(vm())).toBeNull(); + expect( + getResourceGuestReadPrecaution({ ...vm(), proxmox: { guestAgentStatus: 'available' } }), + ).toBeNull(); + expect( + getResourceGuestReadPrecaution({ + ...vm(), + platformType: 'vmware-vsphere', + platformData: { vmware: { lock: 'backup' } }, + }), + ).toBeNull(); + expect( + getResourceGuestReadPrecaution({ + ...vm(), + proxmox: { lock: 'private provider text', diskStatusReason: 'private read reason' }, + }), + ).not.toMatch(/private provider text|private read reason/); + expect(getResourceGuestReadPrecaution({ ...vm(), platformData: { proxmox: [] } })).toBeNull(); + }); +}); + describe('toDiscoveryConfig', () => { it('prefers backend discoveryTarget over heuristic IDs', () => { const resource: Resource = { diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx new file mode 100644 index 0000000000..3f3a1ced6b --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.guest-safety.test.tsx @@ -0,0 +1,135 @@ +import { createSignal, Suspense } from 'solid-js'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { ResourceDetailDrawer } from '../ResourceDetailDrawer'; +import { guestDiskDeferrals } from '@/components/Workloads/__fixtures__/guestDiskDeferrals'; +import { resetAIRuntimeState, syncAIRuntimeSettings } from '@/stores/aiRuntimeState'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; +import * as discoveryApi from '@/api/discovery'; +import { getShippedDocUrl } from '@/utils/docsLinks'; + +vi.mock('@/api/discovery', async (importOriginal) => ({ + ...(await importOriginal()), + getDiscovery: vi.fn(async () => null), + getDiscoveryInfo: vi.fn(async () => ({ + ai_provider: { provider: 'test', model: 'test', is_local: true, label: 'Local' }, + commands: [], + command_categories: [], + })), + getConnectedAgents: vi.fn(async () => ({ count: 1, agents: [{ agent_id: 'node-agent' }] })), + triggerDiscovery: vi.fn(async () => null), +})); +vi.mock('@/api/resources', async (importOriginal) => ({ + ...(await importOriginal()), + ResourceAPI: { + getFacetBundle: vi.fn(async () => ({ + capabilities: [], + relationships: [], + recentChanges: [], + counts: {}, + })), + }, +})); +vi.mock('@/api/ai', () => ({ AIAPI: { getResourceIntelligence: vi.fn(async () => null) } })); +vi.mock('@/api/actionAudit', () => ({ + ActionAuditAPI: { + listActionAudits: vi.fn(async () => ({ audits: [], count: 0, available: false })), + }, +})); + +const vm = (patch: Partial = {}): Resource => ({ + id: 'fixture:pve1:101', + type: 'vm', + name: 'Backup guest', + status: 'online', + sourceType: 'api', + platformType: 'proxmox-pve', + lastSeen: 1, + proxmox: { nodeName: 'pve1', vmid: 101, guestAgentStatus: 'available' }, + discoveryTarget: { resourceType: 'vm', agentId: 'node-agent', resourceId: '101' }, + ...patch, +}); +const mount = (value: () => Resource) => + render(() => ( + + + + )); +const openAnalysis = async () => { + fireEvent.click(await screen.findByRole('tab', { name: 'Manage' })); + fireEvent.click(await screen.findByRole('button', { name: 'Open analysis' })); + return within(screen.getByTestId('resource-access-analysis')).findByRole('button', { + name: 'Run Discovery', + }); +}; + +beforeEach(() => { + resetAIRuntimeState(); + syncAIRuntimeSettings({ discovery_enabled: true }); + resetCreateNonSuspendingQueryCacheForTest(); +}); +afterEach(() => { + cleanup(); + resetAIRuntimeState(); + vi.clearAllMocks(); +}); + +describe('canonical drawer real Discovery safety wiring', () => { + it('blocks every retained/current deferral reactively without remounting or dispatching a scan', async () => { + const [value, setValue] = createSignal(vm({ proxmox: { lock: 'backup' } })); + mount(value); + const button = await openAnalysis(); + await waitFor(() => expect(button).toBeDisabled()); + expect(screen.getByTestId('resource-guest-read-precaution')).toBeVisible(); + expect( + within(screen.getByTestId('resource-guest-read-precaution')).getByRole('link', { + name: 'Backup safety guidance', + }), + ).toHaveAttribute('href', getShippedDocUrl('VM_DISK_MONITORING.md')); + for (const [reason] of guestDiskDeferrals) { + for (const diskStatusReason of [reason, `prev-${reason}`]) { + setValue(vm({ proxmox: { diskStatusReason, guestAgentStatus: 'available' } })); + await waitFor(() => expect(button).toBeDisabled()); + fireEvent.click(button); + } + } + setValue(vm({ platformData: { proxmox: { backupInProgress: true } } })); + await waitFor(() => expect(button).toBeDisabled()); + setValue(vm()); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument(); + expect( + within(screen.getByTestId('resource-access-analysis')).getByRole('button', { + name: 'Run Discovery', + }), + ).toBe(button); + expect(discoveryApi.triggerDiscovery).not.toHaveBeenCalled(); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + expect(discoveryApi.triggerDiscovery).toHaveBeenCalledWith('vm', 'node-agent', '101', { + force: true, + hostname: 'Backup guest', + }); + }); + + it('keeps ordinary agent Discovery tab actions usable despite host-side PVE locks', async () => { + mount(() => + vm({ + id: 'node-agent', + type: 'agent', + sourceType: 'agent', + platformType: 'agent', + sources: ['agent'], + proxmox: { lock: 'backup' }, + discoveryTarget: { resourceType: 'agent', agentId: 'node-agent', resourceId: 'node-agent' }, + }), + ); + fireEvent.click(await screen.findByRole('tab', { name: 'Discovery' })); + const button = await screen.findByRole('button', { name: 'Run Discovery' }); + await waitFor(() => expect(button).toBeEnabled()); + expect(screen.queryByTestId('resource-guest-read-precaution')).not.toBeInTheDocument(); + fireEvent.click(button); + await waitFor(() => expect(discoveryApi.triggerDiscovery).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts index c7c915a6e7..d9a5dbd404 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDiscoveryModel.ts @@ -1,4 +1,5 @@ import type { Resource } from '@/types/resource'; +import { getGuestDrawerGuestReadPrecaution } from '@/components/Workloads/guestDrawerModel'; import type { ResourceType as DiscoveryResourceType } from '@/types/discovery'; import { canonicalDiscoveryResourceType, @@ -64,6 +65,33 @@ type PlatformData = { const asString = (value: unknown): string | undefined => typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +// The canonical drawer has both typed and legacy provider facets. A healthy +// facet cannot cancel a deferral still reported by the other one. Inspect only +// this VM's own PVE evidence, not its parent, Discovery target or cached version. +export const getResourceGuestReadPrecaution = (resource: Resource): string | null => { + if (resource.type !== 'vm') return null; + const legacy = resource.platformData?.proxmox; + const facets = [ + resource.proxmox, + legacy && typeof legacy === 'object' && !Array.isArray(legacy) + ? (legacy as Record) + : undefined, + ]; + for (const facet of facets) { + if (!facet) continue; + const precaution = getGuestDrawerGuestReadPrecaution({ + type: 'vm', + platformScopes: ['proxmox-pve'], + guestAgentStatus: asString(facet.guestAgentStatus), + diskStatusReason: asString(facet.diskStatusReason), + lock: asString(facet.lock), + backupInProgress: facet.backupInProgress === true, + }); + if (precaution) return precaution; + } + return null; +}; + const asNumber = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined; diff --git a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts index 0131b0df70..66e329b8e3 100644 --- a/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts +++ b/frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts @@ -41,7 +41,10 @@ import { type KubernetesPlatformData, type PlatformData, } from '@/components/Infrastructure/resourceDetailMappers'; -import { toDiscoveryConfig } from '@/components/Infrastructure/resourceDetailDiscoveryModel'; +import { + getResourceGuestReadPrecaution, + toDiscoveryConfig, +} from '@/components/Infrastructure/resourceDetailDiscoveryModel'; import { getDiscoveryIdentifiedSummary } from '@/utils/discoveryPresentation'; import { buildPbsActiveTasks, @@ -298,6 +301,7 @@ export const useResourceDetailDrawerDerivedState = ( const hasAliasOverflow = createMemo(() => identityView().hasAliasOverflow); const hasMergedSources = createMemo(() => mergedSources().length > 1); const discoveryConfig = createMemo(() => toDiscoveryConfig(resource())); + const guestReadPrecaution = createMemo(() => getResourceGuestReadPrecaution(resource())); const discoveryContextSummary = createMemo(() => buildDiscoveryContextSummary(discoveryConfig())); const discoverySourceKey = createMemo(() => { if (!discoveryFeatureEnabled()) return null; @@ -517,6 +521,7 @@ export const useResourceDetailDrawerDerivedState = ( metricsHistoryGroups, hasMetricsHistory, hasDiscoveryTab, + guestReadPrecaution, hasVMwareDetails, vmwareDetailsSummary, vmwareDetailSections, diff --git a/frontend-modern/src/components/Workloads/guestDrawerModel.ts b/frontend-modern/src/components/Workloads/guestDrawerModel.ts index 974a0b8f84..5874731709 100644 --- a/frontend-modern/src/components/Workloads/guestDrawerModel.ts +++ b/frontend-modern/src/components/Workloads/guestDrawerModel.ts @@ -439,6 +439,21 @@ export interface GuestDrawerGuestReadPresentation { precaution: boolean; } +// Both drawer shapes use the same provider-owned signals. A canonical resource +// need not fabricate a complete workload (or infer liveness from metrics). +export type GuestDrawerGuestReadEvidence = Pick< + Guest, + | 'type' + | 'workloadType' + | 'agentKind' + | 'platformType' + | 'platformScopes' + | 'guestAgentStatus' + | 'diskStatusReason' + | 'lock' + | 'backupInProgress' +>; + export const GUEST_DRAWER_BACKUP_PRECAUTION = 'Do not run live diagnostics or restart the guest agent during a backup, freeze/thaw or an unresponsive-guest incident. An OK backup or a running VM does not prove thaw. Confirm thaw and writes to the filesystems covered by the backup independently.'; @@ -457,9 +472,9 @@ const guestReadDeferrals = new Set([ // parent-agent action target establishes QGA liveness. Use only the current // provider state and fixed read reasons; never print an unknown raw value. export const getGuestDrawerGuestReadPresentation = ( - guest: Guest, + guest: GuestDrawerGuestReadEvidence, ): GuestDrawerGuestReadPresentation | null => { - if (!isGuestDrawerVM(guest)) return null; + if (resolveWorkloadType(guest) !== 'vm') return null; const hasProxmoxEvidence = guest.type === 'qemu' || guest.agentKind === 'qemu-guest' || @@ -545,8 +560,9 @@ export const getGuestDrawerGuestReadPresentation = ( // A read-specific precaution is independent of the reported availability flag. // For example Proxmox can report QGA enabled while its last request timed out. -export const getGuestDrawerGuestReadPrecaution = (guest: Guest): string | null => { - if (!isGuestDrawerVM(guest)) return null; +export const getGuestDrawerGuestReadPrecaution = ( + guest: GuestDrawerGuestReadEvidence, +): string | null => { const presentation = getGuestDrawerGuestReadPresentation(guest); if (!presentation?.precaution) return null; const reason = (guest.diskStatusReason || '').replace(/^prev-/, '');