Preview: Runner-Verfolger verdraengt sauber, R15-Grenzen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4258b5b9f1
commit
d788dba937
2 files changed
+126
-15
No files matched your search
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<sub> 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' } })
|
||||
})
|
||||
Reference in new issue
Block a user