1import { existsSync, readdirSync, mkdirSync } from 'fs' 2import { spawn } from 'child_process' 3import { homedir } from 'os' 14import * as _ from 'lodash-es' 18type KopiaBackupCfg = { 21 overrideKopiaHostname: string 23 localDrives: LocalDriveCfg[] 27export const mkKopiaBackupCfg = (cfg: Omit<KopiaBackupCfg, 'localDrives'> & {localDrives: {name: string, volumePath: string}[]}): KopiaBackupCfg => ({ 29 localDrives: cfg.localDrives.map(d => ({...d, volumePath: d.volumePath as absFileDirPath})), 32const b2ConnName = 'b2legacy' 34const getConnectedDrive = async (localDrives: LocalDriveCfg[]) => { 35 return _.compact(await allPromCalls(localDrives, async (drive) => { 40const findDriveName = (haystack: string, localDrives: LocalDriveCfg[]) => { 41 return _.chain(localDrives).map((drive) => { 42 if (_.includes(haystack, drive.volumePath)) return drive.name 43 }).compact().first().value() 46const getKopiaConnName = async (localDrives: LocalDriveCfg[]) => { 47 const ret = await do2ExecFile({cmdA: ['kopia', 'repository', 'status']}) 48 const {stdout, stderr, isSuccess} = ret 50 if (_.includes(stderr, `unable to open repository: cannot open storage: cannot access storage path: stat`)) { 51 const driveName = findDriveName(stderr, localDrives) 52 if (driveName) return driveName 53 throw new Error('kopia: could not determine drive from error') 57 const lineStart = 'Description:' 58 const descript = findOnlyReq(stdout.split("\n"), (lineS: string) => { 59 return _.startsWith(lineS, lineStart) 60 }).replace(lineStart, '').trim() 62 if (_.includes(descript, 's3.') && _.includes(descript, 'backblazeb2.com')) { 65 const driveName = findDriveName(descript, localDrives) 66 if (driveName) return driveName 67 throw new Error('kopia: unknown connection type') 70const doKopiaSnapshot = async (cfg: KopiaBackupCfg, didSwitch = false) => { 71 const {bucketName, endpoint, overrideKopiaHostname, b2SecretName, localDrives, snapshotPath} = cfg 72 const {accessKeyId, secretAccessKey} = getReqJsonSecret(b2SecretName as any) as {accessKeyId: string, secretAccessKey: string} 73 const commonArgS = `--bucket=${bucketName} --endpoint=${endpoint} --override-hostname=${overrideKopiaHostname}` 75 const connName = await getKopiaConnName(localDrives) 76 const isB2Connected = connName === b2ConnName 77 const driveNames = _.map(localDrives, 'name') 78 const isDriveConnected = _.includes(driveNames, connName) 81 const connectedDrive = await getConnectedDrive(localDrives) 82 const drivePluggedIn = !!connectedDrive 84 console.log({didSwitch, drivePluggedIn, isB2Connected, isDriveConnected}) 86 if (drivePluggedIn && isDriveConnected) { 87 } else if (!drivePluggedIn && !isDriveConnected) { 88 } else if (drivePluggedIn && !isDriveConnected) { 89 if (didSwitch) throw new Error('kopia: switched but still not connected to drive') 90 await live2Spawn({cmd: `kopia repository connect filesystem --path=${connectedDrive.volumePath}/kopiaa`}) 91 return await doKopiaSnapshot(cfg, true) 92 } else if (!drivePluggedIn && isDriveConnected) { 93 if (didSwitch) throw new Error('kopia: switched but still not connected to b2') 94 await live2Spawn({cmd: `AWS_ACCESS_KEY_ID=${accessKeyId} AWS_SECRET_ACCESS_KEY=${secretAccessKey} kopia repository connect s3 ${commonArgS}`}) 95 return await doKopiaSnapshot(cfg, true) 97 throw new Error('kopia: unexpected state') 103export const kopiaSnapshot = async (cfg: KopiaBackupCfg) => { 104 await doKopiaSnapshot(cfg) 107export const zhongKopiaBackup = (cfg: KopiaBackupCfg) => { 110 console.warn(`kopia: skipping, secret '${cfg.b2SecretName}' not found`) 114 name: 'kopia_backup', 115 runFnc: () => kopiaSnapshot(cfg) 119export const discoverKopiaDrives = (): LocalDriveCfg[] => { 121 return readdirSync('/Volumes').flatMap(volName => { 122 if (volName === 'Macintosh HD') return [] 124 return existsSync(`${volPath}/kopiaa`) ? [{name: volName, volumePath: volPath}] : [] 126 } catch { return [] } 129const ensureKopiaInstalled = async () => { 131 if (ret.isSuccess) return 132 console.log('kopia not found, installing...') 136const ensureRepoConnected = async (cfg: KopiaBackupCfg) => { 137 const statusRet = await do2ExecFile({cmdA: ['kopia', 'repository', 'status']}) 138 if (statusRet.isSuccess) return 139 const connectedDrive = await getConnectedDrive(cfg.localDrives) 140 if (connectedDrive) { 141 await live2SpawnThrow({cmd: `kopia repository connect filesystem --path=${connectedDrive.volumePath}/kopiaa`}) 142 } else if (cfg.b2SecretName) { 143 const creds = getOptJsonSecret(cfg.b2SecretName as any) as {accessKeyId: string, secretAccessKey: string} | null 144 if (!creds) throw new Error(`kopia: no drive plugged in and secret '${cfg.b2SecretName}' not found`) 145 const commonArgS = `--bucket=${cfg.bucketName} --endpoint=${cfg.endpoint} --override-hostname=${cfg.overrideKopiaHostname}` 146 await live2SpawnThrow({cmd: `AWS_ACCESS_KEY_ID=${creds.accessKeyId} AWS_SECRET_ACCESS_KEY=${creds.secretAccessKey} kopia repository connect s3 ${commonArgS}`}) 148 throw new Error('kopia: no drive plugged in and no B2 config') 152const getLatestSnapshotId = async (snapshotPath: string) => { 153 const ret = await do2ExecFile({cmdA: ['kopia', 'snapshot', 'list', '-a', '--snapshot-path', snapshotPath, '-m']}) 154 if (!ret.isSuccess) throw new Error('kopia: failed to list snapshots') 155 const lines = _.compact(ret.stdout.split('\n')) 156 const lastLine = _.last(lines) 157 if (!lastLine) throw new Error('kopia: no snapshots found') 158 const match = lastLine.match(/^(\S+)/) 159 if (!match) throw new Error('kopia: could not parse snapshot id') 163export const kopiaIncrementalRestore = async (cfg: KopiaBackupCfg, snapshotId?: string) => { 164 await ensureKopiaInstalled() 165 await ensureRepoConnected(cfg) 166 const snapId = snapshotId || await getLatestSnapshotId(cfg.snapshotPath) 167 const destPath = cfg.snapshotPath.replace(/^~/, homedir()) 168 console.log(`restoring snapshot ${snapId} -> ${destPath}`) 169 const mountPoint = '/tmp/kopia-mount' 170 if (!existsSync(mountPoint)) { mkdirSync(mountPoint, {recursive: true}) } 171 const kopiaMountProc = spawn('sudo', ['kopia', 'mount', snapId, mountPoint], {stdio: 'inherit'}) 173 try { return readdirSync(mountPoint).length > 0 } catch { return false } 174 }, {timeoutMs: 30000, intervalMs: 1000}) 176 kopiaMountProc.kill() 177 throw new Error('kopia: mount timed out (did you enter sudo password?)') 182 await do2ExecFile({cmdA: ['kopia', 'mount', '--unmount', mountPoint]}) 183 kopiaMountProc.kill() 188export const printRestoreGuide = (cfg: Partial<KopiaBackupCfg> & {snapshotPath: string}) => { 189 const {bucketName, endpoint, overrideKopiaHostname, localDrives = discoverKopiaDrives(), snapshotPath} = cfg 190 const driveLines = localDrives.map(d => ` kopia repository connect filesystem --path=${d.volumePath}/kopiaa`).join('\n') 191 const b2Line = bucketName && endpoint && overrideKopiaHostname 192 ? `\n kopia repository connect s3 --bucket=${bucketName} --endpoint=${endpoint} --override-hostname=${overrideKopiaHostname}` 194 console.log(`to restore: 1951. brew install kopia macfuse 197${driveLines || ' (no drives with /kopiaa found)'}${b2Line} 1983. kopia mount <snapshot-id> /tmp/kopia-mount 1994. rsync -av --checksum /tmp/kopia-mount/ ${snapshotPath}/ 2005. kopia mount --unmount /tmp/kopia-mount 205 printRestoreGuide({snapshotPath: '~'})