diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9c9c185261..872dd58ec1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -349,19 +349,107 @@ replace live runtime data or delete it to make a rollback fit. ## 🗑️ Uninstall -**Docker**: +Removing or stopping the Pulse server also stops monitoring and alert delivery. +This is not an update, rollback or password-reset procedure. Keep persistent +data by default; deleting it erases configuration, history, credentials and +the keys needed to decrypt that installation's data. + +Before removing anything, identify the actual service, container or Helm +release and **every effective data path** privately. Keep a consistent, +private [full-state backup](MIGRATION.md#full-state-recovery), including the +matching encryption key and deployment configuration. A configuration export +alone is not a full backup. Let any in-progress Pulse update finish before +removing its installation. Do not post backups, environment files or full +container inspections in an issue. + +### Docker and Compose: retain the data mount + +The commands below assume `/data` is on a persistent named volume or bind +mount, as in this guide's examples. **Do not use them for state stored only in +the container's writable layer or temporary storage until it has a consistent +backup.** A container created with `--rm` can also delete anonymous volumes +when stopped. Container removal is not a backup. + +For the `docker run` example, stop the container normally before removing it: + ```bash -docker rm -f pulse && docker volume rm pulse_data +docker stop pulse +docker rm pulse ``` -**Kubernetes**: +For Compose, run this from the **existing** project, using its actual Pulse +service name: + ```bash -helm uninstall pulse -n pulse +docker compose stop pulse +docker compose rm pulse ``` -**Systemd**: +These commands do not request volume deletion. Keep the named volume or bind +directory, original image/runtime and deployment settings; a reinstall must +reattach the **same** data mount. Compose normally prefixes volume names with +its project name, so creating a new project or an empty `pulse_data` volume +can look like data loss. Do not add `-v`/`--volumes`, remove the volume or run +volume pruning as part of a data-preserving removal. + +### Kubernetes: check claim ownership before uninstalling + +Do not assume `helm uninstall pulse -n pulse` retains data. The default Pulse +chart creates a PersistentVolumeClaim without a keep policy; Helm removal can +delete that claim, and the storage reclaim policy can delete its backing data. +`persistence.existingClaim` refers to a separately managed claim, whose +lifecycle must be checked separately. With `persistence.enabled=false`, the +chart uses temporary `emptyDir` storage, lost when the pod is removed. + +After verifying persistent storage and its backup, you can stop the default +deployment without uninstalling the chart: + ```bash -sudo systemctl disable --now pulse -sudo rm -rf /etc/pulse /etc/systemd/system/pulse.service /usr/local/bin/pulse -sudo systemctl daemon-reload +kubectl scale deployment pulse \ + --namespace pulse \ + --replicas=0 ``` + +Use the actual deployment and namespace. Record the previous replica count +and suspend any controller that would recreate pods. Before permanently +uninstalling, verify the live claim's ownership, retention and reclaim policy +and test recovery from the private backup; do not delete a PVC or namespace +as a troubleshooting step. + +### Systemd / Proxmox LXC: disable without erasing data + +Identify the active service and any updater first; legacy installs can use +`pulse-backend`, and custom installs can have other names. In an LXC, run +these steps **inside the Pulse container**, not on the Proxmox host. + +For the default signed installation, disable its update timer **if present**: + +```bash +sudo systemctl disable --now \ + pulse-update.timer +``` + +Then stop and disable the server: + +```bash +sudo systemctl disable --now \ + pulse.service +``` + +Disable other deployment-managed updaters through their owner too. These +commands leave the binary, service account, units and data in place; they +disable Pulse, not fully uninstall it. Keep `/etc/pulse` (or the actual custom +data directory), its keys and authentication sources together. Do not remove +the service account while retained files still need its ownership. + +**Complete removal is a separate, destructive choice.** The signed server +installer's `--uninstall` deletes its configuration/data directory without a +keep-data prompt; it is not a data-preserving alternative. Use it only after +checking the effective install/config paths and a tested private backup, when +you intend that erasure. Do not delete `/bin/update` unless you have verified +it belongs to Pulse; community-scripts containers can use a different helper. + +Removing the server does not remove agents on monitored hosts. Use each +agent's [uninstall procedure](UNIFIED_AGENT.md#uninstall), or +[stop an orphaned agent](TROUBLESHOOTING.md#removed-pulse-server-but-pulse-agent-still-logs-connection-failures), +so it does not keep retrying the absent server. diff --git a/frontend-modern/browser-tests/server-removal-help.cjs b/frontend-modern/browser-tests/server-removal-help.cjs new file mode 100644 index 0000000000..a13c0bdabe --- /dev/null +++ b/frontend-modern/browser-tests/server-removal-help.cjs @@ -0,0 +1,125 @@ +// Render the shipped help and follow its recovery/agent links. Never execute +// any removal command: this verifies guidance, not native uninstall behaviour. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + const artifacts = path.join(root, 'node_modules', 'server-removal-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-server-removal-help'), + server: { host: '127.0.0.1', port: 5252, strictPort: true }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const results = []; + let browser; + try { + await server.listen(); + for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) { + 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: 1000 }, + ...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) }); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + await page.goto('http://127.0.0.1:5252/browser-tests/docs-fragment-navigation.html?scenario=installation', + { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const uninstall = page.getByRole('heading', { level: 2, name: '🗑️ Uninstall', exact: true }); + await uninstall.waitFor(); + if (engine === 'webkit') { + await page.evaluate(async () => { + document.documentElement.classList.add('dark'); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {}))); + }); + } + const section = await uninstall.evaluate((heading) => { + const nodes = []; + for (let node = heading.nextElementSibling; node; node = node.nextElementSibling) + nodes.push(node); + return { + text: nodes.map((node) => node.innerText).join(' ').replace(/\s+/g, ' '), + commands: nodes.filter((node) => node.tagName === 'PRE').flatMap((node) => + node.innerText.replace(/\\\n\s*/g, ' ').trim().split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim())), + commandBoxes: nodes.filter((node) => node.tagName === 'PRE').map((node) => ({ + clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, + })), + }; + }); + for (const text of ['stops monitoring and alert delivery', 'Keep persistent data by default', + 'every effective data path', 'A configuration export alone is not a full backup', + 'container\'s writable layer or temporary storage', '--rm can also delete anonymous volumes', + 'reattach the same data mount', 'prefixes volume names', 'without a keep policy', + 'reclaim policy can delete its backing data', 'emptyDir storage, lost when the pod is removed', + 'inside the Pulse container', 'disable Pulse, not fully uninstall it', + 'without a keep-data prompt', 'Removing the server does not remove agents']) + assert.ok(section.text.includes(text), text); + assert.deepEqual(section.commands, [ + 'docker stop pulse', 'docker rm pulse', 'docker compose stop pulse', 'docker compose rm pulse', + 'kubectl scale deployment pulse --namespace pulse --replicas=0', + 'sudo systemctl disable --now pulse-update.timer', 'sudo systemctl disable --now pulse.service', + ]); + assert.ok(section.commandBoxes.every((box) => box.scrollWidth <= box.clientWidth + 1), + 'removal commands must fit without phone clipping'); + for (const [name, heading] of [ + ['docker', uninstall], + ['kubernetes', page.getByRole('heading', { level: 3, + name: 'Kubernetes: check claim ownership before uninstalling', exact: true })], + ['systemd', page.getByRole('heading', { level: 3, + name: 'Systemd / Proxmox LXC: disable without erasing data', exact: true })], + ]) { + await heading.evaluate((element) => element.scrollIntoView({ block: 'start' })); + await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) }); + } + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + const backup = page.getByRole('link', { name: 'full-state backup', exact: true }); + assert.equal(await backup.getAttribute('href'), '/docs/MIGRATION#full-state-recovery'); + await backup.focus(); + await page.keyboard.press('Enter'); + await page.waitForURL('**/docs/MIGRATION#full-state-recovery'); + await page.getByRole('heading', { level: 3, name: 'Full-state recovery', exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement?.id), 'full-state-recovery'); + await page.goBack(); + await uninstall.waitFor(); + const agent = page.getByRole('link', { name: 'uninstall procedure', exact: true }); + assert.equal(await agent.getAttribute('href'), '/docs/UNIFIED_AGENT#uninstall'); + await agent.focus(); + await page.keyboard.press('Enter'); + await page.waitForURL('**/docs/UNIFIED_AGENT#uninstall'); + await page.getByRole('heading', { level: 2, name: 'Uninstall', exact: true }).waitFor(); + assert.equal(await page.evaluate(() => document.activeElement?.id), 'uninstall'); + assert.deepEqual(errors, []); + results.push({ engine, version: browser.version(), width, + persistentDataWarning: true, commandsFit: true, noDocumentOverflow: true, + helmRetentionWarning: true, installerErasureWarning: true, + keyboardFullStateRecovery: true, keyboardAgentUninstall: true, errors }); + await browser.close(); + browser = undefined; + } + const files = ['public/docs/INSTALL.md', 'browser-tests/server-removal-help.cjs', + 'browser-tests/docs-fragment-navigation.tsx', 'src/features/docs/docMarkdown.ts', + 'src/pages/Docs.tsx'].map((file) => ({ file, + sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') })); + const result = { result: 'passed', playwright: require('playwright/package.json').version, + scope: 'shipped server-removal help rendering and keyboard links; no removal/native commands executed', + dompurify: JSON.parse(fs.readFileSync(path.join(root, 'node_modules/dompurify/package.json'))).version, + files, 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/INSTALL.md b/frontend-modern/public/docs/INSTALL.md index 9c9c185261..872dd58ec1 100644 --- a/frontend-modern/public/docs/INSTALL.md +++ b/frontend-modern/public/docs/INSTALL.md @@ -349,19 +349,107 @@ replace live runtime data or delete it to make a rollback fit. ## 🗑️ Uninstall -**Docker**: +Removing or stopping the Pulse server also stops monitoring and alert delivery. +This is not an update, rollback or password-reset procedure. Keep persistent +data by default; deleting it erases configuration, history, credentials and +the keys needed to decrypt that installation's data. + +Before removing anything, identify the actual service, container or Helm +release and **every effective data path** privately. Keep a consistent, +private [full-state backup](MIGRATION.md#full-state-recovery), including the +matching encryption key and deployment configuration. A configuration export +alone is not a full backup. Let any in-progress Pulse update finish before +removing its installation. Do not post backups, environment files or full +container inspections in an issue. + +### Docker and Compose: retain the data mount + +The commands below assume `/data` is on a persistent named volume or bind +mount, as in this guide's examples. **Do not use them for state stored only in +the container's writable layer or temporary storage until it has a consistent +backup.** A container created with `--rm` can also delete anonymous volumes +when stopped. Container removal is not a backup. + +For the `docker run` example, stop the container normally before removing it: + ```bash -docker rm -f pulse && docker volume rm pulse_data +docker stop pulse +docker rm pulse ``` -**Kubernetes**: +For Compose, run this from the **existing** project, using its actual Pulse +service name: + ```bash -helm uninstall pulse -n pulse +docker compose stop pulse +docker compose rm pulse ``` -**Systemd**: +These commands do not request volume deletion. Keep the named volume or bind +directory, original image/runtime and deployment settings; a reinstall must +reattach the **same** data mount. Compose normally prefixes volume names with +its project name, so creating a new project or an empty `pulse_data` volume +can look like data loss. Do not add `-v`/`--volumes`, remove the volume or run +volume pruning as part of a data-preserving removal. + +### Kubernetes: check claim ownership before uninstalling + +Do not assume `helm uninstall pulse -n pulse` retains data. The default Pulse +chart creates a PersistentVolumeClaim without a keep policy; Helm removal can +delete that claim, and the storage reclaim policy can delete its backing data. +`persistence.existingClaim` refers to a separately managed claim, whose +lifecycle must be checked separately. With `persistence.enabled=false`, the +chart uses temporary `emptyDir` storage, lost when the pod is removed. + +After verifying persistent storage and its backup, you can stop the default +deployment without uninstalling the chart: + ```bash -sudo systemctl disable --now pulse -sudo rm -rf /etc/pulse /etc/systemd/system/pulse.service /usr/local/bin/pulse -sudo systemctl daemon-reload +kubectl scale deployment pulse \ + --namespace pulse \ + --replicas=0 ``` + +Use the actual deployment and namespace. Record the previous replica count +and suspend any controller that would recreate pods. Before permanently +uninstalling, verify the live claim's ownership, retention and reclaim policy +and test recovery from the private backup; do not delete a PVC or namespace +as a troubleshooting step. + +### Systemd / Proxmox LXC: disable without erasing data + +Identify the active service and any updater first; legacy installs can use +`pulse-backend`, and custom installs can have other names. In an LXC, run +these steps **inside the Pulse container**, not on the Proxmox host. + +For the default signed installation, disable its update timer **if present**: + +```bash +sudo systemctl disable --now \ + pulse-update.timer +``` + +Then stop and disable the server: + +```bash +sudo systemctl disable --now \ + pulse.service +``` + +Disable other deployment-managed updaters through their owner too. These +commands leave the binary, service account, units and data in place; they +disable Pulse, not fully uninstall it. Keep `/etc/pulse` (or the actual custom +data directory), its keys and authentication sources together. Do not remove +the service account while retained files still need its ownership. + +**Complete removal is a separate, destructive choice.** The signed server +installer's `--uninstall` deletes its configuration/data directory without a +keep-data prompt; it is not a data-preserving alternative. Use it only after +checking the effective install/config paths and a tested private backup, when +you intend that erasure. Do not delete `/bin/update` unless you have verified +it belongs to Pulse; community-scripts containers can use a different helper. + +Removing the server does not remove agents on monitored hosts. Use each +agent's [uninstall procedure](UNIFIED_AGENT.md#uninstall), or +[stop an orphaned agent](TROUBLESHOOTING.md#removed-pulse-server-but-pulse-agent-still-logs-connection-failures), +so it does not keep retrying the absent server. diff --git a/frontend-modern/src/utils/__tests__/docsLinks.test.ts b/frontend-modern/src/utils/__tests__/docsLinks.test.ts index 497dd069d2..a08c2189a0 100644 --- a/frontend-modern/src/utils/__tests__/docsLinks.test.ts +++ b/frontend-modern/src/utils/__tests__/docsLinks.test.ts @@ -61,6 +61,84 @@ function getRuntimeSourceFiles(dir: string): string[] { } describe('docsLinks', () => { + it('separates server removal from persistent-data erasure', () => { + const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8'); + const removal = installation.split('## 🗑️ Uninstall')[1]; + expect(removal).toContain('stops monitoring and alert delivery'); + expect(removal).toContain('Keep persistent\ndata by default'); + expect(removal).toContain('every effective data path'); + expect(removal).toContain('[full-state backup](MIGRATION.md#full-state-recovery)'); + expect(removal).toContain('A configuration export\nalone is not a full backup'); + expect(removal).toContain('Let any in-progress Pulse update finish'); + expect(removal).toContain('without a\nkeep-data prompt'); + expect(removal).toContain('Do not remove\nthe service account'); + expect(removal).toContain('Do not delete `/bin/update` unless'); + expect(removal).toContain('Removing the server does not remove agents'); + + // Pin the copyable recipes, not merely the surrounding warnings. The old + // Docker/root commands erased the complete data store as part of removal. + const commands = [...removal.matchAll(/```bash\n([\s\S]*?)```/g)].flatMap((match) => + match[1] + .replace(/\\\n\s*/g, ' ') + .trim() + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()), + ); + expect(commands).toEqual([ + 'docker stop pulse', + 'docker rm pulse', + 'docker compose stop pulse', + 'docker compose rm pulse', + 'kubectl scale deployment pulse --namespace pulse --replicas=0', + 'sudo systemctl disable --now pulse-update.timer', + 'sudo systemctl disable --now pulse.service', + ]); + }); + + it('bounds container-removal advice to retained persistent mounts', () => { + const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8'); + const docker = ( + installation.split('### Docker and Compose: retain the data mount')[1] ?? '' + ).split('### Kubernetes:')[0]; + expect(docker).toContain('persistent named volume or bind'); + expect(docker).toContain("container's writable layer or temporary storage"); + expect(docker).toContain('`--rm` can also delete anonymous volumes'); + expect(docker).toContain('**existing** project'); + expect(docker).toContain('reattach the **same** data mount'); + expect(docker).toContain('prefixes volume names'); + expect(docker).toContain('Do not add `-v`/`--volumes`'); + expect(docker).not.toContain('docker rm -f'); + expect(docker).not.toContain('docker volume rm'); + }); + + it('does not promise Helm retention where the current chart owns the claim', () => { + const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8'); + const kubernetes = ( + installation.split('### Kubernetes: check claim ownership before uninstalling')[1] ?? '' + ).split('### Systemd / Proxmox LXC:')[0]; + expect(kubernetes).toContain('Do not assume `helm uninstall pulse -n pulse` retains data'); + expect(kubernetes).toContain('without a keep policy'); + expect(kubernetes).toContain('reclaim policy can delete its backing data'); + expect(kubernetes).toContain('`persistence.existingClaim`'); + expect(kubernetes).toContain('`emptyDir` storage, lost when the pod is removed'); + expect(kubernetes.indexOf('After verifying persistent storage and its backup')).toBeLessThan( + kubernetes.indexOf('```bash'), + ); + expect(kubernetes).toContain('suspend any controller'); + const chart = readFileSync( + path.join(repoRoot, 'deploy', 'helm', 'pulse', 'templates', 'pvc.yaml'), + 'utf8', + ); + expect(chart).toContain('kind: PersistentVolumeClaim'); + expect(chart).toContain('(not .Values.persistence.existingClaim)'); + expect(chart).not.toContain('helm.sh/resource-policy'); + const values = readFileSync( + path.join(repoRoot, 'deploy', 'helm', 'pulse', 'values.yaml'), + 'utf8', + ); + expect(values.split('persistence:')[1].split('server:')[0]).toContain('annotations: {}'); + }); + it('keeps custom systemd server help on the signed non-root install path', () => { const installation = readFileSync(path.join(repoRoot, 'docs', 'INSTALL.md'), 'utf8'); const custom = installation