show.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import commandLineArgs from 'command-line-args'
  2. import {
  3. loadAtVersion,
  4. getChunkMetadataForVersion,
  5. getProjectChunksFromVersion,
  6. } from '../lib/chunk_store/index.js'
  7. import { client } from '../lib/mongodb.js'
  8. import knex from '../lib/knex.js'
  9. import redis from '../lib/redis.js'
  10. import {
  11. loadGlobalBlobs,
  12. BlobStore,
  13. makeProjectKey,
  14. } from '../lib/blob_store/index.js'
  15. import { TextDecoder } from 'node:util'
  16. import {
  17. backupPersistor,
  18. chunksBucket,
  19. projectBlobsBucket,
  20. } from '../lib/backupPersistor.mjs'
  21. import fs from 'node:fs'
  22. import { pipeline } from 'node:stream/promises'
  23. import os from 'node:os'
  24. import path from 'node:path'
  25. import { createHash } from 'node:crypto'
  26. import projectKey from '../lib/project_key.js'
  27. import { createGunzip } from 'node:zlib'
  28. import { text } from 'node:stream/consumers'
  29. const optionDefinitions = [
  30. { name: 'historyId', alias: 'p', type: String },
  31. { name: 'version', alias: 'v', type: Number },
  32. { name: 'blob', alias: 'b', type: String },
  33. { name: 'remote', alias: 'r', type: Boolean },
  34. { name: 'keep', alias: 'k', type: Boolean },
  35. ]
  36. function makeChunkKey(projectId, startVersion) {
  37. return path.join(projectKey.format(projectId), projectKey.pad(startVersion))
  38. }
  39. async function listChunks(historyId) {
  40. for await (const chunkRecord of getProjectChunksFromVersion(historyId, 0)) {
  41. console.log('Chunk record:', chunkRecord)
  42. }
  43. }
  44. async function fetchChunkLocal(historyId, version) {
  45. const chunkRecord = await getChunkMetadataForVersion(historyId, version)
  46. const chunk = await loadAtVersion(historyId, version)
  47. return { key: version, chunk, metadata: chunkRecord, source: 'local storage' }
  48. }
  49. async function fetchChunkRemote(historyId, version) {
  50. const chunkRecord = await getChunkMetadataForVersion(historyId, version)
  51. const startVersion = chunkRecord.startVersion
  52. const key = makeChunkKey(historyId, startVersion)
  53. const backupPersistorForProject = await backupPersistor.forProject(
  54. chunksBucket,
  55. key
  56. )
  57. const backupChunkStream = await backupPersistorForProject.getObjectStream(
  58. chunksBucket,
  59. key
  60. )
  61. const backupStr = await text(backupChunkStream.pipe(createGunzip()))
  62. return {
  63. key,
  64. chunk: JSON.parse(backupStr),
  65. metadata: chunkRecord,
  66. source: 'remote backup',
  67. }
  68. }
  69. async function displayChunk(historyId, version, options) {
  70. const { key, chunk, metadata, source } = await (options.remote
  71. ? fetchChunkRemote(historyId, version)
  72. : fetchChunkLocal(historyId, version))
  73. console.log('Source:', source)
  74. console.log('Chunk record', metadata)
  75. console.log('Key', key)
  76. // console.log('Number of changes', chunk.getChanges().length)
  77. console.log(JSON.stringify(chunk))
  78. }
  79. async function fetchBlobRemote(historyId, blobHash) {
  80. const backupPersistorForProject = await backupPersistor.forProject(
  81. projectBlobsBucket,
  82. makeProjectKey(historyId, '')
  83. )
  84. const blobKey = makeProjectKey(historyId, blobHash)
  85. return {
  86. stream: await backupPersistorForProject.getObjectStream(
  87. projectBlobsBucket,
  88. blobKey,
  89. { autoGunzip: true }
  90. ),
  91. metadata: { hash: blobHash },
  92. source: 'remote backup',
  93. }
  94. }
  95. async function fetchBlobLocal(historyId, blobHash) {
  96. const blobStore = new BlobStore(historyId)
  97. const blob = await blobStore.getBlob(blobHash)
  98. if (!blob) throw new Error(`Blob ${blobHash} not found`)
  99. return {
  100. stream: await blobStore.getStream(blobHash),
  101. metadata: blob,
  102. source: 'local storage',
  103. }
  104. }
  105. async function displayBlobContent(filepath, metadata, source, blobHash) {
  106. console.log('Source:', source)
  107. console.log('Blob metadata:', metadata)
  108. // Compute git hash using streaming
  109. const stat = fs.statSync(filepath)
  110. const header = `blob ${stat.size}\0`
  111. const hash = createHash('sha1')
  112. hash.update(header)
  113. const hashStream = fs.createReadStream(filepath)
  114. for await (const chunk of hashStream) {
  115. hash.update(chunk)
  116. }
  117. const gitHash = hash.digest('hex')
  118. // Check content type and display preview
  119. const fd = fs.openSync(filepath, 'r')
  120. try {
  121. const headBuf = Buffer.alloc(16)
  122. const tailBuf = Buffer.alloc(16)
  123. try {
  124. // Stream through TextDecoderStream to check for valid UTF-8
  125. const textStream = fs.createReadStream(filepath)
  126. const decoder = new TextDecoder('utf-8', { fatal: true })
  127. for await (const chunk of textStream) {
  128. decoder.decode(chunk, { stream: true })
  129. }
  130. decoder.decode()
  131. // If we get here, it's valid UTF-8
  132. if (stat.size <= 1024) {
  133. console.log('Content (text):', await fs.readFileSync(filepath, 'utf8'))
  134. } else {
  135. console.log('Content (text, truncated):')
  136. console.log(` Length: ${stat.size} bytes`)
  137. fs.readSync(fd, headBuf, 0, 16, 0)
  138. fs.readSync(fd, tailBuf, 0, 16, stat.size - 16)
  139. console.log(
  140. ' Content:',
  141. headBuf.toString('utf8') +
  142. ' ...(truncated)... ' +
  143. tailBuf.toString('utf8')
  144. )
  145. }
  146. } catch (e) {
  147. // Binary content - show head and tail
  148. console.log('Content (binary):')
  149. console.log(` Length: ${stat.size} bytes`)
  150. if (stat.size <= 32) {
  151. // Small file - read it all
  152. const buf = Buffer.alloc(stat.size)
  153. fs.readSync(fd, buf, 0, stat.size, 0)
  154. const hexBytes = buf.toString('hex').match(/../g).join(' ')
  155. console.log(' Bytes:', hexBytes)
  156. } else {
  157. // Read tail for large files
  158. fs.readSync(fd, headBuf, 0, 16, 0)
  159. fs.readSync(fd, tailBuf, 0, 16, stat.size - 16)
  160. const headHex = headBuf.toString('hex').match(/../g).join(' ')
  161. const tailHex = tailBuf.toString('hex').match(/../g).join(' ')
  162. console.log(' Bytes:', headHex + ' ... ' + tailHex)
  163. }
  164. console.log(' Git-style SHA1:', gitHash)
  165. if (gitHash !== blobHash) {
  166. console.log(' Warning: Git hash differs from blob hash!\x1b[0m')
  167. console.log(' Blob hash:', blobHash)
  168. }
  169. }
  170. } finally {
  171. fs.closeSync(fd)
  172. }
  173. }
  174. async function withTempDir(prefix, fn, options = {}) {
  175. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  176. try {
  177. return await Promise.resolve(fn(tmpDir))
  178. } finally {
  179. if (!options.keep) {
  180. fs.rmSync(tmpDir, { recursive: true, force: true })
  181. } else {
  182. console.log('Keeping temporary file:', path.join(tmpDir, 'blob'))
  183. }
  184. }
  185. }
  186. async function displayBlob(historyId, blobHash, options) {
  187. try {
  188. const { stream, metadata, source } = await (options.remote
  189. ? fetchBlobRemote(historyId, blobHash)
  190. : fetchBlobLocal(historyId, blobHash))
  191. await withTempDir(
  192. 'blob-show-',
  193. async tmpDir => {
  194. const tmpPath = path.join(tmpDir, 'blob')
  195. await pipeline(stream, fs.createWriteStream(tmpPath))
  196. await displayBlobContent(tmpPath, metadata, source, blobHash)
  197. },
  198. { keep: options.keep }
  199. )
  200. } catch (err) {
  201. if (err.code === 'NoSuchKey') {
  202. throw new Error(`Blob ${blobHash} not found in backup`)
  203. }
  204. throw err
  205. }
  206. }
  207. async function main() {
  208. const { historyId, version, blob, remote, keep } =
  209. commandLineArgs(optionDefinitions)
  210. if (!historyId) {
  211. console.error('Error: --historyId is required.')
  212. process.exit(1)
  213. }
  214. await loadGlobalBlobs()
  215. if (version != null) {
  216. await displayChunk(historyId, version, { remote })
  217. } else if (blob != null) {
  218. await displayBlob(historyId, blob, { remote, keep })
  219. } else {
  220. await listChunks(historyId)
  221. }
  222. }
  223. main()
  224. .then(() => console.log('Done.'))
  225. .catch(err => {
  226. console.error('Error:', err)
  227. process.exit(1)
  228. })
  229. .finally(() => {
  230. knex.destroy().catch(err => console.error('Error closing Postgres:', err))
  231. client.close().catch(err => console.error('Error closing MongoDB:', err))
  232. redis
  233. .disconnect()
  234. .catch(err => console.error('Error disconnecting Redis:', err))
  235. })