conductor

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

database.js (5563B)


      1 // test/helpers/database.js - real postgres and mysql for the tests
      2 //
      3 // The suite otherwise jobs entirely on sqlite, which means the postgres
      4 // and mysql migrations and the dialect specific query compilation were
      5 // only ever read, never executed. A migration that does not apply, or a
      6 // query that binds its parameters in a way one driver dislikes, would
      7 // reach whoever jobs that database rather than the test suite.
      8 //
      9 // Set CONDUCTOR_TEST_POSTGRES_URL or CONDUCTOR_TEST_MYSQL_URL to use a
     10 // server you already have instead of starting a container.
     11 
     12 import { execFile, execFileSync } from 'node:child_process';
     13 import { promisify } from 'node:util';
     14 import crypto from 'node:crypto';
     15 import net from 'node:net';
     16 
     17 import { openDatabase, dialectFromUrl } from '../../src/lib/db/index.js';
     18 import { reapStale } from './containers.js';
     19 
     20 const execFileAsync = promisify(execFile);
     21 
     22 // Pinned, so an upstream release cannot change what is being tested
     23 // against without the change being visible here.
     24 export const IMAGES = {
     25   postgres: 'postgres:16-alpine',
     26   mysql: 'mysql:8.4',
     27 };
     28 
     29 const READY_TIMEOUT = 180 * 1000;
     30 const NAME_PREFIX = 'conductor-db-test-';
     31 const PASSWORD = 'conductortestpassword';
     32 
     33 // Checked synchronously: the runner needs to know whether to skip while
     34 // it is collecting tests, and an unsettled top-level await at that point
     35 // aborts the whole file.
     36 export function dockerAvailableSync() {
     37   if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false;
     38   try {
     39     execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], {
     40       timeout: 15000,
     41       stdio: 'ignore',
     42     });
     43     return true;
     44   } catch {
     45     return false;
     46   }
     47 }
     48 
     49 // Whether a given dialect can be exercised: a url was supplied, or docker
     50 // is here to start one. The drivers are optional dependencies, so a
     51 // checkout that skipped them cannot job these either.
     52 export function dialectAvailable(dialect) {
     53   if (envUrl(dialect)) return true;
     54   if (!dockerAvailableSync()) return false;
     55   try {
     56     // require rather than import, to stay synchronous for the same reason.
     57     import.meta.resolve(dialect === 'postgres' ? 'pg' : 'mysql2');
     58     return true;
     59   } catch {
     60     return false;
     61   }
     62 }
     63 
     64 function envUrl(dialect) {
     65   return dialect === 'postgres'
     66     ? process.env.CONDUCTOR_TEST_POSTGRES_URL
     67     : process.env.CONDUCTOR_TEST_MYSQL_URL;
     68 }
     69 
     70 async function freePort() {
     71   return new Promise((resolve, reject) => {
     72     const server = net.createServer();
     73     server.unref();
     74     server.on('error', reject);
     75     server.listen(0, '127.0.0.1', () => {
     76       const { port } = server.address();
     77       server.close(() => resolve(port));
     78     });
     79   });
     80 }
     81 
     82 // Whether the server is actually usable yet.
     83 //
     84 // Not pg_isready or mysqladmin ping over docker exec, which is the
     85 // obvious choice and the wrong one: both images job a temporary server
     86 // during initialisation to create the database, and those clients answer
     87 // for it over the local socket. The probe then passes, the temporary
     88 // server shuts down, and the first real query dies with ECONNRESET.
     89 //
     90 // Both images keep that temporary server off the network, so connecting
     91 // over the published port and running a statement is the thing that
     92 // cannot be satisfied early.
     93 async function probe(url) {
     94   let db;
     95   try {
     96     db = await openDatabase({ database: { url, dialect: dialectFromUrl(url) } });
     97     await db.get('SELECT 1 AS ok', {});
     98     return true;
     99   } catch {
    100     return false;
    101   } finally {
    102     await db?.close().catch(() => {});
    103   }
    104 }
    105 
    106 // Starts one, returning its url and a stop function. Each caller gets its
    107 // own database so tests cannot see each other's rows.
    108 export async function startDatabase(dialect) {
    109   const supplied = envUrl(dialect);
    110   if (supplied) {
    111     return { url: supplied, async stop() {} };
    112   }
    113 
    114   await reapStale(NAME_PREFIX);
    115 
    116   const name = `${NAME_PREFIX}${dialect}-${crypto.randomBytes(4).toString('hex')}`;
    117   const port = await freePort();
    118   const inner = dialect === 'postgres' ? 5432 : 3306;
    119 
    120   const env = dialect === 'postgres'
    121     ? [
    122       '--env', 'POSTGRES_USER=conductor',
    123       '--env', `POSTGRES_PASSWORD=${PASSWORD}`,
    124       '--env', 'POSTGRES_DB=conductor',
    125     ]
    126     : [
    127       '--env', `MYSQL_ROOT_PASSWORD=${PASSWORD}`,
    128       '--env', 'MYSQL_DATABASE=conductor',
    129     ];
    130 
    131   await execFileAsync('docker', [
    132     'run', '--detach', '--rm',
    133     '--name', name,
    134     '--publish', `127.0.0.1:${port}:${inner}`,
    135     // Nothing here outlives the test, so durability is only a cost.
    136     '--tmpfs', dialect === 'postgres' ? '/var/lib/postgresql/data' : '/var/lib/mysql',
    137     ...env,
    138     IMAGES[dialect],
    139   ], { timeout: 300000 });
    140 
    141   const stop = async () => {
    142     await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 }).catch(() => {});
    143   };
    144 
    145   const url = dialect === 'postgres'
    146     ? `postgres://conductor:${PASSWORD}@127.0.0.1:${port}/conductor`
    147     : `mysql://root:${PASSWORD}@127.0.0.1:${port}/conductor`;
    148 
    149   const deadline = Date.now() + READY_TIMEOUT;
    150   let ready = false;
    151   while (Date.now() < deadline) {
    152     if (await probe(url)) {
    153       ready = true;
    154       break;
    155     }
    156     await new Promise((r) => { setTimeout(r, 500); });
    157   }
    158 
    159   if (!ready) {
    160     const diagnosis = await execFileAsync('docker', ['logs', '--tail', '20', name], { timeout: 10000 })
    161       .then(({ stdout, stderr }) => `${stdout}${stderr}`.trim())
    162       .catch((e) => `could not read container logs: ${e.message}`);
    163     await stop();
    164     throw new Error(`${dialect} did not become ready within ${READY_TIMEOUT / 1000}s\n${diagnosis}`);
    165   }
    166 
    167   return { url, stop };
    168 }