Preserve persistent state in server removal guidance

Replace destructive default uninstall recipes with bounded service/container removal and explicit erasure warnings. Explain default Helm PVC ownership, temporary storage and updater/agent lifecycle boundaries; retain matching data and encryption keys.

Contract-Neutral: Existing documentation and focused verification only; no installer, runtime, API, storage, privilege or lifecycle contract delta.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 05:26:00 +01:00
parent 6cf691ecf1
commit fa6282b716
4 changed files with 395 additions and 16 deletions
+96 -8
View File
@@ -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.
@@ -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; });
+96 -8
View File
@@ -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.
@@ -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