conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

s3.js (12418B)


      1 // src/lib/storage/s3.js - S3 compatible object storage
      2 //
      3 // Engaged as soon as storage.s3.bucket is configured. Tested against Garage
      4 // and MinIO, which both need path style addressing; set force_path_style to
      5 // false for AWS S3 proper.
      6 //
      7 // Requests go out over fetch with SigV4 headers.
      8 //
      9 // Large uploads go through multipart, one part at a time. Handing a stream
     10 // to fetch does not stream it: undici collects the whole body before
     11 // sending, which was measured at just over a gigabyte of live heap for a
     12 // one gigabyte artifact. Build output is routinely that size, so a single
     13 // upload could exhaust the conductor. Multipart also lifts the five
     14 // gigabyte ceiling on a single PUT, and lets an upload proceed when the
     15 // total length is not known in advance.
     16 //
     17 // Small bodies still go as one request, since three round trips to save
     18 // buffering a few megabytes is a poor trade.
     19 
     20 import crypto from 'node:crypto';
     21 import { Readable } from 'node:stream';
     22 import { signRequest, presignUrl, sha256Hex, uriEncode } from './sigv4.js';
     23 import { StorageNotFound, assertKey } from './key.js';
     24 
     25 // Replays what has already been read, then continues with the rest.
     26 async function* concatSources(head, rest) {
     27   yield head;
     28   yield* rest;
     29 }
     30 
     31 // S3 requires every part except the last to be at least 5 MiB, and allows
     32 // at most 10000 parts. The default part size is comfortably above the
     33 // minimum, and is scaled up when the total is large enough that 10000
     34 // parts would not cover it.
     35 const MIN_PART_SIZE = 5 * 1024 * 1024;
     36 const DEFAULT_PART_SIZE = 16 * 1024 * 1024;
     37 const MAX_PARTS = 10000;
     38 
     39 // Below this a stream is collected and sent as one request.
     40 const SINGLE_PUT_MAX = 32 * 1024 * 1024;
     41 
     42 export function partSizeFor(size) {
     43   if (!Number.isFinite(size) || size <= 0) return DEFAULT_PART_SIZE;
     44   const needed = Math.ceil(size / (MAX_PARTS - 1));
     45   const rounded = Math.ceil(needed / (1024 * 1024)) * 1024 * 1024;
     46   return Math.max(DEFAULT_PART_SIZE, MIN_PART_SIZE, rounded);
     47 }
     48 
     49 // Regroups an arbitrarily chunked stream into buffers of a fixed size, so
     50 // memory stays bounded by the part size no matter how the source behaves.
     51 export async function* inParts(source, partSize) {
     52   let held = [];
     53   let heldLength = 0;
     54 
     55   for await (const chunk of source) {
     56     const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
     57     held.push(buffer);
     58     heldLength += buffer.length;
     59 
     60     while (heldLength >= partSize) {
     61       const joined = Buffer.concat(held, heldLength);
     62       yield joined.subarray(0, partSize);
     63       const rest = joined.subarray(partSize);
     64       held = rest.length > 0 ? [rest] : [];
     65       heldLength = rest.length;
     66     }
     67   }
     68 
     69   if (heldLength > 0) yield Buffer.concat(held, heldLength);
     70 }
     71 
     72 // Values inside S3 XML are hex digests and quotes, but escaping keeps a
     73 // surprising key or ETag from breaking the document.
     74 function xmlEscape(value) {
     75   return String(value).replace(/[<>&'"]/g, (c) => (
     76     { '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[c]
     77   ));
     78 }
     79 
     80 function firstTag(xml, tag) {
     81   const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml);
     82   return match ? match[1] : null;
     83 }
     84 
     85 export function createS3Storage(cfg) {
     86   const s3 = cfg.storage.s3;
     87   const endpoint = new URL(s3.endpoint);
     88   const creds = {
     89     region: s3.region,
     90     accessKeyId: s3.access_key_id,
     91     secretAccessKey: s3.secret_access_key,
     92   };
     93 
     94   // Any path prefix on the endpoint is preserved, so an S3 gateway mounted
     95   // under a subpath keeps working.
     96   const prefix = endpoint.pathname.replace(/\/+$/, '');
     97 
     98   function objectUrl(key) {
     99     assertKey(key);
    100     const url = new URL(endpoint.toString());
    101     // The signer treats the path as final, so encode it here and exactly
    102     // once. Slashes stay literal to keep the key hierarchy intact.
    103     const encoded = uriEncode(key, false);
    104     if (s3.force_path_style) {
    105       url.pathname = `${prefix}/${s3.bucket}/${encoded}`;
    106     } else {
    107       url.host = `${s3.bucket}.${endpoint.host}`;
    108       url.pathname = `${prefix}/${encoded}`;
    109     }
    110     return url;
    111   }
    112 
    113   async function send(method, key, { headers = {}, body, payloadHash, query } = {}) {
    114     const url = objectUrl(key);
    115     // Query parameters are part of the signature, so they must be set
    116     // before signing and not touched afterwards.
    117     for (const [name, value] of Object.entries(query ?? {})) url.searchParams.set(name, value);
    118 
    119     const signed = signRequest({
    120       method,
    121       url,
    122       headers,
    123       payloadHash: payloadHash ?? sha256Hex(''),
    124       ...creds,
    125     });
    126 
    127     const init = { method, headers: signed };
    128     if (body !== undefined) init.body = body;
    129     return fetch(url, init);
    130   }
    131 
    132   async function errorFrom(res, action, key) {
    133     let detail = '';
    134     try {
    135       detail = (await res.text()).slice(0, 512);
    136     } catch {
    137       // The status alone is still useful.
    138     }
    139     return new Error(`s3 ${action} failed for ${key}: ${res.status} ${res.statusText}\n${detail}`);
    140   }
    141 
    142   // One request, for a body already in memory.
    143   async function putBuffer(key, buffer, contentType) {
    144     const digest = crypto.createHash('sha256').update(buffer).digest('hex');
    145     const headers = { 'content-length': String(buffer.length) };
    146     if (contentType) headers['content-type'] = contentType;
    147 
    148     const res = await send('PUT', key, { headers, body: buffer, payloadHash: digest });
    149     if (!res.ok) throw await errorFrom(res, 'put', key);
    150     await res.arrayBuffer();
    151 
    152     return { key, size: buffer.length, sha256: digest };
    153   }
    154 
    155   async function createMultipart(key, contentType) {
    156     const headers = {};
    157     if (contentType) headers['content-type'] = contentType;
    158 
    159     const res = await send('POST', key, { headers, query: { uploads: '' }, body: undefined });
    160     if (!res.ok) throw await errorFrom(res, 'create multipart upload', key);
    161 
    162     const uploadId = firstTag(await res.text(), 'UploadId');
    163     if (!uploadId) throw new Error(`s3 did not return an upload id for ${key}`);
    164     return uploadId;
    165   }
    166 
    167   async function uploadPart(key, uploadId, partNumber, buffer) {
    168     const res = await send('PUT', key, {
    169       headers: { 'content-length': String(buffer.length) },
    170       query: { partNumber: String(partNumber), uploadId },
    171       body: buffer,
    172       payloadHash: crypto.createHash('sha256').update(buffer).digest('hex'),
    173     });
    174     if (!res.ok) throw await errorFrom(res, `upload part ${partNumber}`, key);
    175     await res.arrayBuffer();
    176 
    177     const etag = res.headers.get('etag');
    178     if (!etag) throw new Error(`s3 did not return an ETag for part ${partNumber} of ${key}`);
    179     return etag;
    180   }
    181 
    182   async function completeMultipart(key, uploadId, parts) {
    183     const body = Buffer.from(
    184       '<CompleteMultipartUpload>' +
    185       parts.map((p) => `<Part><PartNumber>${p.number}</PartNumber><ETag>${xmlEscape(p.etag)}</ETag></Part>`).join('') +
    186       '</CompleteMultipartUpload>',
    187       'utf8'
    188     );
    189 
    190     const res = await send('POST', key, {
    191       headers: { 'content-length': String(body.length), 'content-type': 'application/xml' },
    192       query: { uploadId },
    193       body,
    194       payloadHash: crypto.createHash('sha256').update(body).digest('hex'),
    195     });
    196     if (!res.ok) throw await errorFrom(res, 'complete multipart upload', key);
    197 
    198     // S3 can report failure inside a 200 response, because the connection
    199     // is held open while the parts are assembled.
    200     const text = await res.text();
    201     if (/<Error>/.test(text)) {
    202       const code = firstTag(text, 'Code') ?? 'unknown';
    203       throw new Error(`s3 complete multipart upload failed for ${key}: ${code}`);
    204     }
    205   }
    206 
    207   async function abortMultipart(key, uploadId) {
    208     // Parts left behind are billable and invisible, so this matters even
    209     // though nothing depends on its result.
    210     const res = await send('DELETE', key, { query: { uploadId } });
    211     await res.arrayBuffer().catch(() => {});
    212   }
    213 
    214   async function putMultipart(key, source, { contentType, size }) {
    215     const uploadId = await createMultipart(key, contentType);
    216     const partSize = partSizeFor(size);
    217     const hash = crypto.createHash('sha256');
    218     const parts = [];
    219     let total = 0;
    220 
    221     // Parts go up one at a time. Overlapping them was measured at about
    222     // eight percent faster while doubling peak memory, which is not a
    223     // trade worth the extra failure handling.
    224     try {
    225       for await (const part of inParts(source, partSize)) {
    226         hash.update(part);
    227         total += part.length;
    228         if (parts.length >= MAX_PARTS) {
    229           throw new Error(`s3 upload of ${key} exceeded ${MAX_PARTS} parts`);
    230         }
    231         const etag = await uploadPart(key, uploadId, parts.length + 1, part);
    232         parts.push({ number: parts.length + 1, etag });
    233       }
    234 
    235       // A multipart upload must have at least one part.
    236       if (parts.length === 0) {
    237         parts.push({ number: 1, etag: await uploadPart(key, uploadId, 1, Buffer.alloc(0)) });
    238       }
    239 
    240       if (size !== undefined && size !== total) {
    241         throw new Error(`s3 put size mismatch for ${key}: declared ${size}, read ${total}`);
    242       }
    243 
    244       await completeMultipart(key, uploadId, parts);
    245     } catch (e) {
    246       await abortMultipart(key, uploadId).catch(() => {});
    247       throw e;
    248     }
    249 
    250     return { key, size: total, sha256: hash.digest('hex') };
    251   }
    252 
    253   return {
    254     driver: 's3',
    255 
    256     async put(key, body, opts = {}) {
    257       assertKey(key);
    258 
    259       if (Buffer.isBuffer(body) || typeof body === 'string') {
    260         return putBuffer(key, Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'), opts.contentType);
    261       }
    262 
    263       const source = body instanceof Readable ? body : Readable.from(body);
    264 
    265       // Small and of known length: collect it and send one request rather
    266       // than paying for three.
    267       if (opts.size !== undefined && opts.size <= SINGLE_PUT_MAX) {
    268         const chunks = [];
    269         let length = 0;
    270         for await (const chunk of source) {
    271           const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
    272           chunks.push(buffer);
    273           length += buffer.length;
    274           if (length > SINGLE_PUT_MAX) break;
    275         }
    276 
    277         if (length <= SINGLE_PUT_MAX) {
    278           const buffer = Buffer.concat(chunks, length);
    279           if (opts.size !== buffer.length) {
    280             throw new Error(`s3 put size mismatch for ${key}: declared ${opts.size}, read ${buffer.length}`);
    281           }
    282           return putBuffer(key, buffer, opts.contentType);
    283         }
    284 
    285         // It was larger than it claimed; carry on as multipart without
    286         // losing what has already been read.
    287         const head = Buffer.concat(chunks, length);
    288         return putMultipart(key, concatSources(head, source), {
    289           contentType: opts.contentType,
    290           size: opts.size,
    291         });
    292       }
    293 
    294       return putMultipart(key, source, { contentType: opts.contentType, size: opts.size });
    295     },
    296 
    297     async get(key, opts = {}) {
    298       const headers = {};
    299       if (opts.range) {
    300         const { start = 0, end } = opts.range;
    301         headers.range = `bytes=${start}-${end === undefined ? '' : end}`;
    302       }
    303 
    304       const res = await send('GET', key, { headers });
    305       if (res.status === 404) throw new StorageNotFound(key);
    306       if (!res.ok) throw await errorFrom(res, 'get', key);
    307 
    308       const len = res.headers.get('content-length');
    309       return {
    310         key,
    311         size: len === null ? null : Number(len),
    312         stream: Readable.fromWeb(res.body),
    313       };
    314     },
    315 
    316     async head(key) {
    317       const res = await send('HEAD', key);
    318       if (res.status === 404) return null;
    319       if (!res.ok) throw await errorFrom(res, 'head', key);
    320       const len = res.headers.get('content-length');
    321       const modified = res.headers.get('last-modified');
    322       return {
    323         key,
    324         size: len === null ? null : Number(len),
    325         modified: modified ? Date.parse(modified) : null,
    326       };
    327     },
    328 
    329     async delete(key) {
    330       const res = await send('DELETE', key);
    331       // A missing object is already in the desired state.
    332       if (!res.ok && res.status !== 404) throw await errorFrom(res, 'delete', key);
    333       await res.arrayBuffer();
    334     },
    335 
    336     // Lets a download redirect straight at the object store
    337     // instead of proxying the bytes.
    338     async presign(key, opts = {}) {
    339       return presignUrl({
    340         method: opts.method || 'GET',
    341         url: objectUrl(key),
    342         expires: opts.expires ?? 3600,
    343         ...creds,
    344       });
    345     },
    346   };
    347 }