// Dev-Server bei Bedarf: Container mit `npm run dev` starten, bei Leerlauf stoppen, max. devMax gleichzeitig (LRU-Verdrängung). import net from 'node:net'; import { createHash } from 'node:crypto'; import { join } from 'node:path'; import * as nodeFs from 'node:fs/promises'; import { devRunArgs } from './docker.js'; import { readMemAvailable } from './server.js'; const LOG_LINES = 300; const MAX_LINE = 2000; const POLL_MS = 1000; const RETRY_MS = 60e3; const UID = 1000; const PREFIX = 'preview-dev-'; const LIVE_CHECK_MS = 10e3; // I2: Stand der Startparameter (Variablen, Branch, Modus): Aenderung => Neustart const cfgHash = (job) => createHash('sha256').update(JSON.stringify([job.branch, job.mode, Object.entries(job.env || {}).sort(([a], [b]) => (a < b ? -1 : 1))])).digest('hex'); const cacheName = (repo) => repo.replace('/', '__'); async function chownR(p) { const st = await nodeFs.lstat(p); await nodeFs.lchown(p, UID, UID); if (st.isDirectory()) for (const e of await nodeFs.readdir(p)) await chownR(join(p, e)); } // R7: Container laufen als 1000:1000, also gehören Arbeitsordner und Cache dieser Nutzer-ID. const defaultPrepareDirs = async (workDir, cacheDir) => { await nodeFs.mkdir(cacheDir, { recursive: true }); await chownR(workDir); await chownR(cacheDir); }; // TCP-Verbindung zum Dev-Server (R5: Docker-DNS-Name im Netz preview-sandbox). export function tcpConnect(host, port, timeoutMs = 2000) { return new Promise((resolve, reject) => { const s = net.connect({ host, port }); s.setTimeout(timeoutMs); s.on('connect', () => { s.destroy(); resolve(true); }); s.on('timeout', () => { s.destroy(); reject(new Error('Zeitüberschreitung')); }); s.on('error', (e) => { s.destroy(); reject(e); }); }); } export function createDev({ config, docker, jobs, mem = readMemAvailable, now = Date.now, connect = tcpConnect, setTimer = (fn, ms) => setTimeout(fn, ms).unref?.() ?? 0, prepareDirs = defaultPrepareDirs, rmDir = (p) => nodeFs.rm(p, { recursive: true, force: true }), }) { const specs = new Map(); // R12: letzte Job-Spezifikation je sub (bleibt auch im Zustand 'sleeping') const entries = new Map(); // sub ⇒ { sub, state, sha, lastUsed, log, port, job, release, promise, cancelled } const sleep = (ms) => new Promise((r) => setTimer(r, ms)); const isRunning = (e) => e.state === 'online' || e.state === 'starting'; function masker(job) { const secrets = []; if (config.giteaToken) secrets.push(config.giteaToken); for (const v of Object.values(job?.env || {})) if (typeof v === 'string' && v.length >= 4) secrets.push(v); secrets.sort((a, b) => b.length - a.length); return (line) => { let s = String(line).slice(0, MAX_LINE); for (const x of secrets) s = s.split(x).join('•••'); return s; }; } // Container entfernen, Cache freigeben; Zielzustand 'sleeping' (Leerlauf/Verdrängung) oder Eintrag löschen. async function shutdown(e, finalState) { e.cancelled = true; e.release?.(); e.release = null; if (finalState === 'off') { if (entries.get(e.sub) === e) entries.delete(e.sub); } else { e.state = finalState; e.port = null; } // Nur der Eintrag, der den Container angelegt hat, entfernt ihn (Name ist je sub geteilt). Läuft `docker run` noch, // räumt start() nach der Rückkehr selbst auf. if (!e.stopping) { e.stopping = (async () => { if (!e.created) return; try { const r = await docker.rm(PREFIX + e.sub); if (r && r.code) console.error('dev rm', e.sub, 'Exit-Code', r.code); } catch (err) { console.error('dev rm', e.sub, err); } })(); } return e.stopping; } async function start(e, job) { const name = PREFIX + e.sub; const mask = masker(job); const log = (l) => { e.log.push(mask(l)); if (e.log.length > LOG_LINES) e.log.splice(0, e.log.length - LOG_LINES); }; const fail = async (msg) => { if (e.cancelled) return; log(msg); e.release?.(); e.release = null; e.state = 'failed'; e.port = null; e.failedAt = now(); if (e.created) { try { await docker.rm(name); } catch { /* egal */ } } }; try { if (e.cancelled) return; // vor dem Start abgebrochen: nichts einreihen (ein laufendes prepareWorkspace ließe sich nicht abbrechen) const workDir = join(config.workRoot, 'dev', e.sub); const cacheDir = join(config.cacheRoot, cacheName(job.repo)); // Bleibt ein Rest: ist prepareWorkspace schon gestartet, belegt es den Build-Slot bis zum Ende, auch nach Abbruch. const prep = await jobs.prepareWorkspace(job, { onLine: log, workDir }); if (e.cancelled) return; const d = prep.detected; if (!d?.devCmd) return await fail('Kein dev-Skript in package.json'); await prepareDirs(prep.workDir ?? workDir, cacheDir); if (e.cancelled) return; const cmd = d.pm === 'pnpm' || d.pm === 'yarn' ? ['corepack', ...d.devCmd] : d.devCmd; // R9 const args = devRunArgs({ image: d.pm === 'bun' ? 'oven/bun:1' : 'node:20', sub: e.sub, workDir, cacheDir, workRoot: config.workRoot, cacheRoot: config.cacheRoot, cmd, memory: '512m', }); // Feste Variable zuletzt, damit Projektvariablen sie nicht überschreiben; Werte laufen per --env-file (R10). const env = { ...job.env, __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS: `.${config.baseHost}` }; e.created = true; const r = await docker.run(args, { env, onLine: (l) => log(l) }); if (e.cancelled) { await docker.rm(name).catch(() => {}); return; } if (r.code !== 0) return await fail(`Container konnte nicht gestartet werden (Exit-Code ${r.code})`); e.port = d.devPort; const deadline = now() + config.devReadyMs; while (!e.cancelled) { let ok = false; try { ok = await connect(name, d.devPort); } catch { /* noch nicht bereit */ } if (e.cancelled) return; if (ok) { e.state = 'online'; e.lastUsed = now(); return; } if (now() >= deadline) break; await sleep(POLL_MS); } if (e.cancelled) return; await docker.run(['logs', '--tail', '100', name], { onLine: (l) => log(l) }).catch(() => {}); await fail(`Dev-Server nach ${Math.round(config.devReadyMs / 1000)} s nicht erreichbar`); } catch (err) { await fail(err?.message || 'Unbekannter Fehler'); } } async function ensure(sub, job) { specs.set(sub, job); const cur = entries.get(sub); // I7: ein abgestuerzter/beendeter Container darf nicht als online gelten if (cur && cur.state === 'online' && docker.running && now() - (cur.checkedAt || 0) >= LIVE_CHECK_MS) { let alive = true; try { alive = await docker.running(PREFIX + sub); } catch { /* unklar: als lebendig behandeln */ } cur.checkedAt = now(); if (!alive && entries.get(sub) === cur && cur.state === 'online') { console.error('dev', sub, 'Container laeuft nicht mehr - Neustart'); await shutdown(cur, 'sleeping'); } } if (cur && cur.sha === job.sha && cur.cfg === cfgHash(job)) { if (cur.state === 'failed' && now() - cur.failedAt < RETRY_MS) return { state: 'failed' }; // nicht öfter als alle 60 s neu versuchen if (cur.state === 'online') { cur.lastUsed = now(); return { state: 'online', target: `http://${PREFIX}${sub}:${cur.port}` }; } if (cur.state === 'starting') { cur.lastUsed = now(); return { state: 'starting' }; } } let free = 0; try { free = mem(); } catch { /* Fehler = zu wenig Speicher */ } if (!(free >= config.minFreeBytes)) throw Object.assign(new Error('Server ausgelastet'), { status: 503 }); const stops = []; if (cur) stops.push(shutdown(cur, 'sleeping'), cur.promise); // neuer sha oder failed/sleeping: frisch starten, erst nach dem alten Eintrag const others = [...entries.values()].filter((e) => e !== cur && isRunning(e)).sort((a, b) => a.lastUsed - b.lastUsed); while (others.length >= config.devMax) stops.push(shutdown(others.shift(), 'sleeping')); // LRU const e = { sub, state: 'starting', sha: job.sha, cfg: cfgHash(job), checkedAt: now(), lastUsed: now(), log: [], port: null, release: jobs.retainCache(job.repo), promise: null, cancelled: false, created: false, stopping: null, failedAt: 0 }; entries.set(sub, e); e.promise = Promise.allSettled(stops).then(() => start(e, job)); return { state: 'starting' }; } // R12: Spezifikation merken, ohne zu starten. Läuft der sub mit anderem sha, wird nach den üblichen Regeln neu gestartet. async function register(sub, job) { const cur = entries.get(sub); if (cur && isRunning(cur) && (cur.sha !== job.sha || cur.cfg !== cfgHash(job))) return ensure(sub, job); specs.set(sub, job); if (cur && cur.sha === job.sha && cur.cfg === cfgHash(job) && (cur.state === 'online' || cur.state === 'starting' || cur.state === 'failed')) return { state: cur.state }; return { state: 'sleeping' }; } // R12: Start mit der gemerkten Spezifikation (Proxy kennt nur die Subdomain). async function wake(sub) { const job = specs.get(sub); if (!job) return { state: 'unknown' }; return ensure(sub, job); } // I7: Proxy meldet einen nicht erreichbaren Container: als schlafend markieren und entfernen (naechster Aufruf startet neu). async function markDead(sub) { const e = entries.get(sub); if (e && e.state === 'online') await shutdown(e, 'sleeping'); } const touch = (sub) => { const e = entries.get(sub); if (e) e.lastUsed = now(); }; async function stop(sub) { specs.delete(sub); // 'off': Spezifikation vergessen, bis das Portal neu registriert const e = entries.get(sub); if (!e) return; await shutdown(e, 'off'); await e.promise.catch(() => {}); if (!entries.has(sub)) await rmDir(join(config.workRoot, 'dev', sub)).catch(() => {}); // Arbeitsordner aufräumen } // R18: Subdomains mit gemerkter Spezifikation (auch schlafende); das Portal registriert fehlende nach. const known = () => [...specs.keys()]; const list = () => [...entries.values()].map((e) => ({ sub: e.sub, state: e.state, lastUsed: e.lastUsed, sha: e.sha })); function status(sub) { const e = entries.get(sub); return e ? { state: e.state, sha: e.sha, lastUsed: e.lastUsed, log: [...e.log] } : { state: 'off', log: [] }; } async function sweep() { const t = now(); await Promise.all([...entries.values()].filter((e) => e.state === 'online' && t - e.lastUsed > config.devIdleMs).map((e) => shutdown(e, 'sleeping'))); } // Zustand liegt nur im Speicher: nach Runner-Neustart übrig gebliebene Dev-Container entfernen. async function cleanupOrphans() { const names = (await docker.ps('webklar.preview')).filter((n) => n.startsWith(PREFIX)); for (const r of await Promise.allSettled(names.map((n) => docker.rm(n)))) if (r.status === 'rejected') console.error('orphan rm', r.reason); } const idle = () => Promise.all([...entries.values()].map((e) => e.promise)).then(() => {}); return { ensure, register, wake, touch, markDead, known, stop, list, status, sweep, cleanupOrphans, idle }; }