feat(installer): install picpeak directly from a backup via trigger file
Closes the six-step DR dance ("onboard throwaway admin → restore via
wizard → log out → log back in with originals") by letting admins
recover an install with zero clicks past `docker compose up`.
Convention: drop a file named `RESTORE_ON_INSTALL` (no extension OR
.txt) into the existing `/backup` bind mount. On next container
start, the new boot hook detects it, runs the restore, and starts
the server with the restored state. Admin opens the browser, login
works first try.
Payload variants:
- empty file → auto-picks newest backup-manifest-*.json from
/backup/manifests/. Useful for "restore the latest".
- path inside the file → uses that specific manifest. Useful for
"I want this older backup, not the most recent".
Safety gates (three layers):
1. Trigger file must exist — no auto-magic, admin signals intent
2. DB must be empty (no events, ≤1 admin) — refuses to clobber
production data
3. Restore failure leaves the trigger file in place for retry on
next container start. Success deletes it so subsequent boots
don't redo the work.
Override hook: INSTALL_FROM_BACKUP_FORCE=true skips guard #2 for the
"I know what I'm doing" edge case (dev env rebuilds, etc).
No docker-compose changes required — uses the bind mount picpeak
already has, env vars are optional. The minimal admin workflow now
matches the bare-minimum mental model: "copy my backup files,
restart the container, log in with original credentials."
Tests: 7 scenarios covering trigger detection, payload variants,
safety gates, success/failure trigger-file lifecycle.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Install-from-backup boot hook — pins the trigger-file convention.
|
||||
*
|
||||
* The hook itself depends on `restoreService.restore`, which is hard
|
||||
* to fully exercise in an integration test without a real PG cluster
|
||||
* (sequence resync, DROP/CREATE, etc.). So we stub the actual restore
|
||||
* and verify the BOOT HOOK logic:
|
||||
*
|
||||
* - No trigger file → no-op, ran=false
|
||||
* - Empty trigger file → picks newest manifest from manifests/
|
||||
* - Non-empty trigger file → uses the path inside
|
||||
* - DB not empty → refuses (no restore call)
|
||||
* - DB not empty + FORCE env → proceeds
|
||||
* - Successful restore → deletes trigger file
|
||||
* - Failed restore → leaves trigger file in place
|
||||
*
|
||||
* These are the surfaces an admin will hit when actually using the
|
||||
* feature — the docker-compose-on-real-PG end-to-end test belongs in
|
||||
* the follow-up CI work captured as task #7 earlier today.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
// Stub the heavy lifting so the test stays fast + portable.
|
||||
const mockRestore = jest.fn();
|
||||
jest.mock('../../src/services/restoreService', () => ({
|
||||
restoreService: {
|
||||
restore: (...args) => mockRestore(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('installFromBackupBoot', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let backupRoot;
|
||||
let manifestsDir;
|
||||
let tryInstallFromBackup;
|
||||
let originalBackupRootEnv;
|
||||
let originalForceEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupRoot = path.join(storagePath, 'backup');
|
||||
manifestsDir = path.join(backupRoot, 'manifests');
|
||||
fs.mkdirSync(manifestsDir, { recursive: true });
|
||||
|
||||
originalBackupRootEnv = process.env.BACKUP_ROOT;
|
||||
originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
process.env.BACKUP_ROOT = backupRoot;
|
||||
|
||||
({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (originalBackupRootEnv === undefined) {
|
||||
delete process.env.BACKUP_ROOT;
|
||||
} else {
|
||||
process.env.BACKUP_ROOT = originalBackupRootEnv;
|
||||
}
|
||||
if (originalForceEnv === undefined) {
|
||||
delete process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
} else {
|
||||
process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv;
|
||||
}
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRestore.mockReset();
|
||||
mockRestore.mockResolvedValue({ success: true });
|
||||
delete process.env.INSTALL_FROM_BACKUP_FORCE;
|
||||
|
||||
// Clean trigger files + manifests between tests
|
||||
for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) {
|
||||
const p = path.join(backupRoot, name);
|
||||
if (fs.existsSync(p)) fs.unlinkSync(p);
|
||||
}
|
||||
for (const f of fs.readdirSync(manifestsDir)) {
|
||||
fs.unlinkSync(path.join(manifestsDir, f));
|
||||
}
|
||||
|
||||
// Reset DB to fresh-install state
|
||||
await db('events').del();
|
||||
// Leave admin_users alone — fresh-install state has 1 row.
|
||||
});
|
||||
|
||||
it('no trigger file → no-op', async () => {
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(mockRestore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty trigger file picks the newest manifest from manifests/', async () => {
|
||||
const older = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
const newer = path.join(manifestsDir, 'backup-manifest-002.json');
|
||||
fs.writeFileSync(older, '{}');
|
||||
// Set the newer file's mtime slightly later so it wins the sort
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(older, past, past);
|
||||
fs.writeFileSync(newer, '{}');
|
||||
|
||||
// Empty trigger
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(true);
|
||||
expect(result.manifestPath).toBe(newer);
|
||||
expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: 'local',
|
||||
manifestPath: newer,
|
||||
restoreType: 'full',
|
||||
force: true,
|
||||
skipPreBackup: true,
|
||||
}));
|
||||
});
|
||||
|
||||
it('non-empty trigger file uses the path inside', async () => {
|
||||
const specific = path.join(manifestsDir, 'backup-manifest-specific.json');
|
||||
fs.writeFileSync(specific, '{}');
|
||||
|
||||
// Relative to backupRoot
|
||||
fs.writeFileSync(
|
||||
path.join(backupRoot, 'RESTORE_ON_INSTALL'),
|
||||
'manifests/backup-manifest-specific.json\n',
|
||||
);
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(true);
|
||||
expect(result.manifestPath).toBe(specific);
|
||||
});
|
||||
|
||||
it('deletes the trigger file after a successful restore', async () => {
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
|
||||
fs.writeFileSync(triggerPath, '');
|
||||
|
||||
await tryInstallFromBackup(db);
|
||||
expect(fs.existsSync(triggerPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the trigger file in place when restore throws', async () => {
|
||||
mockRestore.mockRejectedValueOnce(new Error('restore exploded'));
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL');
|
||||
fs.writeFileSync(triggerPath, '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.error).toMatch(/restore exploded/);
|
||||
expect(fs.existsSync(triggerPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to run when the install already has events', async () => {
|
||||
// Simulate an install with existing data
|
||||
await db('events').insert({
|
||||
slug: 'existing-event',
|
||||
event_name: 'Existing Event',
|
||||
event_type: 'wedding',
|
||||
event_date: new Date(),
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'host@example.com',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
share_link: 'existing-event-token',
|
||||
password_hash: 'dummy-hash-for-test',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
const result = await tryInstallFromBackup(db);
|
||||
expect(result.ran).toBe(false);
|
||||
expect(result.error).toMatch(/Database not empty/);
|
||||
expect(mockRestore).not.toHaveBeenCalled();
|
||||
|
||||
// Trigger file should NOT be deleted — admin needs to fix + retry
|
||||
expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true);
|
||||
});
|
||||
|
||||
it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => {
|
||||
await db('events').insert({
|
||||
slug: 'existing-event-2',
|
||||
event_name: 'Existing Event 2',
|
||||
event_type: 'wedding',
|
||||
event_date: new Date(),
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'host@example.com',
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
share_link: 'existing-event-2-token',
|
||||
password_hash: 'dummy-hash-for-test-2',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const manifest = path.join(manifestsDir, 'backup-manifest-001.json');
|
||||
fs.writeFileSync(manifest, '{}');
|
||||
fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), '');
|
||||
|
||||
process.env.INSTALL_FROM_BACKUP_FORCE = 'true';
|
||||
const result = await tryInstallFromBackup(db);
|
||||
|
||||
expect(result.ran).toBe(true);
|
||||
expect(mockRestore).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -824,6 +824,24 @@ async function startServer() {
|
||||
logger.warn('restore-settings self-heal failed at boot:', err.message);
|
||||
}
|
||||
|
||||
// Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or
|
||||
// `.txt`) exists in the /backup mount AND the DB is empty, run
|
||||
// the restore HERE before any admin UI surfaces. Lets admins
|
||||
// recover a picpeak install with: (a) place backup files in the
|
||||
// bind mount, (b) drop the trigger file, (c) `docker compose up`.
|
||||
// No onboarding wizard, no throwaway admin, no compose-file
|
||||
// changes. See _installFromBackupBoot.js for the full rationale
|
||||
// + the safety gates.
|
||||
try {
|
||||
const { tryInstallFromBackup } = require('./src/services/_installFromBackupBoot');
|
||||
const result = await tryInstallFromBackup(db, logger);
|
||||
if (result.ran) {
|
||||
logger.info(`Install-from-backup: completed from ${result.manifestPath}. Server will start with restored state.`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Install-from-backup hook threw:', err.message);
|
||||
}
|
||||
|
||||
// Start backup service
|
||||
await startBackupService();
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Install-from-backup boot hook.
|
||||
*
|
||||
* **The problem this closes**
|
||||
*
|
||||
* Without this hook, recovering a picpeak install from a backup is a
|
||||
* six-step process:
|
||||
* 1. Stand up the compose stack with empty volumes
|
||||
* 2. Wait for boot → land on the onboarding wizard
|
||||
* 3. Create a throwaway fresh-install admin
|
||||
* 4. Navigate to Backup → Restore
|
||||
* 5. Walk through the wizard with Force Restore ticked
|
||||
* 6. Log out, log back in with original (pre-disaster) credentials
|
||||
*
|
||||
* With this hook, admins skip steps 2-6. They place their backup
|
||||
* artefacts in the existing bind-mounted /backup directory, drop a
|
||||
* trigger file alongside, and the next container start runs the
|
||||
* restore BEFORE creating the throwaway onboarding admin. Server
|
||||
* comes up populated, original login works first try.
|
||||
*
|
||||
* **The trigger file convention (chosen for zero compose-file changes)**
|
||||
*
|
||||
* Drop a file named `RESTORE_ON_INSTALL` (no extension, or .txt) into
|
||||
* the root of the `/backup` mount. Two payload variants:
|
||||
*
|
||||
* 1. EMPTY file (or pure whitespace) — auto-pick the newest
|
||||
* `backup-manifest-*.json` from `/backup/manifests/`. Useful when
|
||||
* the admin doesn't know or care which one is most recent.
|
||||
*
|
||||
* 2. NON-EMPTY file containing a relative or absolute path to a
|
||||
* specific manifest. Trimmed; first line wins. Useful when the
|
||||
* admin wants a specific older backup.
|
||||
*
|
||||
* After a successful restore the trigger file is DELETED so the next
|
||||
* boot doesn't re-trigger. On failure the file is preserved + the
|
||||
* error is logged, so the admin can fix the input and retry by just
|
||||
* restarting the container.
|
||||
*
|
||||
* **Safety**
|
||||
*
|
||||
* Three layers gate this against accidental data loss:
|
||||
* 1. Trigger file must exist (intentional admin action, not auto-magic)
|
||||
* 2. DB must be empty — admin_users.count = 0 AND events.count = 0.
|
||||
* If either is non-zero, the hook refuses to run.
|
||||
* 3. The Stage A restore path (with all of tonight's fixes) handles
|
||||
* the actual swap atomically. If anything fails, the rollback
|
||||
* runs and the install stays in fresh-install state.
|
||||
*
|
||||
* Override: `INSTALL_FROM_BACKUP_FORCE=true` skips the empty-DB check
|
||||
* for the "I know what I'm doing" edge case (e.g. dev environment
|
||||
* rebuilds where there's leftover data that's safe to clobber).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const TRIGGER_FILENAMES = ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt'];
|
||||
|
||||
/**
|
||||
* Resolve which file should be treated as the trigger. Returns
|
||||
* `{ triggerPath, manifestPath }` if found, or null if no trigger
|
||||
* file is present (the common case — most boots).
|
||||
*/
|
||||
async function findTrigger(backupRoot, logger) {
|
||||
for (const name of TRIGGER_FILENAMES) {
|
||||
const triggerPath = path.join(backupRoot, name);
|
||||
if (fs.existsSync(triggerPath)) {
|
||||
let payload;
|
||||
try {
|
||||
payload = fs.readFileSync(triggerPath, 'utf8').trim();
|
||||
} catch (err) {
|
||||
logger.warn(`Install-from-backup: trigger file ${triggerPath} is unreadable: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
// Auto-pick the newest manifest
|
||||
const manifestsDir = path.join(backupRoot, 'manifests');
|
||||
if (!fs.existsSync(manifestsDir)) {
|
||||
logger.warn(`Install-from-backup: trigger file found but ${manifestsDir} doesn't exist`);
|
||||
return null;
|
||||
}
|
||||
const entries = fs.readdirSync(manifestsDir)
|
||||
.filter((f) => /^backup-manifest-.+\.(json|ya?ml)$/i.test(f))
|
||||
.map((f) => {
|
||||
const full = path.join(manifestsDir, f);
|
||||
return { full, mtime: fs.statSync(full).mtimeMs };
|
||||
})
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
if (entries.length === 0) {
|
||||
logger.warn(`Install-from-backup: no manifests found in ${manifestsDir}`);
|
||||
return null;
|
||||
}
|
||||
return { triggerPath, manifestPath: entries[0].full };
|
||||
}
|
||||
|
||||
// Take the first non-empty line as the manifest path
|
||||
const firstLine = payload.split(/\r?\n/).find((l) => l.trim()) || '';
|
||||
const manifestPath = path.isAbsolute(firstLine)
|
||||
? firstLine
|
||||
: path.join(backupRoot, firstLine);
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
logger.warn(`Install-from-backup: trigger file points at ${manifestPath} which doesn't exist`);
|
||||
return null;
|
||||
}
|
||||
return { triggerPath, manifestPath };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the DB is empty enough that restoring on top is safe.
|
||||
* Returns `true` if safe, `false` if there's existing data.
|
||||
*/
|
||||
async function isDatabaseFresh(db, logger) {
|
||||
try {
|
||||
if (!(await db.schema.hasTable('admin_users'))) {
|
||||
// No admin_users table yet — schema is mid-migration or wholly
|
||||
// empty. Definitely safe to restore on top.
|
||||
return true;
|
||||
}
|
||||
const adminCount = await db('admin_users').count('* as c').first();
|
||||
const adminN = Number(adminCount?.c || 0);
|
||||
|
||||
let eventN = 0;
|
||||
if (await db.schema.hasTable('events')) {
|
||||
const eventCount = await db('events').count('* as c').first();
|
||||
eventN = Number(eventCount?.c || 0);
|
||||
}
|
||||
|
||||
if (adminN > 1 || eventN > 0) {
|
||||
logger.warn(
|
||||
`Install-from-backup: refusing — install has ${adminN} admin(s) and ${eventN} event(s). `
|
||||
+ 'This guard prevents accidental clobbering of production data. '
|
||||
+ 'Override with INSTALL_FROM_BACKUP_FORCE=true if you really want to restore on top.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// adminN === 1 is the "fresh install ran migration 001 and auto-created
|
||||
// the default admin" case. That admin is throwaway — the restore will
|
||||
// replace it with the backup's admin row. So we treat 1 admin + 0
|
||||
// events as fresh.
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn(`Install-from-backup: fresh-install check threw: ${err.message}. Assuming NOT fresh.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry point — called from server.js after migrations and
|
||||
* before startServer.
|
||||
*
|
||||
* @returns {Promise<{ ran: boolean, manifestPath?: string, error?: string }>}
|
||||
*/
|
||||
async function tryInstallFromBackup(db, logger) {
|
||||
const log = logger || { info: () => {}, warn: () => {}, error: () => {} };
|
||||
|
||||
const backupRoot = process.env.BACKUP_ROOT || '/backup';
|
||||
if (!fs.existsSync(backupRoot)) {
|
||||
return { ran: false };
|
||||
}
|
||||
|
||||
const trigger = await findTrigger(backupRoot, log);
|
||||
if (!trigger) {
|
||||
return { ran: false };
|
||||
}
|
||||
|
||||
log.info(`Install-from-backup: trigger file found at ${trigger.triggerPath}, target manifest ${trigger.manifestPath}`);
|
||||
|
||||
const forceOverride = process.env.INSTALL_FROM_BACKUP_FORCE === 'true';
|
||||
const isFresh = await isDatabaseFresh(db, log);
|
||||
if (!isFresh && !forceOverride) {
|
||||
log.warn('Install-from-backup: skipping. Trigger file left in place so you can correct + retry.');
|
||||
return { ran: false, error: 'Database not empty' };
|
||||
}
|
||||
|
||||
log.info(`Install-from-backup: restoring from ${trigger.manifestPath}...`);
|
||||
|
||||
try {
|
||||
const { restoreService } = require('./restoreService');
|
||||
const result = await restoreService.restore({
|
||||
source: 'local',
|
||||
manifestPath: trigger.manifestPath,
|
||||
restoreType: 'full',
|
||||
// Force=true because the fresh-install admin auto-created by
|
||||
// migration 001 trips the "1 active admin" warning — we WANT to
|
||||
// override that warning, since replacing the throwaway admin
|
||||
// with the backup's admin is exactly the goal.
|
||||
force: true,
|
||||
// SkipPreBackup=true because backing up an empty install is
|
||||
// pointless. Saves a few seconds and reduces disk noise.
|
||||
skipPreBackup: true,
|
||||
operator: {
|
||||
type: 'install-from-backup',
|
||||
userId: null,
|
||||
ip: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.success === false) {
|
||||
throw new Error(result?.error || 'Restore service reported failure');
|
||||
}
|
||||
|
||||
log.info(`Install-from-backup: restore completed successfully from ${trigger.manifestPath}`);
|
||||
|
||||
// Remove the trigger so the next boot doesn't redo it.
|
||||
try {
|
||||
fs.unlinkSync(trigger.triggerPath);
|
||||
log.info(`Install-from-backup: removed trigger file ${trigger.triggerPath}`);
|
||||
} catch (unlinkErr) {
|
||||
log.warn(`Install-from-backup: could not remove trigger file (manual cleanup needed): ${unlinkErr.message}`);
|
||||
}
|
||||
|
||||
return { ran: true, manifestPath: trigger.manifestPath };
|
||||
} catch (err) {
|
||||
log.error(`Install-from-backup: FAILED — ${err.message}`);
|
||||
log.warn('Trigger file left in place so you can fix the input and retry by restarting the container.');
|
||||
return { ran: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { tryInstallFromBackup };
|
||||
Reference in New Issue
Block a user