From 4ed7e5d7a591ec98a8e923a9e81820102d661e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:57:07 +0000 Subject: [PATCH] Runner: Build-Auftraege mit Warteschlange Co-Authored-By: Claude Sonnet 5.5 --- runner/src/index.js | 7 +- runner/src/jobs.js | 281 +++++++++++++++++++++++++++++++++++++++ runner/test/jobs.test.js | 163 +++++++++++++++++++++++ 3 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 runner/src/jobs.js create mode 100644 runner/test/jobs.test.js diff --git a/runner/src/index.js b/runner/src/index.js index e7e6cf1..63af295 100644 --- a/runner/src/index.js +++ b/runner/src/index.js @@ -1,9 +1,14 @@ import { loadConfig } from './config.js'; import { createApiServer } from './server.js'; +import { createDocker } from './docker.js'; +import { createJobs } from './jobs.js'; // Jobs/Dev/Proxy kommen in späteren Tasks; bis dahin nur Stubs, damit /status antwortet. const config = loadConfig(process.env); -const jobs = { submit: () => { throw Object.assign(new Error('Noch nicht verfügbar'), { status: 503 }); }, get: () => null, queueLength: () => 0 }; +const realJobs = createJobs({ config, docker: createDocker() }); +// Die API antwortet auf POST /jobs/build mit { runId }. +const jobs = { submit: (job) => ({ runId: realJobs.submit(job) }), get: realJobs.get, queueLength: realJobs.queueLength }; +setInterval(() => realJobs.pruneCache().catch((e) => console.error('pruneCache', e)), 24 * 3600e3).unref(); const dev = { ensure: async () => { throw Object.assign(new Error('Noch nicht verfügbar'), { status: 503 }); }, stop: async () => {}, list: () => [] }; createApiServer({ config, jobs, dev }).listen(config.apiPort, () => { diff --git a/runner/src/jobs.js b/runner/src/jobs.js new file mode 100644 index 0000000..7f07e44 --- /dev/null +++ b/runner/src/jobs.js @@ -0,0 +1,281 @@ +// Build-Aufträge: Einzel-Slot-Warteschlange, Klonen, Erkennung, Installation/Build im Container, atomare Veröffentlichung. +import crypto from 'node:crypto'; +import { spawn } from 'node:child_process'; +import * as nodeFs from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, dirname, sep } from 'node:path'; +import { buildRunArgs } from './docker.js'; +import { detectFromDir } from './detect.js'; +import { readMemAvailable } from './server.js'; + +const LOG_LINES = 300; +const MAX_LINE = 2000; +const KEEP_MS = 60 * 60e3; +const MEM_RETRY_MS = 30e3; +const DAY = 24 * 3600e3; +const UID = 1000; + +// ---- Dateisystem-Helfer (überschreibbar für Tests) ---- +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)); +} + +// Kopiert nur Verzeichnisse und reguläre Dateien (keine Symlinks, nie .git), Rechte 755/644 (R7). +async function copyTree(fs, src, dst, { skipGit = false } = {}) { + await fs.mkdir(dst, { recursive: true }); + await fs.chmod(dst, 0o755); + for (const e of await fs.readdir(src, { withFileTypes: true })) { + if (skipGit && e.name === '.git') continue; + const s = join(src, e.name); + const d = join(dst, e.name); + if (e.isDirectory()) await copyTree(fs, s, d); + else if (e.isFile()) { await fs.copyFile(s, d); await fs.chmod(d, 0o644); } + } +} + +async function dirSize(fs, p) { + const st = await fs.lstat(p); + if (!st.isDirectory()) return st.size; + let sum = 0; + for (const e of await fs.readdir(p)) sum += await dirSize(fs, join(p, e)); + return sum; +} + +const defaultFs = { ...nodeFs, chownR, copyTree, dirSize: (p) => dirSize(nodeFs, p) }; + +// ---- git (R11) ---- +export function createGit(config) { + return function git({ repo, branch, dir, onLine }) { + const b64 = Buffer.from(`x-access-token:${config.giteaToken}`).toString('base64'); + const url = `${String(config.giteaUrl).replace(/\/+$/, '')}/${repo}.git`; + const args = ['-c', `http.extraHeader=Authorization: Basic ${b64}`, 'clone', '--depth', '1', '--branch', branch, '--', url, dir]; + const env = { PATH: process.env.PATH || '/usr/bin:/bin', GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1', HOME: tmpdir() }; + return new Promise((resolve, reject) => { + const child = spawn('git', args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); + const feed = (s) => { let buf = ''; s.setEncoding('utf8'); s.on('data', (d) => { const parts = (buf + d).split(/\r?\n|\r/); buf = parts.pop(); parts.forEach((l) => l && onLine?.(l)); }); s.on('end', () => buf && onLine?.(buf)); }; + feed(child.stdout); feed(child.stderr); + child.on('error', reject); + child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`git clone fehlgeschlagen (Exit-Code ${code})`)))); + }); + }; +} + +const withCorepack = (pm, cmd) => (pm === 'pnpm' || pm === 'yarn' ? ['corepack', ...cmd] : cmd); // R9 +const imageFor = (pm) => (pm === 'bun' ? 'oven/bun:1' : 'node:20'); +const cacheName = (repo) => repo.replace('/', '__'); + +export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMemAvailable, now = Date.now, setTimer = (fn, ms) => setTimeout(fn, ms).unref?.() ?? 0 }) { + const fs = { ...defaultFs, ...fsOver }; + const gitFn = git || createGit(config); + const runs = new Map(); + const queue = []; + let active = false; + let retry = false; + const activeCaches = new Set(); + + // ---- Warteschlange mit genau einem aktiven Slot und Speicherwächter ---- + function pump() { + if (active || queue.length === 0) return; + if (mem() < config.minFreeBytes) { + if (!retry) { retry = true; setTimer(() => { retry = false; pump(); }, MEM_RETRY_MS); } + return; + } + active = true; + const task = queue.shift(); + Promise.resolve().then(task).finally(() => { active = false; pump(); }); + } + const enqueue = (fn) => new Promise((resolve, reject) => { + queue.push(() => fn().then(resolve, reject)); + pump(); + }); + + function purge() { + const t = now(); + for (const [id, r] of runs) if (r.finishedAt && t - r.finishedAt > KEEP_MS) runs.delete(id); + } + + // ---- Log: Ringpuffer, Token/Variablenwerte maskiert ---- + function masker(job) { + const secrets = []; + const tok = config.giteaToken; + if (tok) secrets.push(tok, Buffer.from(`x-access-token:${tok}`).toString('base64'), Buffer.from(`${tok}:`).toString('base64')); + 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.replace(/(Authorization:\s*)(Basic|Bearer)\s+\S+/gi, '$1$2 •••').replace(/(https?:\/\/)[^/\s@]+@/g, '$1•••@'); + }; + } + const makeLog = (log, mask) => (line) => { log.push(mask(line)); if (log.length > LOG_LINES) log.splice(0, log.length - LOG_LINES); }; + + // ---- Container-Schritt ---- + async function containerStep({ job, id, step, cmd, pm, workDir, cacheDir, env, onLine, deadline }) { + await fs.mkdir(cacheDir, { recursive: true }); + await fs.chownR(workDir); + await fs.chownR(cacheDir); // R7 + const remaining = Math.max(1000, deadline - now()); + const args = buildRunArgs({ image: imageFor(pm), name: `preview-build-${id}-${step}`, workDir, cacheDir, workRoot: config.workRoot, cacheRoot: config.cacheRoot, cmd: withCorepack(pm, cmd), memory: '1g' }); + return docker.run(args, { env, timeoutMs: remaining, onLine: (l) => onLine(l) }); + } + const failText = (what, code, limit) => (code === 124 ? `${what}: Zeitlimit von ${Math.round(limit / 60e3)} Minuten überschritten` : `${what} fehlgeschlagen (Exit-Code ${code})`); + + // Klonen + Erkennung + Installation (R1); Aufrufer hält den Slot. + async function prepare(job, { onLine, workDir, id = crypto.randomUUID(), deadline = now() + config.buildTimeoutMs }) { + const cacheDir = join(config.cacheRoot, cacheName(job.repo)); + activeCaches.add(cacheName(job.repo)); + try { + await fs.rm(workDir, { recursive: true, force: true }); + await fs.mkdir(dirname(workDir), { recursive: true }); + await gitFn({ repo: job.repo, branch: job.branch, dir: workDir, onLine }); + const detected = detectFromDir(workDir, { mode: job.mode, index: job.index }); + if (detected.error || detected.kind === 'static' || !detected.install?.length) return { workDir, detected }; + const r = await containerStep({ job, id, step: 'install', cmd: detected.install, pm: detected.pm, workDir, cacheDir, onLine, deadline }); + if (r.code !== 0) return { workDir, detected, error: failText('Installation', r.code, config.buildTimeoutMs) }; + return { workDir, detected }; + } finally { + activeCaches.delete(cacheName(job.repo)); + } + } + + // ---- Atomare Veröffentlichung (R11) ---- + async function publish(job, srcDir, { skipGit }) { + const target = join(config.previewsRoot, job.sub); + const fresh = `${target}.new`; + const old = `${target}.old`; + await fs.rm(fresh, { recursive: true, force: true }); + await fs.rm(old, { recursive: true, force: true }); + try { + await fs.mkdir(config.previewsRoot, { recursive: true }); + await fs.copyTree(fs, srcDir, fresh, { skipGit }); + let hadOld = true; + try { await fs.rename(target, old); } catch (e) { if (e.code !== 'ENOENT') throw e; hadOld = false; } + try { await fs.rename(fresh, target); } catch (e) { + if (hadOld) await fs.rename(old, target).catch(() => {}); + throw e; + } + await fs.rm(old, { recursive: true, force: true }); + } catch (e) { + await fs.rm(fresh, { recursive: true, force: true }).catch(() => {}); + throw e; + } + } + + async function findOutput(workDir, dirs) { + const realWork = await fs.realpath(workDir); + for (const d of dirs) { + const p = join(workDir, d); + try { + const st = await fs.lstat(p); + if (!st.isDirectory()) continue; + const real = await fs.realpath(p); + if (real === realWork || !real.startsWith(realWork + sep)) continue; + return p; + } catch { /* nicht vorhanden */ } + } + return null; + } + + // ---- Ablauf eines Build-Laufs ---- + async function execute(run, job) { + const onLine = makeLog(run.log, masker(job)); + const workDir = join(config.workRoot, 'build', run.runId); + const deadline = now() + config.buildTimeoutMs; + run.state = 'running'; + try { + const prep = await prepare(job, { onLine, workDir, id: run.runId, deadline }); + const d = prep.detected; + run.detected = d; + if (d.error) throw new Error(d.error); + if (prep.error) throw new Error(prep.error); + if (d.kind === 'static') { + if (d.hint) onLine(d.hint); + await publish(job, workDir, { skipGit: true }); + run.result = 'static'; + } else if (d.kind === 'dev') { + run.result = 'dev'; + } else { + let problem = null; + const cacheDir = join(config.cacheRoot, cacheName(job.repo)); + const r = await containerStep({ job, id: run.runId, step: 'build', cmd: d.buildCmd, pm: d.pm, workDir, cacheDir, env: job.env, onLine, deadline }); + if (r.code !== 0) problem = failText('Build', r.code, config.buildTimeoutMs); + let out = null; + if (!problem) { + out = await findOutput(workDir, d.outputDirs); + if (!out) problem = `Kein Ausgabeordner gefunden (gesucht: ${d.outputDirs.join(', ')})`; + } + if (problem) { + if (job.mode === 'auto' && d.devCmd) { onLine(`${problem} – Dev-Server wird verwendet`); run.result = 'dev'; } + else throw new Error(problem); + } else { + await publish(job, out, { skipGit: false }); + run.result = 'build'; + } + } + run.state = 'done'; + } catch (e) { + run.state = 'failed'; + run.result = undefined; + run.error = masker(job)(e?.message || 'Unbekannter Fehler'); + onLine(run.error); + } finally { + await fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); + run.finishedAt = now(); + } + } + + function submit(job) { + purge(); + const runId = crypto.randomUUID(); + const run = { runId, state: 'queued', detected: null, sha: job.sha, log: [], error: null, result: null, finishedAt: 0 }; + runs.set(runId, run); + enqueue(() => execute(run, job)); + return runId; + } + + function get(runId) { + purge(); + const r = runs.get(runId); + if (!r) return null; + const { finishedAt, ...pub } = r; // eslint-disable-line no-unused-vars + return { ...pub, log: [...r.log] }; + } + + // Für Dev (Task 5): gleicher Slot, Klon + Installation in workDir. + function prepareWorkspace(job, { onLine = () => {}, workDir } = {}) { + const log = (l) => onLine(masker(job)(l)); + return enqueue(async () => { + const r = await prepare(job, { onLine: log, workDir }); + if (r.error) throw new Error(r.error); + return { workDir: r.workDir, detected: r.detected }; + }); + } + + async function pruneCache() { + const root = config.cacheRoot; + const maxAge = config.cacheMaxAgeMs ?? 14 * DAY; + const maxBytes = config.cacheMaxBytes ?? 5 * 1024 ** 3; + let names = []; + try { names = await fs.readdir(root); } catch { return; } + const items = []; + for (const n of names) { + if (activeCaches.has(n)) continue; + try { const p = join(root, n); const st = await fs.lstat(p); items.push({ p, mtime: st.mtimeMs, size: await fs.dirSize(p) }); } catch { /* weg */ } + } + let kept = []; + for (const it of items) { + if (now() - it.mtime > maxAge) await fs.rm(it.p, { recursive: true, force: true }); else kept.push(it); + } + kept.sort((a, b) => a.mtime - b.mtime); + let total = kept.reduce((s, i) => s + i.size, 0); + for (const it of kept) { + if (total <= maxBytes) break; + await fs.rm(it.p, { recursive: true, force: true }); + total -= it.size; + } + } + + return { submit, get, queueLength: () => queue.length + (active ? 1 : 0), prepareWorkspace, pruneCache }; +} diff --git a/runner/test/jobs.test.js b/runner/test/jobs.test.js new file mode 100644 index 0000000..20740c0 --- /dev/null +++ b/runner/test/jobs.test.js @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, statSync, utimesSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createJobs } from '../src/jobs.js'; + +const TOKEN = 'tok_SECRET_123456'; +const B64 = Buffer.from(`x-access-token:${TOKEN}`).toString('base64'); + +function setup({ files, dockerImpl, mem = () => 10 * 1024 ** 3, cfg = {}, git } = {}) { + const root = mkdtempSync(join(tmpdir(), 'jobs-')); + const config = { giteaUrl: 'https://git.x', giteaToken: TOKEN, previewsRoot: join(root, 'previews'), workRoot: join(root, 'work'), cacheRoot: join(root, 'cache'), minFreeBytes: 1000, buildTimeoutMs: 900000, ...cfg }; + mkdirSync(config.previewsRoot, { recursive: true }); + const calls = []; + const chowned = []; + const timers = []; + const docker = { run: async (args, opts) => { calls.push({ args, opts }); return dockerImpl ? dockerImpl(args, opts) : { code: 0 }; } }; + const gitFake = git || (async ({ dir }) => { for (const [f, c] of Object.entries(files || {})) { mkdirSync(join(dir, f, '..'), { recursive: true }); writeFileSync(join(dir, f), c); } mkdirSync(join(dir, '.git'), { recursive: true }); writeFileSync(join(dir, '.git', 'config'), 'x'); }); + const jobs = createJobs({ config, docker, git: gitFake, mem, fs: { chownR: async (p) => { chowned.push(p); } }, setTimer: (fn, ms) => timers.push({ fn, ms }) }); + return { root, config, calls, chowned, timers, jobs }; +} +const job = (o = {}) => ({ sub: 'demo', repo: 'own/rep', sha: 'abcdef1', branch: 'main', mode: 'auto', env: {}, ...o }); +const wait = async (jobs, id) => { for (let i = 0; i < 400; i++) { const r = jobs.get(id); if (r.state === 'done' || r.state === 'failed') return r; await new Promise((r2) => setTimeout(r2, 5)); } throw new Error('timeout'); }; +const workOf = (args) => args.find((a) => a.endsWith(':/work')).split(':')[0]; +const PKG = (extra = {}) => JSON.stringify({ scripts: { build: 'vite build', ...extra } }); + +test('static: kopiert ohne .git, Rechte 755/644', async () => { + const s = setup({ files: { 'index.html': 'hi', 'a/b.css': 'x' } }); + const r = await wait(s.jobs, s.jobs.submit(job())); + assert.equal(r.state, 'done'); assert.equal(r.result, 'static'); + const t = join(s.config.previewsRoot, 'demo'); + assert.equal(readFileSync(join(t, 'index.html'), 'utf8'), 'hi'); + assert.ok(!existsSync(join(t, '.git'))); + assert.equal(statSync(join(t, 'a')).mode & 0o777, 0o755); + assert.equal(statSync(join(t, 'a/b.css')).mode & 0o777, 0o644); + assert.ok(!existsSync(join(s.config.workRoot, 'build', r.runId))); + assert.equal(s.calls.length, 0); +}); + +test('build: Install+Build im Container (corepack für pnpm), Erfolg ersetzt Ordner atomar', async () => { + const s = setup({ + files: { 'package.json': PKG(), 'pnpm-lock.yaml': '' }, + dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), 'NEU'); } return { code: 0 }; }, + }); + mkdirSync(join(s.config.previewsRoot, 'demo')); writeFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'ALT'); + const r = await wait(s.jobs, s.jobs.submit(job({ env: { K: 'wert1234' } }))); + assert.equal(r.state, 'done'); assert.equal(r.result, 'build'); + assert.equal(readFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'utf8'), 'NEU'); + assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']); + assert.equal(s.calls.length, 2); + assert.deepEqual(s.calls[0].args.slice(-4), ['corepack', 'pnpm', 'install', '--frozen-lockfile']); + assert.deepEqual(s.calls[1].args.slice(-4), ['corepack', 'pnpm', 'run', 'build']); + assert.deepEqual(s.calls[0].opts.env ?? {}, {}); assert.deepEqual(s.calls[1].opts.env, { K: 'wert1234' }); + assert.ok(s.chowned.some((p) => p.endsWith('own__rep')) && s.chowned.some((p) => p.includes('/build/'))); + assert.ok(s.calls[0].args.includes(join(s.config.cacheRoot, 'own__rep') + ':/cache')); +}); + +test('Build-Fehler: alter Ordner byte-gleich, failed + deutscher Text', async () => { + const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => ({ code: args.includes('build') ? 2 : 0 }) }); + mkdirSync(join(s.config.previewsRoot, 'demo')); writeFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'ALT'); + const r = await wait(s.jobs, s.jobs.submit(job())); + assert.equal(r.state, 'failed'); assert.match(r.error, /Build fehlgeschlagen \(Exit-Code 2\)/); + assert.equal(readFileSync(join(s.config.previewsRoot, 'demo', 'index.html'), 'utf8'), 'ALT'); + assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']); +}); + +test('Zeitlimit (124) ⇒ failed mit Zeitlimit-Text', async () => { + const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => ({ code: args.includes('build') ? 124 : 0 }) }); + const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' }))); + assert.match(r.error, /Zeitlimit/); +}); + +test('auto mit dev-Skript nach Build-Fehler ⇒ result dev, kein Publish', async () => { + const s = setup({ files: { 'package.json': PKG({ dev: 'vite' }) }, dockerImpl: (args) => ({ code: args.includes('build') ? 1 : 0 }) }); + const r = await wait(s.jobs, s.jobs.submit(job())); + assert.equal(r.state, 'done'); assert.equal(r.result, 'dev'); + assert.deepEqual(readdirSync(s.config.previewsRoot), []); + const b = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' }))); + assert.equal(b.state, 'failed'); +}); + +test('erzwungener Modus mit Erkennungsfehler ⇒ failed', async () => { + const s = setup({ files: { 'index.html': 'x' } }); + const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' }))); + assert.equal(r.state, 'failed'); assert.match(r.error, /build-Skript/); +}); + +test('Ausgabeordner als Symlink nach außen wird ignoriert', async () => { + const s = setup({ files: { 'package.json': PKG() }, dockerImpl: (args) => { if (args.includes('build')) symlinkSync('/etc', join(workOf(args), 'dist')); return { code: 0 }; } }); + const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build' }))); + assert.equal(r.state, 'failed'); assert.match(r.error, /Ausgabeordner/); +}); + +test('Token nie im Log (auch base64), Log auf 300 Zeilen gekürzt', async () => { + const s = setup({ + files: { 'package.json': PKG() }, + git: async ({ onLine }) => { onLine(`fatal: url https://x-access-token:${TOKEN}@git.x/own/rep`); onLine(`Authorization: Basic ${B64}`); onLine(`raw ${B64}`); throw new Error(`clone ${TOKEN} kaputt`); }, + }); + const r = await wait(s.jobs, s.jobs.submit(job())); + assert.equal(r.state, 'failed'); + const all = r.log.join('\n') + r.error; + assert.ok(!all.includes(TOKEN) && !all.includes(B64)); + const s2 = setup({ files: { 'package.json': PKG() }, dockerImpl: (a, o) => { for (let i = 0; i < 1000; i++) o.onLine(`zeile ${i}`, 'out'); return { code: 1 }; } }); + const r2 = await wait(s2.jobs, s2.jobs.submit(job({ mode: 'build' }))); + assert.equal(r2.log.length, 300); + assert.equal(r2.log.filter((l) => l === 'zeile 999').length >= 1, true); +}); + +test('zwei Aufträge laufen nacheinander', async () => { + let running = 0; let maxRunning = 0; + const s = setup({ files: { 'index.html': 'x' }, git: async ({ dir }) => { running++; maxRunning = Math.max(maxRunning, running); await new Promise((r) => setTimeout(r, 20)); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), 'x'); running--; } }); + const a = s.jobs.submit(job({ sub: 'a' })); const b = s.jobs.submit(job({ sub: 'b' })); + assert.equal(s.jobs.queueLength(), 2); + await wait(s.jobs, a); await wait(s.jobs, b); + assert.equal(maxRunning, 1); + assert.ok(existsSync(join(s.config.previewsRoot, 'a')) && existsSync(join(s.config.previewsRoot, 'b'))); +}); + +test('wenig Speicher ⇒ bleibt queued, Wiederholung nach 30 s', async () => { + let free = 10; // zu wenig: minFreeBytes 1000 + const s2 = setup({ files: { 'index.html': 'x' }, mem: () => free, cfg: { minFreeBytes: 1000 } }); + const id = s2.jobs.submit(job()); + assert.equal(s2.jobs.get(id).state, 'queued'); + assert.equal(s2.timers.length, 1); assert.equal(s2.timers[0].ms, 30000); + free = 5000; s2.timers[0].fn(); + assert.equal((await wait(s2.jobs, id)).state, 'done'); +}); + +test('prepareWorkspace: Klon + Installation in angegebenem workDir, bleibt bestehen', async () => { + const s = setup({ files: { 'package.json': PKG({ dev: 'vite' }) } }); + const wd = join(s.config.workRoot, 'dev', 'demo'); + const r = await s.jobs.prepareWorkspace(job(), { workDir: wd }); + assert.equal(r.workDir, wd); assert.equal(r.detected.kind, 'build'); + assert.ok(existsSync(join(wd, 'package.json'))); + assert.equal(s.calls.length, 1); + assert.ok(s.calls[0].args.includes('install')); +}); + +test('prepareWorkspace: Installationsfehler ⇒ reject', async () => { + const s = setup({ files: { 'package.json': PKG() }, dockerImpl: () => ({ code: 1 }) }); + await assert.rejects(s.jobs.prepareWorkspace(job(), { workDir: join(s.config.workRoot, 'dev', 'demo') }), /Installation fehlgeschlagen/); +}); + +test('erledigte Läufe werden nach 1 h verworfen', async () => { + let t = 1000; + const root = mkdtempSync(join(tmpdir(), 'jobs-')); + const config = { giteaUrl: 'x', giteaToken: 't', previewsRoot: join(root, 'p'), workRoot: join(root, 'w'), cacheRoot: join(root, 'c'), minFreeBytes: 0, buildTimeoutMs: 1000 }; + const jobs = createJobs({ config, docker: {}, git: async ({ dir }) => { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'index.html'), 'x'); }, mem: () => 1e12, now: () => t, fs: { chownR: async () => {} } }); + const id = jobs.submit(job()); + await wait(jobs, id); + t += 3600e3 - 1; assert.ok(jobs.get(id)); + t += 2; assert.equal(jobs.get(id), null); +}); + +test('pruneCache: > 14 Tage weg, dann älteste bis <= Limit', async () => { + const s = setup({ cfg: { cacheMaxBytes: 250 } }); + const mk = (n, bytes, ageDays) => { const d = join(s.config.cacheRoot, n); mkdirSync(d, { recursive: true }); writeFileSync(join(d, 'f'), Buffer.alloc(bytes)); const t = (Date.now() - ageDays * 864e5) / 1000; utimesSync(d, t, t); }; + mk('old__r', 10, 20); mk('a__r', 100, 5); mk('b__r', 100, 3); mk('c__r', 100, 1); + await s.jobs.pruneCache(); + assert.deepEqual(readdirSync(s.config.cacheRoot).sort(), ['b__r', 'c__r']); + rmSync(s.root, { recursive: true, force: true }); +});