Files
Webklar-Kundenbereich/server/services/previewDeploy.js
T

830 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import fs from 'node:fs/promises'
import path from 'node:path'
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, maskLog } from './previewEnv.js'
import { saveRunLog } from './previewLogs.js'
import * as runnerClient from './previewRunnerClient.js'
const execFileAsync = promisify(execFile)
/**
* Subdomains, die als "oeffentliche Website" (ohne Login) laufen sollen.
* Diese bekommen in syncPreviewTls einen Router OHNE preview-auth-ForwardAuth.
* Liefert null, wenn Appwrite nicht antwortet (z.B. direkt nach einem
* Server-Neustart, solange Appwrite noch hochfaehrt).
*/
async function getPublicSubdomains() {
try {
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('isPublic', true),
Query.limit(500),
])
return new Set(docs.map((d) => d.subdomain).filter(Boolean))
} catch (err) {
console.error('[previewDeploy] getPublicSubdomains fehlgeschlagen:', err.message)
return null
}
}
/**
* Oeffentliche Subdomains aus einer von syncPreviewTls geschriebenen
* preview-hosts.yml: alle HTTPS-Router ohne preview-chain.
*/
export function publicSubdomainsFromHostsFile(content) {
const result = new Set()
let current = null
let isProtected = false
const finish = () => {
if (current && !isProtected) result.add(current)
current = null
}
for (const line of String(content || '').split('\n')) {
if (/^ {4}\S/.test(line)) {
finish()
const match = line.match(/^ {4}preview-([a-z0-9-]+)-https:\s*$/)
if (match) {
current = match[1]
isProtected = false
}
} else if (current && line.includes('preview-chain@docker')) {
isProtected = true
}
}
finish()
return result
}
/**
* Dev-Subdomains aus einer von syncPreviewTls geschriebenen preview-hosts.yml:
* alle HTTPS-Router, deren Service preview-runner@docker ist.
*/
export function devSubdomainsFromHostsFile(content) {
const result = new Set()
let current = null
for (const line of String(content || '').split('\n')) {
if (/^ {4}\S/.test(line)) {
const match = line.match(/^ {4}preview-([a-z0-9-]+)-https:\s*$/)
current = match ? match[1] : null
} else if (current && /^ {6}service:\s*preview-runner@docker\s*$/.test(line)) {
result.add(current)
}
}
return result
}
/**
* Projekte mit laufendem/schlafendem Dev-Server (nur mit Preview-Runner).
* Liefert null, wenn Appwrite nicht antwortet.
*/
async function getDevProjects() {
try {
const docs = await listDocuments(config.collections.websiteProjects, [
Query.equal('previewState', ['sleeping', 'starting', 'online', 'building', 'failed']),
Query.limit(500),
])
// Oeffentliche Seiten nur bei explizitem previewMode 'dev' zum Runner, sonst immer nginx
return docs.filter(
(d) =>
d.subdomain &&
/^[a-z0-9-]+$/.test(d.subdomain) &&
String(d.previewDetected || '').startsWith('dev') &&
(d.isPublic !== true || d.previewMode === 'dev')
)
} catch (err) {
console.error('[previewDeploy] getDevProjects fehlgeschlagen:', err.message)
return null
}
}
export async function fetchPreviewConfig(repoFullName, branch) {
const [owner, repo] = repoFullName.split('/')
if (!owner || !repo || !config.gitea.apiToken) {
return null
}
const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}`
const response = await fetch(url, {
headers: { Authorization: `token ${config.gitea.apiToken}` },
// Gleiches Zeitlimit wie giteaFetch (server/services/giteaClient.js) - sonst haengt ein
// Preview-Deploy unbegrenzt, wenn Gitea nicht antwortet.
signal: AbortSignal.timeout(config.gitea.timeoutMs),
})
if (!response.ok) return null
const data = await response.json()
const content = Buffer.from(data.content || '', 'base64').toString('utf8')
return JSON.parse(content)
}
export function defaultPreviewConfig(repoFullName) {
const repoName = repoFullName.split('/').pop() || 'preview'
return {
enabled: false,
type: 'static',
branch: 'main',
displayName: repoName,
subdomain: repoName,
}
}
/**
* Baut aus einer Gitea-Clone-URL eine authentifizierte URL, damit auch PRIVATE
* Repos geklont werden koennen. Ohne Token bleibt die URL unveraendert (anonymer
* Clone fuer oeffentliche Repos).
*/
function authedCloneUrl(cloneUrl, token) {
if (!token) return cloneUrl
try {
const u = new URL(cloneUrl)
u.username = 'oauth2'
u.password = token
return u.toString()
} catch {
return cloneUrl
}
}
async function cloneRepo(cloneUrl, targetDir, branch, token = '') {
await fs.rm(targetDir, { recursive: true, force: true })
await fs.mkdir(targetDir, { recursive: true })
try {
await execFileAsync('git', [
'clone',
'--depth',
'1',
'--branch',
branch,
authedCloneUrl(cloneUrl, token),
targetDir,
])
} catch (err) {
// Token niemals in Logs/Fehlermeldungen durchreichen.
if (token && err && typeof err.message === 'string') {
err.message = err.message.split(token).join('***')
}
throw err
}
}
async function deployStatic(sourceDir, targetDir, indexSubdir = '') {
let webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir
// Falls der in .webklar-preview.json angegebene index-Unterordner nicht (mehr)
// existiert, auf die Repo-Wurzel zurueckfallen statt den cp scheitern zu lassen.
try {
await fs.access(webroot)
} catch {
webroot = sourceDir
}
await fs.rm(targetDir, { recursive: true, force: true })
await fs.mkdir(targetDir, { recursive: true })
await execFileAsync('cp', ['-R', `${webroot}/.`, targetDir])
// Git-Metadaten nie mit ausliefern (verhindert Zugriff auf /.git/ im Preview).
await fs.rm(path.join(targetDir, '.git'), { recursive: true, force: true })
}
/**
* Platzhalter-Seite, die statt eines nackten nginx-403 ausgeliefert wird, wenn
* ein Deploy-Verzeichnis keine index.html enthaelt (z.B. leergeraeumtes Repo,
* fehlerhafter Build oder falscher index-Unterordner). So bleibt die Subdomain
* immer erreichbar und zeigt eine verstaendliche Meldung.
*/
function placeholderHtml(displayName) {
const name = String(displayName || 'Diese Website')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>${name} – wird vorbereitet</title>
<style>
html,body{height:100%;margin:0}
body{display:flex;align-items:center;justify-content:center;
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
background:#0f172a;color:#e2e8f0}
.box{max-width:32rem;padding:2rem;text-align:center}
h1{font-size:1.4rem;margin:0 0 .75rem}
p{margin:0;color:#94a3b8;line-height:1.5}
</style>
</head>
<body>
<div class="box">
<h1>${name}</h1>
<p>Diese Seite wird gerade vorbereitet oder aktualisiert.<br>Bitte in Kürze erneut laden.</p>
</div>
</body>
</html>
`
}
/**
* Sucht im Deploy-Verzeichnis die beste vorhandene HTML-Datei als Startseiten-
* Ersatz: bevorzugte Namen (index.htm, home, main, start, app), sonst die
* einzige bzw. groesste HTML-Datei. Nur Dateien im Wurzelverzeichnis.
*/
async function findIndexCandidate(targetDir) {
let entries = []
try {
entries = await fs.readdir(targetDir, { withFileTypes: true })
} catch {
return null
}
const htmlFiles = entries
.filter((e) => e.isFile() && /\.html?$/i.test(e.name) && !e.name.startsWith('.'))
.map((e) => e.name)
.filter((name) => name.toLowerCase() !== 'index.html')
if (!htmlFiles.length) return null
const preferred = ['index.htm', 'home.html', 'homepage.html', 'main.html', 'start.html', 'app.html']
for (const want of preferred) {
const hit = htmlFiles.find((name) => name.toLowerCase() === want)
if (hit) return hit
}
if (htmlFiles.length === 1) return htmlFiles[0]
// Mehrere Kandidaten ohne sprechenden Namen: die groesste Datei ist mit
// Abstand am wahrscheinlichsten die eigentliche Projektseite.
const sized = await Promise.all(
htmlFiles.map(async (name) => {
try {
const stat = await fs.stat(path.join(targetDir, name))
return { name, size: stat.size }
} catch {
return { name, size: 0 }
}
})
)
sized.sort((a, b) => b.size - a.size)
return sized[0].name
}
/**
* Stellt sicher, dass ein Deploy-Verzeichnis eine index.html besitzt. Fehlt sie,
* wird zuerst eine vorhandene HTML-Datei zur Startseite befoerdert (Kopie),
* damit die Preview das echte Projekt zeigt statt "wird vorbereitet". Erst wenn
* gar keine HTML-Datei existiert, kommt der Platzhalter (403-Schutz, nginx
* liefert mit autoindex off sonst "403 Forbidden").
*/
async function ensureIndexHtml(targetDir, displayName) {
const indexPath = path.join(targetDir, 'index.html')
try {
await fs.access(indexPath)
return { placeholder: false }
} catch {
/* keine index.html vorhanden -> Kandidat befoerdern oder Platzhalter */
}
const candidate = await findIndexCandidate(targetDir)
if (candidate) {
await fs.copyFile(path.join(targetDir, candidate), indexPath)
console.log(`[previewDeploy] keine index.html – "${candidate}" als Startseite uebernommen (${targetDir})`)
return { placeholder: false, promotedFrom: candidate }
}
await fs.mkdir(targetDir, { recursive: true })
await fs.writeFile(indexPath, placeholderHtml(displayName), 'utf8')
return { placeholder: true }
}
/**
* Erkennt App-Repos, die einen Build-Schritt brauchen (Expo, Vite, React ...):
* package.json mit "build"-Script vorhanden, aber keine index.html im Webroot.
* Solche Repos wuerden als "static" nur den Platzhalter zeigen - deshalb wird
* der Deploy-Typ automatisch auf node_build angehoben (Langzeit-Fix, damit
* App-Projekte ohne manuelle Config-Pflege korrekt angezeigt werden).
*/
async function shouldAutoBuild(sourceDir, indexSubdir = '') {
try {
const pkg = JSON.parse(await fs.readFile(path.join(sourceDir, 'package.json'), 'utf8'))
if (!pkg?.scripts?.build) return false
} catch {
return false
}
const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir
try {
await fs.access(path.join(webroot, 'index.html'))
return false // echte statische Startseite vorhanden -> kein Build noetig
} catch {
return true
}
}
/**
* npm ci + npm run build, dann das Build-Ausgabeverzeichnis deployen.
* Ausgabeverzeichnis wird erkannt (dist > build > out > web-build), weil
* Toolchains unterschiedliche Defaults haben (Expo/Vite: dist, CRA: build).
*/
async function deployNodeBuild(sourceDir, targetDir) {
const execOpts = {
cwd: sourceDir,
maxBuffer: 64 * 1024 * 1024,
timeout: 15 * 60 * 1000,
env: { ...process.env, CI: '1' },
}
// Der Server-Container laeuft mit NODE_ENV=production, dadurch setzt npm
// automatisch omit=dev. Die Build-Toolchain (Next, Vite, Tailwind, TypeScript)
// steht aber in den devDependencies -> ohne "--include=dev" bricht jeder
// Build mit "Cannot find module ..." ab (Fall Anelia-Wolf/Next 16).
try {
await execFileAsync('npm', ['ci', '--include=dev'], execOpts)
} catch {
// Ohne (passendes) package-lock.json schlaegt npm ci fehl -> npm install.
await execFileAsync('npm', ['install', '--include=dev', '--no-audit', '--no-fund'], execOpts)
}
await execFileAsync('npm', ['run', 'build'], execOpts)
for (const dirName of ['dist', 'build', 'out', 'web-build']) {
const candidate = path.join(sourceDir, dirName)
try {
await fs.access(path.join(candidate, 'index.html'))
await deployStatic(candidate, targetDir)
return
} catch {
/* naechsten Kandidaten pruefen */
}
}
throw new Error('Build erzeugte kein Ausgabeverzeichnis mit index.html (dist/build/out/web-build)')
}
export function buildPreviewUrl(subdomain) {
return `https://${subdomain}.${config.preview.baseHost}`
}
/** Arbeitsverzeichnis fuer den flachen Klon eines Repos beim Preview-Deploy. */
export function previewWorkDir(repoFullName) {
return path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
}
/**
* Schreibt pro deployter Preview-Subdomain einen Traefik-Router mit Host()-Regel
* nach preview-hosts.yml, damit Let's Encrypt (HTTP-01) gueltige Zertifikate
* ausstellt. HostRegexp allein liefert nur das Default-Zertifikat ("Nicht sicher").
* Ersetzt traefik/scripts/sync-preview-tls.sh (bash fehlt im Alpine-Container).
*/
export async function syncPreviewTls() {
const previewRoot = config.preview.deployRoot
const outDir = config.preview.traefikDynamicDir
if (!previewRoot || !outDir) {
return { synced: false, reason: 'PREVIEW_DEPLOY_ROOT oder TRAEFIK_DYNAMIC_DIR nicht gesetzt' }
}
const baseHost = config.preview.baseHost
let entries = []
try {
entries = await fs.readdir(previewRoot, { withFileTypes: true })
} catch {
entries = []
}
const folderSlugs = entries
.filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name))
.map((entry) => entry.name)
const hostsFile = path.join(outDir, 'preview-hosts.yml')
// Dev-Server (Preview-Runner): Hosts auch ohne Ordner, Service preview-runner
let devSet = new Set()
let devProjects = []
if (config.previewRunner.enabled) {
const found = await getDevProjects()
if (found) {
devProjects = found
devSet = new Set(found.map((d) => d.subdomain))
} else {
// Appwrite antwortet nicht: Dev-Hosts aus der letzten Datei uebernehmen
devSet = await fs.readFile(hostsFile, 'utf8').then(devSubdomainsFromHostsFile, () => new Set())
}
}
const slugs = [...new Set([...folderSlugs, ...devSet])].sort()
let publicSet = await getPublicSubdomains()
let publicSource = 'appwrite'
if (!publicSet) {
// Appwrite antwortet nicht: oeffentliche Seiten aus der letzten Datei
// uebernehmen, statt sie alle hinter den Login zu setzen. Neue
// Subdomains bleiben login-geschuetzt (sicherer Default).
publicSet = await fs.readFile(hostsFile, 'utf8').then(publicSubdomainsFromHostsFile, () => new Set())
publicSource = 'previous-file'
}
const blocks = slugs.map((name) => {
const host = `${name}.${baseHost}`
const id = `preview-${name}`
const isPublic = publicSet.has(name)
const service = devSet.has(name) ? 'preview-runner@docker' : 'preview-nginx@docker'
// Oeffentliche Website: HTTPS-Router OHNE preview-auth -> frei zugaenglich.
// Preview (Standard): mit preview-chain (ForwardAuth) -> login-geschuetzt.
const httpsMiddlewareLines = isPublic
? []
: [' middlewares:', ' - preview-chain@docker']
return [
` ${id}-https:`,
` rule: "Host(\`${host}\`)"`,
' entryPoints:',
' - websecure',
` service: ${service}`,
...httpsMiddlewareLines,
' priority: 100',
' tls:',
' certResolver: letsencrypt',
'',
` ${id}-http:`,
` rule: "Host(\`${host}\`)"`,
' entryPoints:',
' - web',
` service: ${service}`,
' middlewares:',
' - preview-sites-redirect@docker',
' priority: 100',
].join('\n')
})
const content = [
'# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually',
'http:',
' routers:',
...blocks,
'',
].join('\n')
await fs.mkdir(outDir, { recursive: true })
await fs.writeFile(hostsFile, content, 'utf8')
if (devProjects.length) registerDevSpecs(devProjects)
return { synced: true, hosts: slugs.length, publicSource }
}
// R12: Dev-Auftraege beim Runner hinterlegen (Hintergrund, nie blockierend/werfend)
let registering = null
/** Wartet auf eine laufende Registrierungsrunde (Tests). */
export async function waitForDevRegistration() {
while (registering) await registering
}
let pendingProjects = null
export async function registerDevRound(projects) {
const key = envKey()
await Promise.allSettled(
projects.map(async (project) => {
const sub = project.subdomain
try {
const repo = project.repoFullName
const { previewBranch: branch, previewBuiltSha: sha } = project
// Kein Raten (R17): ohne gespeicherten Branch/Stand wird uebersprungen
if (!repo || !branch || !SHA_RE.test(String(sha || ''))) {
console.error(`[previewDeploy] Dev-Registrierung ${sub} uebersprungen: Branch oder Stand unbekannt`)
return
}
let env = {}
if (project.previewEnv) {
try { env = decryptEnv(project.previewEnv, key) } catch {
console.error(`[previewDeploy] Dev-Registrierung ${sub} uebersprungen: Variablen nicht entschluesselbar`)
return
}
}
await runnerClient.startDev(sub, { sub, repo, sha, branch, mode: 'dev', env })
} catch (err) {
console.error(`[previewDeploy] Dev-Registrierung ${sub} fehlgeschlagen:`, err.message)
}
})
)
}
function registerDevSpecs(projects) {
// Laeuft schon eine Runde: genau eine Wiederholung danach (mit dem neuesten Stand)
if (registering) { pendingProjects = projects; return }
registering = (async () => {
let next = projects
while (next) {
pendingProjects = null
await registerDevRound(next)
next = pendingProjects
}
})().finally(() => { registering = null })
}
/**
* Nach einem Server-Neustart startet der Kundenbereich oft vor Appwrite. Dann
* kennt syncPreviewTls die oeffentlichen Seiten nur aus der letzten Datei; der
* Abgleich wird wiederholt, bis er mit Appwrite-Daten geklappt hat.
*/
export async function syncPreviewTlsOnStartup({
sync = syncPreviewTls,
retryMs = 15_000,
maxAttempts = 40,
} = {}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await sync()
console.log('[startup] Preview-TLS-Sync:', JSON.stringify(result))
if (!result.synced || result.publicSource === 'appwrite') return result
} catch (err) {
console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message)
}
if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, retryMs))
}
console.error(
`[startup] Preview-TLS-Sync: Appwrite nach ${maxAttempts} Versuchen nicht erreichbar, oeffentliche Seiten bleiben wie in der letzten Datei`
)
return null
}
// ---- Preview-Runner (PREVIEW_RUNNER=on) ----
// Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar)
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()
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'
/** 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). */
export async function waitForRunFollowers() {
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)
export function detectedLabel(detected, result) {
const kind = result || detected?.kind
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
}
}
// 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) {
data = clampAttrs(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, token, runId, subdomain, job, env, warning }) {
const deadline = Date.now() + runnerTiming.maxMs
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)
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 = 'Status unbekannt – bitte neu bauen'; 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 || '',
previewBranch: job.branch,
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') {
await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' })
try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined }) } catch (err) {
console.error('[previewDeploy] Dev-Registrierung fehlgeschlagen:', err.message)
}
} else {
await patchProject(repoFullName, project, {
...base, previewState: 'online', previewBuiltAt: now,
})
}
} else {
await patchProject(repoFullName, project, {
previewState: 'failed',
previewError: maskLog(failure || run?.error || 'Preview-Build fehlgeschlagen', env),
})
}
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) === 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') {
try {
if (current()) await patchProject(repoFullName, doc, { previewState: 'off' })
} finally { release() }
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 }
}
}
const displayName = String(previewConfig.displayName || doc?.projectName || '').slice(0, 100).replace(/[\x00-\x1f\x7f]/g, ' ').trim()
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 } : {}),
...(displayName ? { displayName } : {}),
env,
}
const { runId } = await runnerClient.submitBuild(job)
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)
try {
if (current()) await patchProject(repoFullName, doc, { previewState: 'failed', previewError: maskLog(err.message, env) })
} finally { release() }
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' }
}
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
const workDir = previewWorkDir(repoFullName)
// Deploys desselben Repos oder derselben Subdomain nacheinander: Projektaenderung und
// Gitea-Webhook kamen gleichzeitig, beide git clone liefen ins selbe workDir und
// brachen ab (Kirschner Gebaeudeservice, 30.09.2026).
return withLocks([`preview-work:${workDir}`, `preview-site:${subdomain}`], () =>
deployLocked(repoFullName, branch, previewConfig, subdomain, workDir)
)
}
async function deployLocked(repoFullName, branch, previewConfig, subdomain, workDir) {
const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git`
const targetDir = path.join(config.preview.deployRoot, subdomain)
await cloneRepo(cloneUrl, workDir, branch, config.gitea.apiToken)
let deployType = previewConfig.type
if (deployType !== 'node_build' && (await shouldAutoBuild(workDir, previewConfig.index || ''))) {
console.log(`[previewDeploy] ${repoFullName}: package.json mit build-Script, keine index.html -> node_build (auto)`)
deployType = 'node_build'
}
if (deployType === 'node_build') {
try {
await deployNodeBuild(workDir, targetDir)
} catch (err) {
// Build-Fehler duerfen die Subdomain nicht toeten: statisch deployen,
// ensureIndexHtml haelt sie mit Kandidat/Platzhalter erreichbar.
console.error(`[previewDeploy] node_build fuer ${repoFullName} fehlgeschlagen, Fallback auf static:`, err.message)
await deployStatic(workDir, targetDir, previewConfig.index || '')
}
} else {
await deployStatic(workDir, targetDir, previewConfig.index || '')
}
// Universeller 403-Schutz: ohne index.html liefert nginx (autoindex off) sonst
// "403 Forbidden". Ein Platzhalter haelt die Subdomain immer erreichbar.
const index = await ensureIndexHtml(targetDir, previewConfig.displayName || subdomain)
let tls = { synced: false }
try {
tls = await syncPreviewTls()
} catch (err) {
console.error('[previewDeploy] TLS-Sync fehlgeschlagen:', err.message)
tls = { synced: false, error: err.message }
}
return { deployed: true, subdomain, targetDir, tls, placeholder: index.placeholder }
}
/**
* Entfernt das Deploy-Verzeichnis einer Subdomain und synchronisiert die
* Traefik-TLS-Router neu (z.B. beim Aendern der Subdomain oder Archivieren).
*/
export async function removeDeploy(subdomain) {
if (!subdomain || !config.preview.deployRoot) return { removed: false }
if (!/^[a-z0-9-]+$/.test(subdomain)) return { removed: false, reason: 'ungueltige Subdomain' }
const targetDir = path.join(config.preview.deployRoot, subdomain)
await fs.rm(targetDir, { recursive: true, force: true })
let tls = { synced: false }
try {
tls = await syncPreviewTls()
} catch (err) {
console.error('[previewDeploy] TLS-Sync nach removeDeploy fehlgeschlagen:', err.message)
tls = { synced: false, error: err.message }
}
return { removed: true, subdomain, tls }
}
// Nur fuer Tests (Vergleich Altpfad gegen Runner-Veroeffentlichung)
export const previewDeployInternals = { deployStatic, ensureIndexHtml, placeholderHtml, findIndexCandidate }