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

150 lines
7.2 KiB
JavaScript

// 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 { 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 UID = 1000;
const PREFIX = 'preview-dev-';
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,
}) {
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') entries.delete(e.sub); else { e.state = finalState; e.port = null; }
try { await docker.rm(PREFIX + e.sub); } catch (err) { console.error('dev rm', e.sub, err); }
}
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;
try { await docker.rm(name); } catch { /* egal */ }
};
try {
e.release = jobs.retainCache(job.repo);
const workDir = join(config.workRoot, 'dev', e.sub);
const cacheDir = join(config.cacheRoot, cacheName(job.repo));
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}` };
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) {
const cur = entries.get(sub);
if (cur && cur.sha === job.sha) {
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')); // neuer sha oder failed/sleeping: frisch starten
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, lastUsed: now(), log: [], port: null, release: null, promise: null, cancelled: false };
entries.set(sub, e);
e.promise = Promise.all(stops).then(() => start(e, job));
return { state: 'starting' };
}
const touch = (sub) => { const e = entries.get(sub); if (e) e.lastUsed = now(); };
async function stop(sub) { const e = entries.get(sub); if (e) await shutdown(e, 'off'); }
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() {
for (const n of await docker.ps('webklar.preview')) if (n.startsWith(PREFIX)) await docker.rm(n);
}
const idle = () => Promise.all([...entries.values()].map((e) => e.promise)).then(() => {});
return { ensure, touch, stop, list, status, sweep, cleanupOrphans, idle };
}