Files
Webklar-Kundenbereich/runner/src/docker.js
T
2026-09-30 21:53:41 +00:00

181 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Docker-Adapter: baut `docker run`-Argumente mit festem Sicherheitsprofil und kapselt die docker-CLI (spawn, nie Shell).
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const SUB_RE = /^[a-z0-9-]{1,63}$/;
const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
const NETWORK = 'preview-sandbox'; // fest (R10), kein Parameter
const IMAGES = ['node:20', 'oven/bun:1'];
const MEMORIES = ['1g', '512m'];
const MAX_LINE = 64 * 1024;
// Feste, unkritische Umgebung im Container (R9): Caches landen im gemounteten Cache-Verzeichnis.
const FIXED_ENV = ['HOME=/cache/home', 'npm_config_cache=/cache/npm', 'COREPACK_HOME=/cache/corepack'];
const FIXED_NAMES = new Set(['HOME', 'COREPACK_HOME']);
// Host-Pfad: absolut, keine Sonderzeichen für -v, keine '..'-Segmente, unterhalb der Wurzel (R10).
function checkPath(p, root, what) {
if (typeof p !== 'string' || typeof root !== 'string' || !p.startsWith('/') || /[:,\0\n\r]/.test(p)
|| p.split('/').includes('..') || !(p === root || p.startsWith(root.replace(/\/+$/, '') + '/'))) {
throw new Error(`Ungültiger Pfad (${what})`);
}
}
function checkEnv(env) {
for (const [k, v] of Object.entries(env || {})) {
if (!ENV_NAME_RE.test(k) || FIXED_NAMES.has(k)) throw new Error(`Ungültiger Variablenname: ${k}`);
if (/[\0\n\r]/.test(String(v))) throw new Error(`Ungültiger Wert für ${k}`);
}
}
function securityArgs({ image, memory, name, workDir, cacheDir, workRoot, cacheRoot, env }) {
if (!IMAGES.includes(image)) throw new Error('Image nicht erlaubt');
if (!MEMORIES.includes(memory)) throw new Error('Ungültiges Speicherlimit');
if (typeof name !== 'string' || !NAME_RE.test(name)) throw new Error('Ungültiger Containername');
checkPath(workDir, workRoot, 'workDir');
checkPath(cacheDir, cacheRoot, 'cacheDir');
checkEnv(env); // Werte selbst laufen nie über die Kommandozeile, siehe run() (--env-file)
const args = [
'--name', name,
'--init',
'--memory', memory,
'--memory-swap', memory,
'--cpus', '1',
'--pids-limit', '512',
'--cap-drop', 'ALL',
'--security-opt', 'no-new-privileges',
'--user', '1000:1000',
'--network', NETWORK,
'-v', `${workDir}:/work`,
'-v', `${cacheDir}:/cache`,
'-w', '/work',
];
for (const e of FIXED_ENV) args.push('-e', e);
return args;
}
// name ist Pflicht (Aufrufer: 'preview-build-<runId>'), damit ein Zeitlimit den Container sicher entfernen kann.
export function buildRunArgs({ image = 'node:20', name, workDir, cacheDir, workRoot, cacheRoot, env, cmd = [], memory = '1g' }) {
return ['run', '--rm', ...securityArgs({ image, memory, name, workDir, cacheDir, workRoot, cacheRoot, env }), image, ...cmd];
}
export function devRunArgs({ image = 'node:20', sub, workDir, cacheDir, workRoot, cacheRoot, env, cmd = [], memory = '512m' }) {
if (typeof sub !== 'string' || !SUB_RE.test(sub)) throw new Error('Ungültige Subdomain');
return [
'run', '-d', '--restart', 'no', '--label', `webklar.preview=${sub}`,
...securityArgs({ image, memory, name: `preview-dev-${sub}`, workDir, cacheDir, workRoot, cacheRoot, env }),
image, ...cmd,
];
}
export const spawnDocker = (args, opts) => spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
function lineSplitter(onLine) {
let buf = '';
return {
push(chunk) {
buf += chunk;
const parts = buf.split(/\r?\n/);
buf = parts.pop().slice(0, MAX_LINE);
for (const l of parts) onLine(l.length > MAX_LINE ? l.slice(0, MAX_LINE) : l);
},
flush() { if (buf !== '') onLine(buf); buf = ''; },
};
}
const checkName = (n) => { if (typeof n !== 'string' || !NAME_RE.test(n)) throw new Error('Ungültiger Containername'); };
// Name aus dem Optionsteil (vor dem Image) – nie aus dem Container-Befehl.
function optionName(args) {
const end = args.findIndex((a) => IMAGES.includes(a));
const opts = end === -1 ? args : args.slice(0, end);
const i = opts.indexOf('--name');
return i !== -1 && NAME_RE.test(opts[i + 1] || '') ? opts[i + 1] : null;
}
export function createDocker({ exec = spawnDocker, baseEnv = process.env, envDir = tmpdir(), rmCapMs = 5000 } = {}) {
// CLI-Umgebung NUR aus PATH/DOCKER_HOST des Runners; Job-Variablen gelangen ausschließlich per --env-file (R10).
const minimalBase = {};
for (const k of ['PATH', 'DOCKER_HOST']) if (baseEnv[k] !== undefined) minimalBase[k] = baseEnv[k];
function writeEnvFile(env) {
checkEnv(env);
const dir = mkdtempSync(join(envDir, 'preview-env-'));
const file = join(dir, 'env');
writeFileSync(file, Object.entries(env).map(([k, v]) => `${k}=${v}\n`).join(''), { mode: 0o600 });
return { file, cleanup: () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* egal */ } } };
}
// docker rm -f mit kurzer Obergrenze abwarten.
function forceRemove(name) {
return new Promise((resolve) => {
const t = setTimeout(resolve, rmCapMs);
try {
const c = exec(['rm', '-f', name], { env: minimalBase });
c.on?.('error', () => { clearTimeout(t); resolve(); });
c.on?.('close', () => { clearTimeout(t); resolve(); });
} catch { clearTimeout(t); resolve(); }
});
}
// onLine(zeile, 'out' | 'err')
function run(args, { env = {}, timeoutMs, onLine } = {}) {
let envFile = null;
let finalArgs = args;
if (Object.keys(env).length > 0) {
if (args[0] !== 'run') return Promise.reject(new Error('Variablen nur für docker run'));
try { envFile = writeEnvFile(env); } catch (e) { return Promise.reject(e); }
finalArgs = ['run', '--env-file', envFile.file, ...args.slice(1)];
}
return new Promise((resolve, reject) => {
let child;
try { child = exec(finalArgs, { env: { ...minimalBase } }); } catch (e) { envFile?.cleanup(); reject(e); return; }
const split = lineSplitter((l) => { if (onLine) onLine(l, 'out'); });
const split2 = lineSplitter((l) => { if (onLine) onLine(l, 'err'); });
child.stdout?.setEncoding?.('utf8');
child.stderr?.setEncoding?.('utf8');
child.stdout?.on('data', (d) => split.push(String(d)));
child.stderr?.on('data', (d) => split2.push(String(d)));
let done = false;
let timer;
const finish = (res) => {
if (done) return;
done = true;
clearTimeout(timer);
envFile?.cleanup();
split.flush(); split2.flush();
resolve(res);
};
child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); envFile?.cleanup(); reject(e); } });
child.on('close', (code) => finish({ code: code ?? 1 }));
if (timeoutMs > 0) {
timer = setTimeout(async () => {
try { child.kill('SIGKILL'); } catch { /* schon beendet */ }
const name = optionName(args);
if (name) await forceRemove(name);
finish({ code: 124 });
}, timeoutMs);
}
});
}
async function capture(args) {
const out = [];
const r = await run(args, { onLine: (l, st) => { if (st === 'out') out.push(l); } });
return { code: r.code, lines: out };
}
return {
run,
async ps(label) {
const { code, lines } = await capture(['ps', '--filter', `label=${label}`, '--format', '{{.Names}}']);
if (code !== 0) throw new Error(`docker ps fehlgeschlagen (${code})`);
return lines.map((l) => l.trim()).filter((l) => NAME_RE.test(l));
},
async rm(name) { checkName(name); return capture(['rm', '-f', name]); },
async stop(name) { checkName(name); return capture(['rm', '-f', name]); },
};
}