"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