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("-actAXH") 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", "\n").split("\n") for (const line of lines) { const parsed = /$([0-9.]+)%/.exec(line)?.[1] if (!parsed) continue percentage = Number.parseFloat(parsed) } }) spawned.stderr.on("data", (data: unknown) => { console.error(`Backups.runAsync`, asError(data)) }) 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}`)) } }) }) const wait = () => waitPromise const progress = () => Promise.resolve(percentage) return { id, wait, progress } }