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..fbc4daf6f3 --- /dev/null +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -0,0 +1,109 @@ +// 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(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', + '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.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.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); + await page.waitForFunction(() => { + const heading = document.getElementById('manual-rollback'); + // 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`) }); + + 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()