mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Merge reviewed evidence-preserving update and recovery help
Change-source: pulse-maintainer
This commit is contained in:
+94
-28
@@ -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
@@ -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; });
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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()
|
||||
Reference in New Issue
Block a user