From 4fbb69a7dd5a439dd94ae036417979523732061e Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:50:08 +0100 Subject: [PATCH 1/3] Make server update and rollback help evidence-preserving Replace the destructive hardcoded restore recipe with path and backup-scope checks, the existing history control and reversible recovery guidance. Keep Compose image selection persistent and recreate only Pulse after a successful pull. Verify shipped mirrors, source/UI scope, synthetic command failures and desktop/phone Docs rendering. Change-source: pulse-maintainer --- docs/AUTO_UPDATE.md | 122 +++++++++--- docs/DOCKER.md | 51 ++++-- .../browser-tests/update-recovery-help.cjs | 99 ++++++++++ frontend-modern/public/docs/AUTO_UPDATE.md | 122 +++++++++--- frontend-modern/public/docs/DOCKER.md | 51 ++++-- scripts/tests/test_update_recovery_docs.py | 173 ++++++++++++++++++ 6 files changed, 534 insertions(+), 84 deletions(-) create mode 100644 frontend-modern/browser-tests/update-recovery-help.cjs create mode 100644 scripts/tests/test_update_recovery_docs.py diff --git a/docs/AUTO_UPDATE.md b/docs/AUTO_UPDATE.md index 6d0fe0de29..6f366f4757 100644 --- a/docs/AUTO_UPDATE.md +++ b/docs/AUTO_UPDATE.md @@ -83,19 +83,16 @@ Auto-update preferences are stored in `system.json` and edited via the UI. ### Docker -```bash -# Pull latest image -docker pull rcourtman/pulse:vX.Y.Z +Use [Docker server updates](DOCKER.md#-updates). Change the configured image to +the exact release first, then pull and recreate only the Pulse service with the +same mounted data. Pulling another tag alone does not change the image in your +Compose file. Do not run `docker compose down` for a Pulse-only update or remove +its data volume. -# Restart container -docker compose down && docker compose up -d -``` - -This command uses the public Community image. Private Pro runtime installs must -use the private image and credentials supplied by the private download/update -path; replacing a Pro image with `rcourtman/pulse` changes the runtime edition. - -If you use the legacy `docker-compose` binary, replace `docker compose` with `docker-compose`. +Private Pro installs must keep the private image supplied by the +[download page](https://pulserelay.pro/download.html); replacing it with +`rcourtman/pulse` changes the runtime edition. Server updates and monitored +container updates are separate operations. ### ProxmoxVE LXC (Manual) @@ -124,30 +121,99 @@ sudo systemctl restart pulse ## Rollback -If an update causes issues: +An update error or a stuck progress modal does not prove that the old version +is still running. Check the running version and service health before retrying +or restoring anything; preserve the update error, logs and history. Do not run +another update just to reproduce a failure. + +Before any rollback, take a consistent private backup of the current state and +check the proposed backup's scope. Keep the failed installation and update +snapshots until recovery is verified. A rollback can revert settings, alert +changes and history written since the selected backup. ### Automatic Rollback -Pulse creates a backup before updating. If the update fails: -1. The previous version is automatically restored -2. Service restarts with the old version -3. Error details are logged + +The in-app updater creates an installation snapshot before applying files. If +applying files fails, it attempts to restore that snapshot and logs any restore +error. This is not a guarantee that every update failure restores all state or +restarts successfully. Confirm the running version, connections and service +health afterwards; a progress message alone is not recovery evidence. + +### Roll back from Update History + +If your installed version offers it, open **Settings → System → Updates → +Update History**. **Roll back** is offered for a successful in-app update with +a retained backup. The confirmation names the version before that update and +warns that later settings and alert changes will be reverted. Check that version +and backup before confirming; Pulse restarts to use the restored binary. + +Older versions may not have this control. An absent button or backup does not +justify deleting data or creating an empty configuration. Docker server installs +use their previous image and matching data backup, not this in-app rollback. + +### What an update snapshot contains + +In-app snapshots normally live at `backup-/` under the runtime data +directory (usually `/etc/pulse`), with `/tmp/pulse-backup-` as a +low-space fallback. Use the `backup_path` recorded for the actual update in +`update-history.jsonl`, rather than guessing a timestamp. The updater retains +the most recent three snapshots; a `/tmp` snapshot may also be lost on reboot. + +The in-app snapshot copies the running server binary, available `VERSION` and +`.env` files, and available `data/` and `config/` directories under +`PULSE_INSTALL_DIR` (default `/opt/pulse`). It is not necessarily a complete +backup of the active `PULSE_DATA_DIR`, external metrics/audit stores, service +units, bundled agents or update helpers. Copy errors can leave a partial +snapshot: a recorded path is not proof that every file was saved. + +Verify the backup against your effective service paths and update logs. Preserve +the active data directory, matching `.encryption.key`, audit history and its +signing key together, including custom paths and organisation directories. Use +a stopped, consistent filesystem/volume backup; copying a live `.db` file alone +can omit SQLite sidecar files. Keep these backups private, not in an issue or +diagnostic attachment. See [configuration paths](CONFIGURATION.md) and +[audit storage and safe recovery](AUDIT_LOGGING.md#storage). ### Manual Rollback -Update backups created by in-app updates are stored as `backup-/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory does not have enough free space, Pulse falls back to `/tmp/pulse-backup-`. Pulse keeps the most recent three in-app rollback snapshots and prunes older ones from retention. -There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart. -Example (systemd/LXC): +If Pulse cannot start, work inside the Pulse host or LXC, not on the Proxmox host +itself. Start with read-only service discovery: + ```bash -sudo systemctl stop pulse -sudo cp -a /etc/pulse/backup-/pulse /opt/pulse/pulse -sudo cp -a /etc/pulse/backup-/VERSION /opt/pulse/VERSION -sudo rm -rf /opt/pulse/data /opt/pulse/config -sudo cp -a /etc/pulse/backup-/data /opt/pulse/data -sudo cp -a /etc/pulse/backup-/config /opt/pulse/config -sudo cp -a /etc/pulse/backup-/.env /opt/pulse/.env -sudo systemctl start pulse +systemctl is-active pulse +systemctl show pulse --property=FragmentPath --property=DropInPaths ``` +An inactive service makes `is-active` return non-zero; that is useful evidence, +not a reason to reset it. Inspect the named unit and drop-ins locally for the +effective executable, `PULSE_INSTALL_DIR` and `PULSE_DATA_DIR`. Do not post full +service environments or `.env` contents; they may contain credentials. + +1. Stop Pulse before taking the failed-state backup or replacing files. Preserve + the actual executable, distribution files, unit/drop-ins and every active + data path, not just a guessed `/opt/pulse/data` directory. +2. Match the retained backup to the intended pre-update version. Check its + files and the update log for missing/copy-failed entries. If the backup is + missing, partial or has the wrong scope, stop here; do not delete live + directories to make a restore fit. +3. Prefer restoring a verified pre-update filesystem/volume snapshot with your + host backup tooling. Check it in an isolated recovery instance first, with + no access to monitored systems or notification destinations. Do not start a + second connected copy with the same agent identities. +4. For a file-level repair, stage verified replacements and keep reversible + copies of the failed state. Restore only the confirmed paths, ownership and + permissions for this deployment. Do not merge unrelated snapshots, delete + data/configuration directories, or regenerate encryption or signing keys. +5. Start Pulse and verify the running version and edition, service health, + saved connections, agent identities and retained history. If any check fails, + preserve the error and backups rather than retrying the update blindly. + +For Docker, change the configured image back to the recorded previous tag or +digest and recreate only Pulse with its existing volume. An older image may not +understand data migrated by the newer version; use a verified matching data +backup when required. Never remove the volume or initialise a fresh one as a +rollback shortcut. An image change alone is not a data rollback. + ## Update History History entries are stored in `update-history.jsonl` under the Pulse data directory (`/etc/pulse` or `/data`), and exposed via `GET /api/updates/history` (admin auth required). diff --git a/docs/DOCKER.md b/docs/DOCKER.md index ed97be315d..9d919d9197 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -118,21 +118,46 @@ services: ## 🔄 Updates -To update Pulse to a specific release tag: +These steps update the Pulse server, not the containers it monitors. Record the +current image and take a consistent private backup of the mounted Pulse data +before changing it. Keep the previous image and data backup for recovery; do +not delete volumes. ```bash -docker pull rcourtman/pulse:vX.Y.Z -docker stop pulse -docker rm pulse -# Re-run your docker run command +docker inspect pulse --format '{{.Config.Image}} {{.Image}}' ``` -If using Compose: +Run commands from the original Compose project directory. First set the exact +target in your Compose `image:` line, or persist `PULSE_IMAGE` in the project's +`.env` if the file uses `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}`. +Setting `PULSE_IMAGE` has no effect on a hardcoded image line. Community uses +`rcourtman/pulse:vX.Y.Z`; paid Pro installs must keep the private image and +existing registry login from . Do not +replace Pro with the Community image or post registry credentials. + +For the `pulse` service in the examples above: + ```bash -docker compose pull -docker compose up -d +( +set -e +docker compose pull pulse +docker compose up -d --no-deps pulse +) ``` +If pulling fails, stop rather than recreating with an unverified or old image. +Pulling a different tag alone does not update your configured image. There is +no need to bring the whole Compose project down. Use your actual service and +container names if they differ; legacy `docker-compose` users can substitute +that command name. + +For `docker run` or an app UI, change the image in the existing saved deployment +and recreate it with the same data mount, ports and settings; do not start a +second Pulse against that data. Check the running image again, Pulse's displayed +server version and service health after recreation. See +[rollback and backup scope](AUTO_UPDATE.md#rollback) before reverting a version: +an image change alone does not undo data migrations. + --- ## 🔄 Docker / Podman Updates @@ -188,12 +213,10 @@ To keep update detection anonymous-only (no credential store reads, no credentia Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than the public `rcourtman/pulse` image. Open , -paste your activation key, run the Docker login command shown there, then run -the shown `PULSE_IMAGE=license.pulserelay.pro/pulse-pro: docker compose pull` -and `docker compose up -d` commands from the host that already runs Pulse. If -your compose file has a hardcoded `image: rcourtman/pulse:...` line, change it -to `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}` or directly to the private -image shown on the download page before running those commands. +enter your activation key in the page and use its registry login instructions +on the host that already runs Pulse. Persist the private image for both pull +and recreation as described in [server updates](#-updates); an image override +used for only the pull does not select it for the later recreation. ### Disabling Update Features diff --git a/frontend-modern/browser-tests/update-recovery-help.cjs b/frontend-modern/browser-tests/update-recovery-help.cjs new file mode 100644 index 0000000000..0b377be7c9 --- /dev/null +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -0,0 +1,99 @@ +// Actual shipped Markdown in the production Docs/router/styles; no backend. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules', 'update-recovery-help-browser'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, + configFile: path.join(root, 'vite.config.ts'), + cacheDir: path.join(root, 'node_modules', '.vite-update-recovery-help'), + server: { host: '127.0.0.1', port: 5248, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width, tone] of [['chromium', 1280, 'light'], ['webkit', 390, 'dark']]) { + browser = await { chromium, webkit }[engine].launch( + engine === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }, + ); + const page = await browser.newPage({ + viewport: { width, height: 900 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}), + }); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + await page.goto('http://127.0.0.1:5248/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByRole('link', { name: '← All documentation' }).click(); + await page.getByRole('link', { name: 'Automatic updates', exact: true }).click(); + await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor(); + if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + const article = await page.locator('article').innerText(); + for (const text of ['Roll back from Update History', 'successful in-app update', + 'not necessarily a complete backup', 'Copy errors can leave a partial snapshot', + 'matching .encryption.key', 'SQLite sidecar files', 'keep reversible copies', + 'An image change alone is not a data rollback']) assert.ok(article.includes(text), text); + assert.ok(!article.includes('There is no rollback UI')); + assert.ok(!article.includes('sudo rm -rf')); + const scope = page.getByRole('heading', { name: 'What an update snapshot contains', exact: true }); + await scope.scrollIntoViewIfNeeded(); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-scope.png`) }); + const manual = page.getByRole('heading', { name: 'Manual Rollback', exact: true }); + await manual.scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) }); + + await page.getByRole('link', { name: 'audit storage and safe recovery', exact: true }).click(); + await page.waitForFunction(() => { + const heading = document.getElementById('storage'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/AUDIT_LOGGING#storage')); + await page.getByRole('link', { name: '← All documentation' }).click(); + await page.getByRole('link', { name: 'Automatic updates', exact: true }).click(); + await page.getByRole('link', { name: 'Docker server updates', exact: true }).click(); + const updates = page.getByRole('heading', { name: '🔄 Updates', exact: true }); + await updates.waitFor(); + await page.waitForFunction(() => { + const heading = document.getElementById('-updates'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/DOCKER#-updates')); + const dockerText = await page.locator('article').innerText(); + for (const text of ['persist PULSE_IMAGE', 'has no effect on a hardcoded image line', + 'docker compose pull pulse', 'docker compose up -d --no-deps pulse', + 'paid Pro installs must keep the private image', 'no need to bring the whole Compose project down']) + assert.ok(dockerText.includes(text), text); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-docker.png`) }); + await page.getByRole('link', { name: 'rollback and backup scope', exact: true }).click(); + await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor(); + assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#rollback')); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, + snapshotScope: true, noDestructiveRecipe: true, scopedCompose: true, + auditLink: true, dockerFragment: true, rollbackLink: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'shipped help rendering and links, not installation or recovery', results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result)); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/AUTO_UPDATE.md b/frontend-modern/public/docs/AUTO_UPDATE.md index 6d0fe0de29..6f366f4757 100644 --- a/frontend-modern/public/docs/AUTO_UPDATE.md +++ b/frontend-modern/public/docs/AUTO_UPDATE.md @@ -83,19 +83,16 @@ Auto-update preferences are stored in `system.json` and edited via the UI. ### Docker -```bash -# Pull latest image -docker pull rcourtman/pulse:vX.Y.Z +Use [Docker server updates](DOCKER.md#-updates). Change the configured image to +the exact release first, then pull and recreate only the Pulse service with the +same mounted data. Pulling another tag alone does not change the image in your +Compose file. Do not run `docker compose down` for a Pulse-only update or remove +its data volume. -# Restart container -docker compose down && docker compose up -d -``` - -This command uses the public Community image. Private Pro runtime installs must -use the private image and credentials supplied by the private download/update -path; replacing a Pro image with `rcourtman/pulse` changes the runtime edition. - -If you use the legacy `docker-compose` binary, replace `docker compose` with `docker-compose`. +Private Pro installs must keep the private image supplied by the +[download page](https://pulserelay.pro/download.html); replacing it with +`rcourtman/pulse` changes the runtime edition. Server updates and monitored +container updates are separate operations. ### ProxmoxVE LXC (Manual) @@ -124,30 +121,99 @@ sudo systemctl restart pulse ## Rollback -If an update causes issues: +An update error or a stuck progress modal does not prove that the old version +is still running. Check the running version and service health before retrying +or restoring anything; preserve the update error, logs and history. Do not run +another update just to reproduce a failure. + +Before any rollback, take a consistent private backup of the current state and +check the proposed backup's scope. Keep the failed installation and update +snapshots until recovery is verified. A rollback can revert settings, alert +changes and history written since the selected backup. ### Automatic Rollback -Pulse creates a backup before updating. If the update fails: -1. The previous version is automatically restored -2. Service restarts with the old version -3. Error details are logged + +The in-app updater creates an installation snapshot before applying files. If +applying files fails, it attempts to restore that snapshot and logs any restore +error. This is not a guarantee that every update failure restores all state or +restarts successfully. Confirm the running version, connections and service +health afterwards; a progress message alone is not recovery evidence. + +### Roll back from Update History + +If your installed version offers it, open **Settings → System → Updates → +Update History**. **Roll back** is offered for a successful in-app update with +a retained backup. The confirmation names the version before that update and +warns that later settings and alert changes will be reverted. Check that version +and backup before confirming; Pulse restarts to use the restored binary. + +Older versions may not have this control. An absent button or backup does not +justify deleting data or creating an empty configuration. Docker server installs +use their previous image and matching data backup, not this in-app rollback. + +### What an update snapshot contains + +In-app snapshots normally live at `backup-/` under the runtime data +directory (usually `/etc/pulse`), with `/tmp/pulse-backup-` as a +low-space fallback. Use the `backup_path` recorded for the actual update in +`update-history.jsonl`, rather than guessing a timestamp. The updater retains +the most recent three snapshots; a `/tmp` snapshot may also be lost on reboot. + +The in-app snapshot copies the running server binary, available `VERSION` and +`.env` files, and available `data/` and `config/` directories under +`PULSE_INSTALL_DIR` (default `/opt/pulse`). It is not necessarily a complete +backup of the active `PULSE_DATA_DIR`, external metrics/audit stores, service +units, bundled agents or update helpers. Copy errors can leave a partial +snapshot: a recorded path is not proof that every file was saved. + +Verify the backup against your effective service paths and update logs. Preserve +the active data directory, matching `.encryption.key`, audit history and its +signing key together, including custom paths and organisation directories. Use +a stopped, consistent filesystem/volume backup; copying a live `.db` file alone +can omit SQLite sidecar files. Keep these backups private, not in an issue or +diagnostic attachment. See [configuration paths](CONFIGURATION.md) and +[audit storage and safe recovery](AUDIT_LOGGING.md#storage). ### Manual Rollback -Update backups created by in-app updates are stored as `backup-/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory does not have enough free space, Pulse falls back to `/tmp/pulse-backup-`. Pulse keeps the most recent three in-app rollback snapshots and prunes older ones from retention. -There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart. -Example (systemd/LXC): +If Pulse cannot start, work inside the Pulse host or LXC, not on the Proxmox host +itself. Start with read-only service discovery: + ```bash -sudo systemctl stop pulse -sudo cp -a /etc/pulse/backup-/pulse /opt/pulse/pulse -sudo cp -a /etc/pulse/backup-/VERSION /opt/pulse/VERSION -sudo rm -rf /opt/pulse/data /opt/pulse/config -sudo cp -a /etc/pulse/backup-/data /opt/pulse/data -sudo cp -a /etc/pulse/backup-/config /opt/pulse/config -sudo cp -a /etc/pulse/backup-/.env /opt/pulse/.env -sudo systemctl start pulse +systemctl is-active pulse +systemctl show pulse --property=FragmentPath --property=DropInPaths ``` +An inactive service makes `is-active` return non-zero; that is useful evidence, +not a reason to reset it. Inspect the named unit and drop-ins locally for the +effective executable, `PULSE_INSTALL_DIR` and `PULSE_DATA_DIR`. Do not post full +service environments or `.env` contents; they may contain credentials. + +1. Stop Pulse before taking the failed-state backup or replacing files. Preserve + the actual executable, distribution files, unit/drop-ins and every active + data path, not just a guessed `/opt/pulse/data` directory. +2. Match the retained backup to the intended pre-update version. Check its + files and the update log for missing/copy-failed entries. If the backup is + missing, partial or has the wrong scope, stop here; do not delete live + directories to make a restore fit. +3. Prefer restoring a verified pre-update filesystem/volume snapshot with your + host backup tooling. Check it in an isolated recovery instance first, with + no access to monitored systems or notification destinations. Do not start a + second connected copy with the same agent identities. +4. For a file-level repair, stage verified replacements and keep reversible + copies of the failed state. Restore only the confirmed paths, ownership and + permissions for this deployment. Do not merge unrelated snapshots, delete + data/configuration directories, or regenerate encryption or signing keys. +5. Start Pulse and verify the running version and edition, service health, + saved connections, agent identities and retained history. If any check fails, + preserve the error and backups rather than retrying the update blindly. + +For Docker, change the configured image back to the recorded previous tag or +digest and recreate only Pulse with its existing volume. An older image may not +understand data migrated by the newer version; use a verified matching data +backup when required. Never remove the volume or initialise a fresh one as a +rollback shortcut. An image change alone is not a data rollback. + ## Update History History entries are stored in `update-history.jsonl` under the Pulse data directory (`/etc/pulse` or `/data`), and exposed via `GET /api/updates/history` (admin auth required). diff --git a/frontend-modern/public/docs/DOCKER.md b/frontend-modern/public/docs/DOCKER.md index ed97be315d..9d919d9197 100644 --- a/frontend-modern/public/docs/DOCKER.md +++ b/frontend-modern/public/docs/DOCKER.md @@ -118,21 +118,46 @@ services: ## 🔄 Updates -To update Pulse to a specific release tag: +These steps update the Pulse server, not the containers it monitors. Record the +current image and take a consistent private backup of the mounted Pulse data +before changing it. Keep the previous image and data backup for recovery; do +not delete volumes. ```bash -docker pull rcourtman/pulse:vX.Y.Z -docker stop pulse -docker rm pulse -# Re-run your docker run command +docker inspect pulse --format '{{.Config.Image}} {{.Image}}' ``` -If using Compose: +Run commands from the original Compose project directory. First set the exact +target in your Compose `image:` line, or persist `PULSE_IMAGE` in the project's +`.env` if the file uses `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}`. +Setting `PULSE_IMAGE` has no effect on a hardcoded image line. Community uses +`rcourtman/pulse:vX.Y.Z`; paid Pro installs must keep the private image and +existing registry login from . Do not +replace Pro with the Community image or post registry credentials. + +For the `pulse` service in the examples above: + ```bash -docker compose pull -docker compose up -d +( +set -e +docker compose pull pulse +docker compose up -d --no-deps pulse +) ``` +If pulling fails, stop rather than recreating with an unverified or old image. +Pulling a different tag alone does not update your configured image. There is +no need to bring the whole Compose project down. Use your actual service and +container names if they differ; legacy `docker-compose` users can substitute +that command name. + +For `docker run` or an app UI, change the image in the existing saved deployment +and recreate it with the same data mount, ports and settings; do not start a +second Pulse against that data. Check the running image again, Pulse's displayed +server version and service health after recreation. See +[rollback and backup scope](AUTO_UPDATE.md#rollback) before reverting a version: +an image change alone does not undo data migrations. + --- ## 🔄 Docker / Podman Updates @@ -188,12 +213,10 @@ To keep update detection anonymous-only (no credential store reads, no credentia Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than the public `rcourtman/pulse` image. Open , -paste your activation key, run the Docker login command shown there, then run -the shown `PULSE_IMAGE=license.pulserelay.pro/pulse-pro: docker compose pull` -and `docker compose up -d` commands from the host that already runs Pulse. If -your compose file has a hardcoded `image: rcourtman/pulse:...` line, change it -to `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}` or directly to the private -image shown on the download page before running those commands. +enter your activation key in the page and use its registry login instructions +on the host that already runs Pulse. Persist the private image for both pull +and recreation as described in [server updates](#-updates); an image override +used for only the pull does not select it for the later recreation. ### Disabling Update Features diff --git a/scripts/tests/test_update_recovery_docs.py b/scripts/tests/test_update_recovery_docs.py new file mode 100644 index 0000000000..5a9c993716 --- /dev/null +++ b/scripts/tests/test_update_recovery_docs.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Exercise update help without touching a service, volume or Docker socket. + +The synthetic commands prove scope and failure handling, not installed recovery. +The remaining checks bind the guide to the existing UI and snapshot writer. +""" + +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +def guide(name): + return (ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8") + + +def section(name, heading, level="##"): + return guide(name).split(f"{level} {heading}\n", 1)[1].split(f"\n{level} ", 1)[0] + + +READER = r'''#!/usr/bin/python3 +import json, os, sys +from pathlib import Path +name = Path(sys.argv[0]).name +args = sys.argv[1:] +with Path(os.environ['CALLS']).open('a') as output: + output.write(json.dumps({'name': name, 'args': args}) + '\n') +if name == 'systemctl': + if args == ['is-active', 'pulse']: + print(os.environ['SERVICE_STATE']) + sys.exit(0 if os.environ['SERVICE_STATE'] == 'active' else 3) + assert args == ['show', 'pulse', '--property=FragmentPath', '--property=DropInPaths'] + print('FragmentPath=/etc/systemd/system/pulse.service\nDropInPaths=/etc/systemd/system/pulse.service.d/local.conf') +elif name == 'docker': + if args == ['inspect', 'pulse', '--format', '{{.Config.Image}} {{.Image}}']: + print('private.example/pulse:old sha256:previous') + elif args == ['compose', 'pull', 'pulse']: + sys.exit(int(os.environ.get('PULL_EXIT', '0'))) + elif args == ['compose', 'up', '-d', '--no-deps', 'pulse']: + sys.exit(int(os.environ.get('UP_EXIT', '0'))) + else: + raise AssertionError('unexpected Docker mutation or unbounded inspection') +else: + raise AssertionError('unexpected command') +''' + + +class UpdateRecoveryDocsTest(unittest.TestCase): + def exercise(self, recipe, **settings): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for name in ("systemctl", "docker"): + executable = root / name + executable.write_text(READER, encoding="utf-8") + executable.chmod(0o700) + calls_path = root / "calls.jsonl" + env = dict(os.environ, PATH=f"{root}:{os.environ['PATH']}", + CALLS=str(calls_path), SERVICE_STATE="active", + SYNTHETIC_CREDENTIAL="never-print-this-private-value") + env.update(settings) + result = subprocess.run(["bash", "-c", recipe], env=env, + text=True, capture_output=True, timeout=10) + calls = [json.loads(line) for line in calls_path.read_text().splitlines()] + self.assertNotIn(env["SYNTHETIC_CREDENTIAL"], result.stdout + result.stderr) + return result, calls + + def compose_recipe(self): + recipes = re.findall(r"```bash\n(.*?)```", section("DOCKER", "🔄 Updates"), re.S) + return next(recipe for recipe in recipes if "compose" in recipe) + + def test_mirrors_match_the_exercised_guides(self): + for name in ("AUTO_UPDATE", "DOCKER"): + with self.subTest(guide=name): + self.assertEqual(guide(name), + (ROOT / "frontend-modern/public/docs" / f"{name}.md").read_text()) + + def test_rollback_matches_the_existing_history_control(self): + help_text = " ".join(section("AUTO_UPDATE", "Rollback").split()) + ui = (ROOT / "frontend-modern/src/components/Settings/UpdateHistorySection.tsx").read_text() + for label in ("Update History", "Roll back"): + self.assertIn(label, ui) + self.assertIn(label, help_text) + self.assertIn("entry.action === 'update' && entry.status === 'success' && Boolean(entry.backup_path)", ui) + for phrase in ("successful in-app update", "retained backup", "version before that update", + "Older versions may not have this control", "later settings and alert changes"): + self.assertIn(phrase, help_text) + self.assertNotIn("There is no rollback UI", help_text) + + def test_snapshot_scope_is_not_claimed_to_be_a_full_data_backup(self): + help_text = " ".join(section("AUTO_UPDATE", "What an update snapshot contains", "###").split()) + manager = (ROOT / "internal/updates/manager.go").read_text() + writer = manager.split("func (m *Manager) createBackup(", 1)[1].split("// ensureApplyTargetIsNewer", 1)[0] + self.assertIn('dirsToBackup := []string{"data", "config"}', writer) + self.assertIn('os.Getenv("PULSE_INSTALL_DIR")', writer) + self.assertIn('os.Executable()', writer) + for phrase in ("running server binary", "`VERSION`", "`.env`", "`data/`", "`config/`", + "`PULSE_INSTALL_DIR`", "not necessarily a complete", "active `PULSE_DATA_DIR`", + "Copy errors can leave a partial", "`backup_path`", "lost on reboot"): + self.assertIn(phrase, help_text) + # The current writer logs some errors and continues; existence alone + # must not be promoted to completeness in operator guidance. + self.assertIn('Msg("Failed to backup directory")', writer) + self.assertIn('return backupDir, nil', writer) + + def test_recovery_preserves_failed_state_keys_and_sidecars(self): + help_text = " ".join(section("AUTO_UPDATE", "Rollback").split()) + for phrase in ("consistent private backup", "matching `.encryption.key`", "signing key together", + "SQLite sidecar files", "Keep these backups private", "missing, partial", + "isolated recovery instance", "no access to monitored systems or notification destinations", + "Do not start a second connected copy", "keep reversible copies", + "regenerate encryption or signing keys", "running version and edition", + "An image change alone is not a data rollback"): + self.assertIn(phrase, help_text) + # No executable deletion, overwrite, restore, start or stop recipe is + # safe without knowing this deployment's effective paths and backup. + blocks = re.findall(r"```bash\n(.*?)```", section("AUTO_UPDATE", "Rollback"), re.S) + self.assertEqual(len(blocks), 1) + self.assertNotRegex(blocks[0], r"\b(?:rm|cp|mv|install|tee|start|stop|restart)\b") + + def test_read_only_discovery_handles_active_and_inactive_services(self): + recipe = re.findall(r"```bash\n(.*?)```", section("AUTO_UPDATE", "Manual Rollback", "###"), re.S)[0] + for state in ("active", "inactive"): + with self.subTest(state=state): + result, calls = self.exercise(recipe, SERVICE_STATE=state) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, [ + {"name": "systemctl", "args": ["is-active", "pulse"]}, + {"name": "systemctl", "args": ["show", "pulse", "--property=FragmentPath", "--property=DropInPaths"]}, + ]) + self.assertIn(state, result.stdout) + self.assertIn("FragmentPath=", result.stdout) + self.assertNotIn("Environment=", result.stdout) + + def test_compose_updates_only_pulse_after_a_successful_pull(self): + result, calls = self.exercise(self.compose_recipe()) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls, [ + {"name": "docker", "args": ["compose", "pull", "pulse"]}, + {"name": "docker", "args": ["compose", "up", "-d", "--no-deps", "pulse"]}, + ]) + + def test_failed_pull_never_recreates_and_failed_recreation_is_not_success(self): + for settings, exit_code, count in (({"PULL_EXIT": "7"}, 7, 1), ({"UP_EXIT": "8"}, 8, 2)): + with self.subTest(settings=settings): + result, calls = self.exercise(self.compose_recipe(), **settings) + self.assertEqual(result.returncode, exit_code, result.stderr) + self.assertEqual(len(calls), count) + + def test_image_selection_is_durable_and_private_edition_is_preserved(self): + help_text = " ".join(section("DOCKER", "🔄 Updates").split()) + for phrase in ("exact target", "persist `PULSE_IMAGE`", "has no effect on a hardcoded image line", + "paid Pro installs must keep the private image", "same data mount, ports and settings", + "running image again", "an image change alone does not undo data migrations"): + self.assertIn(phrase, help_text) + recipe = re.findall(r"```bash\n(.*?)```", section("DOCKER", "🔄 Updates"), re.S)[0] + result, calls = self.exercise(recipe) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(calls[0]["args"], ["inspect", "pulse", "--format", "{{.Config.Image}} {{.Image}}"]) + self.assertIn("private.example/pulse:old sha256:previous", result.stdout) + short = section("AUTO_UPDATE", "Docker", "###") + self.assertIn("DOCKER.md#-updates", short) + self.assertNotIn("docker pull", short) + + +if __name__ == "__main__": + unittest.main() From c71bdbbda718435307c2c59a7fac5fb42056b901 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:04:52 +0100 Subject: [PATCH 2/3] Capture the recovery procedure after theme and scroll settle Visual inspection caught the desktop manual capture still showing the preceding snapshot section and phone theme transition. Put each checked section at the viewport start and wait for the actual theme transition before retaining the screenshot; product source and guidance are unchanged. Change-source: pulse-maintainer --- .../browser-tests/update-recovery-help.cjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend-modern/browser-tests/update-recovery-help.cjs b/frontend-modern/browser-tests/update-recovery-help.cjs index 0b377be7c9..0ebdcce9a2 100644 --- a/frontend-modern/browser-tests/update-recovery-help.cjs +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -39,7 +39,11 @@ const { chromium, webkit } = require('playwright'); await page.getByRole('link', { name: '← All documentation' }).click(); await page.getByRole('link', { name: 'Automatic updates', exact: true }).click(); await page.getByRole('heading', { name: 'Rollback', exact: true }).waitFor(); - if (tone === 'dark') await page.evaluate(() => document.documentElement.classList.add('dark')); + if (tone === 'dark') await page.evaluate(async () => { + document.documentElement.classList.add('dark'); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {}))); + }); const article = await page.locator('article').innerText(); for (const text of ['Roll back from Update History', 'successful in-app update', 'not necessarily a complete backup', 'Copy errors can leave a partial snapshot', @@ -48,11 +52,15 @@ const { chromium, webkit } = require('playwright'); assert.ok(!article.includes('There is no rollback UI')); assert.ok(!article.includes('sudo rm -rf')); const scope = page.getByRole('heading', { name: 'What an update snapshot contains', exact: true }); - await scope.scrollIntoViewIfNeeded(); + await scope.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); await page.screenshot({ path: path.join(artifacts, `${engine}-scope.png`) }); const manual = page.getByRole('heading', { name: 'Manual Rollback', exact: true }); - await manual.scrollIntoViewIfNeeded(); + await manual.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); + await page.waitForFunction(() => { + const heading = document.getElementById('manual-rollback'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 10; + }); await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) }); await page.getByRole('link', { name: 'audit storage and safe recovery', exact: true }).click(); From ae427e6afe1a317301f2ccf802a22940039ae248 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:07:05 +0100 Subject: [PATCH 3/3] Respect the Docs heading inset in recovery visual checks The capture wait incorrectly required a zero-pixel heading offset despite the production scroll-margin. Keep the section visible near the viewport start without changing product styles, then rerun the same visual acceptance. Change-source: pulse-maintainer --- frontend-modern/browser-tests/update-recovery-help.cjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend-modern/browser-tests/update-recovery-help.cjs b/frontend-modern/browser-tests/update-recovery-help.cjs index 0ebdcce9a2..fbc4daf6f3 100644 --- a/frontend-modern/browser-tests/update-recovery-help.cjs +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -59,7 +59,9 @@ const { chromium, webkit } = require('playwright'); await manual.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); await page.waitForFunction(() => { const heading = document.getElementById('manual-rollback'); - return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 10; + // Respect the product's heading scroll-margin rather than demanding + // alignment at zero pixels above its normal reading inset. + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 200; }); await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) });