Files
2026-09-30 21:53:41 +00:00

19 KiB
Raw Permalink Blame History

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, sonst off.
  • 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, Name preview-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, Format v1:<iv>:<tag>:<ct> (base64), Schlüssel PREVIEW_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

  1. Build scheitert bei bestehender Preview ⇒ alte Dateien bleiben unverändert online. → Task 4
    1. Dev-Start bei 3 laufenden ⇒ der am längsten ungenutzte stoppt, nicht der gerade angefragte. → Task 5
  2. Böswillige Eingaben (Subdomain ../x, Repo mit ;) ⇒ 400, kein Docker-Aufruf. → Task 1
  3. Variablenwert erscheint im Build-Protokoll ⇒ im gespeicherten Protokoll maskiert. → Task 7
  4. PREVIEW_RUNNER=off ⇒ runDeploy ruft 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; env Objekt mit gültigen Namen)

  • createApiServer({ config, jobs, dev, mem }) ⇒ http.Server mit Routen POST /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, Repo a/b;rm, sha xyz; 200 /status mit { dev:[], queue:0, memAvailable }; readMemAvailable aus 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.devPort 3000 ⇒ 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; ps parst 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 – pruneCache lö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 ⇒ result dev; 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; sweep nach 21 min stoppt, nach 19 min nicht; wenig Speicher ⇒ Fehler Server 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; maskLog ersetzt 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) unter upload-data/preview-logs/.

  • runDeploy(repoFullName, branch, previewConfig, { project } = {}): bei config.previewRunner.enabled ⇒ Modus aus project.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 (previewState online|failed|sleeping, previewDetected, previewBuiltSha, previewBuiltAt, previewError), Log speichern, danach syncPreviewTls(). Ergebnis dev und Dev-Container läuft (status()) ⇒ startDev mit 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.js bleibt 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 test komplett) → 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-Dienst webklar-preview-runner (build kundenbereich.webklar.com/build/runner, Netze webklar-network + preview-sandbox, Mounts Docker-Socket, /var/www/previews, Volumes work/cache, Traefik-Labels Service preview-runner Port 8081, Env nur Token/Gitea-Deploy-Token/URL). Portal-Compose: PREVIEW_RUNNER, PREVIEW_RUNNER_TOKEN, PREVIEW_ENV_KEY. deploy.sh: nach dem Portal-Neustart docker compose … up -d --build webklar-preview-runner, wenn sich runner/ geändert hat. Schema-Skript (--check zuerst).
  • L2 Portal pushen (PREVIEW_RUNNER=off), Runner-Container starten, /status aus dem Portal-Container prüfen.
  • L3 Echte Probe mit drei Wegwerf-Repos (HTML, Vite-Build, nur dev) in WEBklar/ → aufräumen. Dann PREVIEW_RUNNER=on, die 12 Previews einzeln neu bauen, jede Seite vorher/nachher per HTTP-Status + Hash der index.html vergleichen.
  • 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.