mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
The regex used `$` (end-of-string anchor) instead of no anchor, so it never matched the percentage in rsync output. Every line, including empty ones, was logged instead of parsed.
320 lines
9.5 KiB
TypeScript
320 lines
9.5 KiB
TypeScript
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 { 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<Volumes extends string> = {
|
|
dataPath: `/media/startos/volumes/${Volumes}/${string}`
|
|
backupPath: `/media/startos/backup/${string}`
|
|
options?: Partial<T.SyncOptions>
|
|
backupOptions?: Partial<T.SyncOptions>
|
|
restoreOptions?: Partial<T.SyncOptions>
|
|
}
|
|
|
|
/** 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<M extends T.SDKManifest> implements InitScript {
|
|
private constructor(
|
|
private options = DEFAULT_OPTIONS,
|
|
private restoreOptions: Partial<T.SyncOptions> = {},
|
|
private backupOptions: Partial<T.SyncOptions> = {},
|
|
private backupSet = [] as BackupSync<M['volumes'][number]>[],
|
|
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<M extends T.SDKManifest = never>(
|
|
...volumeNames: Array<M['volumes'][number]>
|
|
): Backups<M> {
|
|
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<M extends T.SDKManifest = never>(
|
|
...syncs: BackupSync<M['volumes'][number]>[]
|
|
) {
|
|
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
|
|
}
|
|
|
|
/**
|
|
* 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<M extends T.SDKManifest = never>(
|
|
options?: Partial<T.SyncOptions>,
|
|
) {
|
|
return new Backups<M>({ ...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<T.SyncOptions>) {
|
|
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<T.SyncOptions>) {
|
|
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<T.SyncOptions>) {
|
|
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<void>) {
|
|
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<void>) {
|
|
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<void>) {
|
|
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<void>) {
|
|
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<M['volumes'][0]>) {
|
|
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<void> {
|
|
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) {
|
|
await 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.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 as BackupEffects)
|
|
return
|
|
}
|
|
}
|
|
|
|
async function runRsync(rsyncOptions: {
|
|
srcPath: string
|
|
dstPath: string
|
|
options: T.SyncOptions
|
|
}): Promise<{
|
|
id: () => Promise<string>
|
|
wait: () => Promise<null>
|
|
progress: () => Promise<number>
|
|
}> {
|
|
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('-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: 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) {
|
|
if (line) 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<null>((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 }
|
|
}
|