diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 2c1a026..ec4fdac 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -59,6 +59,43 @@ export function publicSubdomainsFromHostsFile(content) { 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']), + Query.limit(500), + ]) + return docs.filter( + (d) => d.subdomain && /^[a-z0-9-]+$/.test(d.subdomain) && String(d.previewDetected || '').startsWith('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) { @@ -341,12 +378,25 @@ export async function syncPreviewTls() { entries = [] } - const slugs = entries + const folderSlugs = entries .filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name)) .map((entry) => entry.name) - .sort() 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) { @@ -361,6 +411,7 @@ export async function syncPreviewTls() { 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 @@ -371,7 +422,7 @@ export async function syncPreviewTls() { ` rule: "Host(\`${host}\`)"`, ' entryPoints:', ' - websecure', - ' service: preview-nginx@docker', + ` service: ${service}`, ...httpsMiddlewareLines, ' priority: 100', ' tls:', @@ -381,7 +432,7 @@ export async function syncPreviewTls() { ` rule: "Host(\`${host}\`)"`, ' entryPoints:', ' - web', - ' service: preview-nginx@docker', + ` service: ${service}`, ' middlewares:', ' - preview-sites-redirect@docker', ' priority: 100', @@ -398,9 +449,49 @@ export async function syncPreviewTls() { 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 +} + +function registerDevSpecs(projects) { + if (registering) return + registering = (async () => { + const key = envKey() + await Promise.allSettled( + projects.map(async (project) => { + const sub = project.subdomain + try { + const repo = project.repoFullName + if (!repo) return + const branch = project.previewBranch || 'main' + let sha = project.previewBuiltSha + if (!SHA_RE.test(String(sha || ''))) { + try { sha = await resolveSha(repo, branch, null) } catch (err) { + console.error(`[previewDeploy] Dev-Registrierung ${sub} uebersprungen:`, err.message) + return + } + } + let env = {} + if (project.previewEnv && key) { + try { env = decryptEnv(project.previewEnv, key) } catch { env = {} } + } + await runnerClient.startDev(sub, { sub, repo, sha, branch, mode: 'dev', env }) + } catch (err) { + console.error(`[previewDeploy] Dev-Registrierung ${sub} fehlgeschlagen:`, err.message) + } + }) + ) + })().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 diff --git a/test/previewDevRouter.test.js b/test/previewDevRouter.test.js new file mode 100644 index 0000000..5cdf095 --- /dev/null +++ b/test/previewDevRouter.test.js @@ -0,0 +1,239 @@ +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, config, hostsFile, envLib +const key = crypto.randomBytes(32) +const BUILT = 'c'.repeat(40) +const HEAD = 'd'.repeat(40) +const puts = [] +let giteaDown = false +let runnerFails = false + +before(async () => { + aw = await startFakeAppwrite({ seed: { websiteProjects: [] } }) + runner = http.createServer((req, res) => { + let raw = '' + req.on('data', (c) => (raw += c)) + req.on('end', () => { + res.setHeader('Content-Type', 'application/json') + if (req.method === 'PUT' && req.url.startsWith('/dev/')) { + puts.push({ url: req.url, body: JSON.parse(raw) }) + res.statusCode = runnerFails ? 500 : 200 + return res.end(JSON.stringify(runnerFails ? { error: 'kaputt' } : { state: 'sleeping' })) + } + res.statusCode = 404 + res.end('{}') + }) + }) + await new Promise((r) => runner.listen(0, '127.0.0.1', r)) + gitea = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json') + if (giteaDown) { res.statusCode = 500; return res.end('{}') } + res.end(JSON.stringify({ commit: { id: HEAD } })) + }) + await new Promise((r) => gitea.listen(0, '127.0.0.1', r)) + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-dev-')) + await fs.mkdir(path.join(tmp, 'previews', 'statisch'), { recursive: true }) + await fs.mkdir(path.join(tmp, 'previews', 'offen'), { recursive: true }) + hostsFile = path.join(tmp, 'dynamic', 'preview-hosts.yml') + 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'), + }) + ;({ config } = await import('../server/config.js')) + pd = await import('../server/services/previewDeploy.js') + envLib = await import('../server/services/previewEnv.js') + const { createDocument } = await import('../server/services/appwriteAdmin.js') + const base = { previewMode: 'auto' } + await createDocument('websiteProjects', { ...base, subdomain: 'offen', isPublic: true, previewState: 'online', previewDetected: 'static' }, 'p1') + await createDocument('websiteProjects', { ...base, subdomain: 'statisch', previewState: 'online', previewDetected: 'build:npm' }, 'p2') + await createDocument('websiteProjects', { + ...base, subdomain: 'devapp', repoFullName: 'JUSN/devapp', previewState: 'sleeping', previewDetected: 'dev:npm', + previewBuiltSha: BUILT, previewEnv: envLib.encryptEnv({ API_KEY: 'geheim-wert' }, key), + }, 'p3') + await createDocument('websiteProjects', { + ...base, subdomain: 'devoff', repoFullName: 'JUSN/devoff', previewState: 'failed', previewDetected: 'dev:npm', + }, 'p4') +}) + +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 }) +}) + +beforeEach(() => { + puts.length = 0 + giteaDown = false + runnerFails = false + config.previewRunner.enabled = true +}) + +const read = () => fs.readFile(hostsFile, 'utf8') + +// Erwartete Datei von VOR Task 10 (Schalter aus): zwei Ordner, 'offen' oeffentlich +const block = (name, isPublic) => [ + ` preview-${name}-https:`, + ` rule: "Host(\`${name}.project.webklar.com\`)"`, + ' entryPoints:', + ' - websecure', + ' service: preview-nginx@docker', + ...(isPublic ? [] : [' middlewares:', ' - preview-chain@docker']), + ' priority: 100', + ' tls:', + ' certResolver: letsencrypt', + '', + ` preview-${name}-http:`, + ` rule: "Host(\`${name}.project.webklar.com\`)"`, + ' entryPoints:', + ' - web', + ' service: preview-nginx@docker', + ' middlewares:', + ' - preview-sites-redirect@docker', + ' priority: 100', +].join('\n') +const LEGACY = [ + '# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually', + 'http:', + ' routers:', + block('offen', true), + block('statisch', false), + '', +].join('\n') + +const routerBlock = (content, name) => { + const start = content.indexOf(` preview-${name}-https:`) + assert.ok(start >= 0, `Router ${name} fehlt`) + const rest = content.slice(start + 1) + const next = rest.search(/\n {4}preview-[a-z0-9-]+-https:/) + return next < 0 ? content.slice(start) : content.slice(start, start + 1 + next) +} + +test('Schalter aus: Datei byte-identisch wie bisher, keine Dev-Hosts, keine Registrierung', async () => { + config.previewRunner.enabled = false + const result = await pd.syncPreviewTls() + assert.deepEqual(result, { synced: true, hosts: 2, publicSource: 'appwrite' }) + assert.equal(await read(), LEGACY) + await pd.waitForDevRegistration() + assert.equal(puts.length, 0) +}) + +test('Dev-Projekt bekommt Runner-Service (auch ohne Ordner), static bleibt nginx', async () => { + await pd.syncPreviewTls() + await pd.waitForDevRegistration() + const content = await read() + const dev = routerBlock(content, 'devapp') + assert.equal((dev.match(/service: preview-runner@docker/g) || []).length, 2) // HTTPS + HTTP + assert.match(dev, /preview-chain@docker/) // nicht oeffentlich => geschuetzt + assert.match(dev, /preview-sites-redirect@docker/) + assert.match(routerBlock(content, 'statisch'), /preview-nginx@docker/) + assert.doesNotMatch(routerBlock(content, 'statisch'), /preview-runner/) + assert.doesNotMatch(content, /devoff/) // Zustand failed => kein Dev-Host + assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)], ['devapp']) +}) + +test('Appwrite nicht erreichbar: Dev-Hosts aus der letzten Datei bleiben', async () => { + await pd.syncPreviewTls() + await pd.waitForDevRegistration() + aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getPublicSubdomains + aw.failNext({ method: 'GET', pathIncludes: 'documents', status: 500 }) // getDevProjects + const errors = console.error + console.error = () => {} + try { + const result = await pd.syncPreviewTls() + assert.equal(result.publicSource, 'previous-file') + } finally { console.error = errors } + await pd.waitForDevRegistration() + assert.deepEqual([...pd.devSubdomainsFromHostsFile(await read())], ['devapp']) + assert.match(routerBlock(await read(), 'devapp'), /preview-runner@docker/) + assert.equal(puts.length, 1) // nur die Runde mit Appwrite-Daten +}) + +test('devSubdomainsFromHostsFile: nur HTTPS-Router mit Runner-Service', () => { + const content = [ + 'http:', ' routers:', + ' preview-a-https:', ' service: preview-runner@docker', + ' preview-a-http:', ' service: preview-runner@docker', + ' preview-b-https:', ' service: preview-nginx@docker', + ' preview-c-d-https:', ' rule: x', ' service: preview-runner@docker', + ].join('\n') + assert.deepEqual([...pd.devSubdomainsFromHostsFile(content)].sort(), ['a', 'c-d']) + assert.equal(pd.devSubdomainsFromHostsFile('').size, 0) + assert.equal(pd.devSubdomainsFromHostsFile(undefined).size, 0) +}) + +test('Registrierung: Runner bekommt Spec mit entschluesselten Variablen', async () => { + await pd.syncPreviewTls() + await pd.waitForDevRegistration() + assert.equal(puts.length, 1) + assert.equal(puts[0].url, '/dev/devapp') + assert.deepEqual(puts[0].body, { + sub: 'devapp', repo: 'JUSN/devapp', sha: BUILT, branch: 'main', mode: 'dev', env: { API_KEY: 'geheim-wert' }, + }) +}) + +test('Registrierung: ohne PREVIEW_ENV_KEY env = {}', async () => { + const saved = process.env.PREVIEW_ENV_KEY + delete process.env.PREVIEW_ENV_KEY + try { + await pd.syncPreviewTls() + await pd.waitForDevRegistration() + } finally { process.env.PREVIEW_ENV_KEY = saved } + assert.deepEqual(puts[0].body.env, {}) +}) + +test('Registrierung: Runner-Fehler bricht den Abgleich nicht, Datei ist geschrieben', async () => { + runnerFails = true + await fs.rm(hostsFile, { force: true }) + const errors = console.error + console.error = () => {} + try { + const result = await pd.syncPreviewTls() + assert.equal(result.synced, true) + await pd.waitForDevRegistration() + } finally { console.error = errors } + assert.equal(puts.length, 1) + assert.match(await read(), /preview-runner@docker/) +}) + +test('Registrierung: ohne gebauten Stand wird der Branch-Kopf genutzt, bei Fehlschlag uebersprungen', async () => { + const { updateDocument } = await import('../server/services/appwriteAdmin.js') + await updateDocument('websiteProjects', 'p3', { previewBuiltSha: '' }) + try { + await pd.syncPreviewTls() + await pd.waitForDevRegistration() + assert.equal(puts[0].body.sha, HEAD) + puts.length = 0 + giteaDown = true + const errors = console.error + console.error = () => {} + try { + await pd.syncPreviewTls() + await pd.waitForDevRegistration() + } finally { console.error = errors } + assert.equal(puts.length, 0) + assert.match(await read(), /preview-runner@docker/) // Router trotzdem da + } finally { + await updateDocument('websiteProjects', 'p3', { previewBuiltSha: BUILT }) + } +}) + +test('Registrierung: keine zwei Runden gleichzeitig', async () => { + await Promise.all([pd.syncPreviewTls(), pd.syncPreviewTls()]) + await pd.waitForDevRegistration() + assert.equal(puts.length, 1) +})