🌳
pt0/devpconlyF/zhongF/libKopiaAI.mts
1import { existsSync, readdirSync, mkdirSync } from 'fs'
2import { spawn } from 'child_process'
3import { homedir } from 'os'
13import type { absFileDirPath } from '../../ptDirF.mts'
14import * as _ from 'lodash-es'
16type LocalDriveCfg = { name: string, volumePath: absFileDirPath }
18type KopiaBackupCfg = {
19 bucketName: string
20 endpoint: string
21 overrideKopiaHostname: string
22 b2SecretName: string
23 localDrives: LocalDriveCfg[]
24 snapshotPath: string
27export const mkKopiaBackupCfg = (cfg: Omit<KopiaBackupCfg, 'localDrives'> & {localDrives: {name: string, volumePath: string}[]}): KopiaBackupCfg => ({
28 ...cfg,
29 localDrives: cfg.localDrives.map(d => ({...d, volumePath: d.volumePath as absFileDirPath})),
30})
32const b2ConnName = 'b2legacy'
34const getConnectedDrive = async (localDrives: LocalDriveCfg[]) => {
35 return _.compact(await allPromCalls(localDrives, async (drive) => {
36 if (await fileExists(drive.volumePath)) return drive
37 }))[0]
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
49 if (!isSuccess) {
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')
54 }
55 }
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')) {
63 return b2ConnName
64 }
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)
79 betLog({connName, isDriveConnected})
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)
96 } else {
97 throw new Error('kopia: unexpected state')
98 }
100 await live2SpawnThrow({cmd: `kopia snapshot create ${snapshotPath}`, usePty: true})
103export const kopiaSnapshot = async (cfg: KopiaBackupCfg) => {
104 await doKopiaSnapshot(cfg)
107export const zhongKopiaBackup = (cfg: KopiaBackupCfg) => {
108 const creds = getOptJsonSecret(cfg.b2SecretName as any)
109 if (!creds) {
110 console.warn(`kopia: skipping, secret '${cfg.b2SecretName}' not found`)
111 return undefined
112 }
113 return {
114 name: 'kopia_backup',
115 runFnc: () => kopiaSnapshot(cfg)
116 }
119export const discoverKopiaDrives = (): LocalDriveCfg[] => {
120 try {
121 return readdirSync('/Volumes').flatMap(volName => {
122 if (volName === 'Macintosh HD') return []
123 const volPath = `/Volumes/${volName}` as absFileDirPath
124 return existsSync(`${volPath}/kopiaa`) ? [{name: volName, volumePath: volPath}] : []
125 })
126 } catch { return [] }
129const ensureKopiaInstalled = async () => {
130 const ret = await do2ExecFile({cmdA: ['which', 'kopia']})
131 if (ret.isSuccess) return
132 console.log('kopia not found, installing...')
133 await live2SpawnThrow({cmd: 'brew install kopia macfuse'})
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}`})
147 } else {
148 throw new Error('kopia: no drive plugged in and no B2 config')
149 }
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')
160 return match[1]
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'})
172 const mounted = await pollUntil(() => {
173 try { return readdirSync(mountPoint).length > 0 } catch { return false }
174 }, {timeoutMs: 30000, intervalMs: 1000})
175 if (!mounted) {
176 kopiaMountProc.kill()
177 throw new Error('kopia: mount timed out (did you enter sudo password?)')
178 }
179 try {
180 await live2SpawnThrow({cmd: `rsync -av --update ${mountPoint}/ ${destPath}/`})
181 } finally {
182 await do2ExecFile({cmdA: ['kopia', 'mount', '--unmount', mountPoint]})
183 kopiaMountProc.kill()
184 await sleep(2000)
185 }
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}`
193 : ''
194 console.log(`to restore:
1951. brew install kopia macfuse
1962. connect:
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
201`)
204if (isDirectlyRun(import.meta.url)) {
205 printRestoreGuide({snapshotPath: '~'})