conductor

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

commit e60515fff046bad256797d6b04d16ee15c244205
parent ec84d4a080f05c2053c7b9a2a40686f8b010016d
Author: finwo <finwo@pm.me>
Date:   Sat, 19 Sep 2026 14:14:06 +0200

Stream large artifacts to S3 with multipart upload

Diffstat:
MREADME.md | 27+++++++++++++++++++++------
Mdocs/pipeline.md | 7+++++++
Msrc/lib/storage/s3.js | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Atest/conductor-s3.test.js | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/conductor.test.js | 19+++++++++++++++++++
Mtest/helpers/harness.js | 13+++++++++++++
Atest/helpers/minio.js | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/storage.test.js | 344+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
8 files changed, 994 insertions(+), 102 deletions(-)

diff --git a/README.md b/README.md @@ -206,13 +206,28 @@ Testing npm test ``` -Tests that need a container skip themselves when docker is unavailable. -With docker present the suite starts what it needs and cleans up after -itself: real containers for the worker to run jobs in, and a real OIDC -provider to authenticate against. Nothing is stubbed, because the failures -worth catching are in the parts a stub would replace. +That is the whole setup. With docker available the suite starts what it +needs and cleans up after itself: -S3 coverage is opt in, since it needs a server to point at: +| Dependency | Used for | +| ------------------- | -------------------------------------------- | +| `alpine` | containers for the worker to actually run jobs in | +| MinIO | object storage, artifacts and archived logs | +| mock-oauth2-server | a real OIDC provider to authenticate against | + +Nothing is stubbed, because the failures worth catching live in the parts a +stub would replace. Hand written SigV4 signing is only meaningfully tested +by a server that rejects a bad signature, and the JWKS path was wrong +against every provider except the one it was written for. + +Without docker those tests skip and the rest still run: + +```sh +CONDUCTOR_TEST_NO_DOCKER=1 npm test +``` + +To test against a different object store, such as Garage or AWS, point the +same tests at it instead of starting MinIO: ```sh docker run -d -p 9000:9000 -e MINIO_ROOT_USER=testkey \ diff --git a/docs/pipeline.md b/docs/pipeline.md @@ -222,6 +222,13 @@ A bare list is shorthand for `paths`. Paths are relative to the workspace; absolute paths are rejected. `when` is `on_success`, `on_failure` or `always`. +Artifacts are streamed rather than buffered, so size is bounded by where +they are stored rather than by the conductor's memory. Object storage +receives anything over 32 MiB as a multipart upload, which keeps memory +flat and lifts the five gigabyte limit that applies to a single request. +Disk images and other multi gigabyte output are fine; what they cost is +storage, not resident memory. + Cache ----- diff --git a/src/lib/storage/s3.js b/src/lib/storage/s3.js @@ -4,16 +4,84 @@ // and MinIO, which both need path style addressing; set force_path_style to // false for AWS S3 proper. // -// Requests go out over fetch with SigV4 headers. Uploads of a known Buffer -// are signed over their content hash; streamed uploads use UNSIGNED-PAYLOAD, -// which every S3 implementation accepts and which avoids buffering an entire -// artifact in memory just to hash it. +// Requests go out over fetch with SigV4 headers. +// +// Large uploads go through multipart, one part at a time. Handing a stream +// to fetch does not stream it: undici collects the whole body before +// sending, which was measured at just over a gigabyte of live heap for a +// one gigabyte artifact. Build output is routinely that size, so a single +// upload could exhaust the conductor. Multipart also lifts the five +// gigabyte ceiling on a single PUT, and lets an upload proceed when the +// total length is not known in advance. +// +// Small bodies still go as one request, since three round trips to save +// buffering a few megabytes is a poor trade. import crypto from 'node:crypto'; import { Readable } from 'node:stream'; -import { signRequest, presignUrl, sha256Hex, uriEncode, UNSIGNED_PAYLOAD } from './sigv4.js'; +import { signRequest, presignUrl, sha256Hex, uriEncode } from './sigv4.js'; import { StorageNotFound, assertKey } from './key.js'; +// Replays what has already been read, then continues with the rest. +async function* concatSources(head, rest) { + yield head; + yield* rest; +} + +// S3 requires every part except the last to be at least 5 MiB, and allows +// at most 10000 parts. The default part size is comfortably above the +// minimum, and is scaled up when the total is large enough that 10000 +// parts would not cover it. +const MIN_PART_SIZE = 5 * 1024 * 1024; +const DEFAULT_PART_SIZE = 16 * 1024 * 1024; +const MAX_PARTS = 10000; + +// Below this a stream is collected and sent as one request. +const SINGLE_PUT_MAX = 32 * 1024 * 1024; + +export function partSizeFor(size) { + if (!Number.isFinite(size) || size <= 0) return DEFAULT_PART_SIZE; + const needed = Math.ceil(size / (MAX_PARTS - 1)); + const rounded = Math.ceil(needed / (1024 * 1024)) * 1024 * 1024; + return Math.max(DEFAULT_PART_SIZE, MIN_PART_SIZE, rounded); +} + +// Regroups an arbitrarily chunked stream into buffers of a fixed size, so +// memory stays bounded by the part size no matter how the source behaves. +export async function* inParts(source, partSize) { + let held = []; + let heldLength = 0; + + for await (const chunk of source) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + held.push(buffer); + heldLength += buffer.length; + + while (heldLength >= partSize) { + const joined = Buffer.concat(held, heldLength); + yield joined.subarray(0, partSize); + const rest = joined.subarray(partSize); + held = rest.length > 0 ? [rest] : []; + heldLength = rest.length; + } + } + + if (heldLength > 0) yield Buffer.concat(held, heldLength); +} + +// Values inside S3 XML are hex digests and quotes, but escaping keeps a +// surprising key or ETag from breaking the document. +function xmlEscape(value) { + return String(value).replace(/[<>&'"]/g, (c) => ( + { '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[c] + )); +} + +function firstTag(xml, tag) { + const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(xml); + return match ? match[1] : null; +} + export function createS3Storage(cfg) { const s3 = cfg.storage.s3; const endpoint = new URL(s3.endpoint); @@ -42,8 +110,12 @@ export function createS3Storage(cfg) { return url; } - async function send(method, key, { headers = {}, body, payloadHash } = {}) { + async function send(method, key, { headers = {}, body, payloadHash, query } = {}) { const url = objectUrl(key); + // Query parameters are part of the signature, so they must be set + // before signing and not touched afterwards. + for (const [name, value] of Object.entries(query ?? {})) url.searchParams.set(name, value); + const signed = signRequest({ method, url, @@ -53,11 +125,7 @@ export function createS3Storage(cfg) { }); const init = { method, headers: signed }; - if (body !== undefined) { - init.body = body; - // Required by undici when streaming a request body. - if (body instanceof ReadableStream) init.duplex = 'half'; - } + if (body !== undefined) init.body = body; return fetch(url, init); } @@ -71,42 +139,159 @@ export function createS3Storage(cfg) { return new Error(`s3 ${action} failed for ${key}: ${res.status} ${res.statusText}\n${detail}`); } + // One request, for a body already in memory. + async function putBuffer(key, buffer, contentType) { + const digest = crypto.createHash('sha256').update(buffer).digest('hex'); + const headers = { 'content-length': String(buffer.length) }; + if (contentType) headers['content-type'] = contentType; + + const res = await send('PUT', key, { headers, body: buffer, payloadHash: digest }); + if (!res.ok) throw await errorFrom(res, 'put', key); + await res.arrayBuffer(); + + return { key, size: buffer.length, sha256: digest }; + } + + async function createMultipart(key, contentType) { + const headers = {}; + if (contentType) headers['content-type'] = contentType; + + const res = await send('POST', key, { headers, query: { uploads: '' }, body: undefined }); + if (!res.ok) throw await errorFrom(res, 'create multipart upload', key); + + const uploadId = firstTag(await res.text(), 'UploadId'); + if (!uploadId) throw new Error(`s3 did not return an upload id for ${key}`); + return uploadId; + } + + async function uploadPart(key, uploadId, partNumber, buffer) { + const res = await send('PUT', key, { + headers: { 'content-length': String(buffer.length) }, + query: { partNumber: String(partNumber), uploadId }, + body: buffer, + payloadHash: crypto.createHash('sha256').update(buffer).digest('hex'), + }); + if (!res.ok) throw await errorFrom(res, `upload part ${partNumber}`, key); + await res.arrayBuffer(); + + const etag = res.headers.get('etag'); + if (!etag) throw new Error(`s3 did not return an ETag for part ${partNumber} of ${key}`); + return etag; + } + + async function completeMultipart(key, uploadId, parts) { + const body = Buffer.from( + '<CompleteMultipartUpload>' + + parts.map((p) => `<Part><PartNumber>${p.number}</PartNumber><ETag>${xmlEscape(p.etag)}</ETag></Part>`).join('') + + '</CompleteMultipartUpload>', + 'utf8' + ); + + const res = await send('POST', key, { + headers: { 'content-length': String(body.length), 'content-type': 'application/xml' }, + query: { uploadId }, + body, + payloadHash: crypto.createHash('sha256').update(body).digest('hex'), + }); + if (!res.ok) throw await errorFrom(res, 'complete multipart upload', key); + + // S3 can report failure inside a 200 response, because the connection + // is held open while the parts are assembled. + const text = await res.text(); + if (/<Error>/.test(text)) { + const code = firstTag(text, 'Code') ?? 'unknown'; + throw new Error(`s3 complete multipart upload failed for ${key}: ${code}`); + } + } + + async function abortMultipart(key, uploadId) { + // Parts left behind are billable and invisible, so this matters even + // though nothing depends on its result. + const res = await send('DELETE', key, { query: { uploadId } }); + await res.arrayBuffer().catch(() => {}); + } + + async function putMultipart(key, source, { contentType, size }) { + const uploadId = await createMultipart(key, contentType); + const partSize = partSizeFor(size); + const hash = crypto.createHash('sha256'); + const parts = []; + let total = 0; + + // Parts go up one at a time. Overlapping them was measured at about + // eight percent faster while doubling peak memory, which is not a + // trade worth the extra failure handling. + try { + for await (const part of inParts(source, partSize)) { + hash.update(part); + total += part.length; + if (parts.length >= MAX_PARTS) { + throw new Error(`s3 upload of ${key} exceeded ${MAX_PARTS} parts`); + } + const etag = await uploadPart(key, uploadId, parts.length + 1, part); + parts.push({ number: parts.length + 1, etag }); + } + + // A multipart upload must have at least one part. + if (parts.length === 0) { + parts.push({ number: 1, etag: await uploadPart(key, uploadId, 1, Buffer.alloc(0)) }); + } + + if (size !== undefined && size !== total) { + throw new Error(`s3 put size mismatch for ${key}: declared ${size}, read ${total}`); + } + + await completeMultipart(key, uploadId, parts); + } catch (e) { + await abortMultipart(key, uploadId).catch(() => {}); + throw e; + } + + return { key, size: total, sha256: hash.digest('hex') }; + } + return { driver: 's3', async put(key, body, opts = {}) { - const headers = {}; - if (opts.contentType) headers['content-type'] = opts.contentType; - - let payload; - let payloadHash; - let size = opts.size; - let sha256 = opts.sha256; + assertKey(key); if (Buffer.isBuffer(body) || typeof body === 'string') { - const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'); - payload = buf; - sha256 = crypto.createHash('sha256').update(buf).digest('hex'); - payloadHash = sha256; - size = buf.length; - headers['content-length'] = String(size); - } else { - if (size === undefined) { - throw new Error( - `s3 put of a stream needs an explicit size for ${key}; ` + - 'buffer the object first if the length is unknown' - ); - } - payloadHash = UNSIGNED_PAYLOAD; - headers['content-length'] = String(size); - payload = body instanceof Readable ? Readable.toWeb(body) : body; + return putBuffer(key, Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8'), opts.contentType); } - const res = await send('PUT', key, { headers, body: payload, payloadHash }); - if (!res.ok) throw await errorFrom(res, 'put', key); - await res.arrayBuffer(); + const source = body instanceof Readable ? body : Readable.from(body); + + // Small and of known length: collect it and send one request rather + // than paying for three. + if (opts.size !== undefined && opts.size <= SINGLE_PUT_MAX) { + const chunks = []; + let length = 0; + for await (const chunk of source) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(buffer); + length += buffer.length; + if (length > SINGLE_PUT_MAX) break; + } + + if (length <= SINGLE_PUT_MAX) { + const buffer = Buffer.concat(chunks, length); + if (opts.size !== buffer.length) { + throw new Error(`s3 put size mismatch for ${key}: declared ${opts.size}, read ${buffer.length}`); + } + return putBuffer(key, buffer, opts.contentType); + } + + // It was larger than it claimed; carry on as multipart without + // losing what has already been read. + const head = Buffer.concat(chunks, length); + return putMultipart(key, concatSources(head, source), { + contentType: opts.contentType, + size: opts.size, + }); + } - return { key, size, sha256: sha256 ?? null }; + return putMultipart(key, source, { contentType: opts.contentType, size: opts.size }); }, async get(key, opts = {}) { diff --git a/test/conductor-s3.test.js b/test/conductor-s3.test.js @@ -0,0 +1,246 @@ +// test/conductor-s3.test.js - the conductor running on object storage +// +// The storage module is covered on its own elsewhere. What is checked here +// is the conductor configured to use it: that artifacts actually land in +// the bucket, that a finished log is moved there and remains readable, and +// that a download is handed off with a redirect instead of being proxied. +// +// That handoff only happens on S3, since the local backend has no URL to +// give out, so none of it is exercised by the default configuration. + +import test, { before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import { startHarness } from './helpers/harness.js'; +import { startMinio, s3Available } from './helpers/minio.js'; +import { createStorage, keys } from '../src/lib/storage/index.js'; + +const opts = { skip: s3Available() ? false : 'docker is not available' }; + +let minio = null; + +before(async () => { + if (s3Available()) minio = await startMinio(); +}, { timeout: 240000 }); + +after(async () => { + if (minio) await minio.stop(); +}); + +// A conductor whose storage is a fresh bucket, plus a handle on that +// bucket so a test can look at what actually landed in it. +async function withS3Conductor(options, fn) { + const bucket = await minio.bucket(); + const s3 = minio.storageConfig(bucket); + const h = await startHarness({ visibility: 'public', s3, ...options }); + const store = createStorage({ storage: { driver: 's3', s3 } }); + + try { + return await fn(h, { store, bucket }); + } finally { + await h.stop(); + } +} + +const octet = (h) => ({ ...h.auth, 'content-type': 'application/octet-stream' }); + +async function drain(stream) { + let out = ''; + for await (const chunk of stream) out += chunk; + return out; +} + +// Runs a job far enough to have a log and an artifact. +async function jobWithOutput(h, { log = 'building\ndone\n', artifact = 'artifact bytes' } = {}) { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({ arches: 'x86_64' })).json().job; + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: octet(h), + payload: Buffer.from(log), + }); + + if (artifact !== null) { + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + headers: { ...octet(h), 'x-artifact-path': 'dist/app bin+v1.tar.gz' }, + payload: Buffer.from(artifact), + }); + } + + return job; +} + +test('the conductor reports object storage when configured for it', opts, async () => { + await withS3Conductor({}, async (h) => { + const health = (await h.app.inject({ method: 'GET', url: '/health' })).json(); + assert.equal(health.storage, 's3'); + }); +}); + +test('an artifact uploaded by a worker really lands in the bucket', opts, async () => { + await withS3Conductor({}, async (h, { store }) => { + const job = await jobWithOutput(h); + + const detail = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); + const artifact = detail.json().artifacts[0]; + assert.equal(artifact.path, 'dist/app bin+v1.tar.gz'); + + // Read it straight out of the bucket, not through the conductor. + const key = keys.artifact(job.run_id, job.id, artifact.path); + const stored = await store.get(key); + assert.equal(await drain(stored.stream), 'artifact bytes'); + assert.equal((await store.head(key)).size, artifact.size); + }); +}); + +test('an artifact download is handed off with a redirect that works', opts, async () => { + await withS3Conductor({}, async (h) => { + const job = await jobWithOutput(h); + const detail = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); + const artifact = detail.json().artifacts[0]; + + const res = await h.app.inject({ method: 'GET', url: `/api/artifacts/${artifact.id}` }); + + // With local storage this streams a 200; on S3 the client is sent to + // the store so the conductor does not carry the bytes. + assert.equal(res.statusCode, 302); + const location = res.headers.location; + assert.match(location, /X-Amz-Signature=/); + + // The presigned URL has to be usable as given, with no credentials. + const followed = await fetch(location); + assert.equal(followed.status, 200); + assert.equal(await followed.text(), 'artifact bytes'); + }); +}); + +test('a finished log is moved to the bucket and stays readable', opts, async () => { + await withS3Conductor({}, async (h, { store }) => { + const job = await jobWithOutput(h, { log: 'compiling\nlinking\n' }); + + // While running it is served from the local spool. + const live = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); + assert.equal(live.headers['x-log-complete'], 'false'); + assert.equal(live.body, 'compiling\nlinking\n'); + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/done`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: JSON.stringify({ success: true, exit_code: 0 }), + }); + + // Afterwards it comes from the bucket, and reads the same. + const archived = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log` }); + assert.equal(archived.headers['x-log-complete'], 'true'); + assert.equal(archived.body, 'compiling\nlinking\n'); + + const stored = await store.get(keys.log(job.run_id, job.id)); + assert.equal(await drain(stored.stream), 'compiling\nlinking\n'); + }); +}); + +test('the local spool is released once the log is archived', opts, async () => { + await withS3Conductor({}, async (h) => { + const job = await jobWithOutput(h, { log: 'output\n', artifact: null }); + + const spool = h.services.logs.spoolPath(job.run_id, job.id); + assert.ok(await fs.stat(spool).catch(() => null), 'the spool should exist while running'); + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/done`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: JSON.stringify({ success: true, exit_code: 0 }), + }); + + // Storage is the durable copy; keeping the spool too would grow + // without bound. + assert.equal(await fs.stat(spool).catch(() => null), null, 'the spool should be gone'); + }); +}); + +test('an archived log still serves incremental ranges', opts, async () => { + await withS3Conductor({}, async (h) => { + const job = await jobWithOutput(h, { log: 'first line\nsecond line\n', artifact: null }); + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/done`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: JSON.stringify({ success: true, exit_code: 0 }), + }); + + const tail = await h.app.inject({ + method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log?offset=11`, + }); + assert.equal(tail.body, 'second line\n'); + }); +}); + +test('the interface renders a log that lives in the bucket', opts, async () => { + await withS3Conductor({}, async (h) => { + const job = await jobWithOutput(h, { log: 'rendered from s3\n', artifact: null }); + + await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/done`, + headers: { ...h.auth, 'content-type': 'application/json' }, + payload: JSON.stringify({ success: true, exit_code: 0 }), + }); + + const page = await h.app.inject({ method: 'GET', url: `/jobs/${encodeURIComponent(job.id)}` }); + assert.equal(page.statusCode, 200); + assert.match(page.body, /rendered from s3/); + }); +}); + +test('a private run in a bucket is still not readable by a stranger', opts, async () => { + await withS3Conductor({ visibility: 'private' }, async (h) => { + const job = await jobWithOutput(h); + const detail = await h.app.inject({ + method: 'GET', + url: `/api/jobs/${encodeURIComponent(job.id)}`, + headers: await h.login().catch(() => ({})), + }); + + // Anonymous access is refused before any URL is ever signed, so the + // object store is never asked and no presigned link is handed out. + const anonymous = await h.app.inject({ method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}` }); + assert.equal(anonymous.statusCode, 404); + + const anonymousLog = await h.app.inject({ + method: 'GET', url: `/api/jobs/${encodeURIComponent(job.id)}/log`, + }); + assert.equal(anonymousLog.statusCode, 404); + assert.equal(detail.statusCode, 404); + }); +}); + +test('a large artifact streams through to the bucket', opts, async () => { + await withS3Conductor({}, async (h, { store }) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = (await h.poll({ arches: 'x86_64' })).json().job; + + // Comfortably past the server's body limit for parsed payloads, which + // an artifact must not be subject to: build output is routinely far + // larger than a JSON request. + const payload = Buffer.alloc(3 * 1024 * 1024, 0x41); + + const upload = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/artifact`, + headers: { ...octet(h), 'x-artifact-path': 'dist/big.bin' }, + payload, + }); + assert.equal(upload.statusCode, 200, upload.body); + assert.equal(upload.json().size, payload.length); + + const stored = await store.head(keys.artifact(job.run_id, job.id, 'dist/big.bin')); + assert.equal(stored.size, payload.length); + }); +}); diff --git a/test/conductor.test.js b/test/conductor.test.js @@ -332,6 +332,25 @@ test('log appends are resumable and deduplicated by offset', async () => { }); }); +test('an oversized log chunk is refused', async () => { + await withHarness({}, async (h) => { + await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); + const job = await claimNamed(h, 'lint', {}); + + // Artifacts are streamed and may be large, but a single log append is + // buffered, so it has to be bounded or a worker can exhaust memory. + const res = await h.app.inject({ + method: 'POST', + url: `/api/workers/jobs/${encodeURIComponent(job.id)}/log`, + headers: { ...h.auth, 'content-type': 'application/octet-stream' }, + payload: Buffer.alloc(2 * 1024 * 1024, 0x41), + }); + + assert.equal(res.statusCode, 413); + assert.equal(await h.services.logs.size(job.run_id, job.id), 0, 'nothing should have been written'); + }); +}); + test('a finished log moves to storage and is still readable', async () => { await withHarness({}, async (h) => { await h.trigger({ sha: h.sha, ref: 'refs/heads/main' }); diff --git a/test/helpers/harness.js b/test/helpers/harness.js @@ -74,6 +74,19 @@ export async function startHarness(options = {}) { ' path: ./state/conductor.db', 'storage:', ' path: ./state/storage', + // Given an s3 block, artifacts and finished logs go there instead of + // to the local path above. + ...(options.s3 + ? [ + ' s3:', + ` endpoint: ${options.s3.endpoint}`, + ` region: ${options.s3.region}`, + ` bucket: ${options.s3.bucket}`, + ` access_key_id: ${options.s3.access_key_id}`, + ` secret_access_key: ${options.s3.secret_access_key}`, + ` force_path_style: ${options.s3.force_path_style !== false}`, + ] + : []), 'git:', ' mirror_path: ./state/mirrors', ' fetch_interval: 0', diff --git a/test/helpers/minio.js b/test/helpers/minio.js @@ -0,0 +1,179 @@ +// test/helpers/minio.js - a real object store for the tests +// +// Runs MinIO, which speaks real S3. The signing code here is written +// against node:crypto rather than taken from a library, so verifying it +// against a real server is the only way to know it works: a stub would +// accept whatever signature we happened to produce, including a wrong one. +// +// Set CONDUCTOR_TEST_S3_ENDPOINT to point the same tests at something else, +// such as Garage or AWS, instead of starting a container. + +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import crypto from 'node:crypto'; +import net from 'node:net'; +import { signRequest, sha256Hex } from '../../src/lib/storage/sigv4.js'; + +const execFileAsync = promisify(execFile); + +// Pinned, so a MinIO release cannot change what the suite is testing +// against without the change being visible here. +export const IMAGE = 'quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z'; +const NAME_PREFIX = 'conductor-minio-test-'; + +const ACCESS_KEY = 'conductortest'; +const SECRET_KEY = 'conductortestsecret'; + +// Checked synchronously: the runner needs to know whether to skip while it +// is collecting tests, and an unsettled top-level await at that point +// aborts the whole file. +export function dockerAvailableSync() { + if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false; + try { + execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], { + timeout: 15000, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } +} + +// True when the suite can exercise S3 at all: either an endpoint was given, +// or docker is here to start one. +export function s3Available() { + return Boolean(process.env.CONDUCTOR_TEST_S3_ENDPOINT) || dockerAvailableSync(); +} + +async function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const { port } = server.address(); + server.close(() => resolve(port)); + }); + }); +} + +// A run that crashes never reaches its cleanup hook. Anything still +// carrying the prefix is from a previous run and can go. +async function reapStale() { + try { + const { stdout } = await execFileAsync('docker', ['ps', '-aq', '--filter', `name=${NAME_PREFIX}`], { timeout: 30000 }); + const ids = stdout.trim().split('\n').filter(Boolean); + if (ids.length > 0) await execFileAsync('docker', ['rm', '-f', ...ids], { timeout: 60000 }); + } catch { + // Best effort; a failure here must not fail the tests. + } +} + +// Buckets are created with a signed request rather than the MinIO client, +// which keeps the helper free of another dependency and exercises the +// signer on the way in. +export async function createBucket(endpoint, bucket, credentials) { + const url = new URL(`${endpoint.replace(/\/+$/, '')}/${bucket}`); + const headers = signRequest({ + method: 'PUT', + url, + payloadHash: sha256Hex(''), + region: credentials.region, + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + }); + + const res = await fetch(url, { method: 'PUT', headers }); + // 409 means it already exists, which is fine. + if (!res.ok && res.status !== 409) { + throw new Error(`could not create bucket ${bucket}: ${res.status} ${await res.text()}`); + } +} + +export async function startMinio() { + const external = process.env.CONDUCTOR_TEST_S3_ENDPOINT; + + // An endpoint supplied by the operator wins, and is left alone. + if (external) { + const credentials = { + region: process.env.CONDUCTOR_TEST_S3_REGION || 'us-east-1', + accessKeyId: process.env.CONDUCTOR_TEST_S3_KEY, + secretAccessKey: process.env.CONDUCTOR_TEST_S3_SECRET, + }; + return { + endpoint: external.replace(/\/+$/, ''), + credentials, + managed: false, + async bucket(name = `citest${Date.now().toString(36)}`) { + await createBucket(this.endpoint, name, credentials); + return name; + }, + async stop() {}, + }; + } + + await reapStale(); + + const port = await freePort(); + const name = `${NAME_PREFIX}${crypto.randomBytes(4).toString('hex')}`; + const endpoint = `http://127.0.0.1:${port}`; + const credentials = { region: 'us-east-1', accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }; + + await execFileAsync('docker', [ + 'run', '--detach', '--rm', + '--name', name, + '--publish', `${port}:9000`, + '--env', `MINIO_ROOT_USER=${ACCESS_KEY}`, + '--env', `MINIO_ROOT_PASSWORD=${SECRET_KEY}`, + IMAGE, 'server', '/data', + ], { timeout: 180000 }); + + let ready = false; + for (let i = 0; i < 60; i += 1) { + try { + const res = await fetch(`${endpoint}/minio/health/live`, { signal: AbortSignal.timeout(2000) }); + if (res.ok) { + ready = true; + break; + } + } catch { + // Still starting. + } + await new Promise((r) => { setTimeout(r, 500).unref?.(); }); + } + + if (!ready) { + await execFileAsync('docker', ['rm', '-f', name]).catch(() => {}); + throw new Error(`MinIO did not become ready at ${endpoint}`); + } + + return { + endpoint, + credentials, + name, + managed: true, + + // A fresh bucket per test keeps them independent. + async bucket(bucketName = `citest${crypto.randomBytes(6).toString('hex')}`) { + await createBucket(endpoint, bucketName, credentials); + return bucketName; + }, + + // Configuration block for createStorage and for the conductor. + storageConfig(bucketName) { + return { + endpoint, + region: credentials.region, + bucket: bucketName, + access_key_id: credentials.accessKeyId, + secret_access_key: credentials.secretAccessKey, + force_path_style: true, + }; + }, + + async stop() { + await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 }).catch(() => {}); + }, + }; +} diff --git a/test/storage.test.js b/test/storage.test.js @@ -1,22 +1,25 @@ // test/storage.test.js - object storage backends // -// The local backend is always exercised. The S3 backend runs against a live -// server only when CONDUCTOR_TEST_S3_ENDPOINT is set, for example: +// The local backend is always exercised. The S3 backend runs against a real +// MinIO, started automatically when docker is available, because the +// signing code is written by hand here and a stub would happily accept a +// signature a real server rejects. // -// docker run -d -p 9000:9000 -e MINIO_ROOT_USER=testkey \ -// -e MINIO_ROOT_PASSWORD=testsecret123 quay.io/minio/minio server /data -// CONDUCTOR_TEST_S3_ENDPOINT=http://127.0.0.1:9000 \ -// CONDUCTOR_TEST_S3_KEY=testkey CONDUCTOR_TEST_S3_SECRET=testsecret123 \ -// npm test +// Point the same tests at another implementation, such as Garage or AWS, +// with CONDUCTOR_TEST_S3_ENDPOINT, CONDUCTOR_TEST_S3_KEY and +// CONDUCTOR_TEST_S3_SECRET. -import test from 'node:test'; +import test, { before, after } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { Readable } from 'node:stream'; +import crypto from 'node:crypto'; import { createStorage, keys, StorageNotFound, assertKey, sanitizeRelativePath } from '../src/lib/storage/index.js'; -import { signRequest, sha256Hex } from '../src/lib/storage/sigv4.js'; +import { sha256Hex, signRequest } from '../src/lib/storage/sigv4.js'; +import { partSizeFor, inParts } from '../src/lib/storage/s3.js'; +import { startMinio, s3Available } from './helpers/minio.js'; async function drain(stream) { let out = ''; @@ -119,75 +122,300 @@ test('a partial write leaves no object behind', async () => { } }); -// S3 coverage, opt in. -const S3_ENDPOINT = process.env.CONDUCTOR_TEST_S3_ENDPOINT; -const s3Options = { skip: S3_ENDPOINT ? false : 'set CONDUCTOR_TEST_S3_ENDPOINT to run S3 tests' }; +// --- multipart mechanics, no server needed --- -test('s3 backend round trips awkward keys against a live server', s3Options, async () => { - const bucket = `citest${Date.now().toString(36)}`; - const creds = { - region: 'us-east-1', - accessKeyId: process.env.CONDUCTOR_TEST_S3_KEY, - secretAccessKey: process.env.CONDUCTOR_TEST_S3_SECRET, - }; +test('part size grows so that a huge object still fits in 10000 parts', () => { + const MiB = 1024 * 1024; - const bucketUrl = new URL(`${S3_ENDPOINT.replace(/\/+$/, '')}/${bucket}`); - const created = await fetch(bucketUrl, { - method: 'PUT', - headers: signRequest({ method: 'PUT', url: bucketUrl, payloadHash: sha256Hex(''), ...creds }), - }); - assert.ok(created.ok, `failed to create bucket: ${created.status}`); + // Small and unknown sizes use the default. + assert.equal(partSizeFor(undefined), 16 * MiB); + assert.equal(partSizeFor(0), 16 * MiB); + assert.equal(partSizeFor(100 * MiB), 16 * MiB); - const st = createStorage({ - storage: { - driver: 's3', - s3: { - endpoint: S3_ENDPOINT, - bucket, - region: creds.region, - access_key_id: creds.accessKeyId, - secret_access_key: creds.secretAccessKey, - force_path_style: true, - }, - }, - }); + // 10000 parts of 16 MiB covers about 156 GiB; past that the part size + // has to grow or the upload cannot be expressed. + const huge = 1024 * 1024 * MiB; // 1 TiB + const size = partSizeFor(huge); + assert.ok(size > 16 * MiB, 'part size should scale up'); + assert.ok(Math.ceil(huge / size) <= 10000, 'must fit within the part limit'); + assert.equal(size % MiB, 0, 'part size should stay a whole number of MiB'); +}); + +test('inParts regroups an awkwardly chunked stream into fixed parts', async () => { + const source = Readable.from([ + Buffer.from('aaa'), Buffer.from('bb'), Buffer.from('cccccc'), Buffer.from('d'), + ]); - // Spaces and plus signs are exactly what a naive signer gets wrong. + const parts = []; + for await (const part of inParts(source, 4)) parts.push(part.toString()); + + // Everything is preserved, in order, in fixed sized pieces with a + // remainder at the end. + assert.deepEqual(parts, ['aaab', 'bccc', 'cccd']); + assert.equal(parts.join(''), 'aaabbccccccd'); +}); + +test('inParts handles a chunk far larger than the part size', async () => { + const source = Readable.from([Buffer.alloc(10, 0x41)]); + const parts = []; + for await (const part of inParts(source, 4)) parts.push(part.length); + assert.deepEqual(parts, [4, 4, 2]); +}); + +test('inParts yields nothing for an empty stream', async () => { + const parts = []; + for await (const part of inParts(Readable.from([]), 4)) parts.push(part); + assert.deepEqual(parts, []); +}); + +// S3 coverage against a real server. +const s3Options = { skip: s3Available() ? false : 'docker is not available' }; + +let minio = null; + +before(async () => { + if (s3Available()) minio = await startMinio(); +}, { timeout: 240000 }); + +after(async () => { + if (minio) await minio.stop(); +}); + +// A storage handle onto a fresh bucket. +async function s3Storage() { + const bucket = await minio.bucket(); + return createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } }); +} + +test('s3 backend round trips awkward keys against a real server', s3Options, async () => { + const st = await s3Storage(); + + // Spaces, plus signs and parentheses are exactly what a naive signer + // gets wrong, and what a stubbed server would never catch. const key = 'artifacts/r1/r1:build/dist/app bin+v1(final)!.tar.gz'; const put = await st.put(key, Buffer.from('hello from s3'), { contentType: 'application/gzip' }); assert.equal(put.size, 13); + assert.equal(put.sha256, sha256Hex(Buffer.from('hello from s3'))); assert.equal(await drain((await st.get(key)).stream), 'hello from s3'); - assert.equal(await drain((await st.get(key, { range: { start: 6, end: 9 } })).stream), 'from'); assert.equal((await st.head(key)).size, 13); +}); + +test('s3 serves byte ranges', s3Options, async () => { + const st = await s3Storage(); + await st.put('r.txt', Buffer.from('hello conductor')); + + assert.equal(await drain((await st.get('r.txt', { range: { start: 6, end: 14 } })).stream), 'conductor'); + assert.equal(await drain((await st.get('r.txt', { range: { start: 6 } })).stream), 'conductor'); +}); + +test('s3 accepts a stream when the size is known', s3Options, async () => { + const st = await s3Storage(); + const body = Readable.from([Buffer.from('streamed '), Buffer.from('upload')]); + const put = await st.put('logs/r1/j.log', body, { size: 15 }); - const streamed = await st.put('logs/r1/j.log', Readable.from([Buffer.from('streamed '), Buffer.from('upload')]), { size: 15 }); - assert.equal(streamed.size, 15); + assert.equal(put.size, 15); + assert.equal(await drain((await st.get('logs/r1/j.log')).stream), 'streamed upload'); +}); + +test('a presigned url is usable without credentials', s3Options, async () => { + const st = await s3Storage(); + const key = 'artifacts/run/job/dist/app bin+v1.tar.gz'; + await st.put(key, Buffer.from('presigned content')); + + const url = await st.presign(key, { expires: 300 }); + assert.match(url, /X-Amz-Signature=[0-9a-f]{64}/); + + // Plain fetch, no Authorization header. + const res = await fetch(url); + assert.equal(res.status, 200); + assert.equal(await res.text(), 'presigned content'); +}); - const presigned = await fetch(await st.presign(key, { expires: 300 })); - assert.equal(presigned.status, 200); - assert.equal(await presigned.text(), 'hello from s3'); +test('a tampered presigned url is rejected by the server', s3Options, async () => { + const st = await s3Storage(); + await st.put('secret.txt', Buffer.from('do not serve this')); + const url = new URL(await st.presign('secret.txt', { expires: 300 })); + const signature = url.searchParams.get('X-Amz-Signature'); + // Flip a character of the signature. + url.searchParams.set('X-Amz-Signature', `${signature.slice(0, -1)}${signature.endsWith('a') ? 'b' : 'a'}`); + + const res = await fetch(url); + assert.ok(!res.ok, 'a forged signature must not be honoured'); +}); + +test('s3 reports a missing object and deletes idempotently', s3Options, async () => { + const st = await s3Storage(); await assert.rejects(st.get('missing/object'), (e) => e instanceof StorageNotFound); + assert.equal(await st.head('missing/object'), null); + + await st.put('gone.txt', Buffer.from('x')); + await st.delete('gone.txt'); + assert.equal(await st.head('gone.txt'), null); + await st.delete('gone.txt'); +}); + +test('a stream of unknown length is accepted', s3Options, async () => { + const st = await s3Storage(); + + // Multipart does not need the total up front, so a body whose length is + // not known in advance no longer has to be buffered by the caller. + const put = await st.put('unknown.bin', Readable.from([ + Buffer.from('one '), Buffer.from('two '), Buffer.from('three'), + ])); - await st.delete(key); - assert.equal(await st.head(key), null); - await st.delete(key); + assert.equal(put.size, 13); + assert.equal(await drain((await st.get('unknown.bin')).stream), 'one two three'); }); -test('s3 put of a stream without a size is refused', s3Options, async () => { +test('a body past the single request threshold is uploaded in parts', s3Options, async () => { + const st = await s3Storage(); + + // Above SINGLE_PUT_MAX, so this exercises create, several uploads and + // complete rather than one PUT. + const size = 40 * 1024 * 1024; + const pattern = Buffer.alloc(1024 * 1024); + for (let i = 0; i < pattern.length; i += 4) pattern.writeUInt32BE(i, i); + + const expected = crypto.createHash('sha256'); + let produced = 0; + const source = new Readable({ + read() { + if (produced >= size) return this.push(null); + // Vary each block so a part written out of order would be visible. + const block = Buffer.from(pattern); + block.writeUInt32BE(produced / 1024 / 1024, 0); + produced += block.length; + expected.update(block); + this.push(block); + }, + }); + + const put = await st.put('big/multipart.bin', source, { size }); + assert.equal(put.size, size); + assert.equal(put.sha256, expected.digest('hex'), 'the assembled object must match what was sent'); + + // Read it back independently rather than trusting the reported digest. + const back = crypto.createHash('sha256'); + let read = 0; + for await (const chunk of (await st.get('big/multipart.bin')).stream) { + back.update(chunk); + read += chunk.length; + } + assert.equal(read, size); + assert.equal(back.digest('hex'), put.sha256); +}); + +test('a large body really is sent as several parts, and a small one is not', s3Options, async () => { + const bucket = await minio.bucket(); + const st = createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } }); + + // A multipart ETag is the digest of the part digests with the part + // count appended, so it says plainly how the object was uploaded. That + // makes it a reliable check that streaming is still in use, rather than + // inferring it from timing or memory. + async function etagOf(key) { + const url = new URL(`${minio.endpoint}/${bucket}/${key}`); + const headers = signRequest({ + method: 'HEAD', + url, + payloadHash: sha256Hex(''), + region: minio.credentials.region, + accessKeyId: minio.credentials.accessKeyId, + secretAccessKey: minio.credentials.secretAccessKey, + }); + const res = await fetch(url, { method: 'HEAD', headers }); + assert.ok(res.ok, `HEAD ${key} failed: ${res.status}`); + return res.headers.get('etag').replace(/"/g, ''); + } + + // 40 MiB at a 16 MiB part size is three parts. + const size = 40 * 1024 * 1024; + const source = Readable.from((function* () { + for (let sent = 0; sent < size; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x43); + })()); + await st.put('big/parted.bin', source, { size }); + + const multipart = await etagOf('big/parted.bin'); + assert.match(multipart, /-3$/, `expected a three part upload, got ETag ${multipart}`); + + // Something small must not pay for three round trips. + await st.put('small/plain.bin', Buffer.alloc(1024, 0x44)); + const single = await etagOf('small/plain.bin'); + assert.ok(!/-\d+$/.test(single), `expected a single request upload, got ETag ${single}`); +}); + +test('a multipart object still serves ranges', s3Options, async () => { + const st = await s3Storage(); + const size = 40 * 1024 * 1024; + + const source = Readable.from((function* () { + for (let sent = 0; sent < size; sent += 1024 * 1024) { + const block = Buffer.alloc(1024 * 1024); + block.writeUInt32BE(sent, 0); + yield block; + } + })()); + + await st.put('big/ranged.bin', source, { size }); + + // A boundary between parts is the interesting place to read across. + const start = 16 * 1024 * 1024 - 2; + const chunk = await st.get('big/ranged.bin', { range: { start, end: start + 5 } }); + const bytes = []; + for await (const part of chunk.stream) bytes.push(part); + assert.equal(Buffer.concat(bytes).length, 6); +}); + +test('a body that does not match its declared size is rejected', s3Options, async () => { + const st = await s3Storage(); + const size = 40 * 1024 * 1024; + + // Claims 40 MiB, sends 20 MiB. + const short = Readable.from((function* () { + for (let sent = 0; sent < size / 2; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x41); + })()); + + await assert.rejects(st.put('big/short.bin', short, { size }), /size mismatch/); + assert.equal(await st.head('big/short.bin'), null, 'no partial object should be visible'); +}); + +test('a failed multipart upload does not leave parts behind', s3Options, async () => { + const bucket = await minio.bucket(); + const st = createStorage({ storage: { driver: 's3', s3: minio.storageConfig(bucket) } }); + const size = 40 * 1024 * 1024; + + const short = Readable.from((function* () { + for (let sent = 0; sent < size / 2; sent += 1024 * 1024) yield Buffer.alloc(1024 * 1024, 0x42); + })()); + await assert.rejects(st.put('big/abandoned.bin', short, { size }), /size mismatch/); + + // Abandoned parts are billable and invisible, so the upload must have + // been aborted rather than simply dropped. + const url = new URL(`${minio.endpoint}/${bucket}`); + url.searchParams.set('uploads', ''); + const headers = signRequest({ + method: 'GET', + url, + payloadHash: sha256Hex(''), + region: minio.credentials.region, + accessKeyId: minio.credentials.accessKeyId, + secretAccessKey: minio.credentials.secretAccessKey, + }); + + const listing = await (await fetch(url, { headers })).text(); + assert.ok(!listing.includes('<Upload>'), `an upload was left pending:\n${listing}`); +}); + +test('bad credentials fail loudly rather than silently storing nothing', s3Options, async () => { + const bucket = await minio.bucket(); const st = createStorage({ storage: { driver: 's3', - s3: { - endpoint: S3_ENDPOINT, - bucket: 'irrelevant', - region: 'us-east-1', - access_key_id: 'k', - secret_access_key: 's', - force_path_style: true, - }, + s3: { ...minio.storageConfig(bucket), secret_access_key: 'wrong-secret' }, }, }); - await assert.rejects(st.put('k', Readable.from(['x'])), /needs an explicit size/); + + await assert.rejects(st.put('k.txt', Buffer.from('x')), /s3 put failed/); });