diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 6cbdffb..2c1a026 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -6,7 +6,7 @@ import { config } from '../config.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 { envKey, decryptEnv, maskLog } from './previewEnv.js' import { saveRunLog } from './previewLogs.js' import * as runnerClient from './previewRunnerClient.js' @@ -430,7 +430,7 @@ export async function syncPreviewTlsOnStartup({ // ---- Preview-Runner (PREVIEW_RUNNER=on) ---- // Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar) -export const runnerTiming = { pollMs: 2000, maxMs: 16 * 60 * 1000 } +export const runnerTiming = { pollMs: 2000, maxMs: 60 * 60 * 1000, unreachableMs: 5 * 60 * 1000 } // Neuester Lauf je Repo: aeltere Verfolger schreiben nichts mehr (R14) const latestRun = new Map() const followers = new Set() @@ -484,24 +484,31 @@ async function resolveSha(repoFullName, branch, sha) { return head } -async function followRun({ repoFullName, project, runId, subdomain, job, env, warning }) { +async function followRun({ repoFullName, project, token, runId, subdomain, job, env, warning }) { const deadline = Date.now() + runnerTiming.maxMs - const current = () => latestRun.get(repoFullName) === runId + const current = () => latestRun.get(repoFullName) === token let run = null let failure = null + let unreachableSince = 0 try { while (current()) { await new Promise((r) => setTimeout(r, runnerTiming.pollMs)) if (!current()) return try { run = await runnerClient.getRun(runId) + unreachableSince = 0 } catch (err) { + run = null 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 + unreachableSince ||= Date.now() + if (Date.now() - unreachableSince > runnerTiming.unreachableMs) { + failure = 'Preview-Runner nicht erreichbar' + break + } } if (run?.state === 'done' || run?.state === 'failed') break - if (Date.now() > deadline) { failure = 'Zeitüberschreitung beim Preview-Build'; break } + if (Date.now() > deadline) { failure = 'Status unbekannt – bitte neu bauen'; break } } if (!current()) return @@ -529,7 +536,7 @@ async function followRun({ repoFullName, project, runId, subdomain, job, env, wa } else { await patchProject(repoFullName, project, { previewState: 'failed', - previewError: failure || run?.error || 'Preview-Build fehlgeschlagen', + previewError: maskLog(failure || run?.error || 'Preview-Build fehlgeschlagen', env), }) } if (!current()) return @@ -539,19 +546,25 @@ async function followRun({ repoFullName, project, runId, subdomain, job, env, wa } 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) - } + if (latestRun.get(repoFullName) === token) latestRun.delete(repoFullName) } } async function runDeployViaRunner(repoFullName, branch, previewConfig, { project, sha } = {}) { const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() + // Platz fuer diesen Aufruf sofort belegen (vor jedem await): aeltere Verfolger scheiden aus + const token = Symbol('run') + latestRun.set(repoFullName, token) + const current = () => latestRun.get(repoFullName) === token + const release = () => { if (current()) latestRun.delete(repoFullName) } + const doc = project || (await findProject(repoFullName)) const mode = doc?.previewMode || 'auto' if (mode === 'off') { - await patchProject(repoFullName, doc, { previewState: 'off' }) + try { + if (current()) await patchProject(repoFullName, doc, { previewState: 'off' }) + } finally { release() } return { deployed: false, skipped: true, reason: 'Preview ist ausgeschaltet', subdomain } } @@ -576,15 +589,16 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project 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 }) + if (current()) await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning }) + const follower = followRun({ repoFullName, project: doc, token, 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 }) + try { + if (current()) await patchProject(repoFullName, doc, { previewState: 'failed', previewError: maskLog(err.message, env) }) + } finally { release() } return { deployed: false, error: err.message, subdomain } } } diff --git a/test/previewRunner.test.js b/test/previewRunner.test.js index ea93f4b..b1ab5e0 100644 --- a/test/previewRunner.test.js +++ b/test/previewRunner.test.js @@ -256,3 +256,100 @@ test('Runner ausgelastet (503): failed mit Meldung, kein Wurf', async () => { assert.equal(r.error, 'Server ausgelastet') assert.equal(aw.get('websiteProjects', id).previewState, 'failed') }) + +test('Modus off während ein Lauf läuft: Zustand bleibt off, kein TLS-Sync vom alten Verfolger', async () => { + 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 }) + const { updateDocument } = await import('../server/services/appwriteAdmin.js') + const off = await updateDocument('websiteProjects', id, { previewMode: 'off' }) + await pd.runDeploy(repo, 'main', cfg(repo), { project: off, sha: SHA }) + rs.runs.set(r1.runId, { state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: SHA, log: ['alt'], error: null, result: 'static' }) + await pd.waitForRunFollowers() + assert.equal(aw.get('websiteProjects', id).previewState, 'off') + assert.equal((await logs.listRunLogs(id)).length, 0) +}) + +test('gleichzeitige Aufrufe: der neueste gewinnt', async () => { + const { id, repo, doc } = await project() + rs.script = (job) => ({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: job.sha, log: [job.sha], error: null, result: 'static' }) + const sha2 = 'e'.repeat(40) + const [, r2] = await Promise.all([ + pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }), + pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: sha2 }), + ]) + await pd.waitForRunFollowers() + assert.ok(r2.queued) + assert.equal(aw.get('websiteProjects', id).previewBuiltSha, sha2) +}) + +test('fehlgeschlagener neuer Auftrag verdrängt den alten Verfolger', async () => { + 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.submitError = 503 + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + rs.runs.set(r1.runId, { state: 'done', detected: { kind: 'static' }, sha: SHA, log: [], error: null, result: 'static' }) + await pd.waitForRunFollowers() + assert.equal(aw.get('websiteProjects', id).previewState, 'failed') +}) + +test('R15: folgt queued/running über die alte 16-Minuten-Grenze, Runner-Ausfall lange toleriert', async () => { + const { id, repo, doc } = await project() + let polls = 0 + rs.script = () => ({ state: 'queued', detected: null, sha: SHA, log: [], error: null, result: null }) + const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + const orig = rs.runs.get(r.runId) + Object.defineProperty(orig, 'state', { get() { polls++; return polls < 15 ? 'queued' : 'done' }, enumerable: true }) + await pd.waitForRunFollowers() + assert.ok(polls >= 15) + assert.equal(aw.get('websiteProjects', id).previewState, 'online') +}) + +test('R15: Gesamtzeit überschritten => failed mit "Status unbekannt"', async () => { + const { id, repo, doc } = await project() + rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null }) + const saved = pd.runnerTiming.maxMs + pd.runnerTiming.maxMs = 60 + try { + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + } finally { pd.runnerTiming.maxMs = saved } + const d = aw.get('websiteProjects', id) + assert.equal(d.previewState, 'failed') + assert.equal(d.previewError, 'Status unbekannt – bitte neu bauen') +}) + +test('R15: Runner dauerhaft unerreichbar (> unreachableMs) => failed', async () => { + const { id, repo, doc } = await project() + rs.script = () => ({ state: 'running', detected: null, sha: SHA, log: [], error: null, result: null }) + const { config } = await import('../server/config.js') + const savedUrl = config.previewRunner.url + const saved = pd.runnerTiming.unreachableMs + pd.runnerTiming.unreachableMs = 50 + try { + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + config.previewRunner.url = 'http://127.0.0.1:1' + await pd.waitForRunFollowers() + } finally { config.previewRunner.url = savedUrl; pd.runnerTiming.unreachableMs = saved } + const d = aw.get('websiteProjects', id) + assert.equal(d.previewState, 'failed') + assert.match(d.previewError, /nicht erreichbar/) +}) + +test('Runner-Fehlertext wird maskiert', async () => { + rs.script = done({ state: 'failed', result: null, error: 'Fehler mit supergeheim99', log: [] }) + const { id, repo, doc } = await project({ previewEnv: envLib.encryptEnv({ K: 'supergeheim99' }, key) }) + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + assert.equal(aw.get('websiteProjects', id).previewError, 'Fehler mit •••') +}) + +test('dev-Ergebnis: PUT /dev/ mit vollständigem Auftrag inkl. Variablen', async () => { + rs.script = done({ result: 'dev', detected: { kind: 'dev', pm: 'npm' } }) + const { repo, doc } = await project({ previewEnv: envLib.encryptEnv({ A: 'wert1234' }, key), previewMode: 'dev' }) + await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + await pd.waitForRunFollowers() + const put = rs.calls.find((c) => c.method === 'PUT') + assert.deepEqual(put.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'dev', env: { A: 'wert1234' } }) +})