backupArchiver.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. // @ts-check
  2. import path from 'node:path'
  3. import projectKey from '@overleaf/object-persistor/src/ProjectKey.js'
  4. import {
  5. chunksBucket,
  6. backupPersistor,
  7. projectBlobsBucket,
  8. globalBlobsBucket as backupGlobalBlobsBucket,
  9. } from './backupPersistor.mjs'
  10. import core, { Chunk, History } from 'overleaf-editor-core'
  11. import {
  12. GLOBAL_BLOBS,
  13. makeProjectKey,
  14. getStringLengthOfFile,
  15. makeGlobalKey,
  16. } from './blob_store/index.js'
  17. import streams from './streams.js'
  18. import objectPersistor from '@overleaf/object-persistor'
  19. import OError from '@overleaf/o-error'
  20. import chunkStore from './chunk_store/index.js'
  21. import logger from '@overleaf/logger'
  22. import fs from 'node:fs'
  23. import { pipeline } from 'node:stream/promises'
  24. import withTmpDir from '../../api/controllers/with_tmp_dir.js'
  25. import { loadChunk } from './backupVerifier.mjs'
  26. import globalBlobPersistor from './persistor.js'
  27. import config from 'config'
  28. import { NoKEKMatchedError } from '@overleaf/object-persistor/src/Errors.js'
  29. const globalBlobsBucket = config.get('blobStore.globalBucket')
  30. class BackupBlobStore {
  31. /**
  32. *
  33. * @param {string} historyId
  34. * @param {string} tmp
  35. * @param {CachedPerProjectEncryptedS3Persistor} persistor
  36. * @param {boolean} useBackupGlobalBlobs
  37. */
  38. constructor(historyId, tmp, persistor, useBackupGlobalBlobs) {
  39. this.historyId = historyId
  40. this.tmp = tmp
  41. this.blobs = new Map()
  42. this.persistor = persistor
  43. this.useBackupGlobalBlobs = useBackupGlobalBlobs
  44. }
  45. /**
  46. * Required for BlobStore interface - not supported.
  47. *
  48. * @template T
  49. * @param {string} hash
  50. * @return {Promise<T>}
  51. */
  52. async getObject(hash) {
  53. try {
  54. const stream = await this.getStream(hash)
  55. const buffer = await streams.readStreamToBuffer(stream)
  56. return JSON.parse(buffer.toString())
  57. } catch (err) {
  58. logger.warn({ err, hash }, 'Failed to fetch chunk blob')
  59. throw err
  60. }
  61. }
  62. /**
  63. *
  64. * @param {Set<string>} hashes
  65. * @return {Promise<void>}
  66. */
  67. async fetchBlobs(hashes) {
  68. for await (const hash of hashes) {
  69. if (this.blobs.has(hash)) return
  70. const path = `${this.tmp}/${hash}`
  71. /** @type {core.Blob} */
  72. let blob
  73. /** @type {NodeJS.ReadableStream} */
  74. let blobStream
  75. if (GLOBAL_BLOBS.has(hash)) {
  76. try {
  77. const blobData = await this.fetchGlobalBlob(hash)
  78. await pipeline(blobData.stream, fs.createWriteStream(path))
  79. blob = blobData.blob
  80. } catch (err) {
  81. logger.warn({ hash, err }, 'Failed to fetch global blob')
  82. continue
  83. }
  84. } else {
  85. try {
  86. blobStream = await fetchBlob(this.historyId, hash, this.persistor)
  87. await pipeline(blobStream, fs.createWriteStream(path))
  88. blob = await this.makeBlob(hash, path)
  89. } catch (err) {
  90. logger.warn({ err, hash }, 'Failed to fetch chunk blob')
  91. continue
  92. }
  93. }
  94. this.blobs.set(hash, blob)
  95. }
  96. }
  97. /**
  98. *
  99. * @param {string} hash
  100. * @return {Promise<{ blob: core.Blob, stream: NodeJS.ReadableStream }>}
  101. */
  102. async fetchGlobalBlob(hash) {
  103. const globalBlob = GLOBAL_BLOBS.get(hash)
  104. if (!globalBlob) {
  105. throw new Error('blob does not exist or is not a global blob')
  106. }
  107. let stream
  108. const key = makeGlobalKey(hash)
  109. if (this.useBackupGlobalBlobs) {
  110. stream = await this.persistor.getObjectStream(
  111. backupGlobalBlobsBucket,
  112. key
  113. )
  114. } else {
  115. stream = await globalBlobPersistor.getObjectStream(globalBlobsBucket, key)
  116. }
  117. return { blob: globalBlob.blob, stream }
  118. }
  119. /**
  120. *
  121. * @param {string} hash
  122. * @param {string} pathname
  123. * @return {Promise<core.Blob>}
  124. */
  125. async makeBlob(hash, pathname) {
  126. const stat = await fs.promises.stat(pathname)
  127. const byteLength = stat.size
  128. const stringLength = await getStringLengthOfFile(byteLength, pathname)
  129. if (stringLength) {
  130. return new core.Blob(hash, byteLength, stringLength)
  131. }
  132. return new core.Blob(hash, byteLength)
  133. }
  134. /**
  135. *
  136. * @param {string} hash
  137. * @return {Promise<string>}
  138. */
  139. async getString(hash) {
  140. const stream = await this.getStream(hash)
  141. const buffer = await streams.readStreamToBuffer(stream)
  142. return buffer.toString()
  143. }
  144. /**
  145. *
  146. * @param {string} hash
  147. * @return {Promise<fs.ReadStream>}
  148. */
  149. async getStream(hash) {
  150. return fs.createReadStream(this.getBlobPathname(hash))
  151. }
  152. /**
  153. *
  154. * @param {string} hash
  155. * @return {Promise<core.Blob>}
  156. */
  157. async getBlob(hash) {
  158. return this.blobs.get(hash)
  159. }
  160. /**
  161. *
  162. * @param {string} hash
  163. * @return {string}
  164. */
  165. getBlobPathname(hash) {
  166. return path.join(this.tmp, hash)
  167. }
  168. }
  169. /**
  170. * @typedef {(import('@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor.js').CachedPerProjectEncryptedS3Persistor)} CachedPerProjectEncryptedS3Persistor
  171. */
  172. /**
  173. * @typedef {(import('archiver').Archiver)} Archiver
  174. */
  175. /**
  176. * @typedef {(import('overleaf-editor-core').FileMap)} FileMap
  177. */
  178. /**
  179. *
  180. * @param historyId
  181. * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
  182. */
  183. async function getProjectPersistor(historyId) {
  184. try {
  185. return await backupPersistor.forProjectRO(
  186. projectBlobsBucket,
  187. makeProjectKey(historyId, '')
  188. )
  189. } catch (error) {
  190. if (error instanceof NoKEKMatchedError) {
  191. logger.info({}, 'no kek matched')
  192. }
  193. throw new BackupPersistorError(
  194. 'Failed to get project persistor',
  195. { historyId },
  196. error instanceof Error ? error : undefined
  197. )
  198. }
  199. }
  200. /**
  201. *
  202. * @param persistor
  203. * @param {string} key
  204. * @return {Promise<{chunkData: any, buffer: Buffer}>}
  205. */
  206. async function loadChunkByKey(persistor, key) {
  207. try {
  208. const buf = await streams.gunzipStreamToBuffer(
  209. await persistor.getObjectStream(chunksBucket, key)
  210. )
  211. return { chunkData: JSON.parse(buf.toString('utf-8')), buffer: buf }
  212. } catch (err) {
  213. if (err instanceof objectPersistor.Errors.NotFoundError) {
  214. throw new Chunk.NotPersistedError('chunk not found')
  215. }
  216. if (err instanceof Error) {
  217. throw OError.tag(err, 'Failed to load chunk', { key })
  218. }
  219. throw err
  220. }
  221. }
  222. /**
  223. *
  224. * @param {string} historyId
  225. * @param {string} hash
  226. * @param {CachedPerProjectEncryptedS3Persistor} persistor
  227. * @return {Promise<NodeJS.ReadableStream>}
  228. */
  229. async function fetchBlob(historyId, hash, persistor) {
  230. const path = makeProjectKey(historyId, hash)
  231. return await persistor.getObjectStream(projectBlobsBucket, path, {
  232. autoGunzip: true,
  233. })
  234. }
  235. /**
  236. * @typedef {object} AddChunkOptions
  237. * @property {string} [prefix] Should include trailing slash (if length > 0)
  238. * @property {boolean} [useBackupGlobalBlobs]
  239. */
  240. /**
  241. *
  242. * @param {History} history
  243. * @param {Archiver} archive
  244. * @param {CachedPerProjectEncryptedS3Persistor} projectCache
  245. * @param {string} historyId
  246. * @param {AddChunkOptions} [options]
  247. * @returns {Promise<void>}
  248. */
  249. async function addChunkToArchive(
  250. history,
  251. archive,
  252. projectCache,
  253. historyId,
  254. { prefix = '', useBackupGlobalBlobs = false } = {}
  255. ) {
  256. const chunkBlobs = new Set()
  257. history.findBlobHashes(chunkBlobs)
  258. await withTmpDir('recovery-blob-', async tmpDir => {
  259. const blobStore = new BackupBlobStore(
  260. historyId,
  261. tmpDir,
  262. projectCache,
  263. useBackupGlobalBlobs
  264. )
  265. await blobStore.fetchBlobs(chunkBlobs)
  266. await history.loadFiles('lazy', blobStore)
  267. const snapshot = history.getSnapshot()
  268. snapshot.applyAll(history.getChanges())
  269. const filePaths = snapshot.getFilePathnames()
  270. if (filePaths.length === 0) {
  271. logger.warn(
  272. { historyId, projectVersion: snapshot.projectVersion },
  273. 'No files found in snapshot backup'
  274. )
  275. }
  276. for (const filePath of filePaths) {
  277. /** @type {core.File | null | undefined} */
  278. const file = snapshot.getFile(filePath)
  279. if (!file) {
  280. logger.error({ filePath }, 'File not found in snapshot')
  281. continue
  282. }
  283. try {
  284. await file.load('eager', blobStore)
  285. } catch (err) {
  286. logger.error(
  287. { filePath, err },
  288. 'Failed to load file from snapshot, skipping'
  289. )
  290. continue
  291. }
  292. const hash = file.getHash()
  293. /** @type {string | fs.ReadStream | null | undefined} */
  294. let content = file.getContent({ filterTrackedDeletes: true })
  295. if (content === null) {
  296. if (!hash) {
  297. logger.error({ filePath }, 'File does not have a hash')
  298. continue
  299. }
  300. const blob = await blobStore.getBlob(hash)
  301. if (!blob) {
  302. logger.error({ filePath }, 'Blob not found in blob store')
  303. continue
  304. }
  305. content = await blobStore.getStream(hash)
  306. }
  307. archive.append(content, {
  308. name: `${prefix}${filePath}`,
  309. })
  310. }
  311. })
  312. }
  313. /**
  314. *
  315. * @param {string} historyId
  316. * @return {Promise<number>}
  317. */
  318. async function findStartVersionOfLatestChunk(historyId) {
  319. const backend = chunkStore.getBackend(historyId)
  320. const chunk = await backend.getLatestChunk(historyId, { readOnly: true })
  321. if (!chunk) {
  322. throw new Error('Latest chunk could not be loaded')
  323. }
  324. return chunk.startVersion
  325. }
  326. /**
  327. * Restore a project from the latest snapshot
  328. *
  329. * There is an assumption that the database backup has been restored.
  330. *
  331. * @param {Archiver} archive
  332. * @param {string} historyId
  333. * @param {boolean} [useBackupGlobalBlobs]
  334. * @return {Promise<void>}
  335. */
  336. export async function archiveLatestChunk(
  337. archive,
  338. historyId,
  339. useBackupGlobalBlobs = false
  340. ) {
  341. logger.info({ historyId, useBackupGlobalBlobs }, 'Archiving latest chunk')
  342. const projectCache = await getProjectPersistor(historyId)
  343. const startVersion = await findStartVersionOfLatestChunk(historyId)
  344. const backedUpChunkRaw = await loadChunk(
  345. historyId,
  346. startVersion,
  347. projectCache
  348. )
  349. const backedUpChunk = History.fromRaw(backedUpChunkRaw)
  350. await addChunkToArchive(backedUpChunk, archive, projectCache, historyId, {
  351. useBackupGlobalBlobs,
  352. })
  353. return archive
  354. }
  355. /**
  356. * Fetches all raw blobs from the project and adds them to the archive.
  357. *
  358. * @param {string} historyId
  359. * @param {Archiver} archive
  360. * @param {CachedPerProjectEncryptedS3Persistor} projectCache
  361. * @return {Promise<void>}
  362. */
  363. async function addRawBlobsToArchive(historyId, archive, projectCache) {
  364. const blobKeys = await projectCache.listDirectoryKeys(
  365. projectBlobsBucket,
  366. projectKey.format(historyId)
  367. )
  368. for (const key of blobKeys) {
  369. try {
  370. const stream = await projectCache.getObjectStream(
  371. projectBlobsBucket,
  372. key,
  373. { autoGunzip: true }
  374. )
  375. archive.append(stream, {
  376. name: path.join(historyId, 'blobs', key),
  377. })
  378. } catch (err) {
  379. logger.warn({ err, path: key }, 'Failed to append blob to archive')
  380. }
  381. }
  382. }
  383. /**
  384. * Download raw files from the backup.
  385. *
  386. * This can work without the database being backed up.
  387. *
  388. * It will split the project into chunks per directory and download the blobs alongside the chunk.
  389. *
  390. * @param {Archiver} archive
  391. * @param {string} historyId
  392. * @param {boolean} [useBackupGlobalBlobs]
  393. * @return {Promise<void>}
  394. */
  395. export async function archiveRawProject(
  396. archive,
  397. historyId,
  398. useBackupGlobalBlobs = false
  399. ) {
  400. const projectCache = await getProjectPersistor(historyId)
  401. const chunkKeys = await projectCache.listDirectoryKeys(
  402. chunksBucket,
  403. projectKey.format(historyId)
  404. )
  405. if (chunkKeys.length === 0) {
  406. throw new Error('No chunks found')
  407. }
  408. for (const key of chunkKeys) {
  409. const chunkId = key.split('/').pop()
  410. logger.debug({ chunkId, key }, 'Processing chunk')
  411. const { buffer } = await loadChunkByKey(projectCache, key)
  412. archive.append(buffer, {
  413. name: `${historyId}/chunks/${chunkId}/chunk.json`,
  414. })
  415. }
  416. await addRawBlobsToArchive(historyId, archive, projectCache)
  417. }
  418. export class BackupPersistorError extends OError {}