diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 1398425542..ac46ce3f9e 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -77,7 +77,7 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid | `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* | | `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* | | `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` | -| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` | +| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disable container image-update actions; does not disable start/stop/restart | `false` | | `PULSE_METRICS_DB_PATH` | Optional path for only `metrics.db`, useful with tmpfs | `/data/metrics.db` | | `PULSE_METRICS_ROLLUP_INTERVAL` | Metrics aggregation cadence; minimum 5 minutes | `15m` | @@ -169,25 +169,48 @@ an image change alone does not undo data migrations. ## 🔄 Docker / Podman Updates -Pulse can detect and apply updates to your Docker / Podman containers directly from the UI. +These actions update **monitored containers**, not the Pulse server. Pulse can +detect image updates without command execution; applying an update requires a +separately enabled command channel and administrator review. Leave command +execution disabled when you only need monitoring. + +### Before updating a workload + +- Plan for downtime: a running container is stopped and replaced, not updated + in place. Check the application's upgrade and data-migration requirements. +- Take an independent, consistent backup of its application data, including + volumes and bind mounts, using the application's backup procedure. The + renamed old container is **not a backup of mounted data**: the replacement + uses those same mounts and can change their contents. +- Keep the previous image identity and your deployment definition privately. + For Compose, Yacht or another deployment manager, prefer that manager's + update procedure so its saved definition records the intended image. Pulse's + recreation does not edit your Compose file or manager's desired state. +- Do not use an update as a diagnostic test or start another container against + the same writable data to check a failure. ### How It Works 1. **Update Detection**: Pulse compares the local image digest with the latest digest from the container registry 2. **Visual Indicator**: Containers with available updates show a blue upward arrow icon -3. **Reviewed Update**: Click the update button, approve the reviewed action, and Pulse handles the rest +3. **Reviewed Update**: Review the target and approve the action, then verify the application yourself ### Updating a Container -1. Navigate to the **Workloads** page (or filter by Docker sources on **Infrastructure**) +1. Open **Docker → Overview** (`/docker/overview`) and find the host and container 2. Look for containers with a blue update arrow (⬆️) 3. Click the update button and approve the action in the review dialog (admin approval required) -4. Pulse will: +4. Pulse attempts to: - Pull the latest image - - Stop the current container - - Create a backup (renamed with `_pulse_backup_` suffix) - - Start a new container with the same configuration - - Clean up the backup after 15 minutes (if the update succeeds) + - Stop the current container if it was running + - Rename the original container with the `_pulse_backup_` suffix + - Recreate the container from its inspected configuration, reusing its data mounts + - Start it only if the original was running, then perform a short runtime check + - Schedule removal of the renamed old container after a successful update + +Afterwards, check the actual image, container state, application readiness and +data through your normal administration tools. A successful action is not an +end-to-end application or data-integrity test. ### Batch Updates @@ -195,9 +218,29 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd ### Safety Features -- **Automatic Backup**: The old container is renamed, not deleted, until the update succeeds -- **Rollback on Failure**: If the new container fails to start, the old one is restored -- **Configuration Preserved**: Networks, volumes, ports, environment variables are all preserved +- **Temporary old container**: the `_pulse_backup_` name retains the old + container, not a separate copy of its volumes or bind mounts. Current agents + schedule removal **five minutes after a successful update**; periodic cleanup + also removes old backup containers. Do not rely on a 15-minute recovery window + or on a remaining container as a durable backup. +- **Best-effort rollback**: when recreation, network attachment or the early + runtime check fails, Pulse attempts to restore the original name and running + state. Removal, rename or restart can fail too. A failed banner does not prove + that the original container is running or that no change occurred. +- **Limited verification**: the running replacement is inspected after a short + wait and rejected if stopped or explicitly unhealthy. A missing healthcheck + or a `starting` health status is not proof that the application is ready. +- **Inspected configuration**: Pulse reuses container settings and mounts; it + does not reconcile your deployment manager or roll back application data + migrations. Verify networks, ports, mounts and the application after the change. + +If the result is failed or uncertain, **check the current state before retrying**. +Retain the action error and time, old/new container and image identities, and +any rollback error. Inspect these locally; do not post full `docker inspect` +output, environments or registry credentials. Do not delete the old container +or volumes to clear a warning. Recover through the application's and deployment +manager's procedures using a verified matching data backup where required; +returning to the old image alone does not undo a data migration. ### Requirements @@ -231,9 +274,9 @@ Pulse provides granular control over update features via environment variables o | Variable | Description | |----------|-------------| -| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hides update buttons from the UI while still detecting updates. Use this for "read-only" monitoring. | +| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disables image-update actions while still detecting updates. Start/stop/restart have separate controls. | -**Example - Read-Only Mode** (detect updates but prevent actions): +**Example - Disable image updates** (continue detecting updates): ```yaml services: pulse: @@ -242,6 +285,12 @@ services: - PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true ``` +This setting is not a monitoring-only security boundary: it does not disable +container lifecycle actions or revoke agent command permissions. For +monitoring-only installs, leave command execution disabled on the agent and do +not grant command execution permission to monitoring tokens. See +[container lifecycle requirements](#️-container-lifecycle-actions). + To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**. You can also toggle "Hide Docker Update Buttons" from the UI in **Settings → System → General** under **Docker / Podman updates**. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0bf42b1a70..7760e00f05 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -623,6 +623,20 @@ scrolling. Explicit caller-owned native listeners remain caller-owned. isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance. The production PBS table/drawer browser fixture exercises first-tap disclosure and History in WebKit phone emulation, not installed collection or devices. +The custom Workloads `GuestRow` shell reuses `Table`'s exported +`nativeRowClickTarget` marker, conditional on its own action, without inheriting +shared row styling or changing alert/highlight treatment. Its name and row body +must open the full guest drawer on the first touch even when the compact-layout +disclosure button is visually hidden. Keep the action document-delegated; +nested disclosure SVGs, external links and keyboard controls must still perform +only their own action. Removing the action removes the native marker, and +re-enabling it must work without replacing the row. Pointer-up and touch-end +alone never perform the action. `GuestRow.test.tsx` covers native binding, +non-activation, dynamic removal and child ordering; +`browser-tests/guest-row-touch.cjs` covers trusted row/name taps, the production +full drawer's backup/read guidance, nested controls and shared-row compatibility +on Chromium desktop/phone and WebKit phone VM/CT fixtures. This is emulated +input/presentation proof, not physical-device, native thaw or released acceptance. `PlatformResourceDetailToggleButton` is the desktop disclosure affordance and is visually removed on phone layouts where the complete row is the touch target; provider tables must not add a second mobile chevron. When row activation 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 1aa8041768..db65ab3598 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -198,6 +198,15 @@ poller, per-agent goroutine, inventory scan, or action-session lookup to normal request routing; local assets are streamed once and published fallbacks retain the existing bounded HTTP client and artifact-size checks. +The custom Workloads guest row shares `Table`'s singleton non-activating native +click marker for WebKit touch eligibility. Bind it only while a row action +exists, without changing row identity, alert/highlight classes, metric derivation +or delegated child-control ordering. The marker must not perform actions, read +telemetry, start History work, add polling/timers or activate on pointer-up or +touch-end. `GuestRow.test.tsx` verifies its conditional lifecycle and +non-activation; the production row/full-drawer touch fixture verifies first +activation and unchanged identity independently of native collector acceptance. + ## Canonical Files 1. `pkg/metrics/store.go` diff --git a/frontend-modern/browser-tests/docker-update-guidance.cjs b/frontend-modern/browser-tests/docker-update-guidance.cjs new file mode 100644 index 0000000000..bfddf3c4ae --- /dev/null +++ b/frontend-modern/browser-tests/docker-update-guidance.cjs @@ -0,0 +1,131 @@ +// Production Docs viewer and shipped Docker guide; no container action is run. +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', 'docker-update-guidance-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-docker-update-guidance'), + server: { host: '127.0.0.1', port: 5250, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + let browser; + const results = []; + try { + await server.listen(); + for (const [engine, width, dark] of [ + ['chromium', 1280, false], + ['webkit', 390, true], + ]) { + 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: 60000, + }); + await page.evaluate(() => { + history.pushState({}, '', '/docs/DOCKER#before-updating-a-workload'); + dispatchEvent(new PopStateEvent('popstate')); + }); + if (dark) await page.evaluate(() => document.documentElement.classList.add('dark')); + const waitForHeading = async (id) => { + await page.waitForFunction((target) => { + const heading = document.getElementById(target); + if (!heading) return false; + const rect = heading.getBoundingClientRect(); + return document.activeElement === heading && rect.top >= 0 && rect.top < innerHeight; + }, id); + assert.ok( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), + 'page must not overflow the viewport', + ); + }; + await waitForHeading('before-updating-a-workload'); + // Theme colours transition after the root class changes. Capture settled + // content, not an intermediate grey background on the phone fixture. + await page.evaluate( + () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + await page.waitForFunction(() => document.getAnimations().length === 0); + const article = await page.locator('article').innerText(); + for (const text of [ + 'independent, consistent backup', + 'not a backup of mounted data', + 'five minutes after a successful update', + 'Removal, rename or restart can fail too', + 'check the current state before retrying', + 'not a monitoring-only security boundary', + ]) + assert.ok(article.replace(/\s+/g, ' ').includes(text), text); + await page.screenshot({ path: path.join(artifacts, `${engine}-precautions.png`) }); + await page.evaluate(() => { + history.pushState({}, '', '/docs/DOCKER#safety-features'); + dispatchEvent(new PopStateEvent('popstate')); + }); + await waitForHeading('safety-features'); + await page.screenshot({ path: path.join(artifacts, `${engine}-limits.png`) }); + const link = page.getByRole('link', { + name: 'container lifecycle requirements', + exact: true, + }); + const target = decodeURIComponent((await link.getAttribute('href')).split('#')[1]); + await link.focus(); + await page.keyboard.press('Enter'); + await waitForHeading(target); + assert.equal( + await page + .getByRole('heading', { name: '▶️ Container Lifecycle Actions', exact: true }) + .textContent(), + '▶️ Container Lifecycle Actions', + ); + assert.deepEqual(errors, []); + results.push({ + engine, + version: browser.version(), + width, + dark, + precautionsAndLimitsRendered: true, + keyboardRequirementsLink: true, + headingsFocusedAndVisible: true, + noHorizontalOverflow: true, + errors, + }); + await browser.close(); + browser = undefined; + } + const result = { + result: 'passed', + playwright: require('playwright/package.json').version, + scope: 'shipped help rendering and keyboard navigation, not native updates or rollback', + 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/browser-tests/guest-row-touch.cjs b/frontend-modern/browser-tests/guest-row-touch.cjs new file mode 100644 index 0000000000..b75faf4c31 --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.cjs @@ -0,0 +1,299 @@ +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-row-touch'; +const origin = 'http://127.0.0.1:5313'; +const runtime = [ + 'frontend-modern/src/components/Workloads/GuestRow.tsx', + 'frontend-modern/src/components/shared/Table.tsx', +]; +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + +(async () => { + fs.mkdirSync(output, { recursive: true }); + const result = { + playwright: require('playwright/package.json').version, + runtime_hashes: Object.fromEntries(runtime.map((file) => [file, sha256('/workspace/' + file)])), + results: [], + screenshots: [], + result: 'incomplete', + }; + assert.equal( + result.playwright, + JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[ + 'node_modules/@playwright/test' + ].version, + ); + 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: output + '/cache', + server: { host: '127.0.0.1', port: 5313, strictPort: true }, + }); + let browser; + try { + await server.listen(); + for (const [name, engine, width, dark, kinds] of [ + ['webkit-phone', webkit, 390, true, ['qemu', 'lxc']], + ['chromium-desktop', chromium, 1365, false, ['qemu', 'lxc']], + ['chromium-phone', chromium, 390, false, ['qemu']], + ['webkit-narrow', webkit, 360, false, ['lxc']], + ]) { + browser = await engine.launch( + engine === chromium + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const context = await browser.newContext({ + viewport: { width, height: width <= 768 ? 844 : 900 }, + isMobile: width <= 768, + hasTouch: width <= 768, + locale: 'en-GB', + timezoneId: 'UTC', + }); + for (const kind of kinds) { + const page = await context.newPage(); + page.setDefaultTimeout(15000); + page.setDefaultNavigationTimeout(60000); + const record = { + name, + kind, + browser: browser.version(), + viewport: { width, height: width <= 768 ? 844 : 900 }, + dark, + errors: [], + requests: [], + events: [], + checks: [], + }; + result.results.push(record); + page.on('pageerror', (error) => record.errors.push(error.message)); + await page.route('**/*', (route) => { + const request = route.request(); + const url = new URL(request.url()); + record.requests.push({ + method: request.method(), + origin: url.origin, + path: url.pathname, + }); + if (url.origin !== origin) return route.abort(); + if (url.pathname.startsWith('/api/')) + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: [], + alerts: [], + anomalies: [], + config: null, + }), + }); + return route.continue(); + }); + await page.addInitScript((isDark) => { + window.__inputEvents = []; + for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'click']) + document.addEventListener( + type, + (event) => + window.__inputEvents.push({ + type, + tag: event.target.tagName, + trusted: event.isTrusted, + pointerType: event.pointerType, + }), + true, + ); + document.addEventListener('DOMContentLoaded', () => { + if (isDark) document.documentElement.classList.add('dark'); + }); + }, dark); + await page.goto(`${origin}/browser-tests/guest-row-touch.html?kind=${kind}`); + await page.waitForFunction(() => window.__guestRowTouch); + assert.equal(await page.evaluate(() => innerWidth), width); + const row = page.locator('[data-guest-id]'); + const nameTarget = row.getByText('backup-guest', { exact: true }); + const drawer = page.getByRole('region', { name: 'Guest details' }); + const disclosure = row.locator('[data-row-action]'); + await page.evaluate(() => { + window.__originalGuestRow = document.querySelector('[data-guest-id]'); + }); + const state = () => + page.evaluate(() => ({ + count: window.__guestRowTouch.actionCount(), + expanded: window.__guestRowTouch.expanded(), + closeCount: window.__guestRowTouch.closeCount(), + disclosureExpanded: document + .querySelector('[data-row-action]') + ?.getAttribute('aria-expanded'), + sameRow: document.querySelector('[data-guest-id]') === window.__originalGuestRow, + })); + const activate = async (target) => { + if (width <= 768) await target.tap(); + else await target.click(); + await page.waitForTimeout(650); + }; + const expectState = async (count, expanded) => { + const value = await state(); + record.state = value; + record.events = await page.evaluate(() => window.__inputEvents); + assert.equal(value.count, count, `${name}/${kind}: single row activation`); + assert.equal(value.expanded, expanded); + assert.equal(value.disclosureExpanded, String(expanded)); + assert.equal(value.sameRow, true); + }; + try { + await activate(width === 360 ? row : nameTarget); + await expectState(1, true); + assert(record.events.some((event) => event.type === 'click' && event.trusted)); + if (width <= 768) { + assert(record.events.some((event) => event.type === 'touchend' && event.trusted)); + assert.equal(await row.getAttribute('data-history-lens-active'), null); + } + await drawer.getByText('No completed backup found', { exact: true }).waitFor(); + await drawer.getByText('Running · not completed yet', { exact: true }).waitFor(); + if (kind === 'qemu') + await drawer.getByText(/Using last known disk stats\. Guest reads paused/).waitFor(); + assert.equal( + await disclosure.getAttribute('aria-controls'), + await drawer.getAttribute('id'), + ); + assert.equal(await row.getAttribute('tabindex'), null); + assert.equal(await row.getAttribute('aria-expanded'), null); + assert.equal( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + true, + ); + record.checks.push( + `first trusted ${width === 360 ? 'row-body' : 'name'} activation opens production full drawer with safe backup/read context`, + ); + const screenshot = `${output}/${name}-${kind}-open.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + result.screenshots.push({ + path: path.relative('/workspace', screenshot), + sha256: sha256(screenshot), + }); + await activate(width === 360 ? row : nameTarget); + await expectState(2, false); + await activate(row); + await expectState(3, true); + await activate(page.getByRole('button', { name: 'Collapse backup-guest details' })); + await expectState(3, false); + assert.equal((await state()).closeCount, 1); + record.checks.push( + 'repeated name and row-centre activation toggle once; nested full-drawer collapse stays independent', + ); + + await disclosure.focus(); + await page.keyboard.press('Enter'); + await expectState(4, true); + await page.keyboard.press('Space'); + await expectState(5, false); + await disclosure.locator('svg').click(); + await expectState(6, true); + // Keyboard focus makes the compact disclosure button visible and + // can consume the name's space; the row body stays the touch target. + await activate(row); + await expectState(7, false); + record.checks.push( + 'native disclosure keyboard Enter/Space and nested SVG click do not double-activate', + ); + + if (width >= 1024) { + // The adjacent link is intentionally hidden in condensed phone + // identity. Exercise its real wide-layout action, preventing only + // external navigation, not production propagation handling. + const link = row.getByRole('link', { name: 'Open web interface for backup-guest' }); + await link.evaluate((anchor) => + anchor.addEventListener('click', (event) => event.preventDefault()), + ); + await activate(link.locator('svg')); + await expectState(7, false); + record.checks.push( + 'real external-link SVG retains its separate action; navigation prevented in fixture only', + ); + } + + await page.evaluate(() => window.__guestRowTouch.enabled(false)); + assert.equal(await disclosure.count(), 0); + await activate(row); + assert.equal((await state()).count, 7); + assert.equal((await state()).expanded, false); + await page.evaluate(() => window.__guestRowTouch.enabled(true)); + await activate(row); + await expectState(8, true); + record.checks.push( + 'disabling/re-enabling the row action preserves node identity and first-tap activation', + ); + assert.deepEqual(record.errors, []); + assert( + record.requests.every( + (request) => request.origin === origin && request.method === 'GET', + ), + ); + record.result = 'passed'; + } catch (error) { + record.result = 'failed'; + record.error = error.message; + const screenshot = `${output}/${name}-${kind}-failed.png`; + await page.screenshot({ path: screenshot, fullPage: true }); + result.screenshots.push({ + path: path.relative('/workspace', screenshot), + sha256: sha256(screenshot), + }); + throw error; + } finally { + await page.close(); + } + } + // Exporting the compatibility marker must preserve the original shared + // TableRow's own single-action and embedded-control ordering. + const page = await context.newPage(); + await page.goto(`${origin}/browser-tests/table-row-touch.html`); + const sharedState = () => page.getByRole('status', { name: 'Action state' }).textContent(); + const shared = page.locator('[data-testid="control-row"]'); + if (width <= 768) await shared.getByText('Shared resource name', { exact: true }).tap(); + else await shared.getByText('Shared resource name', { exact: true }).click(); + const sharedValue = JSON.parse(await sharedState()); + assert.equal(sharedValue.rowActions, 1); + await shared.getByRole('button', { name: 'Child action' }).click(); + const childValue = JSON.parse(await sharedState()); + assert.equal(childValue.rowActions, 1); + assert.equal(childValue.childActions, 1); + result.results.push({ + name, + control: 'shared-TableRow', + single: sharedValue, + child: childValue, + result: 'passed', + }); + await page.close(); + await context.close(); + await browser.close(); + browser = null; + } + result.result = 'passed'; + result.verified_at = new Date().toISOString(); + console.log( + 'PASS: trusted guest row/disclosure activation, full drawer safety context and child isolation', + ); + } catch (error) { + result.result = 'failed'; + result.error = error.message; + throw error; + } finally { + fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n'); + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { + console.error(error.stack); + process.exitCode = 1; +}); diff --git a/frontend-modern/browser-tests/guest-row-touch.html b/frontend-modern/browser-tests/guest-row-touch.html new file mode 100644 index 0000000000..c3c2c52ed3 --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.html @@ -0,0 +1,12 @@ + + + + + + Guest row touch verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-row-touch.tsx b/frontend-modern/browser-tests/guest-row-touch.tsx new file mode 100644 index 0000000000..539499c43e --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.tsx @@ -0,0 +1,123 @@ +// Production guest row and full drawer with synthetic observations. +// No collector, backup command, QGA/thaw, installed device 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 { buildSummaryDisclosureControlsId } from '../src/components/shared/summaryInteractionA11y'; +import type { WorkloadTableLayoutMode } from '../src/components/Workloads/guestRowModel'; +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: true, + reason: 'prev-vm-locked', + }); + const [closeCount, setCloseCount] = createSignal(0); + const [actionCount, setActionCount] = createSignal(0); + const [expanded, setExpanded] = createSignal(false); + const [enabled, setEnabled] = createSignal(true); + const [layout, setLayout] = createSignal( + window.innerWidth <= 768 ? 'phone' : 'wide', + ); + const toggle = () => { + setActionCount((count) => count + 1); + setExpanded((value) => !value); + }; + 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).__guestRowTouch = { + update: setObservation, + closeCount, + actionCount, + expanded, + enabled: setEnabled, + layout: setLayout, + identity: () => guest().id, + }; + return ( +
+

Guest row touch verification

+

Synthetic observations; no backup command is sent.

+ + + + +
+ +
+ { + setCloseCount((count) => count + 1); + setExpanded(false); + }} + /> +
+
+
+ ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/7139f2120612aedc.json b/frontend-modern/browser-verification/7139f2120612aedc.json new file mode 100644 index 0000000000..604657fe02 --- /dev/null +++ b/frontend-modern/browser-verification/7139f2120612aedc.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "base_sha": "888d918ba1a0a4bf448542630bc0f47af72e4572", + "verified_at": "2026-10-03T23:27:41.013Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx", + "frontend-modern/src/components/shared/Table.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "1096ad3669baea8bec5c6a80b016b64f2c428d2e5a0b7c85746ff6bf21e103fa", + "frontend-modern/src/components/shared/Table.tsx": "014d9c4382d4de03a547f171e410fb9207686bf00dd76695b50c98249d6eacbd" + }, + "routes": [ + "/browser-tests/guest-row-touch.html?kind=qemu", + "/browser-tests/guest-row-touch.html?kind=lxc", + "/browser-tests/table-row-touch.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 360, + "height": 844 + } + ], + "states": [ + "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.", + "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.", + "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.", + "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately." + ], + "interactions": [ + "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.", + "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.", + "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.", + "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors." + ] +} diff --git a/frontend-modern/browser-verification/9596a94267f10b6d.json b/frontend-modern/browser-verification/9596a94267f10b6d.json new file mode 100644 index 0000000000..b18aa6a289 --- /dev/null +++ b/frontend-modern/browser-verification/9596a94267f10b6d.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "d856689f5b3b200c942db4151a0b657cfdacf1c4", + "verified_at": "2026-10-03T23:44:57.288Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "e531b8c4584a8a8f4c76a3205b472ae6cc41c15780cf70a0205b06f7bdf553ed" + }, + "routes": [ + "/browser-tests/guest-row-touch.html?kind=qemu", + "/browser-tests/guest-row-touch.html?kind=lxc", + "/browser-tests/table-row-touch.html" + ], + "viewports": [ + { + "width": 1365, + "height": 900 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 360, + "height": 844 + } + ], + "states": [ + "Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.", + "Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.", + "Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.", + "Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately.", + "Final GuestRow uses typed splitProps forwarding, matching the shared TableRow native/delegated pattern after the initial JSX lint failure. No lint rule was disabled; all browser interaction checks rerun on these exact final bytes." + ], + "interactions": [ + "Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.", + "Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.", + "Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.", + "Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors." + ] +} diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index 1398425542..ac46ce3f9e 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -77,7 +77,7 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid | `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* | | `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* | | `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` | -| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` | +| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disable container image-update actions; does not disable start/stop/restart | `false` | | `PULSE_METRICS_DB_PATH` | Optional path for only `metrics.db`, useful with tmpfs | `/data/metrics.db` | | `PULSE_METRICS_ROLLUP_INTERVAL` | Metrics aggregation cadence; minimum 5 minutes | `15m` | @@ -169,25 +169,48 @@ an image change alone does not undo data migrations. ## 🔄 Docker / Podman Updates -Pulse can detect and apply updates to your Docker / Podman containers directly from the UI. +These actions update **monitored containers**, not the Pulse server. Pulse can +detect image updates without command execution; applying an update requires a +separately enabled command channel and administrator review. Leave command +execution disabled when you only need monitoring. + +### Before updating a workload + +- Plan for downtime: a running container is stopped and replaced, not updated + in place. Check the application's upgrade and data-migration requirements. +- Take an independent, consistent backup of its application data, including + volumes and bind mounts, using the application's backup procedure. The + renamed old container is **not a backup of mounted data**: the replacement + uses those same mounts and can change their contents. +- Keep the previous image identity and your deployment definition privately. + For Compose, Yacht or another deployment manager, prefer that manager's + update procedure so its saved definition records the intended image. Pulse's + recreation does not edit your Compose file or manager's desired state. +- Do not use an update as a diagnostic test or start another container against + the same writable data to check a failure. ### How It Works 1. **Update Detection**: Pulse compares the local image digest with the latest digest from the container registry 2. **Visual Indicator**: Containers with available updates show a blue upward arrow icon -3. **Reviewed Update**: Click the update button, approve the reviewed action, and Pulse handles the rest +3. **Reviewed Update**: Review the target and approve the action, then verify the application yourself ### Updating a Container -1. Navigate to the **Workloads** page (or filter by Docker sources on **Infrastructure**) +1. Open **Docker → Overview** (`/docker/overview`) and find the host and container 2. Look for containers with a blue update arrow (⬆️) 3. Click the update button and approve the action in the review dialog (admin approval required) -4. Pulse will: +4. Pulse attempts to: - Pull the latest image - - Stop the current container - - Create a backup (renamed with `_pulse_backup_` suffix) - - Start a new container with the same configuration - - Clean up the backup after 15 minutes (if the update succeeds) + - Stop the current container if it was running + - Rename the original container with the `_pulse_backup_` suffix + - Recreate the container from its inspected configuration, reusing its data mounts + - Start it only if the original was running, then perform a short runtime check + - Schedule removal of the renamed old container after a successful update + +Afterwards, check the actual image, container state, application readiness and +data through your normal administration tools. A successful action is not an +end-to-end application or data-integrity test. ### Batch Updates @@ -195,9 +218,29 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd ### Safety Features -- **Automatic Backup**: The old container is renamed, not deleted, until the update succeeds -- **Rollback on Failure**: If the new container fails to start, the old one is restored -- **Configuration Preserved**: Networks, volumes, ports, environment variables are all preserved +- **Temporary old container**: the `_pulse_backup_` name retains the old + container, not a separate copy of its volumes or bind mounts. Current agents + schedule removal **five minutes after a successful update**; periodic cleanup + also removes old backup containers. Do not rely on a 15-minute recovery window + or on a remaining container as a durable backup. +- **Best-effort rollback**: when recreation, network attachment or the early + runtime check fails, Pulse attempts to restore the original name and running + state. Removal, rename or restart can fail too. A failed banner does not prove + that the original container is running or that no change occurred. +- **Limited verification**: the running replacement is inspected after a short + wait and rejected if stopped or explicitly unhealthy. A missing healthcheck + or a `starting` health status is not proof that the application is ready. +- **Inspected configuration**: Pulse reuses container settings and mounts; it + does not reconcile your deployment manager or roll back application data + migrations. Verify networks, ports, mounts and the application after the change. + +If the result is failed or uncertain, **check the current state before retrying**. +Retain the action error and time, old/new container and image identities, and +any rollback error. Inspect these locally; do not post full `docker inspect` +output, environments or registry credentials. Do not delete the old container +or volumes to clear a warning. Recover through the application's and deployment +manager's procedures using a verified matching data backup where required; +returning to the old image alone does not undo a data migration. ### Requirements @@ -231,9 +274,9 @@ Pulse provides granular control over update features via environment variables o | Variable | Description | |----------|-------------| -| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hides update buttons from the UI while still detecting updates. Use this for "read-only" monitoring. | +| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disables image-update actions while still detecting updates. Start/stop/restart have separate controls. | -**Example - Read-Only Mode** (detect updates but prevent actions): +**Example - Disable image updates** (continue detecting updates): ```yaml services: pulse: @@ -242,6 +285,12 @@ services: - PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true ``` +This setting is not a monitoring-only security boundary: it does not disable +container lifecycle actions or revoke agent command permissions. For +monitoring-only installs, leave command execution disabled on the agent and do +not grant command execution permission to monitoring tokens. See +[container lifecycle requirements](#️-container-lifecycle-actions). + To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**. You can also toggle "Hide Docker Update Buttons" from the UI in **Settings → System → General** under **Docker / Podman updates**. diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 2622930599..784665c66b 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, Show } from 'solid-js'; +import { createMemo, createSignal, Show, splitProps } from 'solid-js'; import BoxIcon from 'lucide-solid/icons/box'; import type { VM } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; @@ -27,6 +27,7 @@ import { createSummaryInteractiveRowPreviewHandlers, } from '@/components/shared/summaryInteractionA11y'; import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton'; +import { nativeRowClickTarget } from '@/components/shared/Table'; import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge'; import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation'; import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink'; @@ -49,6 +50,7 @@ export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel'; import { getGuestColumnStyle } from './guestRowModel'; export function GuestRow(props: GuestRowProps) { + const [rowActionProps] = splitProps(props, ['onClick']); const { agentVersion, appContainerRuntimeBadge, @@ -241,7 +243,8 @@ export function GuestRow(props: GuestRowProps) { : undefined } data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'} - onClick={props.onClick} + on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined} + {...rowActionProps} {...interactiveRowHandlers} > {/* Name - always visible */} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 42b1032ce3..16dd981390 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -212,6 +212,7 @@ afterEach(() => { cleanup(); vi.useRealTimers(); vi.clearAllMocks(); + vi.restoreAllMocks(); isMobileMock.mockReturnValue(false); }); @@ -835,6 +836,70 @@ describe('GuestRow', () => { expect(onClick).toHaveBeenCalledOnce(); }); + it('marks only actionable guest rows as native touch click targets', () => { + const addListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener'); + const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener'); + const onClick = vi.fn(); + const [enabled, setEnabled] = createSignal(false); + const { container } = render(() => ( + + + + +
+ )); + const row = container.querySelector('tr')!; + const nativeClickCalls = () => addListener.mock.calls.filter(([type]) => type === 'click'); + + expect(nativeClickCalls()).toHaveLength(0); + setEnabled(true); + expect(nativeClickCalls()).toHaveLength(1); + expect(row).not.toHaveAttribute('tabindex'); + expect(row).not.toHaveAttribute('role', 'button'); + expect(row).not.toHaveAttribute('aria-expanded'); + + // The native compatibility target must never perform the action itself. + const nativeClick = nativeClickCalls()[0][1] as EventListener; + nativeClick.call(row, new MouseEvent('click')); + fireEvent.touchEnd(row); + fireEvent.pointerUp(row, { pointerType: 'touch' }); + expect(onClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('test-vm')); + expect(onClick).toHaveBeenCalledTimes(1); + + setEnabled(false); + expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true); + fireEvent.click(row); + expect(onClick).toHaveBeenCalledTimes(1); + setEnabled(true); + fireEvent.click(screen.getByText('test-vm')); + expect(onClick).toHaveBeenCalledTimes(2); + }); + + it('keeps child disclosure and external-link actions ahead of guest row activation', () => { + const onClick = vi.fn(); + const { container } = renderGuestRow({ + guest: makeGuest(), + customUrl: 'https://guest.example.invalid/', + onClick, + }); + fireEvent.click(screen.getByRole('link', { name: 'Open web interface for test-vm' })); + expect(onClick).not.toHaveBeenCalled(); + const button = screen.getByRole('button', { name: 'Expand test-vm' }); + fireEvent.click(button.querySelector('svg')!); + expect(onClick).toHaveBeenCalledTimes(1); + fireEvent.keyDown(button, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(2); + fireEvent.keyDown(button, { key: ' ', code: 'Space' }); + expect(onClick).toHaveBeenCalledTimes(3); + fireEvent.keyDown(container.querySelector('tr')!, { key: 'Enter' }); + expect(onClick).toHaveBeenCalledTimes(3); + }); + it('calls onHoverChange with canonical guestId on fine-pointer preview', () => { const onHoverChange = vi.fn(); const { container } = renderGuestRow({ diff --git a/frontend-modern/src/components/shared/Table.tsx b/frontend-modern/src/components/shared/Table.tsx index a39c4ebabc..7b87a7d588 100644 --- a/frontend-modern/src/components/shared/Table.tsx +++ b/frontend-modern/src/components/shared/Table.tsx @@ -66,7 +66,8 @@ export type TableRowProps = JSX.HTMLAttributes; // WebKit touch needs a native click target on otherwise-static table rows. // Keep actions document-delegated: nested controls must run first and retain // their existing stopPropagation behaviour instead of opening the row too. -const nativeRowClickTarget = () => undefined; +// Custom row shells reuse this marker without inheriting TableRow's styling. +export const nativeRowClickTarget = () => undefined; export function TableRow(props: TableRowProps) { const [local, rest] = splitProps(props, ['class', 'children']); diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx index b42076dff2..b3da84eeb9 100644 --- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx @@ -5,7 +5,7 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw'; import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw'; import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw'; import { PulseDataGrid } from '@/components/shared/PulseDataGrid'; -import { TableCell } from '@/components/shared/Table'; +import { TableCell, nativeRowClickTarget } from '@/components/shared/Table'; import tableSource from '@/components/shared/Table.tsx?raw'; type TestRow = { @@ -105,16 +105,23 @@ describe('PulseDataGrid', () => { it('inherits the native touch click target from the shared TableRow', () => { const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener'); + const onRowClick = vi.fn(); try { render(() => ( data={[{ id: '1', name: 'Touch tower' }]} columns={[{ key: 'name', label: 'Name' }]} keyExtractor={(row) => row.id} - onRowClick={vi.fn()} + onRowClick={onRowClick} /> )); - expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true); + expect(nativeListener.mock.calls.find(([type]) => type === 'click')?.[1]).toBe( + nativeRowClickTarget, + ); + nativeRowClickTarget(); + expect(onRowClick).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('Touch tower')); + expect(onRowClick).toHaveBeenCalledTimes(1); } finally { nativeListener.mockRestore(); } diff --git a/frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts b/frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts new file mode 100644 index 0000000000..2ff6bcecfb --- /dev/null +++ b/frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { renderDocMarkdown } from '../docMarkdown'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..'); +const guide = readFileSync(path.join(repoRoot, 'docs/DOCKER.md'), 'utf8'); + +function renderGuide(): HTMLElement { + const article = document.createElement('article'); + article.innerHTML = renderDocMarkdown(guide, 'DOCKER'); + return article; +} + +function section(article: HTMLElement, id: string): string { + const heading = article.querySelector(`#${id}`); + expect(heading, `missing guide section ${id}`).not.toBeNull(); + const parts: string[] = []; + for (let element = heading?.nextElementSibling; element; element = element.nextElementSibling) { + if (/^H[1-3]$/.test(element.tagName)) break; + parts.push(element.textContent ?? ''); + } + return parts.join(' ').replace(/\s+/g, ' '); +} + +describe('shipped monitored-container update guide', () => { + it('places independent data backup and deployment-manager limits before the action', () => { + const article = renderGuide(); + const precautions = section(article, 'before-updating-a-workload'); + expect(precautions).toContain('Plan for downtime'); + expect(precautions).toContain('independent, consistent backup'); + expect(precautions).toContain('not a backup of mounted data'); + expect(precautions).toContain('same mounts and can change their contents'); + expect(precautions).toContain("does not edit your Compose file or manager's desired state"); + expect(precautions).toContain('Do not use an update as a diagnostic test'); + const precautionHeading = article.querySelector('#before-updating-a-workload')!; + const actionHeading = article.querySelector('#updating-a-container')!; + expect(precautionHeading.compareDocumentPosition(actionHeading)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(section(article, 'updating-a-container')).toContain('/docker/overview'); + expect(section(article, 'updating-a-container')).not.toContain('Workloads page'); + }); + + it('does not promise durable backup, complete rollback or application readiness', () => { + const limits = section(renderGuide(), 'safety-features'); + expect(limits).toContain('not a separate copy of its volumes or bind mounts'); + expect(limits).toContain('Removal, rename or restart can fail too'); + expect(limits).toContain('A failed banner does not prove'); + expect(limits).toContain('starting health status is not proof'); + expect(limits).toContain('check the current state before retrying'); + expect(limits).toContain('Do not delete the old container or volumes'); + expect(limits).toContain('old image alone does not undo a data migration'); + expect(limits).toContain('do not post full docker inspect output'); + expect(limits).not.toContain('the old one is restored'); + expect(limits).not.toContain('are all preserved'); + }); + + it('matches the implemented early-removal window, not the orphan cleanup interval', () => { + // Static source binding, not a claim that a real update or rollback ran. + const update = readFileSync( + path.join(repoRoot, 'internal/dockeragent/container_update.go'), + 'utf8', + ); + const cleanup = readFileSync(path.join(repoRoot, 'internal/dockeragent/cleanup.go'), 'utf8'); + expect(update).toMatch(/waitForAsyncDelay\(5 \* time.Minute\)/); + expect(cleanup).toMatch(/time.Since\(backupTime\) > 15\*time.Minute/); + const limits = section(renderGuide(), 'safety-features'); + expect(limits).toContain('five minutes after a successful update'); + expect(limits).toContain('Do not rely on a 15-minute recovery window'); + expect(limits).not.toContain('Clean up the backup after 15 minutes'); + }); + + it('keeps image-update suppression separate from command authority and links the real requirements', () => { + const article = renderGuide(); + const disabled = section(article, 'disabling-update-features'); + expect(disabled).toContain('not a monitoring-only security boundary'); + expect(disabled).toContain('does not disable container lifecycle actions'); + expect(disabled).toContain('leave command execution disabled on the agent'); + expect(disabled).toContain('do not grant command execution permission to monitoring tokens'); + expect(disabled).not.toContain('Read-Only Mode'); + const link = Array.from(article.querySelectorAll('a')).find( + (anchor) => anchor.textContent === 'container lifecycle requirements', + ); + expect(link).toBeDefined(); + const targetId = decodeURIComponent(link!.hash.slice(1)); + const target = Array.from(article.querySelectorAll('h2')).find( + (heading) => heading.id === targetId, + ); + expect(target?.textContent).toBe('▶️ Container Lifecycle Actions'); + }); +}); diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index f68fbb09d2..1bb96bed8f 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -2643,8 +2643,9 @@ 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; retain runtime - # settings but require an existing capability without rewriting billing. + # The owned transaction adjusts only the demo runtime profile. It + # requires an existing entitlement; it must not grant one by rewriting + # signed billing state or clearing its integrity check. transaction = read(".github/scripts/demo-runtime-transaction.py") for required in ('"DEMO_MODE": "true"', '"PULSE_MOCK_MODE": "true"', '"PULSE_MOCK_SEED_METRICS_STORE": "false"', @@ -2652,11 +2653,15 @@ class ReleasePromotionPolicyTest(unittest.TestCase): '"PULSE_MOCK_" + key.upper()', 'self.profile_admission()', 'if "demo_fixtures" not in billing["capabilities"]:', 'raise Failure("billing-demo-capability-required")', + 'os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK', + 'self.host.profile_admission()', '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.assertNotIn('billing_file.write_text(', transaction) + self.assertNotIn('billing_file.write_bytes(', 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)