conductor

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

minio.js (6456B)


      1 // test/helpers/minio.js - a real object store for the tests
      2 //
      3 // Jobs MinIO, which speaks real S3. The signing code here is written
      4 // against node:crypto rather than taken from a library, so verifying it
      5 // against a real server is the only way to know it works: a stub would
      6 // accept whatever signature we happened to produce, including a wrong one.
      7 //
      8 // Set CONDUCTOR_TEST_S3_ENDPOINT to point the same tests at something else,
      9 // such as Garage or AWS, instead of starting a container.
     10 
     11 import { execFile, execFileSync } from 'node:child_process';
     12 import { promisify } from 'node:util';
     13 import crypto from 'node:crypto';
     14 import net from 'node:net';
     15 import { signRequest, sha256Hex } from '../../src/lib/storage/sigv4.js';
     16 import { reapStale } from './containers.js';
     17 
     18 const execFileAsync = promisify(execFile);
     19 
     20 // Pinned, so a MinIO release cannot change what the suite is testing
     21 // against without the change being visible here.
     22 export const IMAGE = 'quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z';
     23 // How long to wait for the container to answer. Generous, because it
     24 // competes with image builds for the machine.
     25 const READY_TIMEOUT = 120 * 1000;
     26 
     27 const NAME_PREFIX = 'conductor-minio-test-';
     28 
     29 const ACCESS_KEY = 'conductortest';
     30 const SECRET_KEY = 'conductortestsecret';
     31 
     32 // Checked synchronously: the runner needs to know whether to skip while it
     33 // is collecting tests, and an unsettled top-level await at that point
     34 // aborts the whole file.
     35 export function dockerAvailableSync() {
     36   if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false;
     37   try {
     38     execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], {
     39       timeout: 15000,
     40       stdio: 'ignore',
     41     });
     42     return true;
     43   } catch {
     44     return false;
     45   }
     46 }
     47 
     48 // True when the suite can exercise S3 at all: either an endpoint was given,
     49 // or docker is here to start one.
     50 export function s3Available() {
     51   return Boolean(process.env.CONDUCTOR_TEST_S3_ENDPOINT) || dockerAvailableSync();
     52 }
     53 
     54 async function freePort() {
     55   return new Promise((resolve, reject) => {
     56     const server = net.createServer();
     57     server.unref();
     58     server.on('error', reject);
     59     server.listen(0, '127.0.0.1', () => {
     60       const { port } = server.address();
     61       server.close(() => resolve(port));
     62     });
     63   });
     64 }
     65 
     66 // Buckets are created with a signed request rather than the MinIO client,
     67 // which keeps the helper free of another dependency and exercises the
     68 // signer on the way in.
     69 export async function createBucket(endpoint, bucket, credentials) {
     70   const url = new URL(`${endpoint.replace(/\/+$/, '')}/${bucket}`);
     71   const headers = signRequest({
     72     method: 'PUT',
     73     url,
     74     payloadHash: sha256Hex(''),
     75     region: credentials.region,
     76     accessKeyId: credentials.accessKeyId,
     77     secretAccessKey: credentials.secretAccessKey,
     78   });
     79 
     80   const res = await fetch(url, { method: 'PUT', headers });
     81   // 409 means it already exists, which is fine.
     82   if (!res.ok && res.status !== 409) {
     83     throw new Error(`could not create bucket ${bucket}: ${res.status} ${await res.text()}`);
     84   }
     85 }
     86 
     87 export async function startMinio() {
     88   const external = process.env.CONDUCTOR_TEST_S3_ENDPOINT;
     89 
     90   // An endpoint supplied by the operator wins, and is left alone.
     91   if (external) {
     92     const credentials = {
     93       region: process.env.CONDUCTOR_TEST_S3_REGION || 'us-east-1',
     94       accessKeyId: process.env.CONDUCTOR_TEST_S3_KEY,
     95       secretAccessKey: process.env.CONDUCTOR_TEST_S3_SECRET,
     96     };
     97     return {
     98       endpoint: external.replace(/\/+$/, ''),
     99       credentials,
    100       managed: false,
    101       async bucket(name = `citest${Date.now().toString(36)}`) {
    102         await createBucket(this.endpoint, name, credentials);
    103         return name;
    104       },
    105       async stop() {},
    106     };
    107   }
    108 
    109   await reapStale(NAME_PREFIX);
    110 
    111   const port = await freePort();
    112   const name = `${NAME_PREFIX}${crypto.randomBytes(4).toString('hex')}`;
    113   const endpoint = `http://127.0.0.1:${port}`;
    114   const credentials = { region: 'us-east-1', accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY };
    115 
    116   await execFileAsync('docker', [
    117     'run', '--detach', '--rm',
    118     '--name', name,
    119     '--publish', `${port}:9000`,
    120     '--env', `MINIO_ROOT_USER=${ACCESS_KEY}`,
    121     '--env', `MINIO_ROOT_PASSWORD=${SECRET_KEY}`,
    122     IMAGE, 'server', '/data',
    123   ], { timeout: 180000 });
    124 
    125   // Deadline rather than a count of attempts. A refused connection comes
    126   // back instantly, so sixty attempts spaced by half a second gave up
    127   // after thirty seconds regardless of the interval, and a machine busy
    128   // pulling or building images needs longer than that. Failing there
    129   // looked like a broken test rather than a slow start.
    130   let ready = false;
    131   const deadline = Date.now() + READY_TIMEOUT;
    132   while (Date.now() < deadline) {
    133     try {
    134       const res = await fetch(`${endpoint}/minio/health/live`, { signal: AbortSignal.timeout(2000) });
    135       if (res.ok) {
    136         ready = true;
    137         break;
    138       }
    139     } catch {
    140       // Still starting.
    141     }
    142     await new Promise((r) => { setTimeout(r, 500); });
    143   }
    144 
    145   if (!ready) {
    146     // Whatever the container said on its way down, so a failure here is
    147     // diagnosable from the test output rather than needing a rerun that
    148     // may well not reproduce it.
    149     const diagnosis = await execFileAsync('docker', ['logs', '--tail', '20', name], { timeout: 10000 })
    150       .then(({ stdout, stderr }) => `${stdout}${stderr}`.trim())
    151       .catch((e) => `could not read container logs: ${e.message}`);
    152     await execFileAsync('docker', ['rm', '-f', name]).catch(() => {});
    153     throw new Error(
    154       `MinIO did not become ready at ${endpoint} within ${READY_TIMEOUT / 1000}s\n${diagnosis}`,
    155     );
    156   }
    157 
    158   return {
    159     endpoint,
    160     credentials,
    161     name,
    162     managed: true,
    163 
    164     // A fresh bucket per test keeps them independent.
    165     async bucket(bucketName = `citest${crypto.randomBytes(6).toString('hex')}`) {
    166       await createBucket(endpoint, bucketName, credentials);
    167       return bucketName;
    168     },
    169 
    170     // Configuration block for createStorage and for the conductor.
    171     storageConfig(bucketName) {
    172       return {
    173         endpoint,
    174         region: credentials.region,
    175         bucket: bucketName,
    176         access_key_id: credentials.accessKeyId,
    177         secret_access_key: credentials.secretAccessKey,
    178         force_path_style: true,
    179       };
    180     },
    181 
    182     async stop() {
    183       await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 }).catch(() => {});
    184     },
    185   };
    186 }