import * as T from '../../../base/lib/types' import * as child_process from 'child_process' import * as fs from 'fs/promises' import { Affine, asError } from '../util' import { ExtendedVersion, VersionRange } from '../../../base/lib' import { InitKind, InitScript } from '../../../base/lib/inits' /** Default rsync options used for backup and restore operations */ export const DEFAULT_OPTIONS: T.SyncOptions = { delete: true, exclude: [], } /** A single source-to-destination sync pair for backup and restore */ export type BackupSync = { dataPath: `/media/startos/volumes/${Volumes}/${string}` backupPath: `/media/startos/backup/${string}` options?: Partial backupOptions?: Partial restoreOptions?: Partial } /** Effects type narrowed for backup/restore contexts, preventing reuse outside that scope */ export type BackupEffects = T.Effects & Affine<'Backups'> /** * 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 */ export class Backups implements InitScript { private constructor( private options = DEFAULT_OPTIONS, private restoreOptions: Partial = {}, private backupOptions: Partial = {}, private backupSet = [] as BackupSync[], private preBackup = async (effects: BackupEffects) => {}, private postBackup = async (effects: BackupEffects) => {}, private preRestore = async (effects: BackupEffects) => {}, private postRestore = async (effects: BackupEffects) => {}, ) {} /** * 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: Array ): Backups { return Backups.ofSyncs( ...volumeNames.map((srcVolume) => ({ dataPath: `/media/startos/volumes/${srcVolume}/` as const, backupPath: `/media/startos/backup/volumes/${srcVolume}/` as const, })), ) } /** * 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: BackupSync[] ) { 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?: Partial, ) { return new Backups({ ...DEFAULT_OPTIONS, ...options }) } /** * Override the default rsync options for both backup and restore. * @param options - Partial rsync options to merge with current defaults */ setOptions(options?: Partial) { 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?: Partial) { 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?: Partial) { 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: (effects: BackupEffects) => Promise) { this.preBackup = fn return this } /** * Register a hook to run after backup rsync completes. * @param fn - Async function receiving backup-scoped effects */ setPostBackup(fn: (effects: BackupEffects) => Promise) { this.postBackup = fn return this } /** * Register a hook to run before restore rsync begins. * @param fn - Async function receiving backup-scoped effects */ setPreRestore(fn: (effects: BackupEffects) => Promise) { this.preRestore = fn return this } /** * Register a hook to run after restore rsync completes. * @param fn - Async function receiving backup-scoped effects */ setPostRestore(fn: (effects: BackupEffects) => Promise) { 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: M['volumes'][number], options?: Partial<{ options: T.SyncOptions backupOptions: T.SyncOptions restoreOptions: T.SyncOptions }>, ) { return this.addSync({ dataPath: `/media/startos/volumes/${volume}/` as const, backupPath: `/media/startos/backup/volumes/${volume}/` as const, ...options, }) } /** * Add a custom sync pair to the backup set. * @param sync - A `{ dataPath, backupPath }` object with optional per-sync rsync options */ addSync(sync: BackupSync) { 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: T.Effects) { await this.preBackup(effects as BackupEffects) 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 as BackupEffects) return } async init(effects: T.Effects, kind: InitKind): Promise { 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: T.Effects) { this.preRestore(effects as BackupEffects) for (const item of this.backupSet) { const rsyncResults = await runRsync({ srcPath: item.backupPath, dstPath: item.dataPath, options: { ...this.options, ...this.backupOptions, ...item.options, ...item.backupOptions, }, }) 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 }) this.postRestore(effects as BackupEffects) return } } async function runRsync(rsyncOptions: { srcPath: string dstPath: string options: T.SyncOptions }): Promise<{ id: () => Promise wait: () => Promise progress: () => Promise }> { const { srcPath, dstPath, options } = rsyncOptions await fs.mkdir(dstPath, { recursive: true }) const command = 'rsync' const args: string[] = [] if (options.delete) { args.push('--delete') } for (const exclude of options.exclude) { args.push(`--exclude=${exclude}`) } args.push('-rlptgocAXH') 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: unknown) => { const lines = String(data).replace(/\r/g, '\n').split('\n') for (const line of lines) { const parsed = /$([0-9.]+)%/.exec(line)?.[1] if (!parsed) { console.log(line) continue } percentage = Number.parseFloat(parsed) } }) let stderr = '' spawned.stderr.on('data', (data: string | Buffer) => { const errString = data.toString('utf-8') stderr += errString console.error(`Backups.runAsync`, 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: any) => { 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 } }