conductor

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

docker.js (7436B)


      1 // src/worker/docker.js - container runtime
      2 //
      3 // Shells out to a docker compatible CLI. Podman and nerdctl accept the same
      4 // arguments, so the binary is configurable and nothing here is Docker
      5 // specific beyond the command names.
      6 //
      7 // Arguments are always passed as an array and never through a shell, since
      8 // image names, environment values and mount paths all originate from a
      9 // repository.
     10 
     11 import crypto from 'node:crypto';
     12 import { execFile, spawn } from 'node:child_process';
     13 import { promisify } from 'node:util';
     14 
     15 const execFileAsync = promisify(execFile);
     16 
     17 // Task ids are already within what docker accepts, but the sanitising stays
     18 // because the suffix is a caller supplied service alias and the id is not
     19 // this function's to trust. The hash keeps names unique if either is folded.
     20 export function containerName(prefix, id, suffix = '') {
     21   const digest = crypto.createHash('sha256').update(id).digest('hex').slice(0, 8);
     22   const safe = id.replace(/[^a-zA-Z0-9_.-]/g, '-').replace(/^[^a-zA-Z0-9]+/, '').slice(0, 40);
     23   return [prefix, safe, digest, suffix].filter(Boolean).join('-');
     24 }
     25 
     26 export function createRuntime(cfg, { logger = console } = {}) {
     27   const bin = cfg.docker;
     28 
     29   async function run(args, { timeout = 120000 } = {}) {
     30     try {
     31       const { stdout } = await execFileAsync(bin, args, { timeout, maxBuffer: 8 * 1024 * 1024 });
     32       return stdout.trim();
     33     } catch (e) {
     34       const detail = (e.stderr || e.message || '').toString().trim().split('\n').slice(0, 3).join('; ');
     35       const err = new Error(`${bin} ${args[0]} failed: ${detail}`);
     36       err.cause = e;
     37       throw err;
     38     }
     39   }
     40 
     41   // Turns a task and the worker's feature definitions into run arguments.
     42   function buildRunArgs({ name, image, network, workdir = null, env = {}, mounts = [], privileged = false, devices = [], command = [], entrypoint = null, detach = false, networkAlias = null }) {
     43     const args = ['run', '--rm', '--name', name];
     44     if (detach) args.push('--detach');
     45     else args.push('--attach', 'stdout', '--attach', 'stderr');
     46 
     47     if (network) args.push('--network', network);
     48     if (networkAlias) args.push('--network-alias', networkAlias);
     49     if (privileged) args.push('--privileged');
     50 
     51     for (const device of devices) args.push('--device', device);
     52     for (const mount of mounts) args.push('--volume', mount);
     53 
     54     if (workdir) args.push('--workdir', workdir);
     55 
     56     for (const [key, value] of Object.entries(env)) {
     57       args.push('--env', `${key}=${value}`);
     58     }
     59 
     60     if (entrypoint !== null) args.push('--entrypoint', entrypoint);
     61     args.push(image);
     62     args.push(...command);
     63     return args;
     64   }
     65 
     66   // A task container is created rather than run, so its filesystem can be
     67   // populated before it starts and read after it exits. Nothing from the
     68   // worker's own filesystem is mounted into it: the source arrives over
     69   // the docker API, which means the worker needs no shared directory with
     70   // the host and no storage of its own.
     71   function buildCreateArgs(options) {
     72     const args = buildRunArgs({ ...options, detach: false });
     73 
     74     // Same arguments as run, minus two things.
     75     //
     76     // --attach, because there is nothing to attach to until it starts.
     77     //
     78     // --rm, which matters more than it looks: the artifacts are read out
     79     // of the container after it exits, and a container created with --rm
     80     // deletes itself the moment it stops, taking them with it. Removal
     81     // is done explicitly during cleanup instead.
     82     const filtered = args.filter((arg, i) => {
     83       if (arg === '--attach' || args[i - 1] === '--attach') return false;
     84       if (arg === '--rm') return false;
     85       return true;
     86     });
     87 
     88     filtered[0] = 'create';
     89     return filtered;
     90   }
     91 
     92   return {
     93     buildRunArgs,
     94     buildCreateArgs,
     95 
     96     async create(options) {
     97       await run(buildCreateArgs(options));
     98       return options.name;
     99     },
    100 
    101     // Extracts a tar stream into the container at destination. Used for
    102     // the source tree, which is streamed from the conductor and never
    103     // touches the worker's disk.
    104     async copyStreamIn(name, stream, destination = '/') {
    105       await new Promise((resolve, reject) => {
    106         const child = spawn(bin, ['cp', '-', `${name}:${destination}`], {
    107           stdio: ['pipe', 'ignore', 'pipe'],
    108         });
    109         let stderr = '';
    110         child.stderr.on('data', (c) => { stderr += c.toString().slice(0, 4096); });
    111         child.on('error', reject);
    112         child.on('close', (code) => {
    113           if (code === 0) resolve();
    114           else reject(new Error(`${bin} cp failed: ${stderr.trim() || `exit ${code}`}`));
    115         });
    116         stream.on('error', (e) => {
    117           child.stdin.destroy();
    118           reject(e);
    119         });
    120         stream.pipe(child.stdin);
    121       });
    122     },
    123 
    124     async copyIn(name, hostPath, destination) {
    125       await run(['cp', hostPath, `${name}:${destination}`], { timeout: 600000 });
    126     },
    127 
    128     // Returns false when the path is simply not there, which is a normal
    129     // outcome for an artifact pattern that matched nothing.
    130     async copyOut(name, containerPath, hostPath) {
    131       try {
    132         await run(['cp', `${name}:${containerPath}`, hostPath], { timeout: 600000 });
    133         return true;
    134       } catch (e) {
    135         if (/No such container:path|not found in|no such file or directory/i.test(e.message)) return false;
    136         throw e;
    137       }
    138     },
    139 
    140     // Runs a created container and hands back the process, so the caller
    141     // can stream its output as it happens.
    142     startCreated(name) {
    143       const args = ['start', '--attach', name];
    144       const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
    145       return { child, args };
    146     },
    147 
    148     async available() {
    149       try {
    150         await run(['version', '--format', '{{.Server.Version}}'], { timeout: 15000 });
    151         return true;
    152       } catch {
    153         return false;
    154       }
    155     },
    156 
    157     async createNetwork(name) {
    158       await run(['network', 'create', name]);
    159       return name;
    160     },
    161 
    162     async removeNetwork(name) {
    163       try {
    164         await run(['network', 'rm', name]);
    165       } catch (e) {
    166         logger.warn?.(`could not remove network ${name}: ${e.message}`);
    167       }
    168     },
    169 
    170     // Services run detached; their output is not part of the task log.
    171     async startService(options) {
    172       return run(buildRunArgs({ ...options, detach: true }));
    173     },
    174 
    175     // Starts the task container and hands back the process. The caller reads
    176     // stdout and stderr, and awaits the exit code.
    177     start(options) {
    178       const args = buildRunArgs({ ...options, detach: false });
    179       const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
    180       return { child, args };
    181     },
    182 
    183     // Runs a container to completion and returns its output. Used for
    184     // short housekeeping work rather than for running a task.
    185     async runOnce(options, { timeout = 120000 } = {}) {
    186       return run(buildRunArgs({ ...options, detach: false }), { timeout });
    187     },
    188 
    189     async kill(name, signal = 'KILL') {
    190       try {
    191         await run(['kill', '--signal', signal, name], { timeout: 30000 });
    192       } catch {
    193         // Already gone, which is the desired state.
    194       }
    195     },
    196 
    197     async remove(name) {
    198       try {
    199         await run(['rm', '--force', name], { timeout: 30000 });
    200       } catch {
    201         // Already gone.
    202       }
    203     },
    204 
    205     async pull(image) {
    206       return run(['pull', image], { timeout: 30 * 60 * 1000 });
    207     },
    208   };
    209 }