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())