From c211bfe5229b2823975a708a6fa5c11435396b3d Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:50:18 +0100 Subject: [PATCH] Make monitored-container update help truthful about recovery Replace a claimed 15-minute backup and guaranteed rollback with the implemented five-minute cleanup, shared-data and best-effort limits. Put independent backups and deployment-manager recovery before the action, preserve current-state checks, and distinguish image-update suppression from command authority. Contract-Neutral: Documentation and validation only; no container runtime, command authority or API changes. Change-source: pulse-maintainer --- docs/DOCKER.md | 77 +++++++++-- .../browser-tests/docker-update-guidance.cjs | 125 ++++++++++++++++++ frontend-modern/public/docs/DOCKER.md | 77 +++++++++-- .../docs/__tests__/dockerUpdateGuide.test.ts | 93 +++++++++++++ 4 files changed, 344 insertions(+), 28 deletions(-) create mode 100644 frontend-modern/browser-tests/docker-update-guidance.cjs create mode 100644 frontend-modern/src/features/docs/__tests__/dockerUpdateGuide.test.ts 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/frontend-modern/browser-tests/docker-update-guidance.cjs b/frontend-modern/browser-tests/docker-update-guidance.cjs new file mode 100644 index 0000000000..7bc69a5388 --- /dev/null +++ b/frontend-modern/browser-tests/docker-update-guidance.cjs @@ -0,0 +1,125 @@ +// 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'); + 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/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/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'); + }); +});