Merge reviewed evidence-preserving update and recovery help

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 04:45:59 +01:00
6 changed files with 544 additions and 84 deletions
+94 -28
View File
@@ -83,19 +83,16 @@ Auto-update preferences are stored in `system.json` and edited via the UI.
### Docker
```bash
# Pull latest image
docker pull rcourtman/pulse:vX.Y.Z
Use [Docker server updates](DOCKER.md#-updates). Change the configured image to
the exact release first, then pull and recreate only the Pulse service with the
same mounted data. Pulling another tag alone does not change the image in your
Compose file. Do not run `docker compose down` for a Pulse-only update or remove
its data volume.
# Restart container
docker compose down && docker compose up -d
```
This command uses the public Community image. Private Pro runtime installs must
use the private image and credentials supplied by the private download/update
path; replacing a Pro image with `rcourtman/pulse` changes the runtime edition.
If you use the legacy `docker-compose` binary, replace `docker compose` with `docker-compose`.
Private Pro installs must keep the private image supplied by the
[download page](https://pulserelay.pro/download.html); replacing it with
`rcourtman/pulse` changes the runtime edition. Server updates and monitored
container updates are separate operations.
### ProxmoxVE LXC (Manual)
@@ -124,30 +121,99 @@ sudo systemctl restart pulse
## Rollback
If an update causes issues:
An update error or a stuck progress modal does not prove that the old version
is still running. Check the running version and service health before retrying
or restoring anything; preserve the update error, logs and history. Do not run
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.
### Automatic Rollback
Pulse creates a backup before updating. If the update fails:
1. The previous version is automatically restored
2. Service restarts with the old version
3. Error details are logged
The in-app updater creates an installation snapshot before applying files. If
applying files fails, it attempts to restore that snapshot and logs any restore
error. This is not a guarantee that every update failure restores all state or
restarts successfully. Confirm the running version, connections and service
health afterwards; a progress message alone is not recovery evidence.
### Roll back from Update History
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.
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
use their previous image and matching data backup, not this in-app rollback.
### What an update snapshot contains
In-app snapshots normally live at `backup-<timestamp>/` under the runtime data
directory (usually `/etc/pulse`), with `/tmp/pulse-backup-<timestamp>` as a
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
`.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.
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
a stopped, consistent filesystem/volume backup; copying a live `.db` file alone
can omit SQLite sidecar files. Keep these backups private, not in an issue or
diagnostic attachment. See [configuration paths](CONFIGURATION.md) and
[audit storage and safe recovery](AUDIT_LOGGING.md#storage).
### Manual Rollback
Update backups created by in-app updates are stored as `backup-<timestamp>/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory does not have enough free space, Pulse falls back to `/tmp/pulse-backup-<timestamp>`. Pulse keeps the most recent three in-app rollback snapshots and prunes older ones from retention.
There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart.
Example (systemd/LXC):
If Pulse cannot start, work inside the Pulse host or LXC, not on the Proxmox host
itself. Start with read-only service discovery:
```bash
sudo systemctl stop pulse
sudo cp -a /etc/pulse/backup-<timestamp>/pulse /opt/pulse/pulse
sudo cp -a /etc/pulse/backup-<timestamp>/VERSION /opt/pulse/VERSION
sudo rm -rf /opt/pulse/data /opt/pulse/config
sudo cp -a /etc/pulse/backup-<timestamp>/data /opt/pulse/data
sudo cp -a /etc/pulse/backup-<timestamp>/config /opt/pulse/config
sudo cp -a /etc/pulse/backup-<timestamp>/.env /opt/pulse/.env
sudo systemctl start pulse
systemctl is-active pulse
systemctl show pulse --property=FragmentPath --property=DropInPaths
```
An inactive service makes `is-active` return non-zero; that is useful evidence,
not a reason to reset it. Inspect the named unit and drop-ins locally for the
effective executable, `PULSE_INSTALL_DIR` and `PULSE_DATA_DIR`. Do not post full
service environments or `.env` contents; they may contain credentials.
1. Stop Pulse before taking the failed-state backup or replacing files. Preserve
the actual executable, distribution files, unit/drop-ins and every active
data path, not just a guessed `/opt/pulse/data` directory.
2. Match the retained backup to the intended pre-update version. Check its
files and the update log for missing/copy-failed entries. If the backup is
missing, partial or has the wrong scope, stop here; do not delete live
directories to make a restore fit.
3. Prefer restoring a verified pre-update filesystem/volume snapshot with your
host backup tooling. Check it in an isolated recovery instance first, with
no access to monitored systems or notification destinations. Do not start a
second connected copy with the same agent identities.
4. For a file-level repair, stage verified replacements and keep reversible
copies of the failed state. Restore only the confirmed paths, ownership and
permissions for this deployment. Do not merge unrelated snapshots, delete
data/configuration directories, or regenerate encryption or signing keys.
5. Start Pulse and verify the running version and edition, service health,
saved connections, agent identities and retained history. If any check fails,
preserve the error and backups rather than retrying the update blindly.
For Docker, change the configured image back to the recorded previous tag or
digest and recreate only Pulse with its existing volume. An older image may not
understand data migrated by the newer version; use a verified matching data
backup when required. Never remove the volume or initialise a fresh one as a
rollback shortcut. An image change alone is not a data rollback.
## Update History
History entries are stored in `update-history.jsonl` under the Pulse data directory (`/etc/pulse` or `/data`), and exposed via `GET /api/updates/history` (admin auth required).
+37 -14
View File
@@ -118,21 +118,46 @@ services:
## 🔄 Updates
To update Pulse to a specific release tag:
These steps update the Pulse server, not the containers it monitors. Record the
current image and take a consistent private backup of the mounted Pulse data
before changing it. Keep the previous image and data backup for recovery; do
not delete volumes.
```bash
docker pull rcourtman/pulse:vX.Y.Z
docker stop pulse
docker rm pulse
# Re-run your docker run command
docker inspect pulse --format '{{.Config.Image}} {{.Image}}'
```
If using Compose:
Run commands from the original Compose project directory. First set the exact
target in your Compose `image:` line, or persist `PULSE_IMAGE` in the project's
`.env` if the file uses `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}`.
Setting `PULSE_IMAGE` has no effect on a hardcoded image line. Community uses
`rcourtman/pulse:vX.Y.Z`; paid Pro installs must keep the private image and
existing registry login from <https://pulserelay.pro/download.html>. Do not
replace Pro with the Community image or post registry credentials.
For the `pulse` service in the examples above:
```bash
docker compose pull
docker compose up -d
(
set -e
docker compose pull pulse
docker compose up -d --no-deps pulse
)
```
If pulling fails, stop rather than recreating with an unverified or old image.
Pulling a different tag alone does not update your configured image. There is
no need to bring the whole Compose project down. Use your actual service and
container names if they differ; legacy `docker-compose` users can substitute
that command name.
For `docker run` or an app UI, change the image in the existing saved deployment
and recreate it with the same data mount, ports and settings; do not start a
second Pulse against that data. Check the running image again, Pulse's displayed
server version and service health after recreation. See
[rollback and backup scope](AUTO_UPDATE.md#rollback) before reverting a version:
an image change alone does not undo data migrations.
---
## 🔄 Docker / Podman Updates
@@ -188,12 +213,10 @@ To keep update detection anonymous-only (no credential store reads, no credentia
Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than
the public `rcourtman/pulse` image. Open <https://pulserelay.pro/download.html>,
paste your activation key, run the Docker login command shown there, then run
the shown `PULSE_IMAGE=license.pulserelay.pro/pulse-pro:<version> docker compose pull`
and `docker compose up -d` commands from the host that already runs Pulse. If
your compose file has a hardcoded `image: rcourtman/pulse:...` line, change it
to `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}` or directly to the private
image shown on the download page before running those commands.
enter your activation key in the page and use its registry login instructions
on the host that already runs Pulse. Persist the private image for both pull
and recreation as described in [server updates](#-updates); an image override
used for only the pull does not select it for the later recreation.
### Disabling Update Features
@@ -0,0 +1,109 @@
// Actual shipped Markdown in the production Docs/router/styles; no backend.
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', 'update-recovery-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-update-recovery-help'),
server: { host: '127.0.0.1', port: 5248, 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:5248/browser-tests/docs-fragment-navigation.html',
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Automatic updates', exact: true }).click();
await page.getByRole('heading', { name: 'Rollback', 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 article = await page.locator('article').innerText();
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);
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 });
await scope.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await page.screenshot({ path: path.join(artifacts, `${engine}-scope.png`) });
const manual = page.getByRole('heading', { name: 'Manual Rollback', exact: true });
await manual.evaluate((heading) => heading.scrollIntoView({ block: 'start' }));
await page.waitForFunction(() => {
const heading = document.getElementById('manual-rollback');
// Respect the product's heading scroll-margin rather than demanding
// alignment at zero pixels above its normal reading inset.
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < 200;
});
await page.screenshot({ path: path.join(artifacts, `${engine}-manual.png`) });
await page.getByRole('link', { name: 'audit storage and safe recovery', exact: true }).click();
await page.waitForFunction(() => {
const heading = document.getElementById('storage');
return heading && heading.getBoundingClientRect().top >= 0 && heading.getBoundingClientRect().top < innerHeight;
});
assert.ok(page.url().endsWith('/docs/AUDIT_LOGGING#storage'));
await page.getByRole('link', { name: '← All documentation' }).click();
await page.getByRole('link', { name: 'Automatic updates', exact: true }).click();
await page.getByRole('link', { name: 'Docker server updates', exact: true }).click();
const updates = page.getByRole('heading', { name: '🔄 Updates', exact: true });
await updates.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 text of ['persist PULSE_IMAGE', 'has no effect on a hardcoded image line',
'docker compose pull pulse', 'docker compose up -d --no-deps pulse',
'paid Pro installs must keep the private image', 'no need to bring the whole Compose project down'])
assert.ok(dockerText.includes(text), text);
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
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.deepEqual(errors, []);
results.push({ engine, version: browser.version(), width, tone,
snapshotScope: true, noDestructiveRecipe: true, scopedCompose: true,
auditLink: true, dockerFragment: true, rollbackLink: true, errors });
await browser.close();
browser = undefined;
}
const result = { result: 'passed', playwright: require('playwright/package.json').version,
scope: 'shipped help rendering and links, not installation or recovery', 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; });
+94 -28
View File
@@ -83,19 +83,16 @@ Auto-update preferences are stored in `system.json` and edited via the UI.
### Docker
```bash
# Pull latest image
docker pull rcourtman/pulse:vX.Y.Z
Use [Docker server updates](DOCKER.md#-updates). Change the configured image to
the exact release first, then pull and recreate only the Pulse service with the
same mounted data. Pulling another tag alone does not change the image in your
Compose file. Do not run `docker compose down` for a Pulse-only update or remove
its data volume.
# Restart container
docker compose down && docker compose up -d
```
This command uses the public Community image. Private Pro runtime installs must
use the private image and credentials supplied by the private download/update
path; replacing a Pro image with `rcourtman/pulse` changes the runtime edition.
If you use the legacy `docker-compose` binary, replace `docker compose` with `docker-compose`.
Private Pro installs must keep the private image supplied by the
[download page](https://pulserelay.pro/download.html); replacing it with
`rcourtman/pulse` changes the runtime edition. Server updates and monitored
container updates are separate operations.
### ProxmoxVE LXC (Manual)
@@ -124,30 +121,99 @@ sudo systemctl restart pulse
## Rollback
If an update causes issues:
An update error or a stuck progress modal does not prove that the old version
is still running. Check the running version and service health before retrying
or restoring anything; preserve the update error, logs and history. Do not run
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.
### Automatic Rollback
Pulse creates a backup before updating. If the update fails:
1. The previous version is automatically restored
2. Service restarts with the old version
3. Error details are logged
The in-app updater creates an installation snapshot before applying files. If
applying files fails, it attempts to restore that snapshot and logs any restore
error. This is not a guarantee that every update failure restores all state or
restarts successfully. Confirm the running version, connections and service
health afterwards; a progress message alone is not recovery evidence.
### Roll back from Update History
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.
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
use their previous image and matching data backup, not this in-app rollback.
### What an update snapshot contains
In-app snapshots normally live at `backup-<timestamp>/` under the runtime data
directory (usually `/etc/pulse`), with `/tmp/pulse-backup-<timestamp>` as a
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
`.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.
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
a stopped, consistent filesystem/volume backup; copying a live `.db` file alone
can omit SQLite sidecar files. Keep these backups private, not in an issue or
diagnostic attachment. See [configuration paths](CONFIGURATION.md) and
[audit storage and safe recovery](AUDIT_LOGGING.md#storage).
### Manual Rollback
Update backups created by in-app updates are stored as `backup-<timestamp>/` folders inside the Pulse data directory (`/etc/pulse` or `/data`). If that directory does not have enough free space, Pulse falls back to `/tmp/pulse-backup-<timestamp>`. Pulse keeps the most recent three in-app rollback snapshots and prunes older ones from retention.
There is no rollback UI. To revert, stop Pulse, restore the backup contents to `/opt/pulse`, then restart.
Example (systemd/LXC):
If Pulse cannot start, work inside the Pulse host or LXC, not on the Proxmox host
itself. Start with read-only service discovery:
```bash
sudo systemctl stop pulse
sudo cp -a /etc/pulse/backup-<timestamp>/pulse /opt/pulse/pulse
sudo cp -a /etc/pulse/backup-<timestamp>/VERSION /opt/pulse/VERSION
sudo rm -rf /opt/pulse/data /opt/pulse/config
sudo cp -a /etc/pulse/backup-<timestamp>/data /opt/pulse/data
sudo cp -a /etc/pulse/backup-<timestamp>/config /opt/pulse/config
sudo cp -a /etc/pulse/backup-<timestamp>/.env /opt/pulse/.env
sudo systemctl start pulse
systemctl is-active pulse
systemctl show pulse --property=FragmentPath --property=DropInPaths
```
An inactive service makes `is-active` return non-zero; that is useful evidence,
not a reason to reset it. Inspect the named unit and drop-ins locally for the
effective executable, `PULSE_INSTALL_DIR` and `PULSE_DATA_DIR`. Do not post full
service environments or `.env` contents; they may contain credentials.
1. Stop Pulse before taking the failed-state backup or replacing files. Preserve
the actual executable, distribution files, unit/drop-ins and every active
data path, not just a guessed `/opt/pulse/data` directory.
2. Match the retained backup to the intended pre-update version. Check its
files and the update log for missing/copy-failed entries. If the backup is
missing, partial or has the wrong scope, stop here; do not delete live
directories to make a restore fit.
3. Prefer restoring a verified pre-update filesystem/volume snapshot with your
host backup tooling. Check it in an isolated recovery instance first, with
no access to monitored systems or notification destinations. Do not start a
second connected copy with the same agent identities.
4. For a file-level repair, stage verified replacements and keep reversible
copies of the failed state. Restore only the confirmed paths, ownership and
permissions for this deployment. Do not merge unrelated snapshots, delete
data/configuration directories, or regenerate encryption or signing keys.
5. Start Pulse and verify the running version and edition, service health,
saved connections, agent identities and retained history. If any check fails,
preserve the error and backups rather than retrying the update blindly.
For Docker, change the configured image back to the recorded previous tag or
digest and recreate only Pulse with its existing volume. An older image may not
understand data migrated by the newer version; use a verified matching data
backup when required. Never remove the volume or initialise a fresh one as a
rollback shortcut. An image change alone is not a data rollback.
## Update History
History entries are stored in `update-history.jsonl` under the Pulse data directory (`/etc/pulse` or `/data`), and exposed via `GET /api/updates/history` (admin auth required).
+37 -14
View File
@@ -118,21 +118,46 @@ services:
## 🔄 Updates
To update Pulse to a specific release tag:
These steps update the Pulse server, not the containers it monitors. Record the
current image and take a consistent private backup of the mounted Pulse data
before changing it. Keep the previous image and data backup for recovery; do
not delete volumes.
```bash
docker pull rcourtman/pulse:vX.Y.Z
docker stop pulse
docker rm pulse
# Re-run your docker run command
docker inspect pulse --format '{{.Config.Image}} {{.Image}}'
```
If using Compose:
Run commands from the original Compose project directory. First set the exact
target in your Compose `image:` line, or persist `PULSE_IMAGE` in the project's
`.env` if the file uses `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}`.
Setting `PULSE_IMAGE` has no effect on a hardcoded image line. Community uses
`rcourtman/pulse:vX.Y.Z`; paid Pro installs must keep the private image and
existing registry login from <https://pulserelay.pro/download.html>. Do not
replace Pro with the Community image or post registry credentials.
For the `pulse` service in the examples above:
```bash
docker compose pull
docker compose up -d
(
set -e
docker compose pull pulse
docker compose up -d --no-deps pulse
)
```
If pulling fails, stop rather than recreating with an unverified or old image.
Pulling a different tag alone does not update your configured image. There is
no need to bring the whole Compose project down. Use your actual service and
container names if they differ; legacy `docker-compose` users can substitute
that command name.
For `docker run` or an app UI, change the image in the existing saved deployment
and recreate it with the same data mount, ports and settings; do not start a
second Pulse against that data. Check the running image again, Pulse's displayed
server version and service health after recreation. See
[rollback and backup scope](AUTO_UPDATE.md#rollback) before reverting a version:
an image change alone does not undo data migrations.
---
## 🔄 Docker / Podman Updates
@@ -188,12 +213,10 @@ To keep update detection anonymous-only (no credential store reads, no credentia
Paid Pulse Pro Docker installs use the private Pulse Pro registry rather than
the public `rcourtman/pulse` image. Open <https://pulserelay.pro/download.html>,
paste your activation key, run the Docker login command shown there, then run
the shown `PULSE_IMAGE=license.pulserelay.pro/pulse-pro:<version> docker compose pull`
and `docker compose up -d` commands from the host that already runs Pulse. If
your compose file has a hardcoded `image: rcourtman/pulse:...` line, change it
to `image: ${PULSE_IMAGE:-rcourtman/pulse:vX.Y.Z}` or directly to the private
image shown on the download page before running those commands.
enter your activation key in the page and use its registry login instructions
on the host that already runs Pulse. Persist the private image for both pull
and recreation as described in [server updates](#-updates); an image override
used for only the pull does not select it for the later recreation.
### Disabling Update Features
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""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.
"""
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
def guide(name):
return (ROOT / "docs" / f"{name}.md").read_text(encoding="utf-8")
def section(name, heading, level="##"):
return guide(name).split(f"{level} {heading}\n", 1)[1].split(f"\n{level} ", 1)[0]
READER = r'''#!/usr/bin/python3
import json, os, sys
from pathlib import Path
name = Path(sys.argv[0]).name
args = sys.argv[1:]
with Path(os.environ['CALLS']).open('a') as output:
output.write(json.dumps({'name': name, 'args': args}) + '\n')
if name == 'systemctl':
if args == ['is-active', 'pulse']:
print(os.environ['SERVICE_STATE'])
sys.exit(0 if os.environ['SERVICE_STATE'] == 'active' else 3)
assert args == ['show', 'pulse', '--property=FragmentPath', '--property=DropInPaths']
print('FragmentPath=/etc/systemd/system/pulse.service\nDropInPaths=/etc/systemd/system/pulse.service.d/local.conf')
elif name == 'docker':
if args == ['inspect', 'pulse', '--format', '{{.Config.Image}} {{.Image}}']:
print('private.example/pulse:old sha256:previous')
elif args == ['compose', 'pull', 'pulse']:
sys.exit(int(os.environ.get('PULL_EXIT', '0')))
elif args == ['compose', 'up', '-d', '--no-deps', 'pulse']:
sys.exit(int(os.environ.get('UP_EXIT', '0')))
else:
raise AssertionError('unexpected Docker mutation or unbounded inspection')
else:
raise AssertionError('unexpected command')
'''
class UpdateRecoveryDocsTest(unittest.TestCase):
def exercise(self, recipe, **settings):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name in ("systemctl", "docker"):
executable = root / name
executable.write_text(READER, encoding="utf-8")
executable.chmod(0o700)
calls_path = root / "calls.jsonl"
env = dict(os.environ, PATH=f"{root}:{os.environ['PATH']}",
CALLS=str(calls_path), SERVICE_STATE="active",
SYNTHETIC_CREDENTIAL="never-print-this-private-value")
env.update(settings)
result = subprocess.run(["bash", "-c", recipe], env=env,
text=True, capture_output=True, timeout=10)
calls = [json.loads(line) for line in calls_path.read_text().splitlines()]
self.assertNotIn(env["SYNTHETIC_CREDENTIAL"], result.stdout + result.stderr)
return result, calls
def compose_recipe(self):
recipes = re.findall(r"```bash\n(.*?)```", section("DOCKER", "🔄 Updates"), re.S)
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"):
with self.subTest(guide=name):
self.assertEqual(guide(name),
(ROOT / "frontend-modern/public/docs" / f"{name}.md").read_text())
def test_rollback_matches_the_existing_history_control(self):
help_text = " ".join(section("AUTO_UPDATE", "Rollback").split())
ui = (ROOT / "frontend-modern/src/components/Settings/UpdateHistorySection.tsx").read_text()
for label in ("Update History", "Roll back"):
self.assertIn(label, ui)
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"):
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_recovery_preserves_failed_state_keys_and_sidecars(self):
help_text = " ".join(section("AUTO_UPDATE", "Rollback").split())
for phrase in ("consistent private backup", "matching `.encryption.key`", "signing key together",
"SQLite sidecar files", "Keep these backups private", "missing, partial",
"isolated recovery instance", "no access to monitored systems or notification destinations",
"Do not start a second connected copy", "keep reversible copies",
"regenerate encryption or signing keys", "running version and edition",
"An image change alone is not a data rollback"):
self.assertIn(phrase, help_text)
# No executable deletion, overwrite, restore, start or stop recipe is
# safe without knowing this deployment's effective paths and backup.
blocks = re.findall(r"```bash\n(.*?)```", section("AUTO_UPDATE", "Rollback"), re.S)
self.assertEqual(len(blocks), 1)
self.assertNotRegex(blocks[0], r"\b(?:rm|cp|mv|install|tee|start|stop|restart)\b")
def test_read_only_discovery_handles_active_and_inactive_services(self):
recipe = re.findall(r"```bash\n(.*?)```", section("AUTO_UPDATE", "Manual Rollback", "###"), re.S)[0]
for state in ("active", "inactive"):
with self.subTest(state=state):
result, calls = self.exercise(recipe, SERVICE_STATE=state)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls, [
{"name": "systemctl", "args": ["is-active", "pulse"]},
{"name": "systemctl", "args": ["show", "pulse", "--property=FragmentPath", "--property=DropInPaths"]},
])
self.assertIn(state, result.stdout)
self.assertIn("FragmentPath=", result.stdout)
self.assertNotIn("Environment=", result.stdout)
def test_compose_updates_only_pulse_after_a_successful_pull(self):
result, calls = self.exercise(self.compose_recipe())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls, [
{"name": "docker", "args": ["compose", "pull", "pulse"]},
{"name": "docker", "args": ["compose", "up", "-d", "--no-deps", "pulse"]},
])
def test_failed_pull_never_recreates_and_failed_recreation_is_not_success(self):
for settings, exit_code, count in (({"PULL_EXIT": "7"}, 7, 1), ({"UP_EXIT": "8"}, 8, 2)):
with self.subTest(settings=settings):
result, calls = self.exercise(self.compose_recipe(), **settings)
self.assertEqual(result.returncode, exit_code, result.stderr)
self.assertEqual(len(calls), count)
def test_image_selection_is_durable_and_private_edition_is_preserved(self):
help_text = " ".join(section("DOCKER", "🔄 Updates").split())
for phrase in ("exact target", "persist `PULSE_IMAGE`", "has no effect on a hardcoded image line",
"paid Pro installs must keep the private image", "same data mount, ports and settings",
"running image again", "an image change alone does not undo data migrations"):
self.assertIn(phrase, help_text)
recipe = re.findall(r"```bash\n(.*?)```", section("DOCKER", "🔄 Updates"), re.S)[0]
result, calls = self.exercise(recipe)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(calls[0]["args"], ["inspect", "pulse", "--format", "{{.Config.Image}} {{.Image}}"])
self.assertIn("private.example/pulse:old sha256:previous", result.stdout)
short = section("AUTO_UPDATE", "Docker", "###")
self.assertIn("DOCKER.md#-updates", short)
self.assertNotIn("docker pull", short)
if __name__ == "__main__":
unittest.main()