diff --git a/docs/DEPLOYMENT_MODELS.md b/docs/DEPLOYMENT_MODELS.md index 5542939316..d150b7ad99 100644 --- a/docs/DEPLOYMENT_MODELS.md +++ b/docs/DEPLOYMENT_MODELS.md @@ -4,7 +4,7 @@ Pulse supports multiple deployment models. This page clarifies what differs betw ## Summary -| Model | Recommended for | Data/config path | Updates | +| Model | Recommended for | Default data/config path | Updates | | --- | --- | --- | --- | | Proxmox VE LXC (installer) | Proxmox-first deployments | `/etc/pulse` | In-app updates supported | | systemd (bare metal / VM) | Traditional Linux hosts | `/etc/pulse` | In-app updates supported | @@ -59,11 +59,16 @@ Pulse uses a split config model: - **Relay config**: `relay.enc` (encrypted, Relay and above) - **RBAC roles**: `rbac_roles.json` (Pro/legacy Pro+/Cloud) -Path mapping: +Default path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +These are defaults, not a backup inventory. Check the active deployment's +`PULSE_DATA_DIR`, mounts and any external stores before copying or restoring +files. Metrics can use a separate `PULSE_METRICS_DB_PATH`; see +[Metrics storage](METRICS_HISTORY.md#storage-location). + The audit paths above are the default store, not a licence-dependent guarantee that storage initialised successfully. Runtime-specific storage can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring @@ -84,30 +89,54 @@ Provider-hosted MSP layout: ## Updates by Model +Before changing the server binary or image, record its running version and +edition, keep the saved deployment configuration, and take a consistent private +backup of the actual data and external stores. Preserve encrypted files with +their matching `.encryption.key` and audit signing key. Use a stopped, consistent +filesystem/volume backup or supported backup tooling; copying a live `.db` file +alone can omit SQLite sidecar files. Do not post configuration files, environment +dumps or Helm secret values in an issue. + +An update snapshot is not necessarily a complete data backup, and reverting a +binary or image does not undo data migrations. Check +[rollback and backup scope](AUTO_UPDATE.md#rollback) before updating or reverting; +keep the previous binary/image and matching data backup until recovery is checked. + ### systemd and Proxmox LXC -Use the UI: +Use **Settings → System → Updates**. Follow +[server update guidance](AUTO_UPDATE.md) for the deployment's update controls and +Update History. Pulse attempts snapshot and rollback operations; a recorded +backup path alone does not establish that all active data was copied or that +recovery succeeded. -- **Settings → System → Updates** - -These deployments can apply updates by downloading a release and swapping binaries/config safely with backups and history. +For LXC, Pulse's service and data are inside the container, not on the Proxmox +host. Keep the failed state and check the running version and service health +before retrying a failed update. ### Docker -Pull a new image and restart: +Follow [Docker server updates](DOCKER.md#-updates) from the original Compose +project or saved container deployment. Persist the exact target in its `image:` +line, or in `PULSE_IMAGE` when that line uses the variable; pulling a different +tag alone does not change the configured image. Paid Pro installs must keep +their private image, not replace it with the Community image. + +Pull and recreate only the Pulse service, stopping if the pull fails. Preserve +its data volume, ports and settings, and check the running image and server +version afterwards. This updates the Pulse server, not its agents or the +containers it monitors; there is no need to restart unrelated Compose services. -```bash -docker pull rcourtman/pulse:latest -docker compose up -d -``` ### Kubernetes (Helm) -Upgrade the chart: +Use the existing Helm release and namespace, the chosen chart version and your +saved deployment values. Review changes to persistence, Secret references and +image edition before rollout; keep the current PVC, keys and settings. Do not +let a default Community image replace paid Pro. -```bash -helm repo update -helm upgrade pulse pulse/pulse -n pulse -``` +See [Kubernetes deployment settings](KUBERNETES.md) and use your cluster's +rollout and recovery procedure. A Helm rollback changes release resources; it +does not guarantee restoration of Pulse data changed by a newer binary. ### Provider-hosted MSP diff --git a/frontend-modern/browser-tests/deployment-update-help.cjs b/frontend-modern/browser-tests/deployment-update-help.cjs new file mode 100644 index 0000000000..3c897e5731 --- /dev/null +++ b/frontend-modern/browser-tests/deployment-update-help.cjs @@ -0,0 +1,105 @@ +// Production Docs/router and real shipped Markdown; no backend or deployment. +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', 'deployment-update-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-deployment-update-help'), + server: { host: '127.0.0.1', port: 5250, 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:5250/browser-tests/docs-fragment-navigation.html', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + await page.getByRole('link', { name: '← All documentation' }).click(); + await page.getByRole('link', { name: 'Deployment models', exact: true }).click(); + await page.getByRole('heading', { name: 'Updates by Model', 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 text = await page.locator('article').innerText(); + for (const phrase of ['defaults, not a backup inventory', 'SQLite sidecar files', + 'snapshot is not necessarily a complete data backup', 'Persist the exact target', + 'Paid Pro installs must keep their private image', 'only the Pulse service', + 'stopping if the pull fails', 'existing Helm release and namespace', + 'current PVC, keys and settings']) assert.ok(text.includes(phrase), phrase); + for (const unsafe of ['docker pull rcourtman/pulse:latest', 'docker compose up -d', + 'helm upgrade pulse pulse/pulse', 'swapping binaries/config safely']) + assert.ok(!text.includes(unsafe), unsafe); + await page.getByRole('heading', { name: 'Updates by Model', exact: true }) + .evaluate((heading) => heading.scrollIntoView({ block: 'start' })); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({ path: path.join(artifacts, `${engine}-overview.png`) }); + + // Keyboard activation must reach the actual maintained procedure/anchor. + const docker = page.getByRole('link', { name: 'Docker server updates', exact: true }); + await docker.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('heading', { name: '🔄 Updates', exact: true }).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 phrase of ['persist PULSE_IMAGE', 'docker compose pull pulse', + 'docker compose up -d --no-deps pulse', 'paid Pro installs must keep the private image']) + assert.ok(dockerText.includes(phrase), phrase); + 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.ok((await page.locator('article').innerText()).includes('Copy errors can leave a partial snapshot')); + + await page.goBack(); + await page.goBack(); + await page.getByRole('heading', { name: 'Updates by Model', exact: true }).waitFor(); + await page.getByRole('link', { name: 'Kubernetes deployment settings', exact: true }).click(); + await page.waitForFunction(() => location.pathname === '/docs/KUBERNETES' && + document.querySelector('article')?.textContent.includes('Kubernetes cluster')); + await page.goBack(); + await page.getByRole('link', { name: 'Metrics storage', exact: true }).click(); + await page.getByRole('heading', { name: 'Storage Location', exact: true }).waitFor(); + assert.ok(page.url().endsWith('/docs/METRICS_HISTORY#storage-location')); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, tone, + overviewLimits: true, keyboardDockerLink: true, scopedDockerProcedure: true, + rollbackLink: true, kubernetesLink: true, metricsLink: true, errors }); + await browser.close(); + browser = undefined; + } + const result = { playwright: require('playwright/package.json').version, results }; + fs.writeFileSync(path.join(artifacts, 'result.json'), `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify(result)}\n`); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch((error) => { process.stderr.write(`${error.stack}\n`); process.exitCode = 1; }); diff --git a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md index 5542939316..d150b7ad99 100644 --- a/frontend-modern/public/docs/DEPLOYMENT_MODELS.md +++ b/frontend-modern/public/docs/DEPLOYMENT_MODELS.md @@ -4,7 +4,7 @@ Pulse supports multiple deployment models. This page clarifies what differs betw ## Summary -| Model | Recommended for | Data/config path | Updates | +| Model | Recommended for | Default data/config path | Updates | | --- | --- | --- | --- | | Proxmox VE LXC (installer) | Proxmox-first deployments | `/etc/pulse` | In-app updates supported | | systemd (bare metal / VM) | Traditional Linux hosts | `/etc/pulse` | In-app updates supported | @@ -59,11 +59,16 @@ Pulse uses a split config model: - **Relay config**: `relay.enc` (encrypted, Relay and above) - **RBAC roles**: `rbac_roles.json` (Pro/legacy Pro+/Cloud) -Path mapping: +Default path mapping: - systemd/LXC: `/etc/pulse/*` - Docker/Helm: `/data/*` +These are defaults, not a backup inventory. Check the active deployment's +`PULSE_DATA_DIR`, mounts and any external stores before copying or restoring +files. Metrics can use a separate `PULSE_METRICS_DB_PATH`; see +[Metrics storage](METRICS_HISTORY.md#storage-location). + The audit paths above are the default store, not a licence-dependent guarantee that storage initialised successfully. Runtime-specific storage can differ; see [Audit storage and safe recovery](AUDIT_LOGGING.md#storage) before restoring @@ -84,30 +89,54 @@ Provider-hosted MSP layout: ## Updates by Model +Before changing the server binary or image, record its running version and +edition, keep the saved deployment configuration, and take a consistent private +backup of the actual data and external stores. Preserve encrypted files with +their matching `.encryption.key` and audit signing key. Use a stopped, consistent +filesystem/volume backup or supported backup tooling; copying a live `.db` file +alone can omit SQLite sidecar files. Do not post configuration files, environment +dumps or Helm secret values in an issue. + +An update snapshot is not necessarily a complete data backup, and reverting a +binary or image does not undo data migrations. Check +[rollback and backup scope](AUTO_UPDATE.md#rollback) before updating or reverting; +keep the previous binary/image and matching data backup until recovery is checked. + ### systemd and Proxmox LXC -Use the UI: +Use **Settings → System → Updates**. Follow +[server update guidance](AUTO_UPDATE.md) for the deployment's update controls and +Update History. Pulse attempts snapshot and rollback operations; a recorded +backup path alone does not establish that all active data was copied or that +recovery succeeded. -- **Settings → System → Updates** - -These deployments can apply updates by downloading a release and swapping binaries/config safely with backups and history. +For LXC, Pulse's service and data are inside the container, not on the Proxmox +host. Keep the failed state and check the running version and service health +before retrying a failed update. ### Docker -Pull a new image and restart: +Follow [Docker server updates](DOCKER.md#-updates) from the original Compose +project or saved container deployment. Persist the exact target in its `image:` +line, or in `PULSE_IMAGE` when that line uses the variable; pulling a different +tag alone does not change the configured image. Paid Pro installs must keep +their private image, not replace it with the Community image. + +Pull and recreate only the Pulse service, stopping if the pull fails. Preserve +its data volume, ports and settings, and check the running image and server +version afterwards. This updates the Pulse server, not its agents or the +containers it monitors; there is no need to restart unrelated Compose services. -```bash -docker pull rcourtman/pulse:latest -docker compose up -d -``` ### Kubernetes (Helm) -Upgrade the chart: +Use the existing Helm release and namespace, the chosen chart version and your +saved deployment values. Review changes to persistence, Secret references and +image edition before rollout; keep the current PVC, keys and settings. Do not +let a default Community image replace paid Pro. -```bash -helm repo update -helm upgrade pulse pulse/pulse -n pulse -``` +See [Kubernetes deployment settings](KUBERNETES.md) and use your cluster's +rollout and recovery procedure. A Helm rollback changes release resources; it +does not guarantee restoration of Pulse data changed by a newer binary. ### Provider-hosted MSP diff --git a/scripts/tests/test_deployment_update_docs.py b/scripts/tests/test_deployment_update_docs.py new file mode 100644 index 0000000000..38093525bf --- /dev/null +++ b/scripts/tests/test_deployment_update_docs.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Keep the deployment entry point from bypassing the safe update procedures. + +These are documentation regression checks, not installed update/restore proof. +The linked Compose recipe has executable controls in test_update_recovery_docs; +the production Docs navigation is exercised by deployment-update-help.cjs. +""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] + + +def guide(name): + return (ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8") + + +def overview(): + return guide("DEPLOYMENT_MODELS") + + +class DeploymentUpdateDocsTest(unittest.TestCase): + def test_shipped_overview_matches_the_reviewed_guide(self): + self.assertEqual(overview(), (ROOT / "frontend-modern/public/docs/DEPLOYMENT_MODELS.md") + .read_text(encoding="utf-8")) + + def test_default_paths_are_not_a_complete_backup_inventory(self): + text = " ".join(overview().split()) + for phrase in ("Default data/config path", "defaults, not a backup inventory", + "`PULSE_DATA_DIR`", "external stores", "`PULSE_METRICS_DB_PATH`"): + self.assertIn(phrase, text) + self.assertIn("METRICS_HISTORY.md#storage-location", text) + self.assertIn("AUDIT_LOGGING.md#storage", text) + + def test_backup_and_rollback_claims_preserve_state_and_limits(self): + text = " ".join(overview().split()) + for phrase in ("running version and edition", "consistent private backup", + "matching `.encryption.key` and audit signing key", + "stopped, consistent filesystem/volume backup", "SQLite sidecar files", + "Do not post configuration files, environment dumps or Helm secret values", + "snapshot is not necessarily a complete data backup", + "does not undo data migrations", "backup path alone does not establish", + "Keep the failed state", "before retrying a failed update"): + self.assertIn(phrase, text) + self.assertNotIn("swapping binaries/config safely", text) + + def test_overview_routes_to_the_maintained_procedures_not_a_shortcut(self): + updates = overview().split("## Updates by Model", 1)[1] + # A second shorthand can skip durable image selection, backup checks, + # fail-fast pulling or the actual release/namespace and saved values. + self.assertNotRegex(updates, r"```(?:bash|sh|shell)\b") + for link in ("AUTO_UPDATE.md#rollback", "AUTO_UPDATE.md", "DOCKER.md#-updates", + "KUBERNETES.md"): + self.assertIn(f"]({link})", updates) + self.assertTrue((ROOT / "frontend-modern/public/docs" / link.split("#")[0]).is_file()) + self.assertIn("## 🔄 Updates", guide("DOCKER")) + self.assertIn("## Rollback", guide("AUTO_UPDATE")) + + def test_deployment_specific_context_survives_the_handoff(self): + updates = " ".join(overview().split("## Updates by Model", 1)[1].split()) + for phrase in ("inside the container, not on the Proxmox host", + "original Compose project", "Persist the exact target", + "pulling a different tag alone does not change the configured image", + "Paid Pro installs must keep their private image", + "only the Pulse service, stopping if the pull fails", + "no need to restart unrelated Compose services", + "existing Helm release and namespace", "chosen chart version", + "saved deployment values", "current PVC, keys and settings", + "does not guarantee restoration of Pulse data"): + self.assertIn(phrase, updates) + + +if __name__ == "__main__": + unittest.main()