fix(preview): Status bleibt bei Runner-Builds, dev-Label, Abgleich mit dem Runner, Längenkürzung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
19cd8f90ce
commit
b7cbe3aa38
8 files changed
+388
-7
No files matched your search
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
|
import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js'
|
||||||
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
|
import { verifyDatabaseAccess } from './services/appwriteAdmin.js'
|
||||||
import { syncPreviewTlsOnStartup } from './services/previewDeploy.js'
|
import { syncPreviewTlsOnStartup } from './services/previewDeploy.js'
|
||||||
|
import { runReconcile } from './services/previewReconcile.js'
|
||||||
import { requireSession, sessionMiddleware } from './middleware/session.js'
|
import { requireSession, sessionMiddleware } from './middleware/session.js'
|
||||||
import authRoutes from './routes/auth.js'
|
import authRoutes from './routes/auth.js'
|
||||||
import projectsRoutes from './routes/projects.js'
|
import projectsRoutes from './routes/projects.js'
|
||||||
@@ -172,6 +173,12 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
|||||||
)
|
)
|
||||||
setTimeout(repoAssign, 60 * 1000).unref()
|
setTimeout(repoAssign, 60 * 1000).unref()
|
||||||
setInterval(repoAssign, 10 * 60 * 1000).unref()
|
setInterval(repoAssign, 10 * 60 * 1000).unref()
|
||||||
|
|
||||||
|
// Preview-Runner: Zustaende mit dem Runner abgleichen (alle 2 Minuten, nur wenn eingeschaltet)
|
||||||
|
if (config.previewRunner.enabled) {
|
||||||
|
setTimeout(runReconcile, 30 * 1000).unref()
|
||||||
|
setInterval(runReconcile, 2 * 60 * 1000).unref()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
fetchPreviewConfig,
|
fetchPreviewConfig,
|
||||||
defaultPreviewConfig,
|
defaultPreviewConfig,
|
||||||
runDeploy,
|
runDeploy,
|
||||||
|
deployKeepsStatus,
|
||||||
buildPreviewUrl,
|
buildPreviewUrl,
|
||||||
} from '../../services/previewDeploy.js'
|
} from '../../services/previewDeploy.js'
|
||||||
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
|
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
|
||||||
@@ -143,7 +144,9 @@ router.post('/gitea', async (req, res) => {
|
|||||||
if (subdomain) projectPayload.subdomain = subdomain
|
if (subdomain) projectPayload.subdomain = subdomain
|
||||||
if (previewUrl) projectPayload.previewUrl = previewUrl
|
if (previewUrl) projectPayload.previewUrl = previewUrl
|
||||||
|
|
||||||
if (deployResult.deployed) {
|
if (deployKeepsStatus(deployResult) && hasPreview) {
|
||||||
|
// Preview-Runner (C1): Status/Bereitschaft bleiben unveraendert, der Verfolger setzt sie
|
||||||
|
} else if (deployResult.deployed) {
|
||||||
projectPayload.status = 'deployed'
|
projectPayload.status = 'deployed'
|
||||||
projectPayload.provisioningStatus = 'ready'
|
projectPayload.provisioningStatus = 'ready'
|
||||||
} else if (hasPreview) {
|
} else if (hasPreview) {
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) {
|
|||||||
...payload,
|
...payload,
|
||||||
customerId: payload.customerId ?? '',
|
customerId: payload.customerId ?? '',
|
||||||
ticketId: payload.ticketId ?? '',
|
ticketId: payload.ticketId ?? '',
|
||||||
|
status: payload.status ?? 'pending',
|
||||||
|
provisioningStatus: payload.provisioningStatus ?? 'pending',
|
||||||
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
|
projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
documentId: ID.unique(),
|
documentId: ID.unique(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from './appwriteAdmin.js'
|
} from './appwriteAdmin.js'
|
||||||
import {
|
import {
|
||||||
runDeploy,
|
runDeploy,
|
||||||
|
deployKeepsStatus,
|
||||||
buildPreviewUrl,
|
buildPreviewUrl,
|
||||||
removeDeploy,
|
removeDeploy,
|
||||||
syncPreviewTls,
|
syncPreviewTls,
|
||||||
@@ -334,10 +335,14 @@ export async function createProjectFromTemplate({
|
|||||||
subdomain: safeSubdomain,
|
subdomain: safeSubdomain,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
projectType: hasWebsite ? 'website' : 'repo',
|
projectType: hasWebsite ? 'website' : 'repo',
|
||||||
|
...(hasWebsite && deployKeepsStatus(deployResult)
|
||||||
|
? {} // Preview-Runner (C1): Status setzt der Verfolger
|
||||||
|
: {
|
||||||
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
|
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
|
||||||
provisioningStatus: hasWebsite
|
provisioningStatus: hasWebsite
|
||||||
? (deployResult.deployed ? 'ready' : 'deploy_failed')
|
? (deployResult.deployed ? 'ready' : 'deploy_failed')
|
||||||
: 'ready',
|
: 'ready',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -494,10 +499,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
|
|||||||
projectType: nextType,
|
projectType: nextType,
|
||||||
isPublic,
|
isPublic,
|
||||||
isTemplate,
|
isTemplate,
|
||||||
|
...(hasWebsite && deployKeepsStatus(deployResult)
|
||||||
|
? {} // Preview-Runner (C1): Status setzt der Verfolger
|
||||||
|
: {
|
||||||
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
|
status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created',
|
||||||
provisioningStatus: hasWebsite
|
provisioningStatus: hasWebsite
|
||||||
? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending'))
|
? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending'))
|
||||||
: 'ready',
|
: 'ready',
|
||||||
|
}),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export function devSubdomainsFromHostsFile(content) {
|
|||||||
async function getDevProjects() {
|
async function getDevProjects() {
|
||||||
try {
|
try {
|
||||||
const docs = await listDocuments(config.collections.websiteProjects, [
|
const docs = await listDocuments(config.collections.websiteProjects, [
|
||||||
Query.equal('previewState', ['sleeping', 'starting', 'online']),
|
Query.equal('previewState', ['sleeping', 'starting', 'online', 'building', 'failed']),
|
||||||
Query.limit(500),
|
Query.limit(500),
|
||||||
])
|
])
|
||||||
return docs.filter(
|
return docs.filter(
|
||||||
@@ -463,7 +463,7 @@ export async function waitForDevRegistration() {
|
|||||||
|
|
||||||
let pendingProjects = null
|
let pendingProjects = null
|
||||||
|
|
||||||
async function registerDevRound(projects) {
|
export async function registerDevRound(projects) {
|
||||||
const key = envKey()
|
const key = envKey()
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
projects.map(async (project) => {
|
projects.map(async (project) => {
|
||||||
@@ -541,14 +541,27 @@ const SHA_RE = /^[0-9a-f]{7,40}$/
|
|||||||
const KEY_MISSING = 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)'
|
const KEY_MISSING = 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)'
|
||||||
const KEY_BROKEN = 'Preview-Variablen nicht entschlüsselbar'
|
const KEY_BROKEN = 'Preview-Variablen nicht entschlüsselbar'
|
||||||
|
|
||||||
|
/** Gibt es fuer das Repo einen laufenden Verfolger in diesem Prozess? (Abgleich, R18) */
|
||||||
|
export const hasLiveFollower = (repoFullName) => latestRun.has(repoFullName)
|
||||||
|
|
||||||
/** Wartet auf alle laufenden Hintergrund-Verfolger (Tests, geordnetes Beenden). */
|
/** Wartet auf alle laufenden Hintergrund-Verfolger (Tests, geordnetes Beenden). */
|
||||||
export async function waitForRunFollowers() {
|
export async function waitForRunFollowers() {
|
||||||
while (followers.size) await Promise.allSettled([...followers])
|
while (followers.size) await Promise.allSettled([...followers])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C1: Mit aktivem Runner liefert runDeploy nur "queued"/"skipped"/Fehler des Auftrags - das ist
|
||||||
|
* kein Deploy-Ergebnis. Aufrufer duerfen dann status/provisioningStatus NICHT anfassen
|
||||||
|
* (sonst sperrt auth.js "not_ready" ein bereits bereites Projekt aus). Der Verfolger setzt
|
||||||
|
* bei Erfolg 'deployed'/'ready'.
|
||||||
|
*/
|
||||||
|
export function deployKeepsStatus(deployResult) {
|
||||||
|
return Boolean(config.previewRunner.enabled && deployResult && !deployResult.deployed)
|
||||||
|
}
|
||||||
|
|
||||||
// previewDetected: static | build:<pm> | dev:<pm> (der Runner liefert kein Framework)
|
// previewDetected: static | build:<pm> | dev:<pm> (der Runner liefert kein Framework)
|
||||||
export function detectedLabel(detected, result) {
|
export function detectedLabel(detected, result) {
|
||||||
const kind = detected?.kind || result
|
const kind = result || detected?.kind
|
||||||
if (kind === 'static') return 'static'
|
if (kind === 'static') return 'static'
|
||||||
const pm = detected?.pm || 'npm'
|
const pm = detected?.pm || 'npm'
|
||||||
return kind === 'dev' ? `dev:${pm}` : `build:${pm}`
|
return kind === 'dev' ? `dev:${pm}` : `build:${pm}`
|
||||||
@@ -567,7 +580,19 @@ async function findProject(repoFullName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attributgroessen (Appwrite lehnt laengere Werte ab und der Patch ginge verloren)
|
||||||
|
const ATTR_MAX = { previewError: 500, previewDetected: 40, previewBranch: 100 }
|
||||||
|
|
||||||
|
function clampAttrs(data) {
|
||||||
|
const out = { ...data }
|
||||||
|
for (const [key, max] of Object.entries(ATTR_MAX)) {
|
||||||
|
if (typeof out[key] === 'string' && out[key].length > max) out[key] = out[key].slice(0, max)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
async function patchProject(repoFullName, project, data) {
|
async function patchProject(repoFullName, project, data) {
|
||||||
|
data = clampAttrs(data)
|
||||||
const doc = project?.$id ? project : await findProject(repoFullName)
|
const doc = project?.$id ? project : await findProject(repoFullName)
|
||||||
if (!doc?.$id) return null
|
if (!doc?.$id) return null
|
||||||
try {
|
try {
|
||||||
@@ -630,6 +655,9 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job,
|
|||||||
previewError: warning || '',
|
previewError: warning || '',
|
||||||
previewBranch: job.branch,
|
previewBranch: job.branch,
|
||||||
previewBuiltSha: run.sha || job.sha,
|
previewBuiltSha: run.sha || job.sha,
|
||||||
|
// Wie der Legacy-Erfolg: Projekt ist bereit (C1). Ein Fehlschlag senkt nie herab.
|
||||||
|
status: 'deployed',
|
||||||
|
provisioningStatus: 'ready',
|
||||||
}
|
}
|
||||||
if (run.result === 'dev') {
|
if (run.result === 'dev') {
|
||||||
await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' })
|
await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' })
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// R18: Abgleich der Preview-Zustaende mit dem Runner (alle 2 Minuten, nur mit PREVIEW_RUNNER=on).
|
||||||
|
// (a) 'building' ohne laufenden Verfolger und aelter als 70 Minuten => 'failed'
|
||||||
|
// (b) Dev-Projekte: Zustand laut Runner (laeuft => online/starting, sonst sleeping); Modus 'off' bleibt unberuehrt
|
||||||
|
// (c) Dev-Spezifikationen, die der Runner nicht kennt (z. B. nach Neustart), erneut hinterlegen
|
||||||
|
// Wirft nie: Fehler werden protokolliert und im Ergebnis gemeldet.
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||||
|
import * as runnerClient from './previewRunnerClient.js'
|
||||||
|
import { hasLiveFollower, registerDevRound } from './previewDeploy.js'
|
||||||
|
|
||||||
|
export const STALE_BUILD_MS = 70 * 60 * 1000
|
||||||
|
const STALE_TEXT = 'Status unbekannt – bitte neu bauen'
|
||||||
|
|
||||||
|
const isDev = (d) => String(d.previewDetected || '').startsWith('dev')
|
||||||
|
|
||||||
|
export async function reconcilePreviews({ now = Date.now() } = {}) {
|
||||||
|
if (!config.previewRunner.enabled) return { skipped: true }
|
||||||
|
const result = { staleFailed: 0, stateUpdated: 0, reregistered: 0 }
|
||||||
|
let docs
|
||||||
|
try {
|
||||||
|
docs = await listDocuments(config.collections.websiteProjects, [
|
||||||
|
Query.equal('previewState', ['building', 'sleeping', 'starting', 'online']),
|
||||||
|
Query.limit(500),
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[previewReconcile] Projekte nicht lesbar:', err.message)
|
||||||
|
return { ...result, error: err.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = async (doc, data) => {
|
||||||
|
try { await updateDocument(config.collections.websiteProjects, doc.$id, data); return true } catch (err) {
|
||||||
|
console.error('[previewReconcile] Update fehlgeschlagen:', err.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a)
|
||||||
|
for (const doc of docs) {
|
||||||
|
if (doc.previewState !== 'building' || !doc.repoFullName) continue
|
||||||
|
if (hasLiveFollower(doc.repoFullName)) continue
|
||||||
|
const since = Date.parse(doc.$updatedAt || doc.$createdAt || '')
|
||||||
|
if (!Number.isFinite(since) || now - since <= STALE_BUILD_MS) continue
|
||||||
|
if (await patch(doc, { previewState: 'failed', previewError: STALE_TEXT })) result.staleFailed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// (b) + (c)
|
||||||
|
const devDocs = docs.filter((d) => isDev(d) && d.previewMode !== 'off' && d.previewState !== 'building' && d.subdomain)
|
||||||
|
if (!devDocs.length) return result
|
||||||
|
let st
|
||||||
|
try { st = await runnerClient.status() } catch (err) {
|
||||||
|
console.error('[previewReconcile] Runner-Status nicht abrufbar:', err.message)
|
||||||
|
return { ...result, error: err.message }
|
||||||
|
}
|
||||||
|
const entries = new Map((st?.dev || []).map((e) => [e.sub, e]))
|
||||||
|
const known = new Set([...(st?.known || []), ...entries.keys()])
|
||||||
|
const unknown = []
|
||||||
|
for (const doc of devDocs) {
|
||||||
|
const entry = entries.get(doc.subdomain)
|
||||||
|
const want = entry?.state === 'online' ? 'online' : entry?.state === 'starting' ? 'starting' : 'sleeping'
|
||||||
|
if (doc.previewState !== want && (await patch(doc, { previewState: want }))) result.stateUpdated++
|
||||||
|
if (!known.has(doc.subdomain)) unknown.push(doc)
|
||||||
|
}
|
||||||
|
if (unknown.length) {
|
||||||
|
try { await registerDevRound(unknown); result.reregistered = unknown.length } catch (err) {
|
||||||
|
console.error('[previewReconcile] Registrierung fehlgeschlagen:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer setInterval: nie werfen. */
|
||||||
|
export function runReconcile() {
|
||||||
|
return reconcilePreviews().catch((err) => {
|
||||||
|
console.error('[previewReconcile] Lauf fehlgeschlagen:', err.message)
|
||||||
|
return { error: err.message }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -143,8 +143,9 @@ test('Dev-Projekt bekommt Runner-Service (auch ohne Ordner), static bleibt nginx
|
|||||||
assert.match(dev, /preview-sites-redirect@docker/)
|
assert.match(dev, /preview-sites-redirect@docker/)
|
||||||
assert.match(routerBlock(content, 'statisch'), /preview-nginx@docker/)
|
assert.match(routerBlock(content, 'statisch'), /preview-nginx@docker/)
|
||||||
assert.doesNotMatch(routerBlock(content, 'statisch'), /preview-runner/)
|
assert.doesNotMatch(routerBlock(content, 'statisch'), /preview-runner/)
|
||||||
assert.doesNotMatch(content, /devoff/) // Zustand failed => kein Dev-Host
|
// I5: auch ein fehlgeschlagener/neu bauender Dev-Host bleibt beim Runner (kein Umschalten auf nginx)
|
||||||
assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)], ['devapp'])
|
assert.match(routerBlock(content, 'devoff'), /preview-runner@docker/)
|
||||||
|
assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['devapp', 'devoff'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async () => {
|
test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async () => {
|
||||||
@@ -159,7 +160,7 @@ test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async
|
|||||||
assert.equal(result.publicSource, 'previous-file')
|
assert.equal(result.publicSource, 'previous-file')
|
||||||
} finally { console.error = errors }
|
} finally { console.error = errors }
|
||||||
await pd.waitForDevRegistration()
|
await pd.waitForDevRegistration()
|
||||||
assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())], ['devapp'])
|
assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())].sort(), ['devapp', 'devoff'])
|
||||||
assert.match(routerBlock(await read(), 'devapp'), /preview-runner@docker/)
|
assert.match(routerBlock(await read(), 'devapp'), /preview-runner@docker/)
|
||||||
assert.equal(puts.length, 1) // nur die Runde mit Appwrite-Daten
|
assert.equal(puts.length, 1) // nur die Runde mit Appwrite-Daten
|
||||||
})
|
})
|
||||||
@@ -256,3 +257,14 @@ test('Registrierung: laeuft eine Runde, folgt genau eine Wiederholung', async ()
|
|||||||
await pd.waitForDevRegistration()
|
await pd.waitForDevRegistration()
|
||||||
assert.equal(puts.length, 2)
|
assert.equal(puts.length, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('I5: Dev-Projekt im Zustand building bleibt beim Runner, static im Zustand building nicht', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baut', repoFullName: 'JUSN/baut', previewState: 'building', previewDetected: 'dev:npm' }, 'p5')
|
||||||
|
await createDocument('websiteProjects', { previewMode: 'auto', subdomain: 'baustatic', repoFullName: 'JUSN/baustatic', previewState: 'building', previewDetected: 'static' }, 'p6')
|
||||||
|
await pd.syncPreviewTls()
|
||||||
|
await pd.waitForDevRegistration()
|
||||||
|
const content = await read()
|
||||||
|
assert.match(routerBlock(content, 'baut'), /preview-runner@docker/)
|
||||||
|
assert.ok(!pd.devSubdomainsFromHostsFile(content).has('baustatic'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// Abschluss-Fixes Preview-Runner: C1 (Status bleibt), I1 (Label), I3/I4/I6 (Abgleich), Laengen
|
||||||
|
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 { createHmac } from 'node:crypto'
|
||||||
|
import express from 'express'
|
||||||
|
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||||
|
import { startFakeGitea } from './helpers/fakeGitea.js'
|
||||||
|
|
||||||
|
let aw, runner, gitea, tmp, pd, rec, config, baseUrl, server
|
||||||
|
const key = crypto.randomBytes(32)
|
||||||
|
const SHA = 'a'.repeat(40)
|
||||||
|
const SECRET = 's3cret'
|
||||||
|
const rs = { calls: [], runs: new Map(), next: 0, script: null, status: { dev: [], known: [], queue: 0 }, statusDown: false }
|
||||||
|
|
||||||
|
const done = (over = {}) => (job) => ({ state: 'done', detected: { kind: 'static', pm: 'npm' }, sha: job.sha, log: ['ok'], error: null, result: 'static', ...over })
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
aw = await startFakeAppwrite({ seed: { websiteProjects: [] } })
|
||||||
|
runner = 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, body })
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
const send = (code, obj) => { res.statusCode = code; res.end(JSON.stringify(obj)) }
|
||||||
|
if (req.method === 'GET' && req.url === '/status') return rs.statusDown ? send(500, { error: 'kaputt' }) : send(200, rs.status)
|
||||||
|
if (req.method === 'POST' && req.url === '/jobs/build') {
|
||||||
|
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: 'x' })
|
||||||
|
if (req.method === 'PUT' && req.url.startsWith('/dev/')) return send(200, { state: 'sleeping' })
|
||||||
|
send(404, {})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await new Promise((r) => runner.listen(0, '127.0.0.1', r))
|
||||||
|
gitea = await startFakeGitea({
|
||||||
|
repos: [{ full_name: 'WEBklar/site-x' }, { full_name: 'WEBklar/site-y' }],
|
||||||
|
contents: {
|
||||||
|
'WEBklar/site-x:.webklar-preview.json': JSON.stringify({ enabled: true, subdomain: 'site-x', type: 'static', branch: 'main' }),
|
||||||
|
'WEBklar/site-y:.webklar-preview.json': JSON.stringify({ enabled: true, subdomain: 'site-y', type: 'static', branch: 'main' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-final-'))
|
||||||
|
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: gitea.baseUrl,
|
||||||
|
GITEA_API_TOKEN: 'tok',
|
||||||
|
GITEA_WEBHOOK_TOKEN: SECRET,
|
||||||
|
PREVIEW_RUNNER_URL: `http://127.0.0.1:${runner.address().port}`,
|
||||||
|
PREVIEW_RUNNER_TOKEN: 'sekret',
|
||||||
|
PREVIEW_ENV_KEY: key.toString('base64'),
|
||||||
|
})
|
||||||
|
;({ 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
|
||||||
|
rec = await import('../server/services/previewReconcile.js')
|
||||||
|
const { default: routes } = await import('../server/routes/webhook/gitea.js')
|
||||||
|
const app = express()
|
||||||
|
app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } }))
|
||||||
|
app.use('/webhook', routes)
|
||||||
|
server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) })
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||||
|
})
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await new Promise((r) => server.close(r))
|
||||||
|
await new Promise((r) => runner.close(r))
|
||||||
|
await gitea.close()
|
||||||
|
await aw.close()
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rs.calls = []; rs.runs = new Map(); rs.script = done(); rs.status = { dev: [], known: [], queue: 0 }; rs.statusDown = false
|
||||||
|
config.previewRunner.enabled = true
|
||||||
|
})
|
||||||
|
|
||||||
|
async function push(repo, sha = SHA) {
|
||||||
|
const payload = {
|
||||||
|
ref: 'refs/heads/main',
|
||||||
|
repository: { full_name: repo, name: repo.split('/')[1], html_url: `${gitea.baseUrl}/${repo}` },
|
||||||
|
commits: [{ id: sha, message: 'x', author: { name: 'nico' } }],
|
||||||
|
pusher: { username: 'nico' },
|
||||||
|
after: sha,
|
||||||
|
}
|
||||||
|
const text = JSON.stringify(payload)
|
||||||
|
const sig = createHmac('sha256', SECRET).update(text).digest('hex')
|
||||||
|
const res = await fetch(`${baseUrl}/webhook/gitea`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': sig }, body: text })
|
||||||
|
return res.status
|
||||||
|
}
|
||||||
|
const byRepo = (repo) => aw.all('websiteProjects').find((d) => d.repoFullName === repo)
|
||||||
|
|
||||||
|
// ---- C1 ----
|
||||||
|
test('C1: Push mit Runner an behält status/provisioningStatus eines bereiten Projekts (auch bei Build-Fehler)', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
await createDocument('websiteProjects', { repoFullName: 'WEBklar/site-x', projectName: 'X', subdomain: 'site-x', status: 'deployed', provisioningStatus: 'ready', previewState: 'online' }, 'cx')
|
||||||
|
rs.script = done({ state: 'failed', result: null, error: 'Build kaputt' })
|
||||||
|
assert.equal(await push('WEBklar/site-x'), 200)
|
||||||
|
let d = byRepo('WEBklar/site-x')
|
||||||
|
assert.equal(d.status, 'deployed')
|
||||||
|
assert.equal(d.provisioningStatus, 'ready')
|
||||||
|
await pd.waitForRunFollowers()
|
||||||
|
d = byRepo('WEBklar/site-x')
|
||||||
|
assert.equal(d.previewState, 'failed')
|
||||||
|
assert.equal(d.status, 'deployed') // alte Preview bleibt online: nie herabstufen
|
||||||
|
assert.equal(d.provisioningStatus, 'ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('C1: neues Projekt per Push wird nach Verfolger-Erfolg bereit', async () => {
|
||||||
|
assert.equal(await push('WEBklar/site-y'), 200)
|
||||||
|
let d = byRepo('WEBklar/site-y')
|
||||||
|
assert.equal(d.provisioningStatus, 'pending')
|
||||||
|
await pd.waitForRunFollowers()
|
||||||
|
d = byRepo('WEBklar/site-y')
|
||||||
|
assert.equal(d.previewState, 'online')
|
||||||
|
assert.equal(d.status, 'deployed')
|
||||||
|
assert.equal(d.provisioningStatus, 'ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('C1: deployKeepsStatus nur mit Runner an', () => {
|
||||||
|
assert.equal(pd.deployKeepsStatus({ deployed: false, queued: true }), true)
|
||||||
|
assert.equal(pd.deployKeepsStatus({ deployed: false, skipped: true }), true)
|
||||||
|
assert.equal(pd.deployKeepsStatus({ deployed: true }), false)
|
||||||
|
config.previewRunner.enabled = false
|
||||||
|
assert.equal(pd.deployKeepsStatus({ deployed: false, queued: true }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- I1 ----
|
||||||
|
test('I1: Build fehlgeschlagen -> dev-Rückfall wird als dev:<pm> gespeichert', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const doc = await createDocument('websiteProjects', { repoFullName: 'WEBklar/auto-dev', projectName: 'A' }, 'i1')
|
||||||
|
rs.script = done({ result: 'dev', detected: { kind: 'build', pm: 'pnpm' } })
|
||||||
|
await pd.runDeploy('WEBklar/auto-dev', 'main', { subdomain: 'auto-dev', type: 'static', branch: 'main' }, { project: doc, sha: SHA })
|
||||||
|
await pd.waitForRunFollowers()
|
||||||
|
assert.equal(aw.get('websiteProjects', 'i1').previewDetected, 'dev:pnpm')
|
||||||
|
assert.equal(aw.get('websiteProjects', 'i1').previewState, 'sleeping')
|
||||||
|
assert.equal(pd.detectedLabel({ kind: 'build', pm: 'npm' }, 'dev'), 'dev:npm')
|
||||||
|
assert.equal(pd.detectedLabel({ kind: 'build', pm: 'npm' }, 'build'), 'build:npm')
|
||||||
|
assert.equal(pd.detectedLabel({ kind: 'static' }, undefined), 'static')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Laengen ----
|
||||||
|
test('previewError wird auf 500 Zeichen gekürzt (Patch geht nicht verloren)', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const doc = await createDocument('websiteProjects', { repoFullName: 'WEBklar/lang', projectName: 'L' }, 'lang')
|
||||||
|
rs.script = done({ state: 'failed', result: null, error: 'x'.repeat(2000) })
|
||||||
|
await pd.runDeploy('WEBklar/lang', 'main', { subdomain: 'lang', type: 'static', branch: 'main' }, { project: doc, sha: SHA })
|
||||||
|
await pd.waitForRunFollowers()
|
||||||
|
const d = aw.get('websiteProjects', 'lang')
|
||||||
|
assert.equal(d.previewState, 'failed')
|
||||||
|
assert.equal(d.previewError.length, 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- R18 Abgleich ----
|
||||||
|
test('I3: building ohne Verfolger und > 70 min => failed; jung oder mit Verfolger bleibt', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
await createDocument('websiteProjects', { repoFullName: 'WEBklar/r-alt', subdomain: 'r-alt', previewState: 'building' }, 'r1')
|
||||||
|
await createDocument('websiteProjects', { repoFullName: 'WEBklar/r-jung', subdomain: 'r-jung', previewState: 'building' }, 'r2')
|
||||||
|
const res = await rec.reconcilePreviews({ now: Date.now() + 80 * 60 * 1000 }) // "jetzt" in 80 min: beide dann aelter als 70 min
|
||||||
|
assert.equal(res.staleFailed >= 2, true)
|
||||||
|
assert.equal(aw.get('websiteProjects', 'r1').previewState, 'failed')
|
||||||
|
assert.match(aw.get('websiteProjects', 'r1').previewError, /Status unbekannt/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('I3: junges building bleibt, building mit laufendem Verfolger bleibt', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
await createDocument('websiteProjects', { repoFullName: 'WEBklar/j1', subdomain: 'j1', previewState: 'building' }, 'j1')
|
||||||
|
await rec.reconcilePreviews()
|
||||||
|
assert.equal(aw.get('websiteProjects', 'j1').previewState, 'building')
|
||||||
|
// Verfolger laeuft (Lauf haengt in 'running')
|
||||||
|
const { createDocument: cd } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const doc = await cd('websiteProjects', { repoFullName: 'WEBklar/j2', subdomain: 'j2', projectName: 'J' }, 'j2')
|
||||||
|
rs.script = () => ({ state: 'running', log: [] })
|
||||||
|
await pd.runDeploy('WEBklar/j2', 'main', { subdomain: 'j2', type: 'static', branch: 'main' }, { project: doc, sha: SHA })
|
||||||
|
assert.equal(pd.hasLiveFollower('WEBklar/j2'), true)
|
||||||
|
const res = await rec.reconcilePreviews({ now: Date.now() + 80 * 60 * 1000 })
|
||||||
|
assert.equal(aw.get('websiteProjects', 'j2').previewState, 'building')
|
||||||
|
assert.ok(res.staleFailed >= 0)
|
||||||
|
// aufraeumen: Lauf beenden
|
||||||
|
rs.runs.set('run-' + rs.next, { state: 'done', detected: { kind: 'static' }, sha: SHA, log: [], result: 'static' })
|
||||||
|
await pd.waitForRunFollowers()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('I6: Dev-Zustand folgt dem Runner; Modus off bleibt unberührt', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
const base = { previewDetected: 'dev:npm', previewBranch: 'main', previewBuiltSha: SHA, previewMode: 'auto' }
|
||||||
|
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-on', subdomain: 'd-on', previewState: 'starting' }, 'd1')
|
||||||
|
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-gone', subdomain: 'd-gone', previewState: 'online' }, 'd2')
|
||||||
|
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-off', subdomain: 'd-off', previewState: 'sleeping', previewMode: 'off' }, 'd3')
|
||||||
|
await createDocument('websiteProjects', { ...base, repoFullName: 'WEBklar/d-fail', subdomain: 'd-fail', previewState: 'online' }, 'd4')
|
||||||
|
rs.status = { dev: [{ sub: 'd-on', state: 'online' }, { sub: 'd-fail', state: 'failed' }], known: ['d-on', 'd-gone', 'd-off', 'd-fail'], queue: 0 }
|
||||||
|
const res = await rec.reconcilePreviews()
|
||||||
|
assert.equal(aw.get('websiteProjects', 'd1').previewState, 'online')
|
||||||
|
assert.equal(aw.get('websiteProjects', 'd2').previewState, 'sleeping')
|
||||||
|
assert.equal(aw.get('websiteProjects', 'd3').previewState, 'sleeping')
|
||||||
|
assert.equal(aw.get('websiteProjects', 'd4').previewState, 'sleeping')
|
||||||
|
assert.equal(res.reregistered, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('I4: dem Runner unbekannte Dev-Spezifikation wird erneut hinterlegt', async () => {
|
||||||
|
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
await createDocument('websiteProjects', {
|
||||||
|
previewDetected: 'dev:npm', previewBranch: 'main', previewBuiltSha: SHA, previewMode: 'dev',
|
||||||
|
repoFullName: 'WEBklar/d-neu', subdomain: 'd-neu', previewState: 'sleeping',
|
||||||
|
previewEnv: (await import('../server/services/previewEnv.js')).encryptEnv({ K: 'wert1234' }, key),
|
||||||
|
}, 'd5')
|
||||||
|
rs.status = { dev: [], known: [], queue: 0 }
|
||||||
|
const res = await rec.reconcilePreviews()
|
||||||
|
const put = rs.calls.filter((c) => c.method === 'PUT' && c.url === '/dev/d-neu')
|
||||||
|
assert.equal(put.length, 1)
|
||||||
|
assert.deepEqual(put[0].body, { sub: 'd-neu', repo: 'WEBklar/d-neu', sha: SHA, branch: 'main', mode: 'dev', env: { K: 'wert1234' } })
|
||||||
|
assert.ok(res.reregistered >= 1)
|
||||||
|
assert.equal(aw.get('websiteProjects', 'd5').previewState, 'sleeping')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Abgleich: Runner nicht erreichbar / Schalter aus wirft nie und ändert nichts', async () => {
|
||||||
|
const errors = console.error
|
||||||
|
console.error = () => {}
|
||||||
|
try {
|
||||||
|
rs.statusDown = true
|
||||||
|
const r1 = await rec.reconcilePreviews()
|
||||||
|
assert.ok(r1.error)
|
||||||
|
assert.equal(aw.get('websiteProjects', 'd2').previewState, 'sleeping')
|
||||||
|
assert.equal(await rec.runReconcile().then((r) => typeof r), 'object')
|
||||||
|
} finally { console.error = errors }
|
||||||
|
config.previewRunner.enabled = false
|
||||||
|
assert.deepEqual(await rec.reconcilePreviews(), { skipped: true })
|
||||||
|
})
|
||||||
Reference in new issue
Block a user