336 lines
9.6 KiB
JavaScript
336 lines
9.6 KiB
JavaScript
#!/usr/bin/env node
|
|
import { fork, spawn, spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import https from 'node:https';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import dotenv from 'dotenv';
|
|
|
|
import {
|
|
ACCESS_KEY,
|
|
BUCKET,
|
|
DATA_DIR,
|
|
HOST,
|
|
PORT,
|
|
SECRET_KEY,
|
|
SERVER_CERT,
|
|
s3BaseUrl,
|
|
} from './config.mjs';
|
|
import { caTrustStatus, ensureCerts, installCaTrust } from './ensure-certs.mjs';
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
dotenv.config({ path: path.join(root, '.env') });
|
|
|
|
const argv = process.argv.slice(2);
|
|
const args = new Set(argv.filter((a) => a.startsWith('--')));
|
|
const positional = argv.filter((a) => !a.startsWith('--'));
|
|
|
|
const serverOnly = args.has('--server-only');
|
|
const skipTrust = args.has('--skip-trust');
|
|
const useLatest = args.has('--latest');
|
|
const replace = !args.has('--no-replace');
|
|
|
|
const databaseArg = argv.find((a, i) => argv[i - 1] === '--database');
|
|
const defaultDb = process.env.MSSQL_DATABASE || 'eazybusiness';
|
|
const database = databaseArg || defaultDb;
|
|
|
|
function sqlcmd(query) {
|
|
const server = process.env.MSSQL_SERVER || 'localhost';
|
|
const port = process.env.MSSQL_PORT || '1433';
|
|
const user = process.env.MSSQL_USER || 'sa';
|
|
const password = process.env.MSSQL_PASSWORD || '';
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(
|
|
'sqlcmd',
|
|
['-W', '-C', '-S', `${server},${port}`, '-U', user, '-P', password, '-Q', query],
|
|
{ encoding: 'utf8' }
|
|
);
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout?.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
process.stdout.write(chunk);
|
|
});
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk;
|
|
process.stderr.write(chunk);
|
|
});
|
|
child.on('close', (code) => {
|
|
const output = `${stdout}${stderr}`;
|
|
if (code !== 0 || /^\s*Msg \d+,/m.test(output)) {
|
|
reject(new Error(output.trim() || 'sqlcmd failed'));
|
|
} else {
|
|
resolve(stdout);
|
|
}
|
|
});
|
|
child.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function listBackups(prefix) {
|
|
if (!fs.existsSync(DATA_DIR)) return [];
|
|
return fs
|
|
.readdirSync(DATA_DIR)
|
|
.filter((name) => name.endsWith('.bak') && name.startsWith(`${prefix}-`))
|
|
.map((name) => ({
|
|
name,
|
|
path: path.join(DATA_DIR, name),
|
|
mtime: fs.statSync(path.join(DATA_DIR, name)).mtimeMs,
|
|
}))
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
}
|
|
|
|
function resolveBackupFile() {
|
|
if (positional.length > 1) {
|
|
throw new Error(`Expected at most one backup file, got: ${positional.join(', ')}`);
|
|
}
|
|
|
|
if (positional.length === 1) {
|
|
const input = positional[0];
|
|
if (path.isAbsolute(input) || input.includes('/')) {
|
|
const resolved = path.resolve(input);
|
|
if (!fs.existsSync(resolved)) {
|
|
throw new Error(`Backup file not found: ${resolved}`);
|
|
}
|
|
const base = path.basename(resolved);
|
|
const target = path.join(DATA_DIR, base);
|
|
if (resolved !== target) {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
fs.copyFileSync(resolved, target);
|
|
console.log(`Copied ${resolved} -> ${target}`);
|
|
}
|
|
return base;
|
|
}
|
|
const onDisk = path.join(DATA_DIR, input);
|
|
if (!fs.existsSync(onDisk)) {
|
|
throw new Error(`Backup file not found: ${onDisk}`);
|
|
}
|
|
return input;
|
|
}
|
|
|
|
if (useLatest || positional.length === 0) {
|
|
const matches = listBackups(database);
|
|
if (matches.length === 0) {
|
|
throw new Error(`No backups found for ${database} in ${DATA_DIR}`);
|
|
}
|
|
console.log(`Using latest backup: ${matches[0].name}`);
|
|
return matches[0].name;
|
|
}
|
|
|
|
throw new Error('Specify a backup file or pass --latest');
|
|
}
|
|
|
|
function databaseFromBackup(file) {
|
|
const match = path.basename(file).match(/^(.+)-\d{4}-\d{2}-\d{2}T/);
|
|
return match ? match[1] : database;
|
|
}
|
|
|
|
async function isServerUp() {
|
|
const probeHost = /^\d+\./.test(HOST) ? '127.0.0.1' : HOST;
|
|
return new Promise((resolve) => {
|
|
const opts = {
|
|
host: probeHost,
|
|
port: PORT,
|
|
path: '/',
|
|
method: 'GET',
|
|
rejectUnauthorized: false,
|
|
};
|
|
if (!/^\d+\./.test(HOST)) {
|
|
opts.servername = HOST;
|
|
}
|
|
const req = https.request(opts, (res) => {
|
|
res.resume();
|
|
resolve(res.statusCode === 403 || res.statusCode === 200);
|
|
});
|
|
req.on('error', () => resolve(false));
|
|
req.setTimeout(1000, () => {
|
|
req.destroy();
|
|
resolve(false);
|
|
});
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function waitForServer() {
|
|
for (let i = 0; i < 40; i++) {
|
|
if (await isServerUp()) return;
|
|
await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
throw new Error(`S3 endpoint did not start on https://${HOST}:${PORT}`);
|
|
}
|
|
|
|
function pidOnPort(port) {
|
|
const result = spawnSync('ss', ['-tlnp'], { encoding: 'utf8' });
|
|
const match = result.stdout?.match(new RegExp(`:${port}\\s+.*?pid=(\\d+)`));
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
async function stopPortListener(port) {
|
|
const stale = pidOnPort(port);
|
|
if (!stale) return;
|
|
try {
|
|
process.kill(stale);
|
|
} catch {
|
|
spawnSync('fuser', ['-k', `${port}/tcp`], { stdio: 'pipe' });
|
|
}
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
}
|
|
|
|
async function ensureServerProcess() {
|
|
await stopPortListener(PORT);
|
|
return startServerProcess();
|
|
}
|
|
|
|
function ensureCredential() {
|
|
const cred = s3BaseUrl();
|
|
return sqlcmd(`
|
|
IF NOT EXISTS (SELECT 1 FROM sys.credentials WHERE name = N'${cred}')
|
|
BEGIN
|
|
CREATE CREDENTIAL [${cred}]
|
|
WITH IDENTITY = 'S3 Access Key',
|
|
SECRET = '${ACCESS_KEY}:${SECRET_KEY}';
|
|
END
|
|
`);
|
|
}
|
|
|
|
async function restoreDatabase(db, file) {
|
|
const url = `${s3BaseUrl()}/${file}`;
|
|
const onDisk = path.join(DATA_DIR, file);
|
|
if (!fs.existsSync(onDisk)) {
|
|
throw new Error(`Backup file missing on disk: ${onDisk}`);
|
|
}
|
|
const mb = (fs.statSync(onDisk).size / (1024 * 1024)).toFixed(1);
|
|
console.log(`Restoring ${db} <- ${url} (${mb} MB)`);
|
|
|
|
const replaceClause = replace ? ', REPLACE' : '';
|
|
await sqlcmd(`
|
|
IF DB_ID(N'${db}') IS NOT NULL
|
|
BEGIN
|
|
ALTER DATABASE [${db}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
|
|
END
|
|
RESTORE DATABASE [${db}]
|
|
FROM URL = '${url}'
|
|
WITH STATS = 10, MAXTRANSFERSIZE = 20971520${replaceClause};
|
|
ALTER DATABASE [${db}] SET MULTI_USER;
|
|
`);
|
|
}
|
|
|
|
function waitForSql() {
|
|
for (let i = 0; i < 60; i++) {
|
|
const result = spawnSync(
|
|
'sqlcmd',
|
|
[
|
|
'-W',
|
|
'-C',
|
|
'-S',
|
|
`${process.env.MSSQL_SERVER || 'localhost'},${process.env.MSSQL_PORT || '1433'}`,
|
|
'-U',
|
|
process.env.MSSQL_USER || 'sa',
|
|
'-P',
|
|
process.env.MSSQL_PASSWORD || '',
|
|
'-Q',
|
|
'SELECT 1',
|
|
],
|
|
{ encoding: 'utf8' }
|
|
);
|
|
if (result.status === 0 && !/Msg \d+,/.test(result.stdout || '')) {
|
|
return;
|
|
}
|
|
spawnSync('sleep', ['2']);
|
|
}
|
|
throw new Error('MSSQL did not become ready');
|
|
}
|
|
|
|
function startServerProcess() {
|
|
const child = fork(new URL('./server.mjs', import.meta.url), {
|
|
env: { ...process.env, S3_BACKUP_CHILD: '1' },
|
|
stdio: 'inherit',
|
|
});
|
|
return child;
|
|
}
|
|
|
|
async function main() {
|
|
if (skipTrust) {
|
|
const status = caTrustStatus();
|
|
if (!status.inSync) {
|
|
throw new Error(
|
|
'PAL CA is out of sync with scripts/s3-backup/certs/ca.pem. Run: npm run backup:s3'
|
|
);
|
|
}
|
|
console.log('Skipping PAL CA install (--skip-trust)');
|
|
if (!fs.existsSync(SERVER_CERT)) {
|
|
throw new Error('No TLS certs found. Run: npm run backup:s3');
|
|
}
|
|
} else {
|
|
ensureCerts(HOST);
|
|
const trust = installCaTrust();
|
|
if (!trust.ok) {
|
|
console.warn('Could not install CA into MSSQL container. Run:');
|
|
console.warn(' docker exec -u root mssql mkdir -p /var/opt/mssql/security/ca-certificates');
|
|
console.warn(' docker cp scripts/s3-backup/certs/ca.pem mssql:/var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
|
|
console.warn(' docker exec -u root mssql chown mssql:mssql /var/opt/mssql/security/ca-certificates/jtlsrv-s3-ca.pem');
|
|
console.warn(' docker restart mssql');
|
|
} else {
|
|
console.log('Installed S3 CA into MSSQL PAL trust store');
|
|
if (trust.restarted) {
|
|
console.log('Waiting for MSSQL to restart...');
|
|
waitForSql();
|
|
}
|
|
}
|
|
}
|
|
|
|
const serverChild = await ensureServerProcess();
|
|
try {
|
|
await waitForServer();
|
|
|
|
if (serverOnly) {
|
|
console.log('Server running (--server-only). Ctrl+C to stop.');
|
|
await new Promise((resolve) => serverChild.on('exit', resolve));
|
|
return;
|
|
}
|
|
|
|
const file = resolveBackupFile();
|
|
const db = databaseArg || databaseFromBackup(file);
|
|
await ensureCredential();
|
|
await restoreDatabase(db, file);
|
|
console.log(`\nRestored ${db} from ${file}`);
|
|
} finally {
|
|
if (!serverOnly) {
|
|
serverChild.kill();
|
|
await stopPortListener(PORT);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (args.has('--help')) {
|
|
console.log(`Usage: node scripts/s3-backup/restore.mjs [file.bak] [options]
|
|
|
|
Starts the local S3-compatible HTTPS endpoint and restores MSSQL from a .bak on disk.
|
|
|
|
Arguments:
|
|
file.bak Backup filename in data/${BUCKET}/, or a path to copy from
|
|
|
|
Options:
|
|
--latest Use newest backup matching --database (default if no file given)
|
|
--database <name> Target database (default: MSSQL_DATABASE or name parsed from file)
|
|
--no-replace Do not pass REPLACE to RESTORE
|
|
--server-only Start endpoint only, no restore
|
|
--skip-trust Skip installing CA cert into system trust store
|
|
|
|
Examples:
|
|
npm run restore:s3:quick
|
|
npm run restore:s3:quick -- eazybusiness-2026-07-23T19-02-44-903Z.bak
|
|
npm run restore:s3:quick -- --database eazybusiness --latest
|
|
|
|
Reads MSSQL_* from .env in repo root.
|
|
Backups are read from scripts/s3-backup/data/${BUCKET}/
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err.message || err);
|
|
process.exit(1);
|
|
});
|