desktop-upload-run.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. /** Durable, credential-free evidence for test and production release uploads. */
  2. import { createHash } from 'node:crypto'
  3. import { createReadStream } from 'node:fs'
  4. import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
  5. import { join } from 'node:path'
  6. import { Readable } from 'node:stream'
  7. import type COS from 'cos-nodejs-sdk-v5'
  8. import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-plan.ts'
  9. import { DESKTOP_COS_REGION } from './desktop-cos.ts'
  10. import { recordPackagingEvent } from './packaging-run.mjs'
  11. const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch',
  12. 'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT'])
  13. /** Object keys fingerprinted into every upload record to bind evidence to this uploader. */
  14. const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts']
  15. async function fingerprint(artifact: DesktopUploadArtifact) {
  16. const sha512 = createHash('sha512')
  17. const md5 = createHash('md5')
  18. let size = 0
  19. const source = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)]
  20. for await (const bytes of source) {
  21. size += bytes.length
  22. sha512.update(bytes)
  23. md5.update(bytes)
  24. }
  25. return { size, sha512: sha512.digest('base64'), md5: md5.digest('base64') }
  26. }
  27. function receipt(value: unknown): object {
  28. if (typeof value !== 'object' || value === null) return {}
  29. const response = value as { statusCode?: unknown; RequestId?: unknown }
  30. return {
  31. ...(typeof response.statusCode === 'number' ? { httpStatus: response.statusCode } : {}),
  32. ...(typeof response.RequestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(response.RequestId)
  33. ? { requestId: response.RequestId } : {}),
  34. }
  35. }
  36. function failureReceipt(error: unknown): object {
  37. const codes = typeof error === 'object' && error !== null
  38. ? ['code' in error ? error.code : undefined, 'name' in error ? error.name : undefined] : []
  39. const errorCode = codes.find(code => typeof code === 'string' && FAILURE_CODES.has(code)) ?? 'UNCLASSIFIED'
  40. return { errorCode, ...receipt(error) }
  41. }
  42. function streamedBody(artifact: DesktopUploadArtifact): Readable {
  43. return artifact.contents === undefined
  44. ? createReadStream(artifact.path)
  45. : Readable.from([Buffer.from(artifact.contents)])
  46. }
  47. /**
  48. * Upload an already validated release, flushing intent and response evidence around every PUT.
  49. *
  50. * Each object is sent as one streamed PUT with an explicit length and Content-MD5, which is also
  51. * what keeps the COS SDK's internal retry path unreachable: it repeats a request only when the
  52. * body is not a stream. This function never retries either, so every confirmed PUT is the only
  53. * write for its key.
  54. * @param plan Validated release metadata; credential values must not be included.
  55. * @param cos Caller-owned client from the Desktop COS factory in `desktop-cos.ts`.
  56. * @param recordsRoot Local retained evidence parent, outside disposable artifact directories.
  57. * @returns Fresh record directory after all PUTs succeed; errors retain partial evidence and stop later PUTs.
  58. */
  59. export async function uploadDesktopRelease(plan: DesktopUploadPlan, cos: COS, recordsRoot: string): Promise<string> {
  60. await mkdir(recordsRoot, { recursive: true })
  61. const directory = await mkdtemp(join(recordsRoot, `${plan.environment}-${plan.target}-`))
  62. process.stdout.write(`desktop upload: record ${directory}\n`)
  63. const startedAt = new Date().toISOString()
  64. let stage = 'prepare'
  65. let key: string | undefined
  66. let confirmedPuts = 0
  67. let success = false
  68. let failure: object | undefined
  69. try {
  70. await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true })
  71. recordPackagingEvent(directory, { type: 'upload-start', environment: plan.environment, target: plan.target, version: plan.version })
  72. const artifacts = []
  73. for (const artifact of plan.artifacts) {
  74. stage = 'hash-input'
  75. key = artifact.key
  76. artifacts.push({ ...artifact, ...await fingerprint(artifact) })
  77. }
  78. const sourceSha256: Record<string, string> = {}
  79. for (const filename of UPLOADER_SOURCES) {
  80. sourceSha256[filename] = createHash('sha256').update(await readFile(join(import.meta.dirname, filename))).digest('hex')
  81. }
  82. await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1,
  83. environment: plan.environment, target: plan.target, version: plan.version, bucket: plan.bucket,
  84. publicUrl: plan.publicUrl, maxAttempts: 1, sourceSha256, artifacts }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true })
  85. for (const artifact of artifacts) {
  86. key = artifact.key
  87. stage = 'verify-input'
  88. const current = await fingerprint(artifact)
  89. if (current.sha512 !== artifact.sha512 || current.size !== artifact.size) throw new Error('desktop upload: input changed')
  90. stage = 'put'
  91. recordPackagingEvent(directory, { type: 'put-intent', key, size: artifact.size, sha512: artifact.sha512,
  92. channelMetadata: artifact.channelMetadata })
  93. const body = streamedBody(artifact)
  94. try {
  95. const response = await cos.putObject({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION, Key: key,
  96. Body: body, ContentLength: artifact.size, ContentType: artifact.contentType,
  97. Headers: { 'Content-MD5': artifact.md5 } })
  98. confirmedPuts++
  99. stage = 'record-response'
  100. recordPackagingEvent(directory, { type: 'put-confirmed', key, attempts: 1, ...receipt(response) })
  101. } finally {
  102. body.destroy()
  103. }
  104. process.stdout.write(`desktop upload: uploaded ${key}\n`)
  105. }
  106. stage = 'complete'
  107. recordPackagingEvent(directory, { type: 'upload-complete', confirmedPuts })
  108. success = true
  109. return directory
  110. } catch (error) {
  111. failure = failureReceipt(error)
  112. throw new Error(`desktop upload: stopped at ${stage}; inspect ${directory} before another upload`)
  113. } finally {
  114. await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, startedAt,
  115. finishedAt: new Date().toISOString(), environment: plan.environment, target: plan.target, version: plan.version,
  116. success, stage, key, confirmedPuts, failure, publicReadback: 'not-performed' }, null, 2)}\n`,
  117. { flag: 'wx', mode: 0o600, flush: true })
  118. }
  119. }