From 4258b5b9f190ad5b87baabacf9957ffe689cff24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 20:31:21 +0000 Subject: [PATCH] Preview: Builds ueber den Runner Co-Authored-By: Claude Sonnet 5.5 --- server/config.js | 1 + server/routes/webhook/gitea.js | 2 +- server/services/giteaAdmin.js | 4 +- server/services/previewDeploy.js | 173 ++++++++++++++++- server/services/previewLogs.js | 55 ++++++ server/services/previewRunnerClient.js | 38 ++++ test/previewRunner.test.js | 258 +++++++++++++++++++++++++ 7 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 server/services/previewLogs.js create mode 100644 server/services/previewRunnerClient.js create mode 100644 test/previewRunner.test.js diff --git a/server/config.js b/server/config.js index 9b242fa..6560cb1 100644 --- a/server/config.js +++ b/server/config.js @@ -307,6 +307,7 @@ export const config = { enabled: process.env.PREVIEW_RUNNER === 'on', url: process.env.PREVIEW_RUNNER_URL || 'http://webklar-preview-runner:8080', token: process.env.PREVIEW_RUNNER_TOKEN || '', + logDir: process.env.PREVIEW_LOG_DIR || path.join(process.cwd(), 'upload-data', 'preview-logs'), }, invoiceNinja: { diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index 6561653..ffb576f 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -108,7 +108,7 @@ router.post('/gitea', async (req, res) => { if (hasPreview && previewConfig.enabled !== false && branch === configBranch) { try { - deployResult = await runDeploy(name, branch, previewConfig) + deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha }) } catch (deployErr) { console.error('[webhook] deploy failed:', deployErr.message) deployResult = { deployed: false, error: deployErr.message } diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index d3f9649..1257706 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -475,7 +475,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain, } } try { - deployResult = await runDeploy(repoFullName, branch, previewConfig) + deployResult = await runDeploy(repoFullName, branch, previewConfig, { project: doc }) } catch (err) { console.error('[updateWebsiteProject] deploy fehlgeschlagen:', err.message) deployResult = { deployed: false, error: err.message } @@ -566,7 +566,7 @@ export async function archiveRepo(projectId, archived = true) { subdomain: doc.subdomain, displayName: doc.projectName, } - await runDeploy(repoFullName, branch, cfg) + await runDeploy(repoFullName, branch, cfg, { project: doc }) } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) } diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 69a4592..6cbdffb 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -3,8 +3,12 @@ import { promisify } from 'node:util' import fs from 'node:fs/promises' import path from 'node:path' import { config } from '../config.js' -import { listDocuments, Query } from './appwriteAdmin.js' +import { listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { withLocks } from './keyedLock.js' +import { giteaFetch, splitRepo } from './giteaClient.js' +import { envKey, decryptEnv } from './previewEnv.js' +import { saveRunLog } from './previewLogs.js' +import * as runnerClient from './previewRunnerClient.js' const execFileAsync = promisify(execFile) @@ -423,7 +427,172 @@ export async function syncPreviewTlsOnStartup({ return null } -export async function runDeploy(repoFullName, branch, previewConfig) { +// ---- Preview-Runner (PREVIEW_RUNNER=on) ---- + +// Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar) +export const runnerTiming = { pollMs: 2000, maxMs: 16 * 60 * 1000 } +// Neuester Lauf je Repo: aeltere Verfolger schreiben nichts mehr (R14) +const latestRun = new Map() +const followers = new Set() +const SHA_RE = /^[0-9a-f]{7,40}$/ +const KEY_MISSING = 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)' +const KEY_BROKEN = 'Preview-Variablen nicht entschlüsselbar' + +/** Wartet auf alle laufenden Hintergrund-Verfolger (Tests, geordnetes Beenden). */ +export async function waitForRunFollowers() { + while (followers.size) await Promise.allSettled([...followers]) +} + +// previewDetected: static | build: | dev: (der Runner liefert kein Framework) +export function detectedLabel(detected, result) { + const kind = detected?.kind || result + if (kind === 'static') return 'static' + const pm = detected?.pm || 'npm' + return kind === 'dev' ? `dev:${pm}` : `build:${pm}` +} + +async function findProject(repoFullName) { + try { + const docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + return docs[0] || null + } catch (err) { + console.error('[previewDeploy] Projekt-Lookup fehlgeschlagen:', err.message) + return null + } +} + +async function patchProject(repoFullName, project, data) { + const doc = project?.$id ? project : await findProject(repoFullName) + if (!doc?.$id) return null + try { + return await updateDocument(config.collections.websiteProjects, doc.$id, data) + } catch (err) { + console.error('[previewDeploy] Projekt-Update fehlgeschlagen:', err.message) + return null + } +} + +async function resolveSha(repoFullName, branch, sha) { + if (SHA_RE.test(String(sha || ''))) return String(sha) + const [owner, repo] = splitRepo(repoFullName) + const data = await giteaFetch(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`) + const head = data?.commit?.id || data?.commit?.sha + if (!SHA_RE.test(String(head || ''))) throw new Error('Commit des Branches nicht ermittelbar') + return head +} + +async function followRun({ repoFullName, project, runId, subdomain, job, env, warning }) { + const deadline = Date.now() + runnerTiming.maxMs + const current = () => latestRun.get(repoFullName) === runId + let run = null + let failure = null + try { + while (current()) { + await new Promise((r) => setTimeout(r, runnerTiming.pollMs)) + if (!current()) return + try { + run = await runnerClient.getRun(runId) + } catch (err) { + if (err.status === 404) { failure = 'Der Preview-Lauf ist beim Runner nicht mehr bekannt'; break } + console.error('[previewDeploy] Runner-Abfrage fehlgeschlagen:', err.message) + run = null + } + if (run?.state === 'done' || run?.state === 'failed') break + if (Date.now() > deadline) { failure = 'Zeitüberschreitung beim Preview-Build'; break } + } + if (!current()) return + + if (run?.log?.length) { + try { await saveRunLog((project?.$id || (await findProject(repoFullName))?.$id), run.log, env) } catch (err) { + console.error('[previewDeploy] Protokoll speichern fehlgeschlagen:', err.message) + } + } + if (!current()) return + + const now = new Date().toISOString() + if (!failure && run?.state === 'done') { + const detected = detectedLabel(run.detected, run.result) + const base = { previewDetected: detected, previewError: warning || '' } + if (run.result === 'dev') { + await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' }) + try { await runnerClient.startDev(subdomain, job) } catch (err) { + console.error('[previewDeploy] Dev-Registrierung fehlgeschlagen:', err.message) + } + } else { + await patchProject(repoFullName, project, { + ...base, previewState: 'online', previewBuiltSha: run.sha || job.sha, previewBuiltAt: now, + }) + } + } else { + await patchProject(repoFullName, project, { + previewState: 'failed', + previewError: failure || run?.error || 'Preview-Build fehlgeschlagen', + }) + } + if (!current()) return + try { await syncPreviewTls() } catch (err) { + console.error('[previewDeploy] TLS-Sync fehlgeschlagen:', err.message) + } + } catch (err) { + console.error(`[previewDeploy] Verfolger ${runId} abgebrochen:`, err.message) + } finally { + if (latestRun.get(repoFullName) === runId && run && (run.state === 'done' || run.state === 'failed')) { + latestRun.delete(repoFullName) + } + } +} + +async function runDeployViaRunner(repoFullName, branch, previewConfig, { project, sha } = {}) { + const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() + const doc = project || (await findProject(repoFullName)) + const mode = doc?.previewMode || 'auto' + + if (mode === 'off') { + await patchProject(repoFullName, doc, { previewState: 'off' }) + return { deployed: false, skipped: true, reason: 'Preview ist ausgeschaltet', subdomain } + } + + let env = {} + let warning = '' + if (doc?.previewEnv) { + const key = envKey() + if (!key) warning = KEY_MISSING + else { + try { env = decryptEnv(doc.previewEnv, key) } catch { warning = KEY_BROKEN } + } + } + + try { + const job = { + sub: subdomain, + repo: repoFullName, + sha: await resolveSha(repoFullName, branch, sha), + branch, + mode: ['build', 'dev'].includes(mode) ? mode : 'auto', + ...(previewConfig.index ? { index: previewConfig.index } : {}), + env, + } + const { runId } = await runnerClient.submitBuild(job) + latestRun.set(repoFullName, runId) + await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning }) + const follower = followRun({ repoFullName, project: doc, runId, subdomain, job, env, warning }) + followers.add(follower) + follower.finally(() => followers.delete(follower)) + return { deployed: false, queued: true, runId, subdomain } + } catch (err) { + console.error(`[previewDeploy] Runner-Auftrag fuer ${repoFullName} fehlgeschlagen:`, err.message) + await patchProject(repoFullName, doc, { previewState: 'failed', previewError: err.message }) + return { deployed: false, error: err.message, subdomain } + } +} + +export async function runDeploy(repoFullName, branch, previewConfig, options = {}) { + if (config.previewRunner.enabled) { + return runDeployViaRunner(repoFullName, branch, previewConfig, options) + } if (!config.preview.deployRoot) { return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' } } diff --git a/server/services/previewLogs.js b/server/services/previewLogs.js new file mode 100644 index 0000000..3b4b5e5 --- /dev/null +++ b/server/services/previewLogs.js @@ -0,0 +1,55 @@ +// Build-Protokolle je Projekt: upload-data/preview-logs//, maskiert, letzte 5 +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import { config } from '../config.js' +import { maskLog } from './previewEnv.js' + +const KEEP = 5 +const MAX_LINES = 300 +const PROJECT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ +const FILE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[0-9a-f]{6}\.log$/ + +const projectDir = (projectId) => { + if (!PROJECT_RE.test(String(projectId || '')) || String(projectId).includes('..')) { + throw new Error('Ungültige Projekt-ID') + } + return path.join(config.previewRunner.logDir, projectId) +} + +export async function saveRunLog(projectId, lines, env) { + const dir = projectDir(projectId) + await fs.mkdir(dir, { recursive: true }) + const stamp = new Date().toISOString().replace(/[:.]/g, '-') + const runFile = `${stamp}-${crypto.randomBytes(3).toString('hex')}.log` + const text = maskLog((Array.isArray(lines) ? lines : []).slice(-MAX_LINES).join('\n'), env) + await fs.writeFile(path.join(dir, runFile), text + '\n', 'utf8') + const all = (await fs.readdir(dir)).filter((f) => FILE_RE.test(f)).sort() + for (const old of all.slice(0, Math.max(0, all.length - KEEP))) { + await fs.rm(path.join(dir, old), { force: true }) + } + return runFile +} + +// Neueste zuerst +export async function listRunLogs(projectId) { + let files + try { files = await fs.readdir(projectDir(projectId)) } catch { return [] } + return files + .filter((f) => FILE_RE.test(f)) + .sort() + .reverse() + .map((runFile) => ({ + runFile, + at: runFile.slice(0, 24).replace(/^(.{13})-(.{2})-(.{2})-/, '$1:$2:$3.'), + })) +} + +export async function readRunLog(projectId, runFile) { + if (!FILE_RE.test(String(runFile || ''))) return null + try { + return await fs.readFile(path.join(projectDir(projectId), runFile), 'utf8') + } catch { + return null + } +} diff --git a/server/services/previewRunnerClient.js b/server/services/previewRunnerClient.js new file mode 100644 index 0000000..fb45bbb --- /dev/null +++ b/server/services/previewRunnerClient.js @@ -0,0 +1,38 @@ +// HTTP-Client fuer den Preview-Runner (Bearer-Token, JSON) +import { config } from '../config.js' + +const TIMEOUT_MS = 15_000 + +async function call(method, path, body) { + const { url, token } = config.previewRunner + let response + try { + response = await fetch(`${String(url).replace(/\/$/, '')}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(TIMEOUT_MS), + }) + } catch (err) { + throw new Error(`Preview-Runner nicht erreichbar: ${err.message}`) + } + const text = await response.text() + let data = null + try { data = text ? JSON.parse(text) : null } catch { data = { error: text } } + if (!response.ok) { + const error = new Error(data?.error || `Preview-Runner ${response.status}`) + error.status = response.status + throw error + } + return data +} + +export const submitBuild = (job) => call('POST', '/jobs/build', job) +export const getRun = (runId) => call('GET', `/jobs/${encodeURIComponent(runId)}`) +// Dev-Auftrag beim Runner hinterlegen (startet nicht, R12) +export const startDev = (sub, job) => call('PUT', `/dev/${encodeURIComponent(sub)}`, job) +export const stopDev = (sub) => call('POST', `/dev/${encodeURIComponent(sub)}/stop`, {}) +export const status = () => call('GET', '/status') diff --git a/test/previewRunner.test.js b/test/previewRunner.test.js new file mode 100644 index 0000000..ea93f4b --- /dev/null +++ b/test/previewRunner.test.js @@ -0,0 +1,258 @@ +import { test, before, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import http from 'node:http' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import crypto from 'node:crypto' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let aw, runner, gitea, tmp, pd, logs, envLib +const key = crypto.randomBytes(32) +const SHA = 'a'.repeat(40) + +// Fake-Runner: zaehlt Aufrufe, Laeufe per Skript steuerbar +const rs = { calls: [], runs: new Map(), next: 0, script: null, submitError: null } +function startRunner() { + return http.createServer((req, res) => { + let raw = '' + req.on('data', (c) => (raw += c)) + req.on('end', () => { + const body = raw ? JSON.parse(raw) : null + rs.calls.push({ method: req.method, url: req.url, auth: req.headers.authorization, body }) + res.setHeader('Content-Type', 'application/json') + const send = (code, obj) => { res.statusCode = code; res.end(JSON.stringify(obj)) } + if (req.method === 'POST' && req.url === '/jobs/build') { + if (rs.submitError) return send(rs.submitError, { error: 'Server ausgelastet' }) + const runId = `run-${++rs.next}` + rs.runs.set(runId, rs.script(body)) + return send(200, { runId }) + } + const m = req.url.match(/^\/jobs\/(.+)$/) + if (m && req.method === 'GET') return rs.runs.has(m[1]) ? send(200, rs.runs.get(m[1])) : send(404, { error: 'unbekannt' }) + if (req.method === 'PUT' && req.url.startsWith('/dev/')) return send(200, { state: 'sleeping' }) + send(404, { error: 'x' }) + }) + }) +} +const done = (over = {}) => (job) => ({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: job.sha, log: ['ok'], error: null, result: 'static', ...over }) +const submits = () => rs.calls.filter((c) => c.url === '/jobs/build') + +before(async () => { + aw = await startFakeAppwrite({ seed: { websiteProjects: [] } }) + runner = startRunner() + await new Promise((r) => runner.listen(0, '127.0.0.1', r)) + gitea = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ commit: { id: 'b'.repeat(40) } })) + }) + await new Promise((r) => gitea.listen(0, '127.0.0.1', r)) + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-runner-')) + Object.assign(process.env, { + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'), + TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'), + GITEA_BASE_URL: `http://127.0.0.1:${gitea.address().port}`, + GITEA_API_TOKEN: 'tok', + PREVIEW_RUNNER_URL: `http://127.0.0.1:${runner.address().port}`, + PREVIEW_RUNNER_TOKEN: 'sekret', + PREVIEW_ENV_KEY: key.toString('base64'), + }) + const { config } = await import('../server/config.js') + config.previewRunner.enabled = true + config.previewRunner.logDir = path.join(tmp, 'logs') + pd = await import('../server/services/previewDeploy.js') + pd.runnerTiming.pollMs = 10 + pd.runnerTiming.maxMs = 2000 + logs = await import('../server/services/previewLogs.js') + envLib = await import('../server/services/previewEnv.js') + globalThis.__cfg = config +}) + +after(async () => { + await new Promise((r) => runner.close(r)) + await new Promise((r) => gitea.close(r)) + await aw.close() + await fs.rm(tmp, { recursive: true, force: true }) +}) + +let n = 0 +async function project(extra = {}) { + const id = `proj${++n}` + const repo = `JUSN/site-${n}` + const { createDocument } = await import('../server/services/appwriteAdmin.js') + const doc = await createDocument('websiteProjects', { repoFullName: repo, projectName: `S${n}`, ...extra }, id) + return { id, repo, doc } +} +const cfg = (repo) => ({ subdomain: repo.split('/')[1], type: 'static', branch: 'main' }) + +beforeEach(() => { + rs.calls = []; rs.runs = new Map(); rs.script = done(); rs.submitError = null + globalThis.__cfg.previewRunner.enabled = true +}) + +test('Schalter aus: kein Runner-Aufruf, alter Pfad', async () => { + const { repo } = await project() + globalThis.__cfg.previewRunner.enabled = false + globalThis.__cfg.preview.deployRoot = '' + const r = await pd.runDeploy(repo, 'main', cfg(repo)) + globalThis.__cfg.preview.deployRoot = path.join(tmp, 'previews') + assert.equal(r.deployed, false) + assert.match(r.reason, /PREVIEW_DEPLOY_ROOT/) + assert.equal(rs.calls.length, 0) +}) + +test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => { + const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) }) + const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + assert.equal(r.queued, true) + assert.equal(r.deployed, false) + assert.equal(r.runId, 'run-' + rs.next) + assert.equal(aw.get('websiteProjects', id).previewState, 'building') + const [sub] = submits() + assert.equal(sub.auth, 'Bearer sekret') + assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', env: { API_URL: 'https://x.test' } }) + await pd.waitForRunFollowers() + const d = aw.get('websiteProjects', id) + assert.equal(d.previewState, 'online') + assert.equal(d.previewDetected, 'static') + assert.equal(d.previewBuiltSha, SHA) + assert.ok(d.previewBuiltAt) + assert.equal(d.previewError, '') + assert.equal((await logs.listRunLogs(id)).length, 1) +}) + +test('Sha fehlt: Branch-Kopf per Gitea-API', async () => { + const { repo, doc } = await project() + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc }) + await pd.waitForRunFollowers() + assert.equal(submits()[0].body.sha, 'b'.repeat(40)) +}) + +test('ohne project-Option: Dokument per Repo gefunden', async () => { + const { id, repo } = await project({ previewMode: 'build' }) + await pd.runDeploy(repo, 'main', cfg(repo), { sha: SHA }) + await pd.waitForRunFollowers() + assert.equal(submits()[0].body.mode, 'build') + assert.equal(aw.get('websiteProjects', id).previewState, 'online') +}) + +test('failed: Zustand failed, Fehler gespeichert, Folge-Stand nicht online', async () => { + rs.script = done({ state: 'failed', result: null, error: 'Build fehlgeschlagen', detected: { kind: 'build', pm: 'pnpm' }, log: ['boom'] }) + const { id, repo, doc } = await project({ previewBuiltSha: 'c'.repeat(40), previewState: 'online' }) + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + const d = aw.get('websiteProjects', id) + assert.equal(d.previewState, 'failed') + assert.equal(d.previewError, 'Build fehlgeschlagen') + assert.equal(d.previewBuiltSha, 'c'.repeat(40)) + assert.equal(await fs.stat(path.join(tmp, 'previews', repo.split('/')[1])).catch(() => null), null) +}) + +test('dev: sleeping + Registrierung per PUT /dev/', async () => { + rs.script = done({ result: 'dev', detected: { kind: 'dev', pm: 'pnpm' } }) + const { id, repo, doc } = await project() + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + const d = aw.get('websiteProjects', id) + assert.equal(d.previewState, 'sleeping') + assert.equal(d.previewDetected, 'dev:pnpm') + const put = rs.calls.find((c) => c.method === 'PUT') + assert.equal(put.url, `/dev/${repo.split('/')[1]}`) + assert.equal(put.body.sha, SHA) +}) + +test('build: previewDetected build:', async () => { + rs.script = done({ result: 'build', detected: { kind: 'build', pm: 'yarn' } }) + const { id, repo, doc } = await project() + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + assert.equal(aw.get('websiteProjects', id).previewDetected, 'build:yarn') +}) + +test('Modus off: kein Auftrag, previewState off', async () => { + const { id, repo, doc } = await project({ previewMode: 'off' }) + const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + assert.equal(r.skipped, true) + assert.equal(rs.calls.length, 0) + assert.equal(aw.get('websiteProjects', id).previewState, 'off') +}) + +test('Protokoll maskiert und nur 5 behalten', async () => { + const env = { TOKEN: 'supergeheim' } + const id = 'logproj' + const files = [] + for (let i = 0; i < 7; i++) files.push(await logs.saveRunLog(id, [`Zeile ${i}`, 'nutzt supergeheim hier'], env)) + const list = await logs.listRunLogs(id) + assert.equal(list.length, 5) + assert.equal(list[0].runFile, files[6].toString()) + const text = await logs.readRunLog(id, list[0].runFile) + assert.ok(!text.includes('supergeheim')) + assert.ok(text.includes('•••')) + assert.equal(await logs.readRunLog(id, files[0]), null) +}) + +test('Protokoll: nur die letzten 300 Zeilen, keine Pfadtricks', async () => { + const lines = Array.from({ length: 400 }, (_, i) => `z${i}`) + const f = await logs.saveRunLog('logproj2', lines, {}) + const text = (await logs.readRunLog('logproj2', f)).trim().split('\n') + assert.equal(text.length, 300) + assert.equal(text[0], 'z100') + assert.equal(await logs.readRunLog('logproj2', '../../x.log'), null) + assert.equal(await logs.readRunLog('logproj2', '../logproj/' + f), null) + assert.equal(await logs.readRunLog('..', f), null) + assert.deepEqual(await logs.listRunLogs('..'), []) +}) + +test('Variablen im Lauf-Protokoll maskiert (Ende-zu-Ende)', async () => { + rs.script = done({ log: ['Schluessel=supergeheim123'] }) + const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ K: 'supergeheim123' }, key) }) + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + const [{ runFile }] = await logs.listRunLogs(id) + assert.equal((await logs.readRunLog(id, runFile)).trim(), 'Schluessel=•••') +}) + +test('neuerer Lauf verdrängt den älteren Verfolger', async () => { + // Lauf 1 bleibt lange "running", Lauf 2 ist sofort fertig + const { id, repo, doc } = await project() + rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null }) + const r1 = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + rs.script = done({ log: ['zwei'] }) + const sha2 = 'd'.repeat(40) + const r2 = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: sha2 }) + assert.notEqual(r1.runId, r2.runId) + // Lauf 1 meldet jetzt "failed" - darf nichts mehr schreiben + rs.runs.set(r1.runId, { state: 'failed', error: 'alt', log: ['eins'], detected: null, sha: SHA, result: null }) + await pd.waitForRunFollowers() + const d = aw.get('websiteProjects', id) + assert.equal(d.previewState, 'online') + assert.equal(d.previewBuiltSha, sha2) + assert.equal((await logs.listRunLogs(id)).length, 1) +}) + +test('PREVIEW_ENV_KEY fehlt bei gespeicherten Variablen: Build ohne Variablen, Fehlertext', async () => { + const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ A: 'xxxxxx' }, key) }) + const saved = process.env.PREVIEW_ENV_KEY + delete process.env.PREVIEW_ENV_KEY + try { + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + } finally { + process.env.PREVIEW_ENV_KEY = saved + } + assert.deepEqual(submits()[0].body.env, {}) + const d = aw.get('websiteProjects', id) + assert.equal(d.previewState, 'online') + assert.equal(d.previewError, 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)') +}) + +test('Runner ausgelastet (503): failed mit Meldung, kein Wurf', async () => { + rs.submitError = 503 + const { id, repo, doc } = await project() + const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + assert.equal(r.deployed, false) + assert.equal(r.error, 'Server ausgelastet') + assert.equal(aw.get('websiteProjects', id).previewState, 'failed') +})