"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.Backups = exports.DEFAULT_OPTIONS = void 0; const child_process = __importStar(require("child_process")); const fs = __importStar(require("fs/promises")); const util_1 = require("../util"); const SubContainer_1 = require("../util/SubContainer"); const Mounts_1 = require("../mainFn/Mounts"); const BACKUP_HOST_PATH = '/media/startos/backup'; const BACKUP_CONTAINER_MOUNT = '/backup-target'; async function resolvePassword(pw) { if (pw === null) return null; return typeof pw === 'function' ? pw() : pw; } /** Bind-mount the backup target into a SubContainer's rootfs */ async function mountBackupTarget(rootfs) { const target = `${rootfs}${BACKUP_CONTAINER_MOUNT}`; await fs.mkdir(target, { recursive: true }); await (0, SubContainer_1.execFile)('mount', ['--rbind', BACKUP_HOST_PATH, target]); } /** Default rsync options used for backup and restore operations */ exports.DEFAULT_OPTIONS = { delete: true, exclude: [], }; /** * Configures backup and restore operations using rsync. * * Supports syncing entire volumes or custom path pairs, with optional pre/post hooks * for both backup and restore phases. Implements {@link InitScript} so it can be used * as a restore-init step in `setupInit`. * * @typeParam M - The service manifest type */ class Backups { constructor(options = exports.DEFAULT_OPTIONS, restoreOptions = {}, backupOptions = {}, backupSet = [], preBackup = async (effects) => { }, postBackup = async (effects) => { }, preRestore = async (effects) => { }, postRestore = async (effects) => { }) { this.options = options; this.restoreOptions = restoreOptions; this.backupOptions = backupOptions; this.backupSet = backupSet; this.preBackup = preBackup; this.postBackup = postBackup; this.preRestore = preRestore; this.postRestore = postRestore; } /** * Create a Backups configuration that backs up entire volumes by name. * Each volume is synced to a corresponding directory under `/media/startos/backup/volumes/`. * @param volumeNames - One or more volume IDs from the manifest */ static ofVolumes(...volumeNames) { return Backups.ofSyncs(...volumeNames.map((srcVolume) => ({ dataPath: `/media/startos/volumes/${srcVolume}/`, backupPath: `/media/startos/backup/volumes/${srcVolume}/`, }))); } /** * Create a Backups configuration from explicit source/destination sync pairs. * @param syncs - Array of `{ dataPath, backupPath }` objects with optional per-sync options */ static ofSyncs(...syncs) { return syncs.reduce((acc, x) => acc.addSync(x), new Backups()); } /** * Create an empty Backups configuration with custom default rsync options. * Chain `.addVolume()` or `.addSync()` to add sync targets. * @param options - Partial rsync options to override defaults (e.g. `{ exclude: ['cache'] }`) */ static withOptions(options) { return new Backups({ ...exports.DEFAULT_OPTIONS, ...options }); } /** * Configure PostgreSQL dump-based backup for a volume. * * Instead of rsyncing the raw PostgreSQL data directory (which is slow and error-prone), * this uses `pg_dump` to create a logical dump before backup and `pg_restore` to rebuild * the database after restore. * * The dump file is written directly to the backup target — no data duplication on disk. * * @returns A configured Backups instance with pre/post hooks. Chain `.addVolume()` or * `.addSync()` to include additional volumes/paths in the backup. */ static withPgDump(config) { const { imageId, dbVolume, mountpoint, pgdataPath, database, user, password, initdbArgs = [], pgOptions, } = config; const pgdata = `${mountpoint}${pgdataPath}`; const dumpFile = `${BACKUP_CONTAINER_MOUNT}/${database}-db.dump`; function dbMounts() { return Mounts_1.Mounts.of().mountVolume({ volumeId: dbVolume, mountpoint: mountpoint, readonly: false, subpath: null, }); } async function startPg(sub, label) { await sub.exec(['rm', '-f', `${pgdata}/postmaster.pid`], { user: 'postgres', }); await sub.exec(['mkdir', '-p', '/var/run/postgresql'], { user: 'root', }); await sub.exec(['chown', 'postgres:postgres', '/var/run/postgresql'], { user: 'root', }); console.log(`[${label}] starting postgres`); const pgStartOpts = pgOptions ? `-c listen_addresses= ${pgOptions}` : '-c listen_addresses='; await sub.execFail(['pg_ctl', 'start', '-D', pgdata, '-o', pgStartOpts], { user: 'postgres', }); for (let i = 0; i < 60; i++) { const { exitCode } = await sub.exec(['pg_isready', '-U', user], { user: 'postgres', }); if (exitCode === 0) { console.log(`[${label}] postgres is ready`); return; } await new Promise((r) => setTimeout(r, 1000)); } throw new Error('PostgreSQL failed to become ready within 60 seconds'); } return new Backups() .setPreBackup(async (effects) => { await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'pg-dump', async (sub) => { console.log('[pg-dump] mounting backup target'); await mountBackupTarget(sub.rootfs); await sub.exec(['touch', dumpFile], { user: 'root' }); await sub.exec(['chown', 'postgres:postgres', dumpFile], { user: 'root', }); await startPg(sub, 'pg-dump'); console.log('[pg-dump] dumping database'); await sub.execFail(['pg_dump', '-U', user, '-Fc', '-f', dumpFile, database], { user: 'postgres' }, null); console.log('[pg-dump] stopping postgres'); await sub.execFail(['pg_ctl', 'stop', '-D', pgdata, '-w'], { user: 'postgres', }); console.log('[pg-dump] complete'); }); }) .setPostRestore(async (effects) => { const resolvedPassword = await resolvePassword(password); await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'pg-restore', async (sub) => { await mountBackupTarget(sub.rootfs); await sub.execFail(['chown', '-R', 'postgres:postgres', mountpoint], { user: 'root' }); await sub.execFail(['initdb', '-D', pgdata, '-U', user, ...initdbArgs], { user: 'postgres' }); await startPg(sub, 'pg-restore'); await sub.execFail(['createdb', '-U', user, database], { user: 'postgres', }); await sub.execFail([ 'pg_restore', '-U', user, '-d', database, '--no-owner', '--no-privileges', dumpFile, ], { user: 'postgres' }, null); if (resolvedPassword !== null) { await sub.execFail([ 'psql', '-U', user, '-d', database, '-c', `ALTER USER ${user} WITH PASSWORD '${resolvedPassword}'`, ], { user: 'postgres' }); } await sub.execFail(['pg_ctl', 'stop', '-D', pgdata, '-w'], { user: 'postgres', }); }); }); } /** * Configure MySQL/MariaDB dump-based backup for a volume. * * Instead of rsyncing the raw MySQL data directory (which is slow and error-prone), * this uses `mysqldump` to create a logical dump before backup and `mysql` to restore * the database after restore. * * The dump file is stored temporarily in `dumpVolume` during backup and cleaned up afterward. * * @returns A configured Backups instance with pre/post hooks. Chain `.addVolume()` or * `.addSync()` to include additional volumes/paths in the backup. */ static withMysqlDump(config) { const { imageId, dbVolume, datadir, database, user, password, engine, readyCommand, mysqldOptions = [], } = config; const dumpFile = `${BACKUP_CONTAINER_MOUNT}/${database}-db.dump`; function dbMounts() { return Mounts_1.Mounts.of().mountVolume({ volumeId: dbVolume, mountpoint: datadir, readonly: false, subpath: null, }); } async function waitForMysql(sub, cmd) { for (let i = 0; i < 30; i++) { const { exitCode } = await sub.exec(cmd); if (exitCode === 0) return; await new Promise((r) => setTimeout(r, 1000)); } throw new Error('MySQL/MariaDB failed to become ready within 30 seconds'); } async function startMysql(sub) { if (engine === 'mariadb') { // MariaDB doesn't support --daemonize; fire-and-forget the exec sub .exec([ 'mysqld', '--user=mysql', `--datadir=${datadir}`, '--bind-address=127.0.0.1', ...mysqldOptions, ], { user: 'root' }) .catch((e) => console.error('[mysql-backup] mysqld exited unexpectedly:', e)); } else { await sub.execFail([ 'mysqld', '--user=mysql', `--datadir=${datadir}`, '--bind-address=127.0.0.1', '--daemonize', ...mysqldOptions, ], { user: 'root' }, null); } } return new Backups() .setPreBackup(async (effects) => { const pw = await resolvePassword(password); const readyCmd = readyCommand || [ 'mysqladmin', 'ping', '-u', user, ...(pw !== null ? [`-p${pw}`] : []), '--silent', ]; await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'mysql-dump', async (sub) => { await mountBackupTarget(sub.rootfs); await sub.exec(['mkdir', '-p', '/var/run/mysqld'], { user: 'root', }); await sub.exec(['chown', 'mysql:mysql', '/var/run/mysqld'], { user: 'root', }); if (engine === 'mysql') { await sub.execFail(['chown', '-R', 'mysql:mysql', datadir], { user: 'root', }); } await startMysql(sub); await waitForMysql(sub, readyCmd); await sub.execFail([ 'mysqldump', '-u', user, ...(pw !== null ? [`-p${pw}`] : []), '--single-transaction', `--result-file=${dumpFile}`, database, ], { user: 'root' }, null); // Graceful shutdown via SIGTERM; wait for exit await sub.execFail([ 'sh', '-c', 'PID=$(cat /var/run/mysqld/mysqld.pid) && kill $PID && tail --pid=$PID -f /dev/null', ], { user: 'root' }, null); }); }) .setPostRestore(async (effects) => { const pw = await resolvePassword(password); await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'mysql-restore', async (sub) => { await mountBackupTarget(sub.rootfs); await sub.exec(['mkdir', '-p', '/var/run/mysqld'], { user: 'root', }); await sub.exec(['chown', 'mysql:mysql', '/var/run/mysqld'], { user: 'root', }); // Initialize fresh data directory if (engine === 'mariadb') { await sub.execFail(['mysql_install_db', '--user=mysql', `--datadir=${datadir}`], { user: 'root' }); } else { await sub.execFail([ 'mysqld', '--initialize-insecure', '--user=mysql', `--datadir=${datadir}`, ], { user: 'root' }); } await startMysql(sub); // After fresh init, root has no password await waitForMysql(sub, [ 'mysqladmin', 'ping', '-u', 'root', '--silent', ]); // Create database, user, and set password const grantSql = pw !== null ? `CREATE DATABASE IF NOT EXISTS \`${database}\`; CREATE USER IF NOT EXISTS '${user}'@'localhost' IDENTIFIED BY '${pw}'; GRANT ALL ON \`${database}\`.* TO '${user}'@'localhost'; ALTER USER 'root'@'localhost' IDENTIFIED BY '${pw}'; FLUSH PRIVILEGES;` : `CREATE DATABASE IF NOT EXISTS \`${database}\`; CREATE USER IF NOT EXISTS '${user}'@'localhost'; GRANT ALL ON \`${database}\`.* TO '${user}'@'localhost'; FLUSH PRIVILEGES;`; await sub.execFail(['mysql', '-u', 'root', '-e', grantSql], { user: 'root', }); // Restore from dump await sub.execFail([ 'sh', '-c', `mysql -u root ${pw !== null ? `-p'${pw}'` : ''} ${database} < ${dumpFile}`, ], { user: 'root' }, null); // Graceful shutdown via SIGTERM; wait for exit await sub.execFail([ 'sh', '-c', 'PID=$(cat /var/run/mysqld/mysqld.pid) && kill $PID && tail --pid=$PID -f /dev/null', ], { user: 'root' }, null); }); }); } /** * Override the default rsync options for both backup and restore. * @param options - Partial rsync options to merge with current defaults */ setOptions(options) { this.options = { ...this.options, ...options, }; return this; } /** * Override rsync options used only during backup (not restore). * @param options - Partial rsync options for the backup phase */ setBackupOptions(options) { this.backupOptions = { ...this.backupOptions, ...options, }; return this; } /** * Override rsync options used only during restore (not backup). * @param options - Partial rsync options for the restore phase */ setRestoreOptions(options) { this.restoreOptions = { ...this.restoreOptions, ...options, }; return this; } /** * Register a hook to run before backup rsync begins (e.g. dump a database). * @param fn - Async function receiving backup-scoped effects */ setPreBackup(fn) { this.preBackup = fn; return this; } /** * Register a hook to run after backup rsync completes. * @param fn - Async function receiving backup-scoped effects */ setPostBackup(fn) { this.postBackup = fn; return this; } /** * Register a hook to run before restore rsync begins. * @param fn - Async function receiving backup-scoped effects */ setPreRestore(fn) { this.preRestore = fn; return this; } /** * Register a hook to run after restore rsync completes. * @param fn - Async function receiving backup-scoped effects */ setPostRestore(fn) { this.postRestore = fn; return this; } /** * Add a volume to the backup set by its ID. * @param volume - The volume ID from the manifest * @param options - Optional per-volume rsync overrides */ addVolume(volume, options) { return this.addSync({ dataPath: `/media/startos/volumes/${volume}/`, backupPath: `/media/startos/backup/volumes/${volume}/`, ...options, }); } /** * Add a custom sync pair to the backup set. * @param sync - A `{ dataPath, backupPath }` object with optional per-sync rsync options */ addSync(sync) { this.backupSet.push(sync); return this; } /** * Execute the backup: runs pre-hook, rsyncs all configured paths, saves the data version, then runs post-hook. * @param effects - The effects context */ async createBackup(effects) { await this.preBackup(effects); for (const item of this.backupSet) { const rsyncResults = await runRsync({ srcPath: item.dataPath, dstPath: item.backupPath, options: { ...this.options, ...this.backupOptions, ...item.options, ...item.backupOptions, }, }); await rsyncResults.wait(); } const dataVersion = await effects.getDataVersion(); if (dataVersion) await fs.writeFile('/media/startos/backup/dataVersion.txt', dataVersion, { encoding: 'utf-8', }); await this.postBackup(effects); return; } async init(effects, kind) { if (kind === 'restore') { await this.restoreBackup(effects); } } /** * Execute the restore: runs pre-hook, rsyncs all configured paths from backup to data, restores the data version, then runs post-hook. * @param effects - The effects context */ async restoreBackup(effects) { await this.preRestore(effects); for (const item of this.backupSet) { const rsyncResults = await runRsync({ srcPath: item.backupPath, dstPath: item.dataPath, options: { ...this.options, ...this.restoreOptions, ...item.options, ...item.restoreOptions, }, }); await rsyncResults.wait(); } const dataVersion = await fs .readFile('/media/startos/backup/dataVersion.txt', { encoding: 'utf-8', }) .catch((_) => null); if (dataVersion) await effects.setDataVersion({ version: dataVersion }); await this.postRestore(effects); return; } } exports.Backups = Backups; async function runRsync(rsyncOptions) { const { srcPath, dstPath, options } = rsyncOptions; await fs.mkdir(dstPath, { recursive: true }); const command = 'rsync'; const args = []; if (options.delete) { args.push('--delete'); } for (const exclude of options.exclude) { args.push(`--exclude=${exclude}`); } args.push('-rlptgoAXH'); args.push('--partial'); args.push('--inplace'); args.push('--timeout=300'); args.push('--info=progress2'); args.push('--no-inc-recursive'); args.push(srcPath); args.push(dstPath); const spawned = child_process.spawn(command, args, { detached: true }); let percentage = 0.0; spawned.stdout.on('data', (data) => { const lines = String(data).replace(/\r/g, '\n').split('\n'); for (const line of lines) { const parsed = /([0-9.]+)%/.exec(line)?.[1]; if (!parsed) { if (line) console.log(line); continue; } percentage = Number.parseFloat(parsed); } }); let stderr = ''; spawned.stderr.on('data', (data) => { const errString = data.toString('utf-8'); stderr += errString; console.error(`Backups.runAsync`, (0, util_1.asError)(errString)); }); const id = async () => { const pid = spawned.pid; if (pid === undefined) { throw new Error('rsync process has no pid'); } return String(pid); }; const waitPromise = new Promise((resolve, reject) => { spawned.on('exit', (code) => { if (code === 0) { resolve(null); } else { reject(new Error(`rsync exited with code ${code}\n${stderr}`)); } }); }); const wait = () => waitPromise; const progress = () => Promise.resolve(percentage); return { id, wait, progress }; } //# sourceMappingURL=Backups.js.map