Backups to master, replacement-VPS restore, broker proxy, multi-instance
Four features: - Backup: lib/backup-sync.ts pushes the full settings table and firm credentials (the deliberate exception to "credentials never leave the machine") to the master's bearer-gated /api/instances/backup — every 5 minutes when the content hash changed, within seconds of a settings or credential edit, and on the new backup-now command. Enabled by the master_backup_token setting. - Restore: a dead VPS's replacement pulls the backup and adopts its uuid/name, so roster, payouts and card links carry on. Pre-boot via seed-settings.js --restore-from (setup-linux.sh prompts for it) or the Settings page's restore picker (lib/backup-restore.ts). Stop the old machine first; the master 409s a live-name rename. - Proxy: the proxy_url setting routes BROKER traffic only — TopstepX REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent). The master link, reporter, catalog and backup push stay direct, so a dead proxy cannot take down the control channel. Restart to apply. - Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout runs N instances; scripts/instances.json (per-device, gitignored) drives start-all/update-check, and add-instance.sh / remove-instance.sh manage the extra PM2 processes. Absent registry = single-instance behavior, unchanged. Instance 1 keeps port 3000 for the clicker and extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
97947bffde
commit
8154f25bb9
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Restores a backup pulled from the master onto THIS machine — the other half
|
||||
* of lib/backup-sync.ts, for replacing a dead VPS.
|
||||
*
|
||||
* Applying a backup overwrites this machine's settings, broker credentials,
|
||||
* instruments and assignments, and — the point of the exercise — adopts the
|
||||
* backed-up instance's uuid and name, so the master's roster, payouts and card
|
||||
* links carry straight on. The master already handles adoption: a hello
|
||||
* presenting a known uuid lands on that person's roster row.
|
||||
*
|
||||
* Run it before first boot via scripts/seed-settings.js --restore-from (which
|
||||
* duplicates this apply logic — keep them in sync), or from the settings UI
|
||||
* via POST /api/backup/restore. Either way the old machine must be stopped
|
||||
* first: two live instances presenting one uuid fight over the socket.
|
||||
*/
|
||||
|
||||
import {
|
||||
setSetting,
|
||||
getSetting,
|
||||
setCredentials,
|
||||
setInstrumentEnabled,
|
||||
setAssignment,
|
||||
runInTransaction,
|
||||
} from './db';
|
||||
import type { BackupPayload } from './backup-sync';
|
||||
|
||||
/** Mirror of the master's listing rows (lib/backups.ts BackupMeta). */
|
||||
export interface BackupMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
savedAt: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
// Runtime bookkeeping describing the machine the backup was taken on, not
|
||||
// this one. Kept in sync with the skip-set in scripts/seed-settings.js.
|
||||
const RESTORE_SKIP_KEYS = new Set([
|
||||
'runner_heartbeat',
|
||||
'catalog_last_sync_at',
|
||||
'catalog_last_error',
|
||||
'backup_last_push_at',
|
||||
'backup_last_error',
|
||||
]);
|
||||
|
||||
function authedFetch(masterUrl: string, token: string, path: string) {
|
||||
const url = new URL(path, masterUrl);
|
||||
return fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchBackupList(masterUrl: string, token: string): Promise<BackupMeta[]> {
|
||||
const res = await authedFetch(masterUrl, token, '/api/instances/backup');
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().then((b) => b?.error).catch(() => '');
|
||||
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const body = await res.json();
|
||||
if (!Array.isArray(body?.backups)) throw new Error('unrecognized backup list');
|
||||
return body.backups as BackupMeta[];
|
||||
}
|
||||
|
||||
export async function fetchBackup(masterUrl: string, token: string, id: string): Promise<BackupPayload> {
|
||||
const res = await authedFetch(masterUrl, token, `/api/instances/backup/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) {
|
||||
const detail = await res.json().then((b) => b?.error).catch(() => '');
|
||||
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const payload = await res.json() as BackupPayload;
|
||||
if (payload?.v !== 1 || typeof payload.settings !== 'object' || payload.settings === null) {
|
||||
throw new Error('unrecognized backup payload');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export interface RestoreCounts {
|
||||
settings: number;
|
||||
credentials: number;
|
||||
instruments: number;
|
||||
assignments: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites this machine's configuration with the backup, including
|
||||
* instance_id and instance_name — the identity adoption. Callers restart the
|
||||
* process afterwards: live platform clients, the master link's hello and any
|
||||
* cached settings all predate the restore.
|
||||
*/
|
||||
export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: boolean } = {}): RestoreCounts {
|
||||
// Defaults to keeping this machine's master url: the machine is talking to
|
||||
// that master right now, while an old backup may point at a dead host.
|
||||
const keepMasterUrl = opts.keepMasterUrl ?? true;
|
||||
const ownMasterUrl = getSetting('master_dashboard_url');
|
||||
const ownToken = getSetting('master_backup_token');
|
||||
|
||||
const counts: RestoreCounts = { settings: 0, credentials: 0, instruments: 0, assignments: 0 };
|
||||
|
||||
runInTransaction(() => {
|
||||
for (const [key, value] of Object.entries(payload.settings)) {
|
||||
if (RESTORE_SKIP_KEYS.has(key)) continue;
|
||||
if (typeof value !== 'string') continue;
|
||||
setSetting(key, value);
|
||||
counts.settings++;
|
||||
}
|
||||
if (keepMasterUrl && ownMasterUrl) setSetting('master_dashboard_url', ownMasterUrl);
|
||||
if (ownToken) setSetting('master_backup_token', ownToken);
|
||||
|
||||
for (const c of payload.credentials ?? []) {
|
||||
setCredentials(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '');
|
||||
counts.credentials++;
|
||||
}
|
||||
for (const i of payload.instruments ?? []) {
|
||||
setInstrumentEnabled(i.symbol, i.enabled);
|
||||
counts.instruments++;
|
||||
}
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssignment(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual === 1);
|
||||
counts.assignments++;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
Reference in New Issue
Block a user