diff --git a/docs/superpowers/plans/2026-09-30-preview-runner.md b/docs/superpowers/plans/2026-09-30-preview-runner.md new file mode 100644 index 0000000..b27f655 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-preview-runner.md @@ -0,0 +1,191 @@ +# 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-`, 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 `; 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:::` (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 `.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 +2. 4. Dev-Start bei 3 laufenden ⇒ der am längsten ungenutzte stoppt, nicht der gerade angefragte. → Task 5 +3. Böswillige Eingaben (Subdomain `../x`, Repo mit `;`) ⇒ 400, kein Docker-Aufruf. → Task 1 +4. Variablenwert erscheint im Build-Protokoll ⇒ im gespeicherten Protokoll maskiert. → Task 7 +5. `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-`, Label `webklar.preview=`); +`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 ` mit Token-URL (Token nie im Log); Erkennung; static ⇒ kopieren; build ⇒ install+build im Container, Ausgabeordner suchen, nach `.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/__` (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 `.` ⇒ `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`, ``), 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.