Preview: Builds ueber den Runner

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:54:01 +00:00
1 parent 40a8aad3f9
commit 4258b5b9f1
7 files changed
+526 -5

No files matched your search

+2 -2
View File
@@ -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)
}
+171 -2
View File
@@ -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:<pm> | dev:<pm> (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' }
}
+55
View File
@@ -0,0 +1,55 @@
// Build-Protokolle je Projekt: upload-data/preview-logs/<projectId>/<runFile>, 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
}
}
+38
View File
@@ -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')