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:
pulse-triage[bot]
2026-10-03 15:52:53 +01:00
parent b62042798c
commit 73d2c64e0f
12 changed files with 686 additions and 220 deletions
+104 -70
View File
@@ -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.
+6 -4
View File
@@ -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')!,
);
+53 -33
View File
@@ -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"
}
+104 -70
View File
@@ -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.
+6 -4
View File
@@ -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];
+118
View File
@@ -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
}