Make monitored-container update help truthful about recovery

Replace a claimed 15-minute backup and guaranteed rollback with the implemented five-minute cleanup, shared-data and best-effort limits. Put independent backups and deployment-manager recovery before the action, preserve current-state checks, and distinguish image-update suppression from command authority.

Contract-Neutral: Documentation and validation only; no container runtime, command authority or API changes.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 00:50:18 +01:00
parent 3c0fdcc742
commit c211bfe522
4 changed files with 344 additions and 28 deletions
+63 -14
View File
@@ -77,7 +77,7 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
| `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* |
| `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* |
| `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disable container image-update actions; does not disable start/stop/restart | `false` |
| `PULSE_METRICS_DB_PATH` | Optional path for only `metrics.db`, useful with tmpfs | `/data/metrics.db` |
| `PULSE_METRICS_ROLLUP_INTERVAL` | Metrics aggregation cadence; minimum 5 minutes | `15m` |
@@ -169,25 +169,48 @@ an image change alone does not undo data migrations.
## 🔄 Docker / Podman Updates
Pulse can detect and apply updates to your Docker / Podman containers directly from the UI.
These actions update **monitored containers**, not the Pulse server. Pulse can
detect image updates without command execution; applying an update requires a
separately enabled command channel and administrator review. Leave command
execution disabled when you only need monitoring.
### Before updating a workload
- Plan for downtime: a running container is stopped and replaced, not updated
in place. Check the application's upgrade and data-migration requirements.
- Take an independent, consistent backup of its application data, including
volumes and bind mounts, using the application's backup procedure. The
renamed old container is **not a backup of mounted data**: the replacement
uses those same mounts and can change their contents.
- Keep the previous image identity and your deployment definition privately.
For Compose, Yacht or another deployment manager, prefer that manager's
update procedure so its saved definition records the intended image. Pulse's
recreation does not edit your Compose file or manager's desired state.
- Do not use an update as a diagnostic test or start another container against
the same writable data to check a failure.
### How It Works
1. **Update Detection**: Pulse compares the local image digest with the latest digest from the container registry
2. **Visual Indicator**: Containers with available updates show a blue upward arrow icon
3. **Reviewed Update**: Click the update button, approve the reviewed action, and Pulse handles the rest
3. **Reviewed Update**: Review the target and approve the action, then verify the application yourself
### Updating a Container
1. Navigate to the **Workloads** page (or filter by Docker sources on **Infrastructure**)
1. Open **Docker → Overview** (`/docker/overview`) and find the host and container
2. Look for containers with a blue update arrow (⬆️)
3. Click the update button and approve the action in the review dialog (admin approval required)
4. Pulse will:
4. Pulse attempts to:
- Pull the latest image
- Stop the current container
- Create a backup (renamed with `_pulse_backup_` suffix)
- Start a new container with the same configuration
- Clean up the backup after 15 minutes (if the update succeeds)
- Stop the current container if it was running
- Rename the original container with the `_pulse_backup_` suffix
- Recreate the container from its inspected configuration, reusing its data mounts
- Start it only if the original was running, then perform a short runtime check
- Schedule removal of the renamed old container after a successful update
Afterwards, check the actual image, container state, application readiness and
data through your normal administration tools. A successful action is not an
end-to-end application or data-integrity test.
### Batch Updates
@@ -195,9 +218,29 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd
### Safety Features
- **Automatic Backup**: The old container is renamed, not deleted, until the update succeeds
- **Rollback on Failure**: If the new container fails to start, the old one is restored
- **Configuration Preserved**: Networks, volumes, ports, environment variables are all preserved
- **Temporary old container**: the `_pulse_backup_` name retains the old
container, not a separate copy of its volumes or bind mounts. Current agents
schedule removal **five minutes after a successful update**; periodic cleanup
also removes old backup containers. Do not rely on a 15-minute recovery window
or on a remaining container as a durable backup.
- **Best-effort rollback**: when recreation, network attachment or the early
runtime check fails, Pulse attempts to restore the original name and running
state. Removal, rename or restart can fail too. A failed banner does not prove
that the original container is running or that no change occurred.
- **Limited verification**: the running replacement is inspected after a short
wait and rejected if stopped or explicitly unhealthy. A missing healthcheck
or a `starting` health status is not proof that the application is ready.
- **Inspected configuration**: Pulse reuses container settings and mounts; it
does not reconcile your deployment manager or roll back application data
migrations. Verify networks, ports, mounts and the application after the change.
If the result is failed or uncertain, **check the current state before retrying**.
Retain the action error and time, old/new container and image identities, and
any rollback error. Inspect these locally; do not post full `docker inspect`
output, environments or registry credentials. Do not delete the old container
or volumes to clear a warning. Recover through the application's and deployment
manager's procedures using a verified matching data backup where required;
returning to the old image alone does not undo a data migration.
### Requirements
@@ -231,9 +274,9 @@ Pulse provides granular control over update features via environment variables o
| Variable | Description |
|----------|-------------|
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hides update buttons from the UI while still detecting updates. Use this for "read-only" monitoring. |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disables image-update actions while still detecting updates. Start/stop/restart have separate controls. |
**Example - Read-Only Mode** (detect updates but prevent actions):
**Example - Disable image updates** (continue detecting updates):
```yaml
services:
pulse:
@@ -242,6 +285,12 @@ services:
- PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true
```
This setting is not a monitoring-only security boundary: it does not disable
container lifecycle actions or revoke agent command permissions. For
monitoring-only installs, leave command execution disabled on the agent and do
not grant command execution permission to monitoring tokens. See
[container lifecycle requirements](#️-container-lifecycle-actions).
To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**.
You can also toggle "Hide Docker Update Buttons" from the UI in **Settings → System → General** under **Docker / Podman updates**.
@@ -0,0 +1,125 @@
// Production Docs viewer and shipped Docker guide; no container action is run.
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', 'docker-update-guidance-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-docker-update-guidance'),
server: { host: '127.0.0.1', port: 5250, strictPort: true },
optimizeDeps: { noDiscovery: true, include: [] },
});
let browser;
const results = [];
try {
await server.listen();
for (const [engine, width, dark] of [
['chromium', 1280, false],
['webkit', 390, true],
]) {
browser = await { chromium, webkit }[engine].launch(
engine === 'chromium'
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const page = await browser.newPage({
viewport: { width, height: 1000 },
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}),
});
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('http://127.0.0.1:5250/browser-tests/docs-fragment-navigation.html', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
await page.evaluate(() => {
history.pushState({}, '', '/docs/DOCKER#before-updating-a-workload');
dispatchEvent(new PopStateEvent('popstate'));
});
if (dark) await page.evaluate(() => document.documentElement.classList.add('dark'));
const waitForHeading = async (id) => {
await page.waitForFunction((target) => {
const heading = document.getElementById(target);
if (!heading) return false;
const rect = heading.getBoundingClientRect();
return document.activeElement === heading && rect.top >= 0 && rect.top < innerHeight;
}, id);
assert.ok(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1),
'page must not overflow the viewport',
);
};
await waitForHeading('before-updating-a-workload');
const article = await page.locator('article').innerText();
for (const text of [
'independent, consistent backup',
'not a backup of mounted data',
'five minutes after a successful update',
'Removal, rename or restart can fail too',
'check the current state before retrying',
'not a monitoring-only security boundary',
])
assert.ok(article.replace(/\s+/g, ' ').includes(text), text);
await page.screenshot({ path: path.join(artifacts, `${engine}-precautions.png`) });
await page.evaluate(() => {
history.pushState({}, '', '/docs/DOCKER#safety-features');
dispatchEvent(new PopStateEvent('popstate'));
});
await waitForHeading('safety-features');
await page.screenshot({ path: path.join(artifacts, `${engine}-limits.png`) });
const link = page.getByRole('link', {
name: 'container lifecycle requirements',
exact: true,
});
const target = decodeURIComponent((await link.getAttribute('href')).split('#')[1]);
await link.focus();
await page.keyboard.press('Enter');
await waitForHeading(target);
assert.equal(
await page
.getByRole('heading', { name: '▶️ Container Lifecycle Actions', exact: true })
.textContent(),
'▶️ Container Lifecycle Actions',
);
assert.deepEqual(errors, []);
results.push({
engine,
version: browser.version(),
width,
dark,
precautionsAndLimitsRendered: true,
keyboardRequirementsLink: true,
headingsFocusedAndVisible: true,
noHorizontalOverflow: true,
errors,
});
await browser.close();
browser = undefined;
}
const result = {
result: 'passed',
playwright: require('playwright/package.json').version,
scope: 'shipped help rendering and keyboard navigation, not native updates or rollback',
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;
});
+63 -14
View File
@@ -77,7 +77,7 @@ Pulse is configured via the UI (`system.json`) with optional environment overrid
| `DISCOVERY_SUBNET` | Custom CIDR to scan | *(auto)* |
| `ALLOWED_ORIGINS` | CORS allowed origin (`*` or a single origin). Empty = same-origin only. | *(unset)* |
| `LOG_LEVEL` | Log verbosity (`debug`, `info`, `warn`, `error`) | `info` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hide Docker update buttons (read-only mode) | `false` |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disable container image-update actions; does not disable start/stop/restart | `false` |
| `PULSE_METRICS_DB_PATH` | Optional path for only `metrics.db`, useful with tmpfs | `/data/metrics.db` |
| `PULSE_METRICS_ROLLUP_INTERVAL` | Metrics aggregation cadence; minimum 5 minutes | `15m` |
@@ -169,25 +169,48 @@ an image change alone does not undo data migrations.
## 🔄 Docker / Podman Updates
Pulse can detect and apply updates to your Docker / Podman containers directly from the UI.
These actions update **monitored containers**, not the Pulse server. Pulse can
detect image updates without command execution; applying an update requires a
separately enabled command channel and administrator review. Leave command
execution disabled when you only need monitoring.
### Before updating a workload
- Plan for downtime: a running container is stopped and replaced, not updated
in place. Check the application's upgrade and data-migration requirements.
- Take an independent, consistent backup of its application data, including
volumes and bind mounts, using the application's backup procedure. The
renamed old container is **not a backup of mounted data**: the replacement
uses those same mounts and can change their contents.
- Keep the previous image identity and your deployment definition privately.
For Compose, Yacht or another deployment manager, prefer that manager's
update procedure so its saved definition records the intended image. Pulse's
recreation does not edit your Compose file or manager's desired state.
- Do not use an update as a diagnostic test or start another container against
the same writable data to check a failure.
### How It Works
1. **Update Detection**: Pulse compares the local image digest with the latest digest from the container registry
2. **Visual Indicator**: Containers with available updates show a blue upward arrow icon
3. **Reviewed Update**: Click the update button, approve the reviewed action, and Pulse handles the rest
3. **Reviewed Update**: Review the target and approve the action, then verify the application yourself
### Updating a Container
1. Navigate to the **Workloads** page (or filter by Docker sources on **Infrastructure**)
1. Open **Docker → Overview** (`/docker/overview`) and find the host and container
2. Look for containers with a blue update arrow (⬆️)
3. Click the update button and approve the action in the review dialog (admin approval required)
4. Pulse will:
4. Pulse attempts to:
- Pull the latest image
- Stop the current container
- Create a backup (renamed with `_pulse_backup_` suffix)
- Start a new container with the same configuration
- Clean up the backup after 15 minutes (if the update succeeds)
- Stop the current container if it was running
- Rename the original container with the `_pulse_backup_` suffix
- Recreate the container from its inspected configuration, reusing its data mounts
- Start it only if the original was running, then perform a short runtime check
- Schedule removal of the renamed old container after a successful update
Afterwards, check the actual image, container state, application readiness and
data through your normal administration tools. A successful action is not an
end-to-end application or data-integrity test.
### Batch Updates
@@ -195,9 +218,29 @@ Updates run as reviewed per-container actions, so there is currently no bulk upd
### Safety Features
- **Automatic Backup**: The old container is renamed, not deleted, until the update succeeds
- **Rollback on Failure**: If the new container fails to start, the old one is restored
- **Configuration Preserved**: Networks, volumes, ports, environment variables are all preserved
- **Temporary old container**: the `_pulse_backup_` name retains the old
container, not a separate copy of its volumes or bind mounts. Current agents
schedule removal **five minutes after a successful update**; periodic cleanup
also removes old backup containers. Do not rely on a 15-minute recovery window
or on a remaining container as a durable backup.
- **Best-effort rollback**: when recreation, network attachment or the early
runtime check fails, Pulse attempts to restore the original name and running
state. Removal, rename or restart can fail too. A failed banner does not prove
that the original container is running or that no change occurred.
- **Limited verification**: the running replacement is inspected after a short
wait and rejected if stopped or explicitly unhealthy. A missing healthcheck
or a `starting` health status is not proof that the application is ready.
- **Inspected configuration**: Pulse reuses container settings and mounts; it
does not reconcile your deployment manager or roll back application data
migrations. Verify networks, ports, mounts and the application after the change.
If the result is failed or uncertain, **check the current state before retrying**.
Retain the action error and time, old/new container and image identities, and
any rollback error. Inspect these locally; do not post full `docker inspect`
output, environments or registry credentials. Do not delete the old container
or volumes to clear a warning. Recover through the application's and deployment
manager's procedures using a verified matching data backup where required;
returning to the old image alone does not undo a data migration.
### Requirements
@@ -231,9 +274,9 @@ Pulse provides granular control over update features via environment variables o
| Variable | Description |
|----------|-------------|
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Hides update buttons from the UI while still detecting updates. Use this for "read-only" monitoring. |
| `PULSE_DISABLE_DOCKER_UPDATE_ACTIONS` | Disables image-update actions while still detecting updates. Start/stop/restart have separate controls. |
**Example - Read-Only Mode** (detect updates but prevent actions):
**Example - Disable image updates** (continue detecting updates):
```yaml
services:
pulse:
@@ -242,6 +285,12 @@ services:
- PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=true
```
This setting is not a monitoring-only security boundary: it does not disable
container lifecycle actions or revoke agent command permissions. For
monitoring-only installs, leave command execution disabled on the agent and do
not grant command execution permission to monitoring tokens. See
[container lifecycle requirements](#️-container-lifecycle-actions).
To disable registry checks entirely, set `PULSE_DISABLE_DOCKER_UPDATE_CHECKS=true` on the **agent**.
You can also toggle "Hide Docker Update Buttons" from the UI in **Settings → System → General** under **Docker / Podman updates**.
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { renderDocMarkdown } from '../docMarkdown';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../..');
const guide = readFileSync(path.join(repoRoot, 'docs/DOCKER.md'), 'utf8');
function renderGuide(): HTMLElement {
const article = document.createElement('article');
article.innerHTML = renderDocMarkdown(guide, 'DOCKER');
return article;
}
function section(article: HTMLElement, id: string): string {
const heading = article.querySelector(`#${id}`);
expect(heading, `missing guide section ${id}`).not.toBeNull();
const parts: string[] = [];
for (let element = heading?.nextElementSibling; element; element = element.nextElementSibling) {
if (/^H[1-3]$/.test(element.tagName)) break;
parts.push(element.textContent ?? '');
}
return parts.join(' ').replace(/\s+/g, ' ');
}
describe('shipped monitored-container update guide', () => {
it('places independent data backup and deployment-manager limits before the action', () => {
const article = renderGuide();
const precautions = section(article, 'before-updating-a-workload');
expect(precautions).toContain('Plan for downtime');
expect(precautions).toContain('independent, consistent backup');
expect(precautions).toContain('not a backup of mounted data');
expect(precautions).toContain('same mounts and can change their contents');
expect(precautions).toContain("does not edit your Compose file or manager's desired state");
expect(precautions).toContain('Do not use an update as a diagnostic test');
const precautionHeading = article.querySelector('#before-updating-a-workload')!;
const actionHeading = article.querySelector('#updating-a-container')!;
expect(precautionHeading.compareDocumentPosition(actionHeading)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(section(article, 'updating-a-container')).toContain('/docker/overview');
expect(section(article, 'updating-a-container')).not.toContain('Workloads page');
});
it('does not promise durable backup, complete rollback or application readiness', () => {
const limits = section(renderGuide(), 'safety-features');
expect(limits).toContain('not a separate copy of its volumes or bind mounts');
expect(limits).toContain('Removal, rename or restart can fail too');
expect(limits).toContain('A failed banner does not prove');
expect(limits).toContain('starting health status is not proof');
expect(limits).toContain('check the current state before retrying');
expect(limits).toContain('Do not delete the old container or volumes');
expect(limits).toContain('old image alone does not undo a data migration');
expect(limits).toContain('do not post full docker inspect output');
expect(limits).not.toContain('the old one is restored');
expect(limits).not.toContain('are all preserved');
});
it('matches the implemented early-removal window, not the orphan cleanup interval', () => {
// Static source binding, not a claim that a real update or rollback ran.
const update = readFileSync(
path.join(repoRoot, 'internal/dockeragent/container_update.go'),
'utf8',
);
const cleanup = readFileSync(path.join(repoRoot, 'internal/dockeragent/cleanup.go'), 'utf8');
expect(update).toMatch(/waitForAsyncDelay\(5 \* time.Minute\)/);
expect(cleanup).toMatch(/time.Since\(backupTime\) > 15\*time.Minute/);
const limits = section(renderGuide(), 'safety-features');
expect(limits).toContain('five minutes after a successful update');
expect(limits).toContain('Do not rely on a 15-minute recovery window');
expect(limits).not.toContain('Clean up the backup after 15 minutes');
});
it('keeps image-update suppression separate from command authority and links the real requirements', () => {
const article = renderGuide();
const disabled = section(article, 'disabling-update-features');
expect(disabled).toContain('not a monitoring-only security boundary');
expect(disabled).toContain('does not disable container lifecycle actions');
expect(disabled).toContain('leave command execution disabled on the agent');
expect(disabled).toContain('do not grant command execution permission to monitoring tokens');
expect(disabled).not.toContain('Read-Only Mode');
const link = Array.from(article.querySelectorAll('a')).find(
(anchor) => anchor.textContent === 'container lifecycle requirements',
);
expect(link).toBeDefined();
const targetId = decodeURIComponent(link!.hash.slice(1));
const target = Array.from(article.querySelectorAll('h2')).find(
(heading) => heading.id === targetId,
);
expect(target?.textContent).toBe('▶️ Container Lifecycle Actions');
});
});