Route deployment updates through evidence-preserving procedures

Remove unconfigured Docker and Helm shorthand from the deployment overview. Preserve target edition, existing service and storage scope, and the limits of snapshots and rollback; exercise the maintained help links and regression guards.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 04:56:56 +01:00
parent 3d9105349f
commit d16e665e6c
4 changed files with 272 additions and 32 deletions
+45 -16
View File
@@ -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
@@ -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; });
@@ -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
@@ -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()