19 KiB
Preview 2.0 (Preview-Runner) – Umsetzungsplan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Builds und Dev-Server für Previews laufen in abgeschotteten Containern eines neuen Dienstes
webklar-preview-runner; Modus und Variablen pro Projekt im WOMS.
Architecture: Neuer Node-Dienst in runner/ (eigenes package.json, keine Portal-Imports, spricht Docker
über die CLI). Das Portal (server/) schickt Aufträge per HTTP an den Runner, hält Zustand, Variablen
(verschlüsselt) und Protokolle und schreibt die Traefik-Router. WOMS bekommt einen Preview-Block am Projekt.
Tech Stack: Node 20/24 (ESM, node --test), Docker-CLI, Express (Portal), React/Vite (WOMS).
Spec: docs/superpowers/specs/2026-09-30-preview-runner-design.md (Commit 1e9c2bc)
Worktrees: Portal /home/webklar/apps/kundenbereich.webklar.com/dev-preview (Branch feature/preview-runner).
WOMS: git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/preview-runner ../dev-preview origin/test,
danach git ls-files -z node_modules | xargs -0 git update-index --skip-worktree und eigenes npm install
(getrackte node_modules – nie git add -A).
Global Constraints
- Modi exakt
auto|build|dev|off; leer =auto. Vorgabe bei Zuordnung: Art website/webapp ⇒auto, sonstoff. - Zustände exakt
online|building|failed|sleeping|starting|off. - Build:
--memory 1g --cpus 1 --pids-limit 512 --cap-drop ALL --security-opt no-new-privileges --user 1000:1000 --network preview-sandbox, Zeitlimit 15 min, genau 1 gleichzeitig. - Dev: gleiches Profil mit
--memory 512m, Namepreview-dev-<sub>, max. 3, Leerlauf 20 min, Bereitschaft max. 180 s, Standard-Port 5173. - Schutzschalter: MemAvailable < 1,5 GB ⇒
503 { error: 'Server ausgelastet' }, nichts Neues starten. - Runner-Auth:
Authorization: Bearer <PREVIEW_RUNNER_TOKEN>; API-Port 8080, Proxy-Port 8081; kein veröffentlichter Port. - Runner-Eingaben: Subdomain
^[a-z0-9-]{1,63}$, Repo^[\w.-]+/[\w.-]+$, sha^[0-9a-f]{7,40}$, Branch^[\w./-]{1,100}$. - Variablen: Name
^[A-Z_][A-Z0-9_]*$, max. 30, Wert ≤ 4096 Byte, AES-256-GCM, Formatv1:<iv>:<tag>:<ct>(base64), SchlüsselPREVIEW_ENV_KEY(32 Byte base64). - Protokoll: letzte 300 Zeilen je Lauf, letzte 5 Läufe je Projekt, Variablenwerte (≥ 4 Zeichen) ⇒
•••. - Build-Fehler ⇒ vorhandener Preview-Ordner bleibt; Erfolg ⇒ atomar über
<sub>.new+ rename. PREVIEW_RUNNER=off(Vorgabe) ⇒ Portal verhält sich exakt wie heute.- Texte deutsch; kein
confirm(). Nichts pushen/live schalten ohne OK des Nutzers.
Review Focus
- Build scheitert bei bestehender Preview ⇒ alte Dateien bleiben unverändert online. → Task 4
-
- Dev-Start bei 3 laufenden ⇒ der am längsten ungenutzte stoppt, nicht der gerade angefragte. → Task 5
- Böswillige Eingaben (Subdomain
../x, Repo mit;) ⇒ 400, kein Docker-Aufruf. → Task 1 - Variablenwert erscheint im Build-Protokoll ⇒ im gespeicherten Protokoll maskiert. → Task 7
PREVIEW_RUNNER=off⇒runDeployruft keinen Runner, alte Tests unverändert grün. → Task 9
Runner (runner/)
Task 1: Gerüst, Eingabeprüfung, Auth, /status
Files: Create runner/package.json (type module, "test": "node --test test/*.test.js", keine Laufzeit-Abhängigkeiten),
runner/src/config.js, runner/src/validate.js, runner/src/server.js, runner/src/index.js, runner/Dockerfile
(FROM node:20-alpine, apk add --no-cache docker-cli git), Test runner/test/server.test.js.
Interfaces – Produces:
-
loadConfig(env) ⇒ { token, giteaUrl, giteaToken, previewsRoot, workRoot, cacheRoot, apiPort:8080, proxyPort:8081, minFreeBytes: 1.5*1024**3, buildTimeoutMs: 15*60e3, devIdleMs: 20*60e3, devMax:3, devReadyMs:180e3 } -
validateJob(body) ⇒ { ok:true, job } | { ok:false, error }(Regeln aus Global Constraints;mode∈ auto|build|dev;envObjekt mit gültigen Namen) -
createApiServer({ config, jobs, dev, mem }) ⇒ http.Servermit RoutenPOST /jobs/build,GET /jobs/:runId,POST /dev/:sub/start|stop,GET /status; ohne/mit falschem Token ⇒ 401; ungültig ⇒ 400. -
readMemAvailable(path='/proc/meminfo') ⇒ bytes -
Step 1: Tests – 401 ohne Token; 400 für Subdomain
../x, Repoa/b;rm, shaxyz; 200/statusmit{ dev:[], queue:0, memAvailable };readMemAvailableaus Beispieltext. -
Step 2–4: FAIL → implementieren (reines
node:http) → PASS -
Step 5:
git commit -m "Runner: Geruest, Eingabepruefung, Auth"
Task 2: Erkennung
Files: Create runner/src/detect.js, Test runner/test/detect.test.js
Interfaces – Produces: detectProject(files: { has(path):bool, read(path):string|null }, { mode, index, previewJson }) ⇒ { kind: 'static'|'build'|'dev', pm: 'npm'|'pnpm'|'yarn'|'bun', install: string[], buildCmd: string[]|null, devCmd: string[]|null, outputDirs: string[], devPort: number }
Regeln laut Spec 1.2 „Erkennung“; mode:'build' erzwingt build (Fehler, wenn kein Build-Skript), mode:'dev' erzwingt dev; auto: static ⇒ build ⇒ dev. outputDirs = [previewJson.output] oder ['dist','build','out','web-build']. npm ohne Lock ⇒ npm install --include=dev --no-audit --no-fund, mit Lock ⇒ npm ci --include=dev. devCmd z. B. ['npm','run','dev','--','--host','0.0.0.0','--port','5173'].
- Step 1: Tests – HTML ohne package.json ⇒ static; Vite mit build ⇒ build, pm npm; pnpm-lock ⇒ pnpm; nur
dev-Skript ⇒ dev; index.html + build-Skript ⇒ build;mode:'dev'erzwingt dev;previewJson.devPort3000 ⇒ devPort 3000 und im devCmd. - Step 2–5: FAIL → implementieren → PASS →
git commit -m "Runner: Projekterkennung"
Task 3: Docker-Adapter
Files: Create runner/src/docker.js, Test runner/test/docker.test.js
Interfaces – Produces: buildRunArgs({ image, name, workDir, cacheDir, env, cmd, memory, network }) ⇒ string[] (enthält exakt das Sicherheitsprofil aus Global Constraints, --rm, -w /work, Mounts nur workDir:/work und cacheDir:/cache, -e NAME mit Werten per env des Kindprozesses, nie auf der Kommandozeile);
devRunArgs(...) (ohne --rm, -d, Name preview-dev-<sub>, Label webklar.preview=<sub>);
createDocker({ exec = spawnDocker }) ⇒ { run(args, { env, timeoutMs, onLine }) ⇒ {code}, stop(name), rm(name), ps(label) ⇒ names[], inspectIp(name) }.
- Step 1: Tests – Argumentliste enthält jede Sicherheitsoption; kein Variablenwert in args; Zeitlimit ⇒ Kill und
code:124;psparst Ausgabe. - Step 2–5: FAIL → implementieren (child_process.spawn) → PASS →
git commit -m "Runner: Docker-Adapter mit Sicherheitsprofil"
Task 4: Build-Aufträge
Files: Create runner/src/jobs.js, Test runner/test/jobs.test.js
Interfaces: Consumes Task 2 detectProject, Task 3 createDocker. Produces createJobs({ config, docker, fs, git, mem, now }) ⇒ { submit(job) ⇒ runId, get(runId) ⇒ { state:'queued'|'running'|'done'|'failed', detected, sha, log:string[], error, result:'static'|'build'|'dev' } }.
Ablauf: Warteschlange, genau 1 aktiv; Schutzschalter vor Start (bleibt queued, erneuter Versuch alle 30 s); git clone --depth 1 --branch <branch> mit Token-URL (Token nie im Log); Erkennung; static ⇒ kopieren; build ⇒ install+build im Container, Ausgabeordner suchen, nach <sub>.new kopieren, alten Ordner ersetzen; Fehler ⇒ alter Ordner unberührt, failed (bei auto mit dev-Skript ⇒ Ergebnis dev, kein Fehler); Log-Ringpuffer 300 Zeilen; erledigte Läufe 1 h im Speicher. Cache je Repo unter cacheRoot/<owner>__<repo> (als /cache, Paketmanager-Cache dorthin), pruneCache() täglich: Einträge > 14 Tage und, solange gesamt > 5 GB, die ältesten löschen.
- Step 1: Tests –
pruneCachelöscht > 14 Tage und hält 5 GB ein; zwei Aufträge laufen nacheinander; Build-Fehler lässt vorhandenen Ordner byte-gleich; Erfolg ersetzt atomar; Token nicht im Log; wenig Speicher ⇒ bleibt queued; auto+dev-Skript nach Build-Fehler ⇒ resultdev; Log auf 300 Zeilen gekürzt. - Step 2–5: FAIL → implementieren → PASS →
git commit -m "Runner: Build-Auftraege mit Warteschlange"
Task 5: Dev-Server-Verwaltung
Files: Create runner/src/dev.js, Test runner/test/dev.test.js
Interfaces – Produces: createDev({ config, docker, jobs, mem, now }) ⇒ { ensure(sub, spec) ⇒ { state:'starting'|'online', target? }, touch(sub), stop(sub), list() ⇒ [{sub, lastUsed, state}], sweep() }.
Specs (Repo, sha, env, devCmd, devPort) setzt das Portal per Build-Auftrag mit Ergebnis dev bzw. POST /dev/:sub/start (Body wie build-Job). Max. 3 ⇒ LRU-Verdrängung; sweep() (jede Minute) stoppt nach 20 min ohne touch; Bereitschaft = TCP-Verbindung zum Port des Containers (über Container-IP im Netz preview-sandbox), max. 180 s, sonst Zustand failed mit Log. Dev-Container bekommen zusätzlich __VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=.project.webklar.com (Basis-Host aus config). ensure mit neuem sha für laufenden sub ⇒ Container neu starten.
- Step 1: Tests – 4. Start verdrängt ältesten;
sweepnach 21 min stoppt, nach 19 min nicht; wenig Speicher ⇒ FehlerServer ausgelastet; gleicher sub zweimal ⇒ ein Container. - Step 2–5: FAIL → implementieren → PASS →
git commit -m "Runner: Dev-Server bei Bedarf"
Task 6: Proxy mit Startseite
Files: Create runner/src/proxy.js, Modify runner/src/index.js (API + Proxy + sweep starten), Test runner/test/proxy.test.js
Interfaces – Produces: createProxy({ config, dev }) ⇒ http.Server. Host <sub>.<baseHost> ⇒ dev.touch + Weiterleitung (HTTP und upgrade/WebSocket) an target; nicht online ⇒ dev.ensure anstoßen und HTML-Seite „Preview startet …“ (Status 503, Retry-After: 3, <meta http-equiv="refresh" content="3">), nach Fehler Seite „Preview konnte nicht starten“. Unbekannter Host ⇒ 404.
- Step 1: Tests – laufendes Ziel (lokaler Test-HTTP-Server) wird durchgereicht inkl. WebSocket-Echo; nicht laufend ⇒ 503 mit Starttext; fremder Host ⇒ 404.
- Step 2–5: FAIL → implementieren → PASS (
cd runner && npm test) →git commit -m "Runner: Proxy und Startseite"
Portal (server/)
Task 7: Variablen-Verschlüsselung und Maskierung
Files: Create server/services/previewEnv.js, Test test/previewEnv.test.js
Interfaces – Produces: encryptEnv(obj, key) ⇒ string, decryptEnv(str, key) ⇒ obj, validateEnvChange(current, { set, remove }) ⇒ { ok, env | error }, envNames(str, key) ⇒ string[], maskLog(text, env) ⇒ string, envKey() ⇒ Buffer|null (aus process.env.PREVIEW_ENV_KEY).
- Step 1: Tests – Round-Trip; falscher Schlüssel ⇒ Fehler; manipulierter Tag ⇒ Fehler; 31. Variable ⇒ Fehler; Name
abc⇒ Fehler; Wert 4097 Byte ⇒ Fehler;maskLogersetzt Werte ≥ 4 Zeichen, lässt kürzere. - Step 2–5: FAIL → implementieren (node:crypto) → PASS →
git commit -m "Preview: Variablen verschluesselt"
Task 8: Schema-Skript + Konfiguration
Files: Create scripts/preview-runner-setup.mjs (Muster scripts/projektarten-setup.mjs inkl. limit 200), Modify server/config.js (previewRunner: { enabled: process.env.PREVIEW_RUNNER === 'on', url: process.env.PREVIEW_RUNNER_URL || 'http://webklar-preview-runner:8080', token: process.env.PREVIEW_RUNNER_TOKEN || '' }), Test test/previewRunnerSetup.test.js.
Attribute laut Spec 1.1 (previewMode 10, previewEnv 20000, previewState 20, previewDetected 40, previewBuiltSha 40, previewBuiltAt 40, previewError 500; alle optional).
- Step 1–5: Test (7 Schlüssel, Größen, fehlende werden angelegt) → FAIL → implementieren → PASS →
git commit -m "Preview: Schema-Skript und Runner-Konfiguration"
Task 9: Runner-Anbindung in runDeploy
Files: Create server/services/previewRunnerClient.js, server/services/previewLogs.js, Modify server/services/previewDeploy.js (runDeploy), Test test/previewRunner.test.js
Interfaces:
-
previewRunnerClient:submitBuild(job) ⇒ { runId },getRun(runId),startDev(sub, job),stopDev(sub),status(); Fehler tragen.status. -
previewLogs:saveRunLog(projectId, lines, env) ⇒ runFile(maskiert, behält 5),listRunLogs(projectId),readRunLog(projectId, runFile)unterupload-data/preview-logs/. -
runDeploy(repoFullName, branch, previewConfig, { project } = {}): beiconfig.previewRunner.enabled⇒ Modus ausproject.previewMode(leer = auto;off⇒ nichts bauen,previewState:'off'), Variablen entschlüsseln, Auftrag senden,previewState:'building', bis Ende pollen (alle 2 s, max. 16 min), Ergebnis in Projekt schreiben (previewStateonline|failed|sleeping,previewDetected,previewBuiltSha,previewBuiltAt,previewError), Log speichern, danachsyncPreviewTls(). Ergebnis dev und Dev-Container läuft (status()) ⇒startDevmit neuem sha (Neustart mit neuem Code). Aufrufer (Webhook,updateWebsiteProject) übergeben das Projekt-Dokument. -
Schalter aus ⇒ bisheriger Code unverändert.
-
Step 1: Tests – Schalter aus ⇒ kein Runner-Aufruf (Fake-Runner zählt 0) und
previewDeploy.test.jsbleibt grün; an ⇒ Auftrag mit entschlüsselten Variablen; Ergebnis failed ⇒previewState:'failed', Fehler gespeichert, Ordner unangetastet; Ergebnis dev ⇒sleeping; Log maskiert und nur 5 behalten; Modus off ⇒ kein Auftrag. -
Step 2–5: FAIL → implementieren → PASS →
git commit -m "Preview: Builds ueber den Runner"
Task 10: Traefik-Router für Dev
Files: Modify server/services/previewDeploy.js (syncPreviewTls), Test in test/previewDeploy.test.js
syncPreviewTls() nimmt zusätzlich alle Subdomains, deren Projekt previewState ∈ {sleeping, starting, online} und previewDetected beginnt mit dev (auch ohne Ordner), und schreibt für diese service: preview-runner@docker (HTTPS und HTTP-Redirect wie gehabt, Middleware-Regel für öffentlich/nicht öffentlich unverändert). Appwrite nicht erreichbar ⇒ Dev-Liste aus der letzten Datei übernehmen (wie publicSubdomainsFromHostsFile).
- Step 1–5: Tests (dev-Projekt ⇒ runner-Service; static ⇒ nginx; Appwrite weg ⇒ vorherige Dev-Hosts bleiben) → FAIL → implementieren → PASS →
git commit -m "Preview: Router fuer Dev-Server"
Task 11: Admin-API und Modus-Vorgabe
Files: Create server/routes/admin/preview.js (eingehängt unter /api/admin/website-projects vor den bestehenden /:id-Routen oder als eigener Router mit requireAppwriteStaff), Modify server/services/projectKindAssign.js (Modus-Vorgabe aus Art: website/webapp ⇒ auto, sonst off, nur wenn previewMode leer), Test test/previewAdmin.test.js
Routen laut Spec 1.1. GET …/preview ⇒ { mode, state, detected, builtSha, builtAt, error, envNames, logs:[{runFile, at}] }; PATCH …/preview { mode } (400 bei ungültig) ⇒ Neubau anstoßen (nicht bei off; bei off Dev stoppen); PUT …/preview/env ⇒ validateEnvChange, speichern, Neubau; ohne PREVIEW_ENV_KEY ⇒ 503 { error: 'Preview-Variablen sind nicht eingerichtet' }; POST …/rebuild; POST …/dev/start|stop; GET …/logs/:runFile (Dateiname streng prüfen). Schalter aus ⇒ Schreib-Routen 409 { error: 'Preview-Runner ist nicht aktiv' }, GET funktioniert.
- Step 1–5: Tests (401 ohne JWT, Modus-Validierung, env ohne Schlüssel ⇒ 503, Werte nie in Antwort, Log-Pfad
../x⇒ 400, Zuordnen setzt Vorgabe) → FAIL → implementieren → PASS (npm testkomplett) →git commit -m "Preview: Admin-API und Modus-Vorgabe"
WOMS
Task 12: Preview-API-Client und Anzeige-Logik
Files: Create src/lib/previewApi.js (Muster projectKindsApi.js, Fehler mit .status/.data), src/lib/previewView.js, Test tests/unit/previewView.test.mjs
Interfaces – Produces: previewStatus(p) ⇒ { color:'green'|'yellow'|'red'|'grey', text } mit exakten Texten: online „Online seit TT.MM. HH:MM (Build )“, building „Baut gerade …“, sleeping „Dev-Server schläft, startet beim nächsten Aufruf“, starting „Dev-Server startet …“, failed „Build fehlgeschlagen – letzte funktionierende Version ist online“, off „Keine Preview“; detectedLabel(detected) ⇒ z. B. build:vite:npm ⇒ „Build (Vite, npm)“, dev:npm ⇒ „Dev-Server (npm)“, static ⇒ „Statisch“; MODE_OPTIONS = Automatisch/Build/Dev-Server/Aus.
- Step 1–5: Tests → FAIL → implementieren → PASS →
git commit -m "Preview: Anzeige-Logik und API-Client"
Task 13: Preview-Block in Projektkarte, Bearbeiten-Dialog und Services
Files: Create src/components/PreviewBlock.jsx, Modify src/components/AssignedProjectCard.jsx, src/pages/ProjectsPage.jsx (Bearbeiten-Modal), src/pages/ServicesPage.jsx (Punkt je Zeile aus previewState, Portal liefert das Feld in /api/admin/services – dort in Task 11 ergänzen, falls fehlend)
Block laut Spec Abschnitt 2 (Modus, Status, Knöpfe, Protokoll-Auswahl der 5 Läufe, Variablen-Tabelle mit •••, Hinweis VITE_/NEXT_PUBLIC_). 409 „Preview-Runner ist nicht aktiv“ ⇒ Block zeigt Hinweis statt Knöpfen. Handy-tauglich (390 px).
- Step 1: implementieren · Step 2:
npm test+ Build grün · Step 3:git commit -m "Preview: Block im WOMS"
Task 14: Oberflächen-Check (Playwright, Fixture)
WOMS-dist mit gemockten Portal-/Appwrite-Antworten: Modus wechseln (PATCH-Body), Variablen setzen (Werte danach •••), Protokoll öffnen, 409-Hinweis, Services-Punkt, 390 px ohne horizontales Scrollen. Screenshots nach /root/reports/preview-runner-2026-09-30/.
- Step 1: Checks grün · Step 2: Ergebnis im Ledger
Livegang (jeder Schritt mit OK des Nutzers)
- L1 Sicherung
docker-compose.yml,.env,deploy.sh..env:PREVIEW_RUNNER_TOKEN(32 Byte zufällig),PREVIEW_ENV_KEY(32 Byte base64).docker network create preview-sandbox. Compose-Dienstwebklar-preview-runner(buildkundenbereich.webklar.com/build/runner, Netzewebklar-network+preview-sandbox, Mounts Docker-Socket,/var/www/previews, Volumes work/cache, Traefik-Labels Servicepreview-runnerPort 8081, Env nur Token/Gitea-Deploy-Token/URL). Portal-Compose:PREVIEW_RUNNER,PREVIEW_RUNNER_TOKEN,PREVIEW_ENV_KEY.deploy.sh: nach dem Portal-Neustartdocker compose … up -d --build webklar-preview-runner, wenn sichrunner/geändert hat. Schema-Skript (--checkzuerst). - L2 Portal pushen (
PREVIEW_RUNNER=off), Runner-Container starten,/statusaus dem Portal-Container prüfen. - L3 Echte Probe mit drei Wegwerf-Repos (HTML, Vite-Build, nur
dev) inWEBklar/→ aufräumen. DannPREVIEW_RUNNER=on, die 12 Previews einzeln neu bauen, jede Seite vorher/nachher per HTTP-Status + Hash derindex.htmlvergleichen. - L4 WOMS pushen (
feature/preview-runner:test). - L5 Live-Test des Nutzers mit Dev-Modus an einem echten Projekt. Rückweg:
PREVIEW_RUNNER=off+ Portal neu starten.