From d5eb908cb6f8c3087758ae59b3f162335eddd17c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:19:33 +0000 Subject: [PATCH] =?UTF-8?q?Preview-Router:=20=C3=B6ffentliche=20Seiten=20?= =?UTF-8?q?=C3=BCberstehen=20Server-Neustart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nach einem Neustart startet der Kundenbereich oft vor Appwrite. Bisher lieferte getPublicSubdomains dann ein leeres Set, syncPreviewTls setzte alle Preview-Hosts hinter die Login-Weiche (preview-chain) und wiederholte das nie. Öffentliche Seiten wie anelia-wolf und emmas-schatzkiste waren bis zum nächsten Deploy nur noch mit Login erreichbar bzw. 403. - Antwortet Appwrite nicht, übernimmt syncPreviewTls die öffentlichen Seiten aus der zuletzt geschriebenen preview-hosts.yml. Neue Subdomains bleiben login-geschützt, ohne alte Datei ist alles geschützt. - Beim Start wird der Abgleich alle 15 s wiederholt (max. 10 min), bis er mit Appwrite-Daten geklappt hat. - Tests: Datei lesen, Abgleich mit/ohne Appwrite, Startup-Wiederholung. Co-Authored-By: Claude Opus 5.5 --- server/index.js | 6 +- server/services/previewDeploy.js | 75 ++++++++++++++-- test/previewDeploy.test.js | 147 +++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 test/previewDeploy.test.js diff --git a/server/index.js b/server/index.js index f5f9d73..6b658e3 100644 --- a/server/index.js +++ b/server/index.js @@ -3,7 +3,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { config, assertServerConfig, WOMS_DATABASE_ID } from './config.js' import { verifyDatabaseAccess } from './services/appwriteAdmin.js' -import { syncPreviewTls } from './services/previewDeploy.js' +import { syncPreviewTlsOnStartup } from './services/previewDeploy.js' import { requireSession, sessionMiddleware } from './middleware/session.js' import authRoutes from './routes/auth.js' import projectsRoutes from './routes/projects.js' @@ -101,9 +101,7 @@ app.use((err, _req, res, _next) => { const server = app.listen(config.port, '0.0.0.0', () => { console.log(`Webklar Kundenbereich läuft auf Port ${config.port}`) - syncPreviewTls() - .then((r) => console.log('[startup] Preview-TLS-Sync:', JSON.stringify(r))) - .catch((err) => console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message)) + syncPreviewTlsOnStartup() verifyDatabaseAccess().then((result) => { if (result.ok) return console.error( diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 466d75a..7ef855e 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -10,8 +10,8 @@ const execFileAsync = promisify(execFile) /** * Subdomains, die als "oeffentliche Website" (ohne Login) laufen sollen. * Diese bekommen in syncPreviewTls einen Router OHNE preview-auth-ForwardAuth. - * Faellt bei Appwrite-Fehlern bewusst auf ein leeres Set zurueck -> alle Seiten - * bleiben dann login-geschuetzt (sicherer Default). + * Liefert null, wenn Appwrite nicht antwortet (z.B. direkt nach einem + * Server-Neustart, solange Appwrite noch hochfaehrt). */ async function getPublicSubdomains() { try { @@ -22,10 +22,38 @@ async function getPublicSubdomains() { return new Set(docs.map((d) => d.subdomain).filter(Boolean)) } catch (err) { console.error('[previewDeploy] getPublicSubdomains fehlgeschlagen:', err.message) - return new Set() + 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 +} + export async function fetchPreviewConfig(repoFullName, branch) { const [owner, repo] = repoFullName.split('/') if (!owner || !repo || !config.gitea.apiToken) { @@ -305,7 +333,16 @@ export async function syncPreviewTls() { .map((entry) => entry.name) .sort() - const publicSet = await getPublicSubdomains() + const hostsFile = path.join(outDir, 'preview-hosts.yml') + 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}` @@ -347,8 +384,34 @@ export async function syncPreviewTls() { ].join('\n') await fs.mkdir(outDir, { recursive: true }) - await fs.writeFile(path.join(outDir, 'preview-hosts.yml'), content, 'utf8') - return { synced: true, hosts: slugs.length } + await fs.writeFile(hostsFile, content, 'utf8') + return { synced: true, hosts: slugs.length, publicSource } +} + +/** + * 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 } export async function runDeploy(repoFullName, branch, previewConfig) { diff --git a/test/previewDeploy.test.js b/test/previewDeploy.test.js new file mode 100644 index 0000000..b2f72d1 --- /dev/null +++ b/test/previewDeploy.test.js @@ -0,0 +1,147 @@ +import { test, before, after } 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' + +// Nachgestellter Server-Neustart: Appwrite antwortet erst, wenn appwriteUp true ist. +let appwriteUp = true +const appwrite = http.createServer((req, res) => { + if (!appwriteUp) return req.socket.destroy() // wie beim Boot: "fetch failed" + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ total: 1, documents: [{ subdomain: 'oeffentlich' }] })) +}) + +let tmp +let hostsFile +let previewDeploy + +before(async () => { + await new Promise((resolve) => appwrite.listen(0, '127.0.0.1', resolve)) + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-sync-')) + for (const name of ['oeffentlich', 'privat']) { + await fs.mkdir(path.join(tmp, 'previews', name), { recursive: true }) + } + hostsFile = path.join(tmp, 'dynamic', 'preview-hosts.yml') + Object.assign(process.env, { + APPWRITE_ENDPOINT: `http://127.0.0.1:${appwrite.address().port}/v1`, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'), + TRAEFIK_DYNAMIC_DIR: path.join(tmp, 'dynamic'), + }) + // config.js liest die Umgebung beim Import + previewDeploy = await import('../server/services/previewDeploy.js') +}) + +after(async () => { + appwrite.close() + await fs.rm(tmp, { recursive: true, force: true }) +}) + +const publicInFile = async () => + [...previewDeploy.publicSubdomainsFromHostsFile(await fs.readFile(hostsFile, 'utf8'))].sort() + +/* ------------------------------------------------------------- Datei --- */ + +test('Datei lesen: nur HTTPS-Router ohne preview-chain gelten als öffentlich', () => { + const content = [ + '# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually', + 'http:', + ' routers:', + ' preview-a-b-https:', + ' rule: "Host(`a-b.project.webklar.com`)"', + ' service: preview-nginx@docker', + ' priority: 100', + '', + ' preview-a-b-http:', + ' middlewares:', + ' - preview-sites-redirect@docker', + ' preview-geheim-https:', + ' service: preview-nginx@docker', + ' middlewares:', + ' - preview-chain@docker', + ' preview-geheim-http:', + ' priority: 100', + ].join('\n') + assert.deepEqual([...previewDeploy.publicSubdomainsFromHostsFile(content)], ['a-b']) + assert.equal(previewDeploy.publicSubdomainsFromHostsFile('').size, 0) + assert.equal(previewDeploy.publicSubdomainsFromHostsFile(undefined).size, 0) +}) + +/* --------------------------------------------------------- Abgleich --- */ + +test('Abgleich mit Appwrite: öffentliche Seite ohne Login, Rest geschützt', async () => { + appwriteUp = true + const result = await previewDeploy.syncPreviewTls() + assert.deepEqual(result, { synced: true, hosts: 2, publicSource: 'appwrite' }) + assert.deepEqual(await publicInFile(), ['oeffentlich']) +}) + +test('Neustart, Appwrite noch nicht da: öffentliche Seiten bleiben öffentlich, neue bleiben geschützt', async () => { + appwriteUp = true + await previewDeploy.syncPreviewTls() + await fs.mkdir(path.join(tmp, 'previews', 'neu')) + appwriteUp = false + try { + const result = await previewDeploy.syncPreviewTls() + assert.deepEqual(result, { synced: true, hosts: 3, publicSource: 'previous-file' }) + assert.deepEqual(await publicInFile(), ['oeffentlich']) + assert.match(await fs.readFile(hostsFile, 'utf8'), /preview-neu-https:/) + } finally { + appwriteUp = true + await fs.rm(path.join(tmp, 'previews', 'neu'), { recursive: true }) + } +}) + +test('Ohne Appwrite und ohne alte Datei: alles login-geschützt (sicherer Default)', async () => { + await fs.rm(hostsFile, { force: true }) + appwriteUp = false + try { + const result = await previewDeploy.syncPreviewTls() + assert.equal(result.publicSource, 'previous-file') + assert.deepEqual(await publicInFile(), []) + } finally { + appwriteUp = true + } +}) + +/* ---------------------------------------------------------- Startup --- */ + +test('Startup: wiederholt, bis der Abgleich mit Appwrite-Daten geklappt hat', async () => { + const answers = [ + { synced: true, hosts: 2, publicSource: 'previous-file' }, + new Error('Schreiben fehlgeschlagen'), + { synced: true, hosts: 2, publicSource: 'appwrite' }, + ] + let calls = 0 + const sync = async () => { + const answer = answers[calls++] + if (answer instanceof Error) throw answer + return answer + } + const result = await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0 }) + assert.equal(calls, 3) + assert.equal(result.publicSource, 'appwrite') +}) + +test('Startup: ohne Preview-Konfiguration kein Wiederholen', async () => { + let calls = 0 + const sync = async () => { + calls++ + return { synced: false, reason: 'PREVIEW_DEPLOY_ROOT oder TRAEFIK_DYNAMIC_DIR nicht gesetzt' } + } + await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0 }) + assert.equal(calls, 1) +}) + +test('Startup: gibt nach maxAttempts auf', async () => { + let calls = 0 + const sync = async () => { + calls++ + return { synced: true, hosts: 2, publicSource: 'previous-file' } + } + const result = await previewDeploy.syncPreviewTlsOnStartup({ sync, retryMs: 0, maxAttempts: 4 }) + assert.equal(calls, 4) + assert.equal(result, null) +})