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 }