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
+153
-10
@@ -1,31 +1,174 @@
|
||||
/**
|
||||
* Writes the reporter settings into autotrader.sqlite before first launch.
|
||||
* Writes the reporter settings into the instance database before first launch,
|
||||
* and optionally restores a full backup from the master.
|
||||
*
|
||||
* lib/db.ts seeds these keys with INSERT OR IGNORE, so values written here
|
||||
* survive the app's own startup seeding. Must be run from the project root —
|
||||
* lib/db.ts opens the database at process.cwd().
|
||||
* lib/db.ts seeds its keys with INSERT OR IGNORE, so values written here
|
||||
* survive the app's own startup seeding. Must be run from the project root
|
||||
* unless AUTOFIRMER_DB points elsewhere — lib/db.ts opens the database at
|
||||
* process.cwd() by default, and this script must hit the same file.
|
||||
*
|
||||
* node scripts/seed-settings.js <master_dashboard_url> <instance_name>
|
||||
* node scripts/seed-settings.js <master_dashboard_url> <instance_name> \
|
||||
* [--token <backup_token>] [--restore-from <instance_uuid>]
|
||||
*
|
||||
* --restore-from pulls that instance's encrypted backup from the master
|
||||
* (authenticated by --token) and applies it — settings, firm credentials,
|
||||
* instruments, account assignments — before the app has ever booted, so the
|
||||
* very first hello to the master already presents the adopted uuid. The
|
||||
* backup's instance_name wins over the CLI argument.
|
||||
*/
|
||||
const path = require('path');
|
||||
const { mkdirSync } = require('fs');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const [, , url, name] = process.argv;
|
||||
const args = process.argv.slice(2);
|
||||
const positional = [];
|
||||
let token = '';
|
||||
let restoreFrom = '';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--token') token = args[++i] ?? '';
|
||||
else if (args[i] === '--restore-from') restoreFrom = args[++i] ?? '';
|
||||
else positional.push(args[i]);
|
||||
}
|
||||
const [url, name] = positional;
|
||||
|
||||
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
|
||||
const DB_PATH = process.env.AUTOFIRMER_DB
|
||||
? path.resolve(process.env.AUTOFIRMER_DB)
|
||||
: path.join(process.cwd(), 'autotrader.sqlite');
|
||||
|
||||
mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('busy_timeout = 5000');
|
||||
|
||||
// Mirrors the schema in lib/db.ts for every table a restore touches. The app
|
||||
// has usually never run at this point, so the tables may not exist yet; the
|
||||
// shapes must be kept in sync with lib/db.ts by hand.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS firm_credentials (
|
||||
firm_id TEXT PRIMARY KEY,
|
||||
firm_name TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instruments (
|
||||
symbol TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_assignments (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
firm_id TEXT NOT NULL,
|
||||
account_type_id TEXT,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
try {
|
||||
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
);
|
||||
|
||||
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
|
||||
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
|
||||
if (url) { upsert.run('master_dashboard_url', url); console.log(' master_dashboard_url = ' + url); }
|
||||
if (name) { upsert.run('instance_name', name); console.log(' instance_name = ' + name); }
|
||||
if (token) { upsert.run('master_backup_token', token); console.log(' master_backup_token = (set)'); }
|
||||
|
||||
db.close();
|
||||
// Runtime bookkeeping keys that describe the machine the backup was taken on,
|
||||
// not the one being provisioned. Kept in sync with lib/backup-restore.ts.
|
||||
const RESTORE_SKIP_KEYS = new Set([
|
||||
'runner_heartbeat',
|
||||
'catalog_last_sync_at',
|
||||
'catalog_last_error',
|
||||
'backup_last_push_at',
|
||||
'backup_last_error',
|
||||
]);
|
||||
|
||||
async function restore() {
|
||||
if (!restoreFrom) return;
|
||||
if (!url) { console.error('--restore-from needs the master url argument'); process.exit(1); }
|
||||
if (!token) { console.error('--restore-from needs --token'); process.exit(1); }
|
||||
|
||||
const endpoint = new URL(`/api/instances/backup/${restoreFrom}`, url);
|
||||
console.log(` restoring from ${endpoint}`);
|
||||
const res = await fetch(endpoint, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(` restore failed: master answered ${res.status} ${await res.text().catch(() => '')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const payload = await res.json();
|
||||
if (payload?.v !== 1 || typeof payload.settings !== 'object') {
|
||||
console.error(' restore failed: unrecognized backup payload');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const setCred = db.prepare(`
|
||||
INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(firm_id) DO UPDATE SET
|
||||
firm_name = excluded.firm_name,
|
||||
username = excluded.username,
|
||||
password = excluded.password,
|
||||
api_key = excluded.api_key,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const setInstr = db.prepare(
|
||||
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
|
||||
);
|
||||
const setAssign = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const [key, value] of Object.entries(payload.settings)) {
|
||||
if (RESTORE_SKIP_KEYS.has(key)) continue;
|
||||
if (typeof value !== 'string') continue;
|
||||
upsert.run(key, value);
|
||||
}
|
||||
// The freshly seeded master url wins over whatever the backup recorded:
|
||||
// the operator just typed it, and an old backup may point at a dead host.
|
||||
if (url) upsert.run('master_dashboard_url', url);
|
||||
if (token) upsert.run('master_backup_token', token);
|
||||
|
||||
for (const c of payload.credentials ?? []) {
|
||||
setCred.run(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '', c.updated_at ?? new Date().toISOString());
|
||||
}
|
||||
for (const i of payload.instruments ?? []) {
|
||||
setInstr.run(i.symbol, i.enabled ? 1 : 0);
|
||||
}
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(` restored: ${Object.keys(payload.settings).length} settings, ` +
|
||||
`${(payload.credentials ?? []).length} credentials, ` +
|
||||
`${(payload.instruments ?? []).length} instruments, ` +
|
||||
`${(payload.assignments ?? []).length} assignments`);
|
||||
console.log(` adopted identity: "${payload.settings.instance_name ?? ''}" (${payload.settings.instance_id ?? 'no uuid'})`);
|
||||
}
|
||||
|
||||
restore()
|
||||
.catch((err) => { console.error(` restore failed: ${err.message}`); process.exit(1); })
|
||||
.finally(() => db.close());
|
||||
|
||||
Reference in New Issue
Block a user