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' export const DEFAULT_OPTIONS: T.SyncOptions = { delete: true, exclude: [], } export type BackupSync = { dataPath: `/media/startos/volumes/${Volumes}/${string}` backupPath: `/media/startos/backup/${string}` options?: Partial backupOptions?: Partial restoreOptions?: Partial } export type BackupEffects = T.Effects & Affine<'Backups'> 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) => {}, ) {} 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, })), ) } static ofSyncs( ...syncs: BackupSync[] ) { return syncs.reduce((acc, x) => acc.addSync(x), new Backups()) } static withOptions( options?: Partial, ) { return new Backups({ ...DEFAULT_OPTIONS, ...options }) } setOptions(options?: Partial) { this.options = { ...this.options, ...options, } return this } setBackupOptions(options?: Partial) { this.backupOptions = { ...this.backupOptions, ...options, } return this } setRestoreOptions(options?: Partial) { this.restoreOptions = { ...this.restoreOptions, ...options, } return this } setPreBackup(fn: (effects: BackupEffects) => Promise) { this.preBackup = fn return this } setPostBackup(fn: (effects: BackupEffects) => Promise) { this.postBackup = fn return this } setPreRestore(fn: (effects: BackupEffects) => Promise) { this.preRestore = fn return this } setPostRestore(fn: (effects: BackupEffects) => Promise) { this.postRestore = fn return this } 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, }) } addSync(sync: BackupSync) { this.backupSet.push(sync) return this } 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) } } 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 } }