mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Make configuration migration warnings match the actual backup
Separate configuration transfer from complete stopped-state recovery, correct omitted platform and agent-state claims, and preserve safe credential/retargeting guidance. Validate the real encrypted archive and production dialogs with scoped tests and desktop/phone browser interactions. No new backup capability or runtime contract change. Contract-Neutral: The archive, persistence, authentication and agent contracts are unchanged; help and visible warnings now describe existing scope, with regression proof for that account. Change-source: pulse-maintainer
This commit is contained in:
+104
-70
@@ -1,88 +1,122 @@
|
||||
# 🚚 Migrating Pulse
|
||||
# Migrating Pulse
|
||||
|
||||
This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow.
|
||||
**Create Backup** in **Settings → System → Recovery** creates an encrypted
|
||||
configuration export, not a full backup of the installation. Check its scope
|
||||
before moving Pulse or retiring the old server. Do not use it alone when you
|
||||
need to preserve history, agent enrolment state or settings outside that scope.
|
||||
|
||||
## 🚀 Quick Migration Guide
|
||||
## Choose the backup you need
|
||||
|
||||
### ❌ DON'T: Copy Files
|
||||
Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break.
|
||||
### Configuration transfer
|
||||
|
||||
### ✅ DO: Use Export/Import
|
||||
Use export/import to move the included settings between installations. Pulse
|
||||
decrypts the export with its passphrase and saves the imported credentials with
|
||||
the destination's encryption key. Do not copy individual encrypted files into
|
||||
a fresh installation with a different key.
|
||||
|
||||
#### 1. Export (Old Server)
|
||||
1. Go to **Settings → System → Recovery**.
|
||||
2. Click **Create Backup**.
|
||||
3. Enter a strong passphrase and download the encrypted backup.
|
||||
The current export format includes:
|
||||
|
||||
#### 2. Import (New Server)
|
||||
1. Install a fresh Pulse instance.
|
||||
2. Go to **Settings → System → Recovery**.
|
||||
3. Click **Restore Configuration** and upload your file.
|
||||
4. Enter the passphrase.
|
||||
|
||||
## 📦 What Gets Migrated
|
||||
|
||||
| Included ✅ | Not Included ❌ |
|
||||
| Included | Not included |
|
||||
| :--- | :--- |
|
||||
| Nodes & credentials | Historical metrics history (`metrics.db`) |
|
||||
| Alerts & overrides | Browser sessions and local cookies |
|
||||
| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) |
|
||||
| System settings (`system.json`) | Update history/backup folders |
|
||||
| API token records | — |
|
||||
| OIDC config | — |
|
||||
| SSO / SAML config | — |
|
||||
| TrueNAS connections (`truenas.enc`) | — |
|
||||
| Guest metadata/notes | — |
|
||||
| — | Relay config (`relay.enc`) — re-enable in Settings |
|
||||
| — | Host metadata (notes/tags/AI command overrides) |
|
||||
| — | Docker metadata cache |
|
||||
| — | Agent profiles and assignments |
|
||||
| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) |
|
||||
| — | RBAC roles (`rbac_roles.json`) — re-create after import |
|
||||
| — | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) |
|
||||
| — | Server sessions (`sessions.json`) |
|
||||
| — | Update history (`update-history.jsonl`) |
|
||||
| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections |
|
||||
| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history |
|
||||
| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules |
|
||||
| System settings | Local login credentials, environment overrides and sessions |
|
||||
| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state |
|
||||
| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles |
|
||||
| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments |
|
||||
| — | AI settings, findings, usage and chat history |
|
||||
| — | Licence files, existing Relay configuration, binaries, service definitions and update backups |
|
||||
|
||||
## 🔄 Common Scenarios
|
||||
Contents depend on the version that created the export. Older formats can omit
|
||||
settings included above. Imported API-token records retain their scopes and
|
||||
expiry; they do not reveal the original tokens or transfer every agent's state.
|
||||
SSO configuration **is included**, even though local login credentials are not.
|
||||
|
||||
### Moving to New Hardware
|
||||
Export from old → Install new → Import.
|
||||
### Full-state recovery
|
||||
|
||||
### Docker ↔ Systemd ↔ Kubernetes
|
||||
The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly.
|
||||
If you need the same installation's history and excluded state, retain a
|
||||
consistent filesystem/volume backup as well. Stop Pulse before taking that
|
||||
backup; monitoring and alert delivery pause while it is stopped. Preserve every
|
||||
effective data path, the matching `.encryption.key`, audit data and signing key,
|
||||
environment files, external certificates, binaries and service/container
|
||||
configuration together. Custom paths and organisation directories matter.
|
||||
Copying a live database file alone can omit its sidecar files.
|
||||
|
||||
### Disaster Recovery
|
||||
1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)).
|
||||
2. Import your latest backup.
|
||||
3. Restored in < 5 minutes.
|
||||
Keep that backup private and test recovery on an isolated destination before
|
||||
retiring the source. Do not mix encrypted files and keys from different
|
||||
installations or restore over a running Pulse. See [configuration
|
||||
paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage).
|
||||
The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains)
|
||||
is a third, limited backup; it is not a substitute for either procedure above.
|
||||
|
||||
## 📋 Post-Migration Checklist
|
||||
## Transfer the included configuration
|
||||
|
||||
Because local login credentials are stored in `.env` (not part of exports), you must:
|
||||
1. On the old server, sign in and open **Settings → System → Recovery →
|
||||
Create Backup**. Use a strong, unique passphrase of at least 12 characters
|
||||
and store it separately from the downloaded file. With password login,
|
||||
choose **Use a custom passphrase** if you do not want to use that password.
|
||||
Keep the old instance and your separate full-state backup until validation
|
||||
is complete.
|
||||
2. Install Pulse at the destination using the [installation guide](INSTALL.md).
|
||||
Configure destination-local administrator access before importing. Keep the
|
||||
destination isolated from agents and notification destinations during the
|
||||
trial so two servers do not send duplicate alerts or remote actions.
|
||||
3. If the destination already has useful settings, export them first. Open
|
||||
**Settings → System → Recovery → Restore Configuration**, select the file
|
||||
and enter its passphrase. Import replaces the included configuration and
|
||||
API-token records; it does not merge them with the destination's records.
|
||||
4. Check the result before changing DNS or agent addresses. A success message
|
||||
establishes neither full recovery nor fresh monitoring. If import reports a
|
||||
reload/apply failure, settings may already have been written: preserve both
|
||||
installations and inspect the error before repeating it.
|
||||
|
||||
1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance.
|
||||
2. **Confirm API access**:
|
||||
* If you created API tokens in the UI, those token records are included in the export and should continue working.
|
||||
3. **Update Agents**:
|
||||
* **Unified Agent**: Update the `--token` flag in your service definition.
|
||||
* **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment.
|
||||
* **New server address?** Agents dial the address they were installed
|
||||
with (the `--url` flag, or `PULSE_URL` for containerized agents), so
|
||||
agents disappear from the new instance until that value is updated in
|
||||
each agent's service definition. Fronting Pulse with a DNS name
|
||||
instead of an IP address makes future moves invisible to agents.
|
||||
* *Tip: Use **Settings → Infrastructure → Install on a host** to generate updated install commands.*
|
||||
4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports).
|
||||
Keep the archive, passphrase, configuration files and credentials out of issue
|
||||
threads. Use the authenticated settings form rather than a command containing
|
||||
a password, API token or session cookie.
|
||||
|
||||
## 🔒 Security
|
||||
## Cut over and verify
|
||||
|
||||
* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM).
|
||||
* **Storage**: Safe to store in cloud backups or password managers.
|
||||
* **Passphrase**: Use a strong, unique passphrase (min 12 chars).
|
||||
- **Login and permissions:** verify destination-local administrator access and
|
||||
any imported SSO provider, group mappings and callback address. Re-create
|
||||
custom roles and supply external CA/certificate files if needed. Environment
|
||||
overrides can take precedence over imported settings.
|
||||
- **Connections and history:** check every expected platform, guest and backup
|
||||
against a fresh observation, not a cached row. Re-create excluded TrueNAS,
|
||||
vSphere and Machine Availability connections. Missing historical data after
|
||||
configuration-only transfer is not proof that collection has failed.
|
||||
- **Agents:** preserve each agent's existing identity and private credential.
|
||||
With a changed server address, use the [agent retargeting
|
||||
guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host.
|
||||
Do not create another token just for a server move or paste it into `--token`.
|
||||
Confirm fresh reports and any enrolment/authorisation result; imported token
|
||||
records alone do not prove that every agent is admitted. Re-create excluded
|
||||
profiles and assignments before enabling remote actions.
|
||||
- **Alerts and notifications:** avoid running both instances against the same
|
||||
destinations. After cutover, check thresholds, schedules and one controlled
|
||||
notification test with its actual delivery result. Importing destination
|
||||
settings does not migrate pending notifications or incident history.
|
||||
- **Licence and remaining settings:** re-activate Pro if required and verify
|
||||
any excluded settings you rely on. Existing paired phones need their retained
|
||||
configuration; configuration export alone is not a Mobile/Relay migration.
|
||||
Relay is no longer sold; do not enable it as a new migration route.
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
Retire the old installation only after these checks succeed. Keep the private,
|
||||
consistent pre-move backup for recovery. If validation fails, stop the trial
|
||||
destination before returning traffic to the intact source; check agent URLs
|
||||
and avoid two active writers. There is no guaranteed five-minute recovery time.
|
||||
|
||||
* **"Invalid passphrase"**: Ensure exact match (case-sensitive).
|
||||
* **Missing Nodes**: Verify export date.
|
||||
* **Connection Errors**: Update node IPs in Settings if they changed.
|
||||
* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed.
|
||||
## Troubleshooting
|
||||
|
||||
- **Wrong passphrase or rejected file:** use the original export file and its
|
||||
exact passphrase. Do not delete destination data or regenerate encryption
|
||||
keys to make an import succeed.
|
||||
- **A setting is missing:** compare it with the export scope above and the
|
||||
exporting version. Use the old installation or full-state backup rather
|
||||
than assuming import contained it.
|
||||
- **Agents are absent:** check their primary URL, TLS trust and fresh admission
|
||||
evidence using the linked guide. Do not uninstall, re-enrol or delete saved
|
||||
identities just because the dashboard is empty.
|
||||
- **Need help:** retain the source/destination versions, deployment types,
|
||||
operation time and exact redacted error. Follow [safe
|
||||
reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup.
|
||||
|
||||
@@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse.
|
||||
|
||||
### Moving Pulse to a new address
|
||||
|
||||
Configuration export/import restores the server-side agent records and API
|
||||
tokens. It cannot rewrite the primary Pulse URL on remote machines because
|
||||
agents initiate the connection. Prefer a stable DNS name for the primary URL
|
||||
so replacing the Pulse host does not require an agent migration.
|
||||
Configuration export/import restores API-token records, not the server-side
|
||||
agent inventory, enrolment state, profiles or assignments. See the
|
||||
[configuration-transfer scope](MIGRATION.md#configuration-transfer) before
|
||||
retiring the old server. Import cannot rewrite the primary Pulse URL on remote
|
||||
machines because agents initiate the connection. Prefer a stable DNS name for
|
||||
the primary URL, but still verify fresh reports and agent admission after a move.
|
||||
|
||||
After importing the configuration on a Pulse server with a different address,
|
||||
retarget each existing standard Linux agent from that agent machine. Download
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Run from the assigned root with pulse-worker-browser. This checks the real
|
||||
// dialog/flow and shipped guides, not a native migration or release delivery.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const workspace = process.cwd();
|
||||
const root = path.join(workspace, 'frontend-modern');
|
||||
const output = path.join(workspace, 'tmp', 'configuration-migration');
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
process.chdir(root);
|
||||
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'),
|
||||
plugins: [{ name: 'migration-doc-harness', configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
if (/^\/docs\/[^?]+$/.test(req.url || '') && !req.url.endsWith('.md'))
|
||||
req.url = '/browser-tests/configuration-migration.html';
|
||||
next();
|
||||
});
|
||||
} }],
|
||||
server: { host: '127.0.0.1', port: 5264, strictPort: true },
|
||||
});
|
||||
const observations = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engine, width, height] of [['chromium', 1440, 1000], ['webkit', 390, 844]]) {
|
||||
browser = await (engine === 'chromium' ? chromium : webkit).launch(engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true });
|
||||
const context = await browser.newContext({ viewport: { width, height }, acceptDownloads: true,
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
|
||||
const errors = [], operations = [];
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const req = route.request();
|
||||
if (req.url().endsWith('/api/config/export')) {
|
||||
operations.push({ path: '/api/config/export', method: req.method(), body: req.postDataJSON() });
|
||||
await route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'success', data: 'synthetic-archive-not-a-real-backup' }) });
|
||||
} else if (req.url().endsWith('/api/config/import')) {
|
||||
operations.push({ path: '/api/config/import', method: req.method(), body: req.postDataJSON() });
|
||||
// Deliberately reject this synthetic archive: warning, error and
|
||||
// cancel paths must remain available without pretending to migrate.
|
||||
await route.fulfill({ status: 400, contentType: 'text/plain', body: 'Synthetic rejected archive' });
|
||||
} else await route.fulfill({ status: 200, contentType: 'application/json',
|
||||
body: JSON.stringify({ hasAuthentication: false }) });
|
||||
});
|
||||
context.on('page', (p) => p.on('pageerror', (err) => errors.push(err.message)));
|
||||
const page = await context.newPage();
|
||||
await page.goto('http://127.0.0.1:5264/browser-tests/configuration-migration.html');
|
||||
if (engine === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
await page.getByRole('button', { name: 'Create Backup', exact: true }).click();
|
||||
const exporting = page.getByRole('dialog', { name: 'Export configuration', exact: true });
|
||||
await exporting.waitFor();
|
||||
await exporting.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)));
|
||||
let text = await exporting.innerText();
|
||||
for (const phrase of ['Configuration only:', 'SSO settings', 'API-token records',
|
||||
'not history, TrueNAS/vSphere connections', 'Local login credentials and sessions are not included'])
|
||||
assert.ok(text.includes(phrase), phrase);
|
||||
assert.ok(await exporting.getByRole('button', { name: 'Export', exact: true }).isDisabled());
|
||||
const guide = exporting.getByRole('link', { name: 'migration guide', exact: true });
|
||||
await guide.scrollIntoViewIfNeeded();
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-export.png`) });
|
||||
await guide.focus();
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.keyboard.press('Enter');
|
||||
const docs = await popupPromise;
|
||||
await docs.locator('article').waitFor();
|
||||
const article = await docs.locator('article').innerText();
|
||||
for (const phrase of ['not a full backup', 'TrueNAS, vSphere and Machine Availability',
|
||||
'SSO configuration is included', 'Stop Pulse', 'does not merge', 'reload/apply failure'])
|
||||
assert.ok(article.includes(phrase), phrase);
|
||||
assert.ok(docs.url().endsWith('/docs/MIGRATION'));
|
||||
const scope = docs.locator('#configuration-transfer');
|
||||
await scope.scrollIntoViewIfNeeded();
|
||||
assert.ok(await docs.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await docs.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-scope.png`) });
|
||||
const retarget = docs.getByRole('link', { name: 'agent retargeting guide', exact: true });
|
||||
await retarget.focus();
|
||||
await docs.keyboard.press('Enter');
|
||||
await docs.waitForFunction(() => document.activeElement?.id === 'moving-pulse-to-a-new-address');
|
||||
assert.ok((await docs.locator('article').innerText()).includes('restores API-token records, not the server-side'));
|
||||
assert.ok(docs.url().endsWith('/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address'));
|
||||
await docs.close();
|
||||
await exporting.getByLabel('Encryption Passphrase', { exact: true }).fill('synthetic-migration-passphrase');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await exporting.getByRole('button', { name: 'Export', exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(await download.path(), 'utf8')),
|
||||
{ status: 'success', data: 'synthetic-archive-not-a-real-backup' });
|
||||
await page.getByRole('button', { name: 'Restore Configuration', exact: true }).click();
|
||||
const importing = page.getByRole('dialog', { name: 'Import configuration', exact: true });
|
||||
await importing.waitFor();
|
||||
await importing.evaluate((el) => Promise.all(el.getAnimations({ subtree: true }).map((a) => a.finished)));
|
||||
text = await importing.innerText();
|
||||
for (const phrase of ['Back up the destination first', 'not the whole installation',
|
||||
'inventory, enrolment state, profiles and assignments are not', 'verify fresh admission'])
|
||||
assert.ok(text.includes(phrase), phrase);
|
||||
assert.ok(!text.includes('restores server-side agent records'));
|
||||
await importing.getByRole('link', { name: 'migration guide' }).scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ animations: 'disabled', path: path.join(output, `${engine}-import.png`) });
|
||||
await importing.getByLabel('Backup Password', { exact: true }).fill('synthetic-migration-passphrase');
|
||||
await importing.getByLabel('Configuration File', { exact: true }).setInputFiles({ name: 'synthetic-backup.json',
|
||||
mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({ data: 'synthetic-archive-not-a-real-backup' })) });
|
||||
const rejectedResponse = page.waitForResponse((r) => r.url().endsWith('/api/config/import'));
|
||||
await importing.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await rejectedResponse;
|
||||
assert.equal(operations.length, 2);
|
||||
assert.deepEqual(operations[1], { path: '/api/config/import', method: 'POST',
|
||||
body: { passphrase: 'synthetic-migration-passphrase', data: 'synthetic-archive-not-a-real-backup' } });
|
||||
assert.ok(await importing.isVisible(), 'failed import retains the dialog');
|
||||
await importing.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await importing.waitFor({ state: 'hidden' });
|
||||
assert.equal(operations.length, 2, 'copy/navigation/cancel never send another operation');
|
||||
assert.deepEqual(errors, []);
|
||||
observations.push({ engine, browserVersion: browser.version(), width, height,
|
||||
warningScope: true, keyboardGuideAndRetargetLinks: true, syntheticExportAndRejectedImport: true,
|
||||
noPageOverflow: true, errors });
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
const files = ['frontend-modern/src/components/Settings/BackupTransferDialogs.tsx',
|
||||
'frontend-modern/public/docs/MIGRATION.md', 'frontend-modern/public/docs/UNIFIED_AGENT.md'];
|
||||
const result = { result: 'passed', playwrightVersion: require('playwright/package.json').version,
|
||||
baseSha: 'b62042798c7c9b326408dd5532d7fa330d6bb089',
|
||||
scope: 'Real dialog/flow/production Docs rendering with synthetic API; no native migration or shipment',
|
||||
contentSha256: Object.fromEntries(files.map((file) => [file,
|
||||
createHash('sha256').update(fs.readFileSync(path.join(workspace, file))).digest('hex')])), observations };
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((err) => { console.error(err); process.exitCode = 1; });
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Configuration transfer safety</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/configuration-migration.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
// Actual transfer dialogs and flow, plus the production Docs renderer/router.
|
||||
// The browser driver supplies only synthetic API responses; no server or
|
||||
// operational credentials are involved in this copy/interaction proof.
|
||||
import { render } from 'solid-js/web';
|
||||
import { Router, Route } from '@solidjs/router';
|
||||
import { BackupTransferDialogs } from '../src/components/Settings/BackupTransferDialogs';
|
||||
import { useBackupTransferFlow } from '../src/components/Settings/useBackupTransferFlow';
|
||||
import Docs from '../src/pages/Docs';
|
||||
import '../src/index.css';
|
||||
|
||||
function Transfer() {
|
||||
const securityStatus = () => null;
|
||||
const flow = useBackupTransferFlow({ securityStatus });
|
||||
return (
|
||||
<main class="p-6">
|
||||
<h1>Configuration transfer</h1>
|
||||
<button onClick={() => flow.setShowExportDialog(true)}>Create Backup</button>
|
||||
<button onClick={() => flow.setShowImportDialog(true)}>Restore Configuration</button>
|
||||
<BackupTransferDialogs {...flow} securityStatus={securityStatus} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<Router>
|
||||
<Route path="/docs/*docPath" component={Docs} />
|
||||
<Route path="*" component={Transfer} />
|
||||
</Router>
|
||||
),
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -1,52 +1,72 @@
|
||||
{
|
||||
"version": 1,
|
||||
"result": "passed",
|
||||
"base_sha": "227657b1b0d6924e98da9382f812bb0cb1874f57",
|
||||
"verified_at": "2026-10-03T04:13:25.499572Z",
|
||||
"base_sha": "b62042798c7c9b326408dd5532d7fa330d6bb089",
|
||||
"verified_at": "2026-10-03T14:52:51.979841Z",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts"
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "44f939bd8accdb5662f2f99a2c4d6324d965ca520414d0a0db8daba790af04d9",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "41dadd17f930115374facc8f7a9dc46f3e4eab69e41e4375c90f9a5f57022fe8",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ee9482a2076b86e62c85bffe0ce816a6ccd542ec3b07a85e829a1cc07dd8b1de",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts": "a39e66fc3ca772f0394ad0b5a74feee67a80daac3fdc604d014cadb52cda02b6"
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341"
|
||||
},
|
||||
"routes": [
|
||||
"Existing PBS/resource detail Overview change history",
|
||||
"Existing resource detail Manage action history"
|
||||
"/settings/system/recovery (real transfer dialog/flow harness)",
|
||||
"/docs/MIGRATION",
|
||||
"/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address"
|
||||
],
|
||||
"states": [
|
||||
"Configuration export: includes SSO/API-token records, excludes history/TrueNAS/vSphere/agent enrolment; local login/sessions excluded",
|
||||
"Import: replaces included settings, preserves agent identity and requires fresh admission",
|
||||
"Desktop light Chromium and touch phone dark WebKit warnings readable; all six final screenshots inspected"
|
||||
],
|
||||
"interactions": [
|
||||
"Open Create Backup and Restore Configuration dialogs",
|
||||
"Keyboard-open migration guide without losing dialog state, then follow agent retargeting guide",
|
||||
"Synthetic export download through actual flow; deliberately rejected synthetic import retains dialog and Cancel makes no extra operation"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Final facet HTTP 403 withdraws embedded snapshot events/counts instead of reintroducing denied evidence",
|
||||
"Denied filtered facets do not fall back to the unfiltered bundle",
|
||||
"Retry retains denial while pending; fresh successful response restores events and clears guidance",
|
||||
"First action-history HTTP 503 stays visible in Manage with retry and no false empty state",
|
||||
"Initial facet HTTP 503 retains applicable embedded evidence; snapshot-only table rows still make no remote reads",
|
||||
"All fourteen final screenshots inspected in desktop Chromium and dark touch phone WebKit; Playwright 1.56.1 matches the integration lock"
|
||||
],
|
||||
"interactions": [
|
||||
"Remount full production resource drawer with denied/transient reads",
|
||||
"Activate scoped Retry with keyboard Enter",
|
||||
"Apply and clear Change kind filter",
|
||||
"Switch between Overview and Manage"
|
||||
],
|
||||
"limitations": [
|
||||
"Synthetic HTTP responses and PBS snapshots through real production drawer/client/CSS, not appliance authorisation, reporter cause, release or installed recovery acceptance.",
|
||||
"401 withdrawal is unit-covered; real client login redirection is not overridden or browser-proved.",
|
||||
"Action client existing 401/402/403 unavailable-plan responses are unchanged. Only thrown read failures retain actionable error presentation.",
|
||||
"Independent reader authorisation remains independent; this is not a global session revocation broadcast."
|
||||
]
|
||||
"Synthetic API and dedicated real-component/router harness, not native migration, licence, agent admission or shipment",
|
||||
"Final capture waits for real dialog animation; preceding state checks passed but blank transition captures did not establish visual proof"
|
||||
],
|
||||
"browser_versions": [
|
||||
{
|
||||
"engine": "chromium",
|
||||
"browserVersion": "141.0.7390.37",
|
||||
"width": 1440,
|
||||
"height": 1000,
|
||||
"warningScope": true,
|
||||
"keyboardGuideAndRetargetLinks": true,
|
||||
"syntheticExportAndRejectedImport": true,
|
||||
"noPageOverflow": true,
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"engine": "webkit",
|
||||
"browserVersion": "26.0",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"warningScope": true,
|
||||
"keyboardGuideAndRetargetLinks": true,
|
||||
"syntheticExportAndRejectedImport": true,
|
||||
"noPageOverflow": true,
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"playwright_version": "1.56.1",
|
||||
"shipped_asset_sha256": {
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "f07fb69d5bd6b185d0e156d7affcf7d5f5fa78418b3f82dd776d49afbceb9341",
|
||||
"frontend-modern/public/docs/MIGRATION.md": "ad4b5a4b07af44e1657f9512ef468a578b8b5957b9b115a30816824bbd8300e9",
|
||||
"frontend-modern/public/docs/UNIFIED_AGENT.md": "61a15c9651223ac26f4ca900f5731534f03abf96abfd233c47bf034c2796fc05"
|
||||
},
|
||||
"browser_script": "frontend-modern/browser-tests/configuration-migration.cjs"
|
||||
}
|
||||
|
||||
@@ -1,88 +1,122 @@
|
||||
# 🚚 Migrating Pulse
|
||||
# Migrating Pulse
|
||||
|
||||
This guide covers migrating Pulse to a new host using the built-in encrypted export/import workflow.
|
||||
**Create Backup** in **Settings → System → Recovery** creates an encrypted
|
||||
configuration export, not a full backup of the installation. Check its scope
|
||||
before moving Pulse or retiring the old server. Do not use it alone when you
|
||||
need to preserve history, agent enrolment state or settings outside that scope.
|
||||
|
||||
## 🚀 Quick Migration Guide
|
||||
## Choose the backup you need
|
||||
|
||||
### ❌ DON'T: Copy Files
|
||||
Never copy `/etc/pulse` (or `/data` in Docker/Kubernetes) manually. Encryption keys and credentials can break.
|
||||
### Configuration transfer
|
||||
|
||||
### ✅ DO: Use Export/Import
|
||||
Use export/import to move the included settings between installations. Pulse
|
||||
decrypts the export with its passphrase and saves the imported credentials with
|
||||
the destination's encryption key. Do not copy individual encrypted files into
|
||||
a fresh installation with a different key.
|
||||
|
||||
#### 1. Export (Old Server)
|
||||
1. Go to **Settings → System → Recovery**.
|
||||
2. Click **Create Backup**.
|
||||
3. Enter a strong passphrase and download the encrypted backup.
|
||||
The current export format includes:
|
||||
|
||||
#### 2. Import (New Server)
|
||||
1. Install a fresh Pulse instance.
|
||||
2. Go to **Settings → System → Recovery**.
|
||||
3. Click **Restore Configuration** and upload your file.
|
||||
4. Enter the passphrase.
|
||||
|
||||
## 📦 What Gets Migrated
|
||||
|
||||
| Included ✅ | Not Included ❌ |
|
||||
| Included | Not included |
|
||||
| :--- | :--- |
|
||||
| Nodes & credentials | Historical metrics history (`metrics.db`) |
|
||||
| Alerts & overrides | Browser sessions and local cookies |
|
||||
| Notifications (email, webhooks, Apprise) | Local login username/password (`.env`) |
|
||||
| System settings (`system.json`) | Update history/backup folders |
|
||||
| API token records | — |
|
||||
| OIDC config | — |
|
||||
| SSO / SAML config | — |
|
||||
| TrueNAS connections (`truenas.enc`) | — |
|
||||
| Guest metadata/notes | — |
|
||||
| — | Relay config (`relay.enc`) — re-enable in Settings |
|
||||
| — | Host metadata (notes/tags/AI command overrides) |
|
||||
| — | Docker metadata cache |
|
||||
| — | Agent profiles and assignments |
|
||||
| — | AI settings and findings (`ai.enc`, `ai_findings.json`, `ai_patrol_runs.json`, `ai_usage_history.json`) |
|
||||
| — | RBAC roles (`rbac_roles.json`) — re-create after import |
|
||||
| — | Relay/Pro/legacy Pro+/Cloud license (`license.enc`) |
|
||||
| — | Server sessions (`sessions.json`) |
|
||||
| — | Update history (`update-history.jsonl`) |
|
||||
| Proxmox VE, PBS and PMG connections and credentials | TrueNAS, vSphere and Machine Availability connections |
|
||||
| Alert configuration, overrides and intent policies | Active alerts, incidents, notification queue and delivery history |
|
||||
| Email, webhook, Apprise and external watchdog configuration | Metrics history, audit history and report schedules |
|
||||
| System settings | Local login credentials, environment overrides and sessions |
|
||||
| API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state |
|
||||
| SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles |
|
||||
| Guest metadata and notes | Host/Docker metadata, agent profiles and assignments |
|
||||
| — | AI settings, findings, usage and chat history |
|
||||
| — | Licence files, existing Relay configuration, binaries, service definitions and update backups |
|
||||
|
||||
## 🔄 Common Scenarios
|
||||
Contents depend on the version that created the export. Older formats can omit
|
||||
settings included above. Imported API-token records retain their scopes and
|
||||
expiry; they do not reveal the original tokens or transfer every agent's state.
|
||||
SSO configuration **is included**, even though local login credentials are not.
|
||||
|
||||
### Moving to New Hardware
|
||||
Export from old → Install new → Import.
|
||||
### Full-state recovery
|
||||
|
||||
### Docker ↔ Systemd ↔ Kubernetes
|
||||
The export file works across all installation methods. You can migrate from Docker to Kubernetes or vice versa seamlessly.
|
||||
If you need the same installation's history and excluded state, retain a
|
||||
consistent filesystem/volume backup as well. Stop Pulse before taking that
|
||||
backup; monitoring and alert delivery pause while it is stopped. Preserve every
|
||||
effective data path, the matching `.encryption.key`, audit data and signing key,
|
||||
environment files, external certificates, binaries and service/container
|
||||
configuration together. Custom paths and organisation directories matter.
|
||||
Copying a live database file alone can omit its sidecar files.
|
||||
|
||||
### Disaster Recovery
|
||||
1. Install Pulse using Docker or your preferred method (see [INSTALL.md](INSTALL.md)).
|
||||
2. Import your latest backup.
|
||||
3. Restored in < 5 minutes.
|
||||
Keep that backup private and test recovery on an isolated destination before
|
||||
retiring the source. Do not mix encrypted files and keys from different
|
||||
installations or restore over a running Pulse. See [configuration
|
||||
paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#storage).
|
||||
The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains)
|
||||
is a third, limited backup; it is not a substitute for either procedure above.
|
||||
|
||||
## 📋 Post-Migration Checklist
|
||||
## Transfer the included configuration
|
||||
|
||||
Because local login credentials are stored in `.env` (not part of exports), you must:
|
||||
1. On the old server, sign in and open **Settings → System → Recovery →
|
||||
Create Backup**. Use a strong, unique passphrase of at least 12 characters
|
||||
and store it separately from the downloaded file. With password login,
|
||||
choose **Use a custom passphrase** if you do not want to use that password.
|
||||
Keep the old instance and your separate full-state backup until validation
|
||||
is complete.
|
||||
2. Install Pulse at the destination using the [installation guide](INSTALL.md).
|
||||
Configure destination-local administrator access before importing. Keep the
|
||||
destination isolated from agents and notification destinations during the
|
||||
trial so two servers do not send duplicate alerts or remote actions.
|
||||
3. If the destination already has useful settings, export them first. Open
|
||||
**Settings → System → Recovery → Restore Configuration**, select the file
|
||||
and enter its passphrase. Import replaces the included configuration and
|
||||
API-token records; it does not merge them with the destination's records.
|
||||
4. Check the result before changing DNS or agent addresses. A success message
|
||||
establishes neither full recovery nor fresh monitoring. If import reports a
|
||||
reload/apply failure, settings may already have been written: preserve both
|
||||
installations and inspect the error before repeating it.
|
||||
|
||||
1. **Re-create Admin User**: If not using `.env` overrides, create your admin account on the new instance.
|
||||
2. **Confirm API access**:
|
||||
* If you created API tokens in the UI, those token records are included in the export and should continue working.
|
||||
3. **Update Agents**:
|
||||
* **Unified Agent**: Update the `--token` flag in your service definition.
|
||||
* **Containerized agent**: Update `PULSE_TOKEN` in the agent container environment.
|
||||
* **New server address?** Agents dial the address they were installed
|
||||
with (the `--url` flag, or `PULSE_URL` for containerized agents), so
|
||||
agents disappear from the new instance until that value is updated in
|
||||
each agent's service definition. Fronting Pulse with a DNS name
|
||||
instead of an IP address makes future moves invisible to agents.
|
||||
* *Tip: Use **Settings → Infrastructure → Install on a host** to generate updated install commands.*
|
||||
4. **Relay/Pro/legacy Pro+/Cloud**: Re-activate your license key after migration (license files are not included in exports).
|
||||
Keep the archive, passphrase, configuration files and credentials out of issue
|
||||
threads. Use the authenticated settings form rather than a command containing
|
||||
a password, API token or session cookie.
|
||||
|
||||
## 🔒 Security
|
||||
## Cut over and verify
|
||||
|
||||
* **Encryption**: Exports are encrypted with passphrase-based encryption (PBKDF2 + AES-GCM).
|
||||
* **Storage**: Safe to store in cloud backups or password managers.
|
||||
* **Passphrase**: Use a strong, unique passphrase (min 12 chars).
|
||||
- **Login and permissions:** verify destination-local administrator access and
|
||||
any imported SSO provider, group mappings and callback address. Re-create
|
||||
custom roles and supply external CA/certificate files if needed. Environment
|
||||
overrides can take precedence over imported settings.
|
||||
- **Connections and history:** check every expected platform, guest and backup
|
||||
against a fresh observation, not a cached row. Re-create excluded TrueNAS,
|
||||
vSphere and Machine Availability connections. Missing historical data after
|
||||
configuration-only transfer is not proof that collection has failed.
|
||||
- **Agents:** preserve each agent's existing identity and private credential.
|
||||
With a changed server address, use the [agent retargeting
|
||||
guide](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) on the monitored host.
|
||||
Do not create another token just for a server move or paste it into `--token`.
|
||||
Confirm fresh reports and any enrolment/authorisation result; imported token
|
||||
records alone do not prove that every agent is admitted. Re-create excluded
|
||||
profiles and assignments before enabling remote actions.
|
||||
- **Alerts and notifications:** avoid running both instances against the same
|
||||
destinations. After cutover, check thresholds, schedules and one controlled
|
||||
notification test with its actual delivery result. Importing destination
|
||||
settings does not migrate pending notifications or incident history.
|
||||
- **Licence and remaining settings:** re-activate Pro if required and verify
|
||||
any excluded settings you rely on. Existing paired phones need their retained
|
||||
configuration; configuration export alone is not a Mobile/Relay migration.
|
||||
Relay is no longer sold; do not enable it as a new migration route.
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
Retire the old installation only after these checks succeed. Keep the private,
|
||||
consistent pre-move backup for recovery. If validation fails, stop the trial
|
||||
destination before returning traffic to the intact source; check agent URLs
|
||||
and avoid two active writers. There is no guaranteed five-minute recovery time.
|
||||
|
||||
* **"Invalid passphrase"**: Ensure exact match (case-sensitive).
|
||||
* **Missing Nodes**: Verify export date.
|
||||
* **Connection Errors**: Update node IPs in Settings if they changed.
|
||||
* **Logging**: Adjust `LOG_LEVEL`/`LOG_FORMAT` via environment variables if needed.
|
||||
## Troubleshooting
|
||||
|
||||
- **Wrong passphrase or rejected file:** use the original export file and its
|
||||
exact passphrase. Do not delete destination data or regenerate encryption
|
||||
keys to make an import succeed.
|
||||
- **A setting is missing:** compare it with the export scope above and the
|
||||
exporting version. Use the old installation or full-state backup rather
|
||||
than assuming import contained it.
|
||||
- **Agents are absent:** check their primary URL, TLS trust and fresh admission
|
||||
evidence using the linked guide. Do not uninstall, re-enrol or delete saved
|
||||
identities just because the dashboard is empty.
|
||||
- **Need help:** retain the source/destination versions, deployment types,
|
||||
operation time and exact redacted error. Follow [safe
|
||||
reporting](TROUBLESHOOTING.md#getting-help); do not attach the recovery backup.
|
||||
|
||||
@@ -52,10 +52,12 @@ first unless you are intentionally removing that host from Pulse.
|
||||
|
||||
### Moving Pulse to a new address
|
||||
|
||||
Configuration export/import restores the server-side agent records and API
|
||||
tokens. It cannot rewrite the primary Pulse URL on remote machines because
|
||||
agents initiate the connection. Prefer a stable DNS name for the primary URL
|
||||
so replacing the Pulse host does not require an agent migration.
|
||||
Configuration export/import restores API-token records, not the server-side
|
||||
agent inventory, enrolment state, profiles or assignments. See the
|
||||
[configuration-transfer scope](MIGRATION.md#configuration-transfer) before
|
||||
retiring the old server. Import cannot rewrite the primary Pulse URL on remote
|
||||
machines because agents initiate the connection. Prefer a stable DNS name for
|
||||
the primary URL, but still verify fresh reports and agent admission after a move.
|
||||
|
||||
After importing the configuration on a Pulse server with a different address,
|
||||
retarget each existing standard Linux agent from that agent machine. Download
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
import { controlClass, formField, formHelpText, labelClass } from '@/components/shared/Form';
|
||||
import type { SecurityStatus as SecurityStatusInfo } from '@/types/config';
|
||||
import { getAPITokenManagementLocationMessage } from '@/utils/apiTokenPresentation';
|
||||
import { getShippedDocUrl } from '@/utils/docsLinks';
|
||||
|
||||
interface BackupTransferDialogsProps {
|
||||
securityStatus: Accessor<SecurityStatusInfo | null>;
|
||||
@@ -140,9 +141,19 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-xs text-amber-700 dark:text-amber-300">
|
||||
<strong>Important:</strong> The backup contains node credentials but NOT
|
||||
authentication settings. Each Pulse instance should configure its own login
|
||||
credentials for security. Remember your{' '}
|
||||
<strong>Configuration only:</strong> Includes Proxmox connections, SSO settings
|
||||
and API-token records, but not history, TrueNAS/vSphere connections or agent
|
||||
enrolment state. Local login credentials and sessions are not included. Check
|
||||
the{' '}
|
||||
<a
|
||||
href={getShippedDocUrl('MIGRATION.md')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>
|
||||
migration guide
|
||||
</a>{' '}
|
||||
before relying on this for recovery. Remember your{' '}
|
||||
{props.useCustomPassphrase() || !props.securityStatus()?.hasAuthentication
|
||||
? 'passphrase'
|
||||
: 'password'}{' '}
|
||||
@@ -282,13 +293,24 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-800 rounded p-3">
|
||||
<div class="space-y-2 text-xs text-yellow-700 dark:text-yellow-300">
|
||||
<p>
|
||||
<strong>Warning:</strong> Importing will replace all current configuration. This
|
||||
action cannot be undone.
|
||||
<strong>Warning:</strong> Import replaces the included settings and API-token
|
||||
records, not the whole installation. Back up the destination first. History and
|
||||
excluded settings are not restored; check the{' '}
|
||||
<a
|
||||
href={getShippedDocUrl('MIGRATION.md')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>
|
||||
migration guide
|
||||
</a>{' '}
|
||||
before retiring the old server.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Agent migration:</strong> The backup restores server-side agent records
|
||||
and credentials, but it cannot change the Pulse URL stored on remote agents. If
|
||||
this server has a different address, retarget each agent after the import.
|
||||
<strong>Agent migration:</strong> API-token records are included, but agent
|
||||
inventory, enrolment state, profiles and assignments are not. Import cannot
|
||||
change the Pulse URL stored on remote agents. Preserve each agent's identity and
|
||||
private credential, retarget if the address changes, and verify fresh admission.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,40 +2,65 @@ import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BackupTransferDialogs } from '../BackupTransferDialogs';
|
||||
|
||||
function renderTransfer(kind: 'export' | 'import') {
|
||||
return render(() => (
|
||||
<BackupTransferDialogs
|
||||
securityStatus={() => null}
|
||||
exportPassphrase={() => ''}
|
||||
setExportPassphrase={vi.fn()}
|
||||
useCustomPassphrase={() => false}
|
||||
setUseCustomPassphrase={vi.fn()}
|
||||
importPassphrase={() => 'secure-passphrase'}
|
||||
setImportPassphrase={vi.fn()}
|
||||
importFile={() => null}
|
||||
setImportFile={vi.fn()}
|
||||
showExportDialog={() => kind === 'export'}
|
||||
showImportDialog={() => kind === 'import'}
|
||||
showApiTokenModal={() => false}
|
||||
apiTokenInput={() => ''}
|
||||
setApiTokenInput={vi.fn()}
|
||||
handleExport={vi.fn()}
|
||||
handleImport={vi.fn()}
|
||||
closeExportDialog={vi.fn()}
|
||||
closeImportDialog={vi.fn()}
|
||||
closeApiTokenModal={vi.fn()}
|
||||
handleApiTokenAuthenticate={vi.fn()}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
describe('BackupTransferDialogs', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
afterEach(cleanup);
|
||||
|
||||
it('discloses the actual archive scope before exporting', () => {
|
||||
renderTransfer('export');
|
||||
expect(screen.getByText('Configuration only:')).toBeInTheDocument();
|
||||
const text = screen.getByRole('dialog', { name: 'Export configuration' }).textContent;
|
||||
expect(text).toContain('SSO settings');
|
||||
expect(text).toContain('API-token records');
|
||||
expect(text).toContain('not history, TrueNAS/vSphere connections');
|
||||
expect(text).toContain('Local login credentials and sessions are not included');
|
||||
expect(text).not.toContain('NOT authentication settings');
|
||||
expect(screen.getByRole('link', { name: 'migration guide' })).toHaveAttribute(
|
||||
'href',
|
||||
'/docs/MIGRATION',
|
||||
);
|
||||
});
|
||||
|
||||
it('discloses that configuration import cannot retarget remote agents', () => {
|
||||
render(() => (
|
||||
<BackupTransferDialogs
|
||||
securityStatus={() => null}
|
||||
exportPassphrase={() => ''}
|
||||
setExportPassphrase={vi.fn()}
|
||||
useCustomPassphrase={() => false}
|
||||
setUseCustomPassphrase={vi.fn()}
|
||||
importPassphrase={() => 'secure-passphrase'}
|
||||
setImportPassphrase={vi.fn()}
|
||||
importFile={() => null}
|
||||
setImportFile={vi.fn()}
|
||||
showExportDialog={() => false}
|
||||
showImportDialog={() => true}
|
||||
showApiTokenModal={() => false}
|
||||
apiTokenInput={() => ''}
|
||||
setApiTokenInput={vi.fn()}
|
||||
handleExport={vi.fn()}
|
||||
handleImport={vi.fn()}
|
||||
closeExportDialog={vi.fn()}
|
||||
closeImportDialog={vi.fn()}
|
||||
closeApiTokenModal={vi.fn()}
|
||||
handleApiTokenAuthenticate={vi.fn()}
|
||||
/>
|
||||
));
|
||||
|
||||
it('distinguishes token transfer from agent admission before importing', () => {
|
||||
renderTransfer('import');
|
||||
expect(screen.getByText('Agent migration:')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/it cannot change the Pulse URL stored on remote agents/i),
|
||||
).toBeInTheDocument();
|
||||
const text = screen.getByRole('dialog', { name: 'Import configuration' }).textContent;
|
||||
expect(text).toContain('Back up the destination first');
|
||||
expect(text).toContain('not the whole installation');
|
||||
expect(text).toContain('inventory, enrolment state, profiles and assignments are not');
|
||||
expect(text).toContain('Import cannot change the Pulse URL stored on remote agents');
|
||||
expect(text).toContain('Preserve each agent');
|
||||
expect(text).toContain('verify fresh admission');
|
||||
expect(text).not.toContain('restores server-side agent records');
|
||||
const guide = screen.getByRole('link', { name: 'migration guide' });
|
||||
expect(guide).toHaveAttribute('href', '/docs/MIGRATION');
|
||||
expect(guide).toHaveAttribute('target', '_blank');
|
||||
expect(guide).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,31 @@ describe('docsLinks', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps configuration transfer separate from full-state recovery', () => {
|
||||
const migration = readFileSync(path.join(repoRoot, 'docs', 'MIGRATION.md'), 'utf8');
|
||||
const agent = readFileSync(path.join(repoRoot, 'docs', 'UNIFIED_AGENT.md'), 'utf8');
|
||||
const scope = migration
|
||||
.split('### Configuration transfer')[1]
|
||||
.split('### Full-state recovery')[0];
|
||||
expect(scope).toContain('Proxmox VE, PBS and PMG');
|
||||
expect(scope).toContain('| TrueNAS, vSphere and Machine Availability');
|
||||
expect(scope).toContain('SSO configuration **is included**');
|
||||
expect(scope).toContain(
|
||||
'Server-side host/Docker/Kubernetes agent inventory and enrolment state',
|
||||
);
|
||||
expect(migration).toContain('not a full backup of the installation');
|
||||
expect(migration).toContain('Stop Pulse before taking that');
|
||||
expect(migration).toContain('matching `.encryption.key`');
|
||||
expect(migration).toContain('reload/apply failure');
|
||||
expect(migration).toContain('it does not merge');
|
||||
expect(migration).toContain('UNIFIED_AGENT.md#moving-pulse-to-a-new-address');
|
||||
expect(migration).not.toContain('Update the `--token` flag');
|
||||
expect(migration).not.toContain('Restored in < 5 minutes');
|
||||
expect(migration).not.toContain('re-enable in Settings');
|
||||
expect(agent).toContain('restores API-token records, not the server-side');
|
||||
expect(agent).toContain('MIGRATION.md#configuration-transfer');
|
||||
});
|
||||
|
||||
it('keeps lockout and incident reporting guidance safe for the affected client', () => {
|
||||
const troubleshooting = readFileSync(path.join(repoRoot, 'docs', 'TROUBLESHOOTING.md'), 'utf8');
|
||||
const recovery = troubleshooting.split('### Recovery Mode')[1].split('\n---')[0];
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Exercise the actual archive, not a fabricated ExportData value: the migration
|
||||
// warnings must match what a fresh destination can recover with a different key.
|
||||
func TestConfigurationMigrationArchiveScope(t *testing.T) {
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
sourceDir, targetDir := t.TempDir(), t.TempDir()
|
||||
source, target := NewConfigPersistence(sourceDir), NewConfigPersistence(targetDir)
|
||||
require.NoError(t, source.EnsureConfigDir())
|
||||
require.NoError(t, target.EnsureConfigDir())
|
||||
|
||||
nodes := []PVEInstance{{Name: "synthetic-pve", Host: "https://pve.example:8006", TokenName: "pulse@pve!read", TokenValue: "synthetic-node-secret"}}
|
||||
require.NoError(t, source.SaveNodesConfig(nodes, nil, nil))
|
||||
expires := time.Now().Add(time.Hour).UTC()
|
||||
tokens := []APITokenRecord{{ID: "synthetic-agent-token", Name: "agent", Hash: auth.HashAPIToken("synthetic-agent-secret"), CreatedAt: time.Now().UTC(), ExpiresAt: &expires, Scopes: []string{ScopeAgentReport}, OrgID: "default"}}
|
||||
require.NoError(t, source.SaveAPITokens(tokens))
|
||||
sso := &SSOConfig{Providers: []SSOProvider{{ID: "synthetic-sso", Name: "SSO", Type: SSOProviderTypeOIDC, Enabled: true, OIDC: &OIDCProviderConfig{IssuerURL: "https://idp.example", ClientID: "synthetic", ClientSecret: "synthetic-sso-secret"}}}}
|
||||
require.NoError(t, source.SaveSSOConfig(sso))
|
||||
sourceNAS := []TrueNASInstance{{Name: "source-nas", Host: "https://nas.example", APIKey: "synthetic-nas-secret"}}
|
||||
require.NoError(t, source.SaveTrueNASConfig(sourceNAS))
|
||||
targetNAS := []TrueNASInstance{{Name: "target-nas", Host: "https://target.example", APIKey: "synthetic-target-secret"}}
|
||||
require.NoError(t, target.SaveTrueNASConfig(targetNAS))
|
||||
|
||||
// Excluded state exists at both ends. Import must not pretend to transfer it
|
||||
// or overwrite the destination's excluded files. These are inert sentinels,
|
||||
// not live databases, credentials or operational agent records.
|
||||
excluded := []string{".env", "metrics.db", "audit.db", "host_agents.json", "agent_profiles.json", "rbac_roles.json", "license.enc"}
|
||||
for _, name := range excluded {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(sourceDir, name), []byte("synthetic-source-only"), 0600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(targetDir, name), []byte("synthetic-destination-only"), 0600))
|
||||
}
|
||||
sourceKey, err := os.ReadFile(filepath.Join(sourceDir, ".encryption.key"))
|
||||
require.NoError(t, err)
|
||||
targetKey, err := os.ReadFile(filepath.Join(targetDir, ".encryption.key"))
|
||||
require.NoError(t, err)
|
||||
require.False(t, bytes.Equal(sourceKey, targetKey), "fresh destinations must use a different key")
|
||||
|
||||
const passphrase = "synthetic-migration-passphrase"
|
||||
archive, err := source.ExportConfig(passphrase)
|
||||
require.NoError(t, err)
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(archive)
|
||||
require.NoError(t, err)
|
||||
plaintext, err := decryptWithPassphrase(ciphertext, passphrase)
|
||||
require.NoError(t, err)
|
||||
var payload map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(plaintext, &payload))
|
||||
require.Equal(t, "4.4", stringValue(t, payload["version"]))
|
||||
// Changing archive scope requires updating the actual user-facing account,
|
||||
// not silently assuming every persisted store is in a configuration export.
|
||||
require.ElementsMatch(t, []string{"version", "exportedAt", "nodes", "alerts", "alertIntentPolicies", "email", "webhooks", "apprise", "deadMan", "system", "sso", "apiTokens"}, rawKeys(payload))
|
||||
require.NotContains(t, string(plaintext), "synthetic-nas-secret")
|
||||
require.NotContains(t, string(plaintext), "synthetic-source-only")
|
||||
require.Contains(t, string(plaintext), "synthetic-sso-secret")
|
||||
|
||||
baseline := []PVEInstance{{Name: "destination-before", Host: "https://before.example:8006"}}
|
||||
require.NoError(t, target.SaveNodesConfig(baseline, nil, nil))
|
||||
require.Error(t, target.ImportConfig(archive, "wrong-synthetic-passphrase"))
|
||||
unchanged, err := target.LoadNodesConfig()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, baseline[0].Name, unchanged.PVEInstances[0].Name)
|
||||
|
||||
require.NoError(t, target.ImportConfig(archive, passphrase))
|
||||
reopened := NewConfigPersistence(targetDir)
|
||||
restored, err := reopened.LoadNodesConfig()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, nodes[0].TokenValue, restored.PVEInstances[0].TokenValue)
|
||||
require.Equal(t, nodes[0].Name, restored.PVEInstances[0].Name)
|
||||
restoredSSO, err := reopened.LoadSSOConfig()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sso.Providers[0].OIDC.ClientSecret, restoredSSO.Providers[0].OIDC.ClientSecret)
|
||||
restoredTokens, err := reopened.LoadAPITokens()
|
||||
require.NoError(t, err)
|
||||
cfg := &Config{APITokens: restoredTokens}
|
||||
record, ok := cfg.ValidateAPIToken("synthetic-agent-secret")
|
||||
require.True(t, ok, "restored records validate the existing credential without replacing it")
|
||||
require.Equal(t, tokens[0].ID, record.ID)
|
||||
require.Equal(t, tokens[0].Scopes, record.Scopes)
|
||||
require.WithinDuration(t, expires, *record.ExpiresAt, time.Millisecond)
|
||||
nas, err := reopened.LoadTrueNASConfig()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, targetNAS[0].Name, nas[0].Name)
|
||||
for _, name := range excluded {
|
||||
data, err := os.ReadFile(filepath.Join(targetDir, name))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "synthetic-destination-only", string(data), name)
|
||||
}
|
||||
keyAfter, err := os.ReadFile(filepath.Join(targetDir, ".encryption.key"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, bytes.Equal(targetKey, keyAfter), "configuration import keeps the destination key")
|
||||
}
|
||||
|
||||
func rawKeys(payload map[string]json.RawMessage) []string {
|
||||
keys := make([]string, 0, len(payload))
|
||||
for key := range payload {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func stringValue(t *testing.T, raw json.RawMessage) string {
|
||||
t.Helper()
|
||||
var value string
|
||||
require.NoError(t, json.Unmarshal(raw, &value))
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user