Preview: Builds ueber den Runner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40a8aad3f9
commit
4258b5b9f1
7 files changed
+526
-5
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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/<sub>', 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:<pm>', 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')
|
||||
})
|
||||
Reference in new issue
Block a user