conductor

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

storage.test.js (16120B)


      1 // test/storage.test.js - object storage backends
      2 //
      3 // The local backend is always exercised. The S3 backend jobs against a real
      4 // MinIO, started automatically when docker is available, because the
      5 // signing code is written by hand here and a stub would happily accept a
      6 // signature a real server rejects.
      7 //
      8 // Point the same tests at another implementation, such as Garage or AWS,
      9 // with CONDUCTOR_TEST_S3_ENDPOINT, CONDUCTOR_TEST_S3_KEY and
     10 // CONDUCTOR_TEST_S3_SECRET.
     11 
     12 import test, { before, after } from 'node:test';
     13 import assert from 'node:assert/strict';
     14 import fs from 'node:fs/promises';
     15 import os from 'node:os';
     16 import path from 'node:path';
     17 import { Readable } from 'node:stream';
     18 import crypto from 'node:crypto';
     19 import { createStorage, keys, StorageNotFound, assertKey, sanitizeRelativePath } from '../src/lib/storage/index.js';
     20 import { sha256Hex, signRequest } from '../src/lib/storage/sigv4.js';
     21 import { partSizeFor, inParts } from '../src/lib/storage/s3.js';
     22 import { startMinio, s3Available } from './helpers/minio.js';
     23 
     24 async function drain(stream) {
     25   let out = '';
     26   for await (const chunk of stream) out += chunk;
     27   return out;
     28 }
     29 
     30 async function localStorage() {
     31   const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'conductor-store-'));
     32   const cfg = { storage: { driver: 'local', path: dir, s3: {} } };
     33   return { st: createStorage(cfg), cleanup: () => fs.rm(dir, { recursive: true, force: true }) };
     34 }
     35 
     36 test('key validation rejects traversal and absolute paths', () => {
     37   for (const bad of ['../etc/passwd', '/abs', 'a//b', 'a/../b', 'a/./b', '', 'a\\b', 'a\0b']) {
     38     assert.throws(() => assertKey(bad), new RegExp('storage key'), `expected ${JSON.stringify(bad)} to be rejected`);
     39   }
     40   assert.equal(assertKey('artifacts/job/task/dist/app.tar.gz'), 'artifacts/job/task/dist/app.tar.gz');
     41 });
     42 
     43 test('sanitizeRelativePath strips traversal from worker supplied paths', () => {
     44   assert.equal(sanitizeRelativePath('../../etc/passwd'), 'etc/passwd');
     45   assert.equal(sanitizeRelativePath('./dist/./app.bin'), 'dist/app.bin');
     46   assert.equal(sanitizeRelativePath('/abs/path'), 'abs/path');
     47   assert.equal(sanitizeRelativePath('..'), null);
     48   assert.equal(sanitizeRelativePath(''), null);
     49 });
     50 
     51 test('key layout is stable', () => {
     52   assert.equal(keys.artifact('r1', 'r1:build', 'dist/a.bin'), 'artifacts/r1/r1:build/dist/a.bin');
     53   assert.equal(keys.log('r1', 'r1:build'), 'logs/r1/r1:build.log');
     54 });
     55 
     56 test('local backend round trips a buffer and reports its digest', async () => {
     57   const { st, cleanup } = await localStorage();
     58   try {
     59     const put = await st.put('a/b/c.txt', Buffer.from('hello conductor'));
     60     assert.equal(put.size, 15);
     61     assert.equal(put.sha256, sha256Hex(Buffer.from('hello conductor')));
     62 
     63     assert.equal(await drain((await st.get('a/b/c.txt')).stream), 'hello conductor');
     64     assert.equal((await st.head('a/b/c.txt')).size, 15);
     65   } finally {
     66     await cleanup();
     67   }
     68 });
     69 
     70 test('local backend serves ranges', async () => {
     71   const { st, cleanup } = await localStorage();
     72   try {
     73     await st.put('r.txt', Buffer.from('hello conductor'));
     74     assert.equal(await drain((await st.get('r.txt', { range: { start: 6, end: 14 } })).stream), 'conductor');
     75     assert.equal(await drain((await st.get('r.txt', { range: { start: 6 } })).stream), 'conductor');
     76   } finally {
     77     await cleanup();
     78   }
     79 });
     80 
     81 test('local backend accepts a stream', async () => {
     82   const { st, cleanup } = await localStorage();
     83   try {
     84     const put = await st.put('s.log', Readable.from(['line one\n', 'line two\n']));
     85     assert.equal(put.size, 18);
     86     assert.equal(await drain((await st.get('s.log')).stream), 'line one\nline two\n');
     87   } finally {
     88     await cleanup();
     89   }
     90 });
     91 
     92 test('local backend reports a missing object and deletes idempotently', async () => {
     93   const { st, cleanup } = await localStorage();
     94   try {
     95     await assert.rejects(st.get('nope'), (e) => e instanceof StorageNotFound);
     96     assert.equal(await st.head('nope'), null);
     97     await st.delete('nope');
     98   } finally {
     99     await cleanup();
    100   }
    101 });
    102 
    103 test('local backend has no presigned urls', async () => {
    104   const { st, cleanup } = await localStorage();
    105   try {
    106     assert.equal(await st.presign('a.txt'), null);
    107   } finally {
    108     await cleanup();
    109   }
    110 });
    111 
    112 test('a partial write leaves no object behind', async () => {
    113   const { st, cleanup } = await localStorage();
    114   try {
    115     const failing = new Readable({
    116       read() { this.destroy(new Error('source failed')); },
    117     });
    118     await assert.rejects(st.put('partial.bin', failing), /source failed/);
    119     assert.equal(await st.head('partial.bin'), null);
    120   } finally {
    121     await cleanup();
    122   }
    123 });
    124 
    125 // --- multipart mechanics, no server needed ---
    126 
    127 test('part size grows so that a huge object still fits in 10000 parts', () => {
    128   const MiB = 1024 * 1024;
    129 
    130   // Small and unknown sizes use the default.
    131   assert.equal(partSizeFor(undefined), 16 * MiB);
    132   assert.equal(partSizeFor(0), 16 * MiB);
    133   assert.equal(partSizeFor(100 * MiB), 16 * MiB);
    134 
    135   // 10000 parts of 16 MiB covers about 156 GiB; past that the part size
    136   // has to grow or the upload cannot be expressed.
    137   const huge = 1024 * 1024 * MiB; // 1 TiB
    138   const size = partSizeFor(huge);
    139   assert.ok(size > 16 * MiB, 'part size should scale up');
    140   assert.ok(Math.ceil(huge / size) <= 10000, 'must fit within the part limit');
    141   assert.equal(size % MiB, 0, 'part size should stay a whole number of MiB');
    142 });
    143 
    144 test('inParts regroups an awkwardly chunked stream into fixed parts', async () => {
    145   const source = Readable.from([
    146     Buffer.from('aaa'), Buffer.from('bb'), Buffer.from('cccccc'), Buffer.from('d'),
    147   ]);
    148 
    149   const parts = [];
    150   for await (const part of inParts(source, 4)) parts.push(part.toString());
    151 
    152   // Everything is preserved, in order, in fixed sized pieces with a
    153   // remainder at the end.
    154   assert.deepEqual(parts, ['aaab', 'bccc', 'cccd']);
    155   assert.equal(parts.join(''), 'aaabbccccccd');
    156 });
    157 
    158 test('inParts handles a chunk far larger than the part size', async () => {
    159   const source = Readable.from([Buffer.alloc(10, 0x41)]);
    160   const parts = [];
    161   for await (const part of inParts(source, 4)) parts.push(part.length);
    162   assert.deepEqual(parts, [4, 4, 2]);
    163 });
    164 
    165 test('inParts yields nothing for an empty stream', async () => {
    166   const parts = [];
    167   for await (const part of inParts(Readable.from([]), 4)) parts.push(part);
    168   assert.deepEqual(parts, []);
    169 });
    170 
    171 // S3 coverage against a real server.
    172 const s3Options = { skip: s3Available() ? false : 'docker is not available' };
    173 
    174 let minio = null;
    175 
    176 before(async () => {
    177   if (s3Available()) minio = await startMinio();
    178 }, { timeout: 240000 });
    179 
    180 after(async () => {
    181   if (minio) await minio.stop();
    182 });
    183 
    184 // A storage handle onto a fresh bucket.
    185 async function s3Storage() {
    186   const bucket = await minio.bucket();
    187   return createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } });
    188 }
    189 
    190 test('s3 backend round trips awkward keys against a real server', s3Options, async () => {
    191   const st = await s3Storage();
    192 
    193   // Spaces, plus signs and parentheses are exactly what a naive signer
    194   // gets wrong, and what a stubbed server would never catch.
    195   const key = 'artifacts/r1/r1:build/dist/app bin+v1(final)!.tar.gz';
    196   const put = await st.put(key, Buffer.from('hello from s3'), { contentType: 'application/gzip' });
    197   assert.equal(put.size, 13);
    198   assert.equal(put.sha256, sha256Hex(Buffer.from('hello from s3')));
    199 
    200   assert.equal(await drain((await st.get(key)).stream), 'hello from s3');
    201   assert.equal((await st.head(key)).size, 13);
    202 });
    203 
    204 test('s3 serves byte ranges', s3Options, async () => {
    205   const st = await s3Storage();
    206   await st.put('r.txt', Buffer.from('hello conductor'));
    207 
    208   assert.equal(await drain((await st.get('r.txt', { range: { start: 6, end: 14 } })).stream), 'conductor');
    209   assert.equal(await drain((await st.get('r.txt', { range: { start: 6 } })).stream), 'conductor');
    210 });
    211 
    212 test('s3 accepts a stream when the size is known', s3Options, async () => {
    213   const st = await s3Storage();
    214   const body = Readable.from([Buffer.from('streamed '), Buffer.from('upload')]);
    215   const put = await st.put('logs/r1/j.log', body, { size: 15 });
    216 
    217   assert.equal(put.size, 15);
    218   assert.equal(await drain((await st.get('logs/r1/j.log')).stream), 'streamed upload');
    219 });
    220 
    221 test('a presigned url is usable without credentials', s3Options, async () => {
    222   const st = await s3Storage();
    223   const key = 'artifacts/job/task/dist/app bin+v1.tar.gz';
    224   await st.put(key, Buffer.from('presigned content'));
    225 
    226   const url = await st.presign(key, { expires: 300 });
    227   assert.match(url, /X-Amz-Signature=[0-9a-f]{64}/);
    228 
    229   // Plain fetch, no Authorization header.
    230   const res = await fetch(url);
    231   assert.equal(res.status, 200);
    232   assert.equal(await res.text(), 'presigned content');
    233 });
    234 
    235 test('a tampered presigned url is rejected by the server', s3Options, async () => {
    236   const st = await s3Storage();
    237   await st.put('secret.txt', Buffer.from('do not serve this'));
    238 
    239   const url = new URL(await st.presign('secret.txt', { expires: 300 }));
    240   const signature = url.searchParams.get('X-Amz-Signature');
    241   // Flip a character of the signature.
    242   url.searchParams.set('X-Amz-Signature', `${signature.slice(0, -1)}${signature.endsWith('a') ? 'b' : 'a'}`);
    243 
    244   const res = await fetch(url);
    245   assert.ok(!res.ok, 'a forged signature must not be honoured');
    246 });
    247 
    248 test('s3 reports a missing object and deletes idempotently', s3Options, async () => {
    249   const st = await s3Storage();
    250   await assert.rejects(st.get('missing/object'), (e) => e instanceof StorageNotFound);
    251   assert.equal(await st.head('missing/object'), null);
    252 
    253   await st.put('gone.txt', Buffer.from('x'));
    254   await st.delete('gone.txt');
    255   assert.equal(await st.head('gone.txt'), null);
    256   await st.delete('gone.txt');
    257 });
    258 
    259 test('a stream of unknown length is accepted', s3Options, async () => {
    260   const st = await s3Storage();
    261 
    262   // Multipart does not need the total up front, so a body whose length is
    263   // not known in advance no longer has to be buffered by the caller.
    264   const put = await st.put('unknown.bin', Readable.from([
    265     Buffer.from('one '), Buffer.from('two '), Buffer.from('three'),
    266   ]));
    267 
    268   assert.equal(put.size, 13);
    269   assert.equal(await drain((await st.get('unknown.bin')).stream), 'one two three');
    270 });
    271 
    272 test('a body past the single request threshold is uploaded in parts', s3Options, async () => {
    273   const st = await s3Storage();
    274 
    275   // Above SINGLE_PUT_MAX, so this exercises create, several uploads and
    276   // complete rather than one PUT.
    277   const size = 40 * 1024 * 1024;
    278   const pattern = Buffer.alloc(1024 * 1024);
    279   for (let i = 0; i < pattern.length; i += 4) pattern.writeUInt32BE(i, i);
    280 
    281   const expected = crypto.createHash('sha256');
    282   let produced = 0;
    283   const source = new Readable({
    284     read() {
    285       if (produced >= size) return this.push(null);
    286       // Vary each block so a part written out of order would be visible.
    287       const block = Buffer.from(pattern);
    288       block.writeUInt32BE(produced / 1024 / 1024, 0);
    289       produced += block.length;
    290       expected.update(block);
    291       this.push(block);
    292     },
    293   });
    294 
    295   const put = await st.put('big/multipart.bin', source, { size });
    296   assert.equal(put.size, size);
    297   assert.equal(put.sha256, expected.digest('hex'), 'the assembled object must match what was sent');
    298 
    299   // Read it back independently rather than trusting the reported digest.
    300   const back = crypto.createHash('sha256');
    301   let read = 0;
    302   for await (const chunk of (await st.get('big/multipart.bin')).stream) {
    303     back.update(chunk);
    304     read += chunk.length;
    305   }
    306   assert.equal(read, size);
    307   assert.equal(back.digest('hex'), put.sha256);
    308 });
    309 
    310 test('a large body really is sent as several parts, and a small one is not', s3Options, async () => {
    311   const bucket = await minio.bucket();
    312   const st = createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } });
    313 
    314   // A multipart ETag is the digest of the part digests with the part
    315   // count appended, so it says plainly how the object was uploaded. That
    316   // makes it a reliable check that streaming is still in use, rather than
    317   // inferring it from timing or memory.
    318   async function etagOf(key) {
    319     const url = new URL(`${minio.endpoint}/${bucket}/${key}`);
    320     const headers = signRequest({
    321       method: 'HEAD',
    322       url,
    323       payloadHash: sha256Hex(''),
    324       region: minio.credentials.region,
    325       accessKeyId: minio.credentials.accessKeyId,
    326       secretAccessKey: minio.credentials.secretAccessKey,
    327     });
    328     const res = await fetch(url, { method: 'HEAD', headers });
    329     assert.ok(res.ok, `HEAD ${key} failed: ${res.status}`);
    330     return res.headers.get('etag').replace(/"/g, '');
    331   }
    332 
    333   // 40 MiB at a 16 MiB part size is three parts.
    334   const size = 40 * 1024 * 1024;
    335   const source = Readable.from((function* () {
    336     for (let sent = 0; sent < size; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x43);
    337   })());
    338   await st.put('big/parted.bin', source, { size });
    339 
    340   const multipart = await etagOf('big/parted.bin');
    341   assert.match(multipart, /-3$/, `expected a three part upload, got ETag ${multipart}`);
    342 
    343   // Something small must not pay for three round trips.
    344   await st.put('small/plain.bin', Buffer.alloc(1024, 0x44));
    345   const single = await etagOf('small/plain.bin');
    346   assert.ok(!/-\d+$/.test(single), `expected a single request upload, got ETag ${single}`);
    347 });
    348 
    349 test('a multipart object still serves ranges', s3Options, async () => {
    350   const st = await s3Storage();
    351   const size = 40 * 1024 * 1024;
    352 
    353   const source = Readable.from((function* () {
    354     for (let sent = 0; sent < size; sent += 1024 * 1024) {
    355       const block = Buffer.alloc(1024 * 1024);
    356       block.writeUInt32BE(sent, 0);
    357       yield block;
    358     }
    359   })());
    360 
    361   await st.put('big/ranged.bin', source, { size });
    362 
    363   // A boundary between parts is the interesting place to read across.
    364   const start = 16 * 1024 * 1024 - 2;
    365   const chunk = await st.get('big/ranged.bin', { range: { start, end: start + 5 } });
    366   const bytes = [];
    367   for await (const part of chunk.stream) bytes.push(part);
    368   assert.equal(Buffer.concat(bytes).length, 6);
    369 });
    370 
    371 test('a body that does not match its declared size is rejected', s3Options, async () => {
    372   const st = await s3Storage();
    373   const size = 40 * 1024 * 1024;
    374 
    375   // Claims 40 MiB, sends 20 MiB.
    376   const short = Readable.from((function* () {
    377     for (let sent = 0; sent < size / 2; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x41);
    378   })());
    379 
    380   await assert.rejects(st.put('big/short.bin', short, { size }), /size mismatch/);
    381   assert.equal(await st.head('big/short.bin'), null, 'no partial object should be visible');
    382 });
    383 
    384 test('a failed multipart upload does not leave parts behind', s3Options, async () => {
    385   const bucket = await minio.bucket();
    386   const st = createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } });
    387   const size = 40 * 1024 * 1024;
    388 
    389   const short = Readable.from((function* () {
    390     for (let sent = 0; sent < size / 2; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x42);
    391   })());
    392   await assert.rejects(st.put('big/abandoned.bin', short, { size }), /size mismatch/);
    393 
    394   // Abandoned parts are billable and invisible, so the upload must have
    395   // been aborted rather than simply dropped.
    396   const url = new URL(`${minio.endpoint}/${bucket}`);
    397   url.searchParams.set('uploads', '');
    398   const headers = signRequest({
    399     method: 'GET',
    400     url,
    401     payloadHash: sha256Hex(''),
    402     region: minio.credentials.region,
    403     accessKeyId: minio.credentials.accessKeyId,
    404     secretAccessKey: minio.credentials.secretAccessKey,
    405   });
    406 
    407   const listing = await (await fetch(url, { headers })).text();
    408   assert.ok(!listing.includes('<Upload>'), `an upload was left pending:\n${listing}`);
    409 });
    410 
    411 test('bad credentials fail loudly rather than silently storing nothing', s3Options, async () => {
    412   const bucket = await minio.bucket();
    413   const st = createStorage({
    414     storage: {
    415       driver: 's3',
    416       s3: { ...minio.storageConfig(bucket), secret_access_key: 'wrong-secret' },
    417     },
    418   });
    419 
    420   await assert.rejects(st.put('k.txt', Buffer.from('x')), /s3 put failed/);
    421 });