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 }