From 45835634eae387c3b7c643145b912b0ca947d896 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:02:30 +0100 Subject: [PATCH] Qualify rollback help by updater and backup scope Separate installation rollback from stopped full-state recovery, correct the install guide's automatic-backup promise and name the verified limited published snapshots. Keep shipped help matched, reject the original unsafe claims and exercise desktop and phone Docs links without claiming the unready backend has shipped. Contract-Neutral: Documentation and help verification only; no updater runtime or API contract changes. Change-source: pulse-maintainer --- docs/AUTO_UPDATE.md | 29 ++++++++++-- docs/INSTALL.md | 16 ++++++- .../browser-tests/update-recovery-help.cjs | 47 ++++++++++++++++++- frontend-modern/public/docs/AUTO_UPDATE.md | 29 ++++++++++-- frontend-modern/public/docs/INSTALL.md | 16 ++++++- scripts/tests/test_update_recovery_docs.py | 42 ++++++++++++----- 6 files changed, 151 insertions(+), 28 deletions(-) diff --git a/docs/AUTO_UPDATE.md b/docs/AUTO_UPDATE.md index 6f366f4757..88b9128373 100644 --- a/docs/AUTO_UPDATE.md +++ b/docs/AUTO_UPDATE.md @@ -128,8 +128,9 @@ 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. +snapshots until recovery is verified. Returning to an older binary is not a +full-state recovery. Restoring a matching data snapshot can discard settings, +alert changes and history written since that snapshot. ### Automatic Rollback @@ -144,8 +145,16 @@ health afterwards; a progress message alone is not recovery evidence. 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. +Pulse restarts to use the restored binary. Check that version and the actual +backup contents before confirming. + +**Update History is not a full-state recovery tool.** Its restore scope depends +on the installed updater. Older updaters may replace `data/`, `config/` and +`.env` under the installation directory, but omit active data stored elsewhere. +Do not assume later settings and alert changes will be reverted, even if the +confirmation says so. If active data shares those installation paths, do not +restore it while Pulse is running; use the stopped-service procedure in +[Manual Rollback](#manual-rollback). 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 @@ -159,13 +168,23 @@ 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 +Snapshot scope is version-specific. The published **v6.4.5** and +**v6.4.6-rc.1** in-app updaters use the limited installation snapshot described +below. Do not assume a backup made by another updater version has the same +contents or restore behaviour. + +The limited 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. +A backup's existence, or the presence of a database file, does not establish +completeness, consistency or that Update History will restore it. A snapshot +that includes active data still needs a verified, stopped-service recovery +procedure; its presence is not permission to rewind live runtime stores. + 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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 7a30c0c522..7b0d724b83 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -302,9 +302,21 @@ update. Use **Settings → Infrastructure → Install on a host** for a first in or a v5-to-v6 in-place upgrade. ### Rollback -If an update causes issues on systemd installations, backups are created automatically during the update process. +An update error does not establish which version is running. Check the running +version and service health before retrying or rolling back, and preserve the +failed installation and update logs. -**Manual rollback**: In-app updates store backups under `/etc/pulse/backup-/`. The systemd auto-update timer uses a temporary `/tmp/pulse-backup-` during the update and auto-restores on failure. +In-app update snapshots and updater-script backups have different contents and +lifetimes. Neither is guaranteed to include all active data; an older binary +also may not understand data migrated by a newer version. Update History is +not a full-state recovery tool, and a recorded backup path is not proof of a +complete backup. + +Check the [version-specific snapshot scope](AUTO_UPDATE.md#what-an-update-snapshot-contains) +and use the [stopped-service recovery procedure](AUTO_UPDATE.md#manual-rollback) +when data needs restoring. Keep matching data and keys together, verify the +backup privately and preserve reversible copies of the failed state. Do not +replace live runtime data or delete it to make a rollback fit. --- diff --git a/frontend-modern/browser-tests/update-recovery-help.cjs b/frontend-modern/browser-tests/update-recovery-help.cjs index fbc4daf6f3..a3045896fa 100644 --- a/frontend-modern/browser-tests/update-recovery-help.cjs +++ b/frontend-modern/browser-tests/update-recovery-help.cjs @@ -48,7 +48,10 @@ const { chromium, webkit } = require('playwright'); 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); + 'An image change alone is not a data rollback', 'published v6.4.5 and v6.4.6-rc.1', + 'Update History is not a full-state recovery tool', + 'Do not assume later settings and alert changes will be reverted', + 'not permission to rewind live runtime stores']) 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 }); @@ -91,10 +94,50 @@ const { chromium, webkit } = require('playwright'); 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')); + + await page.getByRole('link', { name: '← All documentation' }).click(); + const installLink = page.getByRole('link', { name: 'Install Pulse', exact: true }); + await installLink.focus(); + await page.keyboard.press('Enter'); + const installRollback = page.getByRole('heading', { name: 'Rollback', exact: true }); + await installRollback.waitFor(); + const installText = await page.locator('article').innerText(); + for (const text of ['Neither is guaranteed to include all active data', + 'Update History is not a full-state recovery tool', 'matching data and keys together', + 'preserve reversible copies', 'Do not replace live runtime data']) + assert.ok(installText.includes(text), text); + assert.ok(!installText.includes('auto-restores on failure')); + await installRollback.evaluate((heading) => heading.scrollIntoView({ block: 'start' })); + await page.waitForFunction(() => { + const heading = document.getElementById('rollback'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 200; + }); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-install-rollback.png`) }); + const snapshotLink = page.getByRole('link', { name: 'version-specific snapshot scope', exact: true }); + await snapshotLink.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const heading = document.getElementById('what-an-update-snapshot-contains'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#what-an-update-snapshot-contains')); + await page.goBack(); + await installRollback.waitFor(); + const stoppedLink = page.getByRole('link', { name: 'stopped-service recovery procedure', exact: true }); + await stoppedLink.focus(); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => { + const heading = document.getElementById('manual-rollback'); + return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight; + }); + assert.ok(page.url().endsWith('/docs/AUTO_UPDATE#manual-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 }); + auditLink: true, dockerFragment: true, rollbackLink: true, + versionQualifiedScope: true, noFullStatePromise: true, + installRollback: true, keyboardSnapshotLink: true, keyboardStoppedRecoveryLink: true, errors }); await browser.close(); browser = undefined; } diff --git a/frontend-modern/public/docs/AUTO_UPDATE.md b/frontend-modern/public/docs/AUTO_UPDATE.md index 6f366f4757..88b9128373 100644 --- a/frontend-modern/public/docs/AUTO_UPDATE.md +++ b/frontend-modern/public/docs/AUTO_UPDATE.md @@ -128,8 +128,9 @@ 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. +snapshots until recovery is verified. Returning to an older binary is not a +full-state recovery. Restoring a matching data snapshot can discard settings, +alert changes and history written since that snapshot. ### Automatic Rollback @@ -144,8 +145,16 @@ health afterwards; a progress message alone is not recovery evidence. 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. +Pulse restarts to use the restored binary. Check that version and the actual +backup contents before confirming. + +**Update History is not a full-state recovery tool.** Its restore scope depends +on the installed updater. Older updaters may replace `data/`, `config/` and +`.env` under the installation directory, but omit active data stored elsewhere. +Do not assume later settings and alert changes will be reverted, even if the +confirmation says so. If active data shares those installation paths, do not +restore it while Pulse is running; use the stopped-service procedure in +[Manual Rollback](#manual-rollback). 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 @@ -159,13 +168,23 @@ 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 +Snapshot scope is version-specific. The published **v6.4.5** and +**v6.4.6-rc.1** in-app updaters use the limited installation snapshot described +below. Do not assume a backup made by another updater version has the same +contents or restore behaviour. + +The limited 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. +A backup's existence, or the presence of a database file, does not establish +completeness, consistency or that Update History will restore it. A snapshot +that includes active data still needs a verified, stopped-service recovery +procedure; its presence is not permission to rewind live runtime stores. + 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 diff --git a/frontend-modern/public/docs/INSTALL.md b/frontend-modern/public/docs/INSTALL.md index 7a30c0c522..7b0d724b83 100644 --- a/frontend-modern/public/docs/INSTALL.md +++ b/frontend-modern/public/docs/INSTALL.md @@ -302,9 +302,21 @@ update. Use **Settings → Infrastructure → Install on a host** for a first in or a v5-to-v6 in-place upgrade. ### Rollback -If an update causes issues on systemd installations, backups are created automatically during the update process. +An update error does not establish which version is running. Check the running +version and service health before retrying or rolling back, and preserve the +failed installation and update logs. -**Manual rollback**: In-app updates store backups under `/etc/pulse/backup-/`. The systemd auto-update timer uses a temporary `/tmp/pulse-backup-` during the update and auto-restores on failure. +In-app update snapshots and updater-script backups have different contents and +lifetimes. Neither is guaranteed to include all active data; an older binary +also may not understand data migrated by a newer version. Update History is +not a full-state recovery tool, and a recorded backup path is not proof of a +complete backup. + +Check the [version-specific snapshot scope](AUTO_UPDATE.md#what-an-update-snapshot-contains) +and use the [stopped-service recovery procedure](AUTO_UPDATE.md#manual-rollback) +when data needs restoring. Keep matching data and keys together, verify the +backup privately and preserve reversible copies of the failed state. Do not +replace live runtime data or delete it to make a rollback fit. --- diff --git a/scripts/tests/test_update_recovery_docs.py b/scripts/tests/test_update_recovery_docs.py index 5a9c993716..0e1df0c8d1 100644 --- a/scripts/tests/test_update_recovery_docs.py +++ b/scripts/tests/test_update_recovery_docs.py @@ -2,7 +2,8 @@ """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. +The remaining checks bind the shipped help to the existing UI labels and +protect version-qualified recovery advice, not a particular snapshot writer. """ import json @@ -76,7 +77,7 @@ class UpdateRecoveryDocsTest(unittest.TestCase): 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"): + for name in ("AUTO_UPDATE", "DOCKER", "INSTALL"): with self.subTest(guide=name): self.assertEqual(guide(name), (ROOT / "frontend-modern/public/docs" / f"{name}.md").read_text()) @@ -89,25 +90,42 @@ class UpdateRecoveryDocsTest(unittest.TestCase): 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"): + "Older versions may not have this control", "actual backup contents"): 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_online_rollback_never_promises_full_state_or_live_data_rewind(self): + help_text = " ".join(section("AUTO_UPDATE", "Rollback").split()) + for phrase in ("Returning to an older binary is not a full-state recovery", + "Update History is not a full-state recovery tool", + "Do not assume later settings and alert changes will be reverted", + "omit active data stored elsewhere", "do not restore it while Pulse is running", + "[Manual Rollback](#manual-rollback)"): + self.assertIn(phrase, help_text) + self.assertNotIn("warns that later settings and alert changes will be reverted", help_text) + + def test_snapshot_and_install_help_qualify_scope_without_claiming_future_support(self): + scope = " ".join(section("AUTO_UPDATE", "What an update snapshot contains", "###").split()) + for phrase in ("published **v6.4.5** and **v6.4.6-rc.1**", "limited installation snapshot", + "backup made by another updater version", "completeness, consistency", + "not permission to rewind live runtime stores"): + self.assertIn(phrase, scope) + install = " ".join(section("INSTALL", "Rollback", "###").split()) + for phrase in ("Neither is guaranteed to include all active data", + "Update History is not a full-state recovery tool", + "data migrated by a newer version", "version-specific snapshot scope", + "AUTO_UPDATE.md#manual-rollback", "matching data and keys together", + "preserve reversible copies", "Do not replace live runtime data"): + self.assertIn(phrase, install) + self.assertNotIn("backups are created automatically", install) + self.assertNotIn("auto-restores on failure", install) def test_recovery_preserves_failed_state_keys_and_sidecars(self): help_text = " ".join(section("AUTO_UPDATE", "Rollback").split())