Skip to content

Commit 5edc81d

Browse files
committed
feat(backup): add \backup restore\ command for restic snapshots
Restore a snapshot (or 'latest' by default) to a target directory on the remote host, with --tag/--host filters. Snapshot files are extracted but never applied — a bad restore would silently trash production, so the user runs the actual recovery step (e.g. psql -f db.sql, rsync -a shared/) manually with the extracted files as input. Uses the systemd EnvironmentFile at /etc/shipnode/backup.env for credentials, same as the scheduled backup, so restore works from any host that can SSH in with sudo — no separate secret plumbing.
1 parent 63e5fbf commit 5edc81d

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

src/cli/commands/backup.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,60 @@ export async function cmdBackupStatus(
364364
);
365365
}
366366

367+
/**
368+
* Restore a restic snapshot to a target directory on the remote host.
369+
*
370+
* We intentionally don't apply DB dumps automatically — a bad restore would
371+
* silently trash production data. The user gets the files under `--target`
372+
* (default: /tmp/shipnode-restore-<ts>) and runs whatever recovery command
373+
* fits their situation (e.g. `psql -f db.sql`, `rsync -a shared/ /var/…`).
374+
*/
375+
export async function cmdBackupRestore(
376+
cwd: string,
377+
snapshot: string | undefined,
378+
options: { config?: string; target?: string; tag?: string; host?: string },
379+
): Promise<void> {
380+
await runRemoteCommand(
381+
cwd,
382+
async ({ config, executor }) => {
383+
if (!config.backup || (config.backup.strategy ?? 'snapshot') !== 'restic') {
384+
ui.error('backup restore only supports the restic strategy today.');
385+
process.exit(1);
386+
}
387+
388+
const target = options.target ?? `/tmp/shipnode-restore-${Date.now()}`;
389+
const snap = snapshot ?? 'latest';
390+
const flags = [
391+
options.tag ? `--tag '${options.tag}'` : '',
392+
options.host ? `--host '${options.host}'` : '',
393+
].filter(Boolean).join(' ');
394+
395+
ui.info(`Restoring snapshot ${snap}${target}`);
396+
const cmd =
397+
`SUDO=""; [ "$EUID" -ne 0 ] && SUDO="sudo"; ` +
398+
`$SUDO bash -c '. ${BACKUP_ENV_PATH} && ` +
399+
`export RESTIC_REPOSITORY RESTIC_PASSWORD AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY HOME && ` +
400+
`mkdir -p "${target}" && ` +
401+
`restic restore ${snap} --target "${target}" ${flags}'`;
402+
403+
const result = await executor.exec(cmd, { timeout: 600_000 });
404+
console.log(result.stdout);
405+
if (result.exitCode !== 0) {
406+
ui.error(result.stderr || 'Restore failed');
407+
process.exit(1);
408+
}
409+
ui.success(`Restored to ${target} on ${config.ssh.host}.`);
410+
ui.info(
411+
'Nothing has been applied — restored files are under the target dir. ' +
412+
'Apply manually, e.g.:\n' +
413+
' psql -U <user> -d <db> < db.sql\n' +
414+
' rsync -a shared/ /var/www/<app>/shared/',
415+
);
416+
},
417+
{ configPath: options.config },
418+
);
419+
}
420+
367421
export async function cmdBackupList(
368422
cwd: string,
369423
options: { config?: string },

src/cli/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { cmdEject } from './commands/eject.js';
2525
import { cmdHelp } from './commands/help.js';
2626
import { cmdUserAdd, cmdUserSync, cmdUserList, cmdUserRemove } from './commands/user.js';
2727
import { cmdUpgrade } from './commands/upgrade.js';
28-
import { cmdBackupSetup, cmdBackupRun, cmdBackupStatus, cmdBackupList } from './commands/backup.js';
28+
import { cmdBackupSetup, cmdBackupRun, cmdBackupStatus, cmdBackupList, cmdBackupRestore } from './commands/backup.js';
2929
import { cmdCloudflareInit, cmdCloudflareAudit, cmdCloudflareStatus } from './commands/cloudflare.js';
3030

3131
// Read version from package.json so `shipnode --version` stays in sync with the published
@@ -246,6 +246,14 @@ backup.command('list')
246246
.option('--config <path>', 'Use a specific config file')
247247
.action((opts) => cmdBackupList(process.cwd(), opts));
248248

249+
backup.command('restore [snapshot]')
250+
.description('Restore a restic snapshot to a target directory (default: latest)')
251+
.option('--target <path>', 'Where to restore files (default: /tmp/shipnode-restore-<ts>)')
252+
.option('--tag <tag>', 'Filter by tag (db | files)')
253+
.option('--host <host>', 'Filter by hostname')
254+
.option('--config <path>', 'Use a specific config file')
255+
.action((snapshot: string | undefined, opts) => cmdBackupRestore(process.cwd(), snapshot, opts));
256+
249257
// ── Cloudflare ────────────────────────────────────────────────────
250258

251259
const cloudflare = program.command('cloudflare').description('Cloudflare Tunnel integration');

0 commit comments

Comments
 (0)