From 6e6c9a4c24dcbec7a5a34f8d6856fb39125f403e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 20:52:15 +0000 Subject: [PATCH] Umzug zugewiesener Repos nach WEBklar Co-Authored-By: Claude Opus 5.5 --- server/services/previewDeploy.js | 7 +- server/services/repoMove.js | 208 +++++++++++++++++++++++++++++++ test/helpers/fakeGitea.js | 7 ++ test/repoMove.test.js | 148 ++++++++++++++++++++++ 4 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 server/services/repoMove.js create mode 100644 test/repoMove.test.js diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 7ef855e..75310b3 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -307,6 +307,11 @@ 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 @@ -421,7 +426,7 @@ export async function runDeploy(repoFullName, branch, previewConfig) { const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git` - const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_')) + const workDir = previewWorkDir(repoFullName) const targetDir = path.join(config.preview.deployRoot, subdomain) await cloneRepo(cloneUrl, workDir, branch, config.gitea.apiToken) diff --git a/server/services/repoMove.js b/server/services/repoMove.js new file mode 100644 index 0000000..a4637b5 --- /dev/null +++ b/server/services/repoMove.js @@ -0,0 +1,208 @@ +import fs from 'node:fs/promises' +import { config } from '../config.js' +import { giteaFetch, splitRepo } from './giteaClient.js' +import { withLocks } from './keyedLock.js' +import { listDocuments, updateDocument, Query } from './appwriteAdmin.js' +import { invalidateProjectCache } from './projectInsights.js' +import { previewWorkDir } from './previewDeploy.js' + +/** Warnungen, wörtlich (siehe Plan, Abschnitt "Global Constraints"). */ +export const WARN = { + internal: 'Internes Repo, bleibt wo es ist.', + nameTaken: (name) => + `In WEBklar gibt es schon ein Repo namens ${name}. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.`, + giteaDown: (reason) => + `Gitea ist gerade nicht erreichbar (${reason}). Bitte später ‚Pushes nachtragen‘ klicken.`, + docNotUpdated: (reason) => + `Das Repo liegt jetzt in WEBklar, aber die Projektdaten konnten nicht angepasst werden (${reason}). Bitte ‚Pushes nachtragen‘ klicken.`, +} + +/** Portal-Webhook-URL: ein Einzel-Webhook gilt als Portal-Webhook, wenn seine URL exakt damit beginnt. */ +function portalWebhookUrl() { + return `${config.publicBaseUrl}/webhook/gitea` +} + +/** true, wenn `owner/repo` (ohne Ruecksicht auf Gross-/Kleinschreibung) in der Ziel-Organisation liegt. */ +export function isOrgRepo(repoFullName) { + const [owner] = splitRepo(repoFullName) + return String(owner || '').toLowerCase() === config.gitea.org.toLowerCase() +} + +/** Sperr-Schluessel fuer ein Repo (fuer `withLocks`), ohne Ruecksicht auf Gross-/Kleinschreibung. */ +export function repoLockKey(repoFullName) { + return 'repo:' + repoFullName.toLowerCase() +} + +/** + * true, wenn das Repo in `GITEA_KEEP_IN_PLACE` steht (ohne Ruecksicht auf Gross-/Kleinschreibung) + * oder einen Webhook hat, dessen URL nicht mit der Portal-Webhook-URL beginnt (Deploy-verdrahtetes + * internes Repo). Wirft weiter, wenn die Gitea-Abfrage der Hooks fehlschlaegt - die Einordnung + * eines Gitea-Fehlers ist Sache der Aufrufer (siehe `moveRepoToOrg`). + */ +export async function isInternalRepo(repoFullName) { + const lower = repoFullName.toLowerCase() + if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true + + const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) + const portalUrl = portalWebhookUrl() + return (hooks || []).some((hook) => !String(hook.url || '').startsWith(portalUrl)) +} + +/** + * Aktueller `repoFullName`: der gespeicherte Projektstand, falls (noch) aktuell, sonst der Name, + * auf den Gitea per Weiterleitung verweist. Liefert `name` unveraendert zurueck, wenn beides nicht + * greift (z. B. Gitea nicht erreichbar) - reine Bestpractice-Aufloesung ohne eigene Warnungen. + */ +export async function currentRepoFullName(name) { + const existing = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', name), + Query.limit(1), + ]) + if (existing[0]) return name + + try { + const repo = await giteaFetch(`/repos/${name}`) + return repo.full_name + } catch { + return name + } +} + +/** Netzwerkfehler (kein `.status`) oder 5xx gelten als "Gitea gerade nicht erreichbar". */ +function isGiteaDownError(err) { + return !err.status || err.status >= 500 +} + +/** + * Ordnet einen waehrend Schritt 2-5 aufgetretenen Gitea-Fehler ein (siehe Controller-Ruling R10): + * Netzwerkfehler/5xx -> `giteaDown`, alles andere (z. B. unerwartete 403/404) -> ein eigener + * Hinweistext, ohne zu werfen. `moveRepoToOrg` darf wegen Gitea-/Appwrite-Problemen nie werfen, + * damit eine Zuweisung (Task 6) in jedem Fall bestehen bleibt. + */ +function classifyGiteaError(err) { + if (isGiteaDownError(err)) return { moved: null, warnings: [WARN.giteaDown(err.message)] } + return { moved: null, warnings: [`Umzug nach WEBklar nicht möglich (${err.message}).`] } +} + +/** Schritt 6: Projektdokument auf den neuen Repo-Namen nachziehen. */ +async function updateProjectDocument(project, to) { + const name = splitRepo(to)[1] + return updateDocument(config.collections.websiteProjects, project.$id, { + repoFullName: to, + giteaRepoUrl: `${config.gitea.baseUrl}/${to}`, + giteaRepoName: name, + updatedAt: new Date().toISOString(), + }) +} + +/** Schritt 8: alte Portal-Webhooks am neuen Ort loeschen. Fehler werden nur geloggt. */ +async function deletePortalHooks(to) { + const portalUrl = portalWebhookUrl() + let hooks + try { + hooks = await giteaFetch(`/repos/${to}/hooks`) + } catch (err) { + console.error(`[repoMove] Webhooks von ${to} konnten nicht gelesen werden:`, err.message) + return + } + + for (const hook of hooks || []) { + if (!String(hook.url || '').startsWith(portalUrl)) continue + try { + await giteaFetch(`/repos/${to}/hooks/${hook.id}`, { method: 'DELETE' }) + } catch (err) { + console.error(`[repoMove] Webhook ${hook.id} bei ${to} konnte nicht geloescht werden:`, err.message) + } + } +} + +/** + * Verschiebt das Repo eines zugewiesenen Projekts nach `config.gitea.org`, wenn noetig, und haelt + * Projektdokument, Cache und Webhooks auf dem Laufenden. Wirft nie wegen eines Gitea- oder + * Appwrite-Problems (siehe `classifyGiteaError`/`docNotUpdated`) - Task 6 verlaesst sich darauf, + * dass eine Zuweisung auch bei einem Ausfall bestehen bleibt. + */ +export async function moveRepoToOrg(project) { + const from = project.repoFullName + + // Schritt 1: schon in der Organisation. + if (isOrgRepo(from)) return { moved: null, warnings: [] } + + // Schritt 2: aktuellen Stand holen (fetch folgt Giteas 307-Weiterleitung von Hand verschobener Repos). + let current + try { + current = await giteaFetch(`/repos/${from}`) + } catch (err) { + return classifyGiteaError(err) + } + if (current.full_name !== from) { + const to = current.full_name + try { + await updateProjectDocument(project, to) + } catch (err) { + return { moved: { from, to }, warnings: [WARN.docNotUpdated(err.message)] } + } + return { moved: { from, to }, warnings: [] } + } + + // Schritt 3: internes (Deploy-verdrahtetes) Repo bleibt, wo es ist. + let internal + try { + internal = await isInternalRepo(from) + } catch (err) { + return classifyGiteaError(err) + } + if (internal) return { moved: null, warnings: [WARN.internal] } + + // Schritt 4: Namenskonflikt in der Organisation pruefen. `candidateTo` ist der Name, den der + // Transfer voraussichtlich ergibt - fuer den zweiten Sperr-Schluessel (siehe Controller-Ruling), + // bestaetigt wird er erst ueber die full_name der Transfer-Antwort (`confirmedTo` unten). + const name = splitRepo(from)[1] + const candidateTo = `${config.gitea.org}/${name}` + try { + await giteaFetch(`/repos/${candidateTo}`) + return { moved: null, warnings: [WARN.nameTaken(name)] } + } catch (err) { + if (err.status !== 404) return classifyGiteaError(err) + // 404: der Name ist frei, weiter mit dem Umzug. + } + + // Schritt 5+6: Transfer und Dokument-Update gemeinsam gesperrt (siehe Controller-Ruling). + const outcome = await withLocks([repoLockKey(from), repoLockKey(candidateTo)], async () => { + let transferResult + try { + transferResult = await giteaFetch(`/repos/${from}/transfer`, { + method: 'POST', + body: JSON.stringify({ new_owner: config.gitea.org }), + }) + } catch (err) { + return { failed: 'gitea', err } + } + + const confirmedTo = transferResult.full_name + try { + await updateProjectDocument(project, confirmedTo) + } catch (err) { + return { failed: 'doc', to: confirmedTo, err } + } + return { to: confirmedTo } + }) + + if (outcome.failed === 'gitea') return classifyGiteaError(outcome.err) + if (outcome.failed === 'doc') { + return { moved: { from, to: outcome.to }, warnings: [WARN.docNotUpdated(outcome.err.message)] } + } + const confirmedTo = outcome.to + + // Schritt 7: Cache fuer alten und neuen Namen verwerfen. + invalidateProjectCache(from) + invalidateProjectCache(confirmedTo) + + // Schritt 8: alten Portal-Webhook am neuen Ort loeschen (die Organisation hat einen eigenen). + await deletePortalHooks(confirmedTo) + + // Schritt 9: Preview-Arbeitsverzeichnis des alten Namens aufraeumen. + await fs.rm(previewWorkDir(from), { recursive: true, force: true }) + + return { moved: { from, to: confirmedTo }, warnings: [] } +} diff --git a/test/helpers/fakeGitea.js b/test/helpers/fakeGitea.js index aca00eb..473843e 100644 --- a/test/helpers/fakeGitea.js +++ b/test/helpers/fakeGitea.js @@ -21,6 +21,10 @@ import { createHash } from 'node:crypto' * Dateiinhalt (Klartext, keine Kodierung) je `"owner/repo:pfad"`, z. B. `"knso/x:.webklar-preview.json"`. * @param {string[]} [options.orgs] * Organisationen, die von Anfang an existieren (mit leerer Owners-Mitgliedschaft). + * @param {Record} [options.redirects] + * Vorab gesetzte Weiterleitungen (alter `"owner/repo"` -> neuer `"owner/repo"`), z. B. fuer ein + * bereits von Hand verschobenes Repo, ohne dass dafuer ein Transfer-Aufruf noetig war. Der Ziel- + * Repo-Eintrag muss separat ueber `options.repos` angelegt werden. * @param {string} [options.user] Login, das `GET /user` meldet. Voreinstellung `'webklar-system'`. * @returns {Promise<{ * baseUrl: string, @@ -439,6 +443,9 @@ export async function startFakeGitea(options = {}) { contents.set(`${keyFrom(fullName)}:${filePath}`, text) } for (const name of options.orgs || []) createOrgRecord(name) + for (const [from, to] of Object.entries(options.redirects || {})) { + redirects.set(keyFrom(from), to) + } /* ------------------------------------------------------ oeffentlich --- */ diff --git a/test/repoMove.test.js b/test/repoMove.test.js new file mode 100644 index 0000000..0d332b3 --- /dev/null +++ b/test/repoMove.test.js @@ -0,0 +1,148 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let gitea +let aw +let tmp +let originalCwd +let previewWorkDir +let moveRepoToOrg +let isOrgRepo +let isInternalRepo +let repoLockKey +let currentRepoFullName + +// Schluessel: urspruenglich gesetzter repoFullName -> Appwrite-$id. Wird beim Seeden befuellt +// (siehe Controller-Ruling R2) - eine Live-Suche ueber repoFullName wuerde nach einem Umzug +// nichts mehr finden, weil sich das Feld dann geaendert hat. +const idByRepo = new Map() + +/** Liefert das gespeicherte Dokument des Repos, ueber den zur Seed-Zeit gebauten Id-Index. */ +function doc(name) { + return aw.get('websiteProjects', idByRepo.get(name)) +} + +before(async () => { + gitea = await startFakeGitea({ + repos: [ + { full_name: 'JUSN/D-ner-Nation' }, + { full_name: 'knso/emmas-schatzkiste' }, + { full_name: 'ANDJ/Steuerungstechnik' }, + { full_name: 'knso/hytail-ark' }, + { full_name: 'WEBklar/schon-da' }, + { full_name: 'JUSN/schon-da' }, + { full_name: 'WEBklar/von-hand' }, + { full_name: 'ANDJ/unberuehrt' }, + ], + hooks: { + 'knso/emmas-schatzkiste': [{ id: 110, url: 'https://project.webklar.com/webhook/gitea' }], + 'knso/hytail-ark': [{ id: 7, url: 'https://webklar.com/webhook/hytail-ark/deploy' }], + }, + // knso/von-hand wurde von Hand bereits nach WEBklar/von-hand verschoben (kein Transfer-Aufruf noetig). + redirects: { + 'knso/von-hand': 'WEBklar/von-hand', + }, + }) + + const repoNames = [ + 'JUSN/D-ner-Nation', + 'knso/emmas-schatzkiste', + 'ANDJ/Steuerungstechnik', + 'knso/hytail-ark', + 'WEBklar/schon-da', + 'JUSN/schon-da', + 'knso/von-hand', // Appwrite kennt noch den alten Namen (siehe Brief) + 'ANDJ/unberuehrt', + ] + const websiteProjects = repoNames.map((repoFullName) => { + const $id = randomUUID() + idByRepo.set(repoFullName, $id) + return { $id, repoFullName, projectName: repoFullName.split('/').pop() } + }) + + aw = await startFakeAppwrite({ seed: { websiteProjects } }) + + originalCwd = process.cwd() + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'repo-move-')) + process.chdir(tmp) + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + }) + // config.js liest die Umgebung beim Import + ;({ previewWorkDir } = await import('../server/services/previewDeploy.js')) + ;({ moveRepoToOrg, isOrgRepo, isInternalRepo, repoLockKey, currentRepoFullName } = await import( + '../server/services/repoMove.js' + )) +}) + +after(async () => { + process.chdir(originalCwd) + await gitea.close() + await aw.close() + await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('verschiebt Repo aus eigenem Konto', async () => { + await fs.mkdir(previewWorkDir('JUSN/D-ner-Nation'), { recursive: true }) + const r = await moveRepoToOrg(doc('JUSN/D-ner-Nation')) + assert.deepEqual(r, { moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' }, warnings: [] }) + assert.deepEqual(gitea.calls.find((c) => c.path.endsWith('/transfer')).body, { new_owner: 'WEBklar' }) + const d = aw.get('websiteProjects', doc('JUSN/D-ner-Nation').$id) + assert.equal(d.repoFullName, 'WEBklar/D-ner-Nation') + assert.equal(d.giteaRepoUrl, `${gitea.baseUrl}/WEBklar/D-ner-Nation`) + assert.equal(d.giteaRepoName, 'D-ner-Nation') + await assert.rejects(fs.access(previewWorkDir('JUSN/D-ner-Nation'))) +}) +test('löscht den alten Portal-Webhook', async () => { + await moveRepoToOrg(doc('knso/emmas-schatzkiste')) + assert.deepEqual(gitea.hooksOf('WEBklar/emmas-schatzkiste'), []) +}) +test('schon in WEBklar: nichts passiert', async () => { + assert.deepEqual(await moveRepoToOrg(doc('WEBklar/schon-da')), { moved: null, warnings: [] }) +}) +test('internes Repo per Liste und per fremdem Webhook', async () => { + for (const name of ['ANDJ/Steuerungstechnik', 'knso/hytail-ark']) { + assert.deepEqual(await moveRepoToOrg(doc(name)), { moved: null, warnings: ['Internes Repo, bleibt wo es ist.'] }) + } + assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && /steuerung|hytail/i.test(c.path)).length, 0) +}) +test('Name existiert in WEBklar', async () => { + const r = await moveRepoToOrg(doc('JUSN/schon-da')) + assert.deepEqual(r, { moved: null, warnings: ['In WEBklar gibt es schon ein Repo namens schon-da. Bitte in Gitea umbenennen und dann ‚Pushes nachtragen‘ klicken.'] }) +}) +test('Weiterleitung korrigiert Projektdaten', async () => { + const r = await moveRepoToOrg(doc('knso/von-hand')) + assert.deepEqual(r.moved, { from: 'knso/von-hand', to: 'WEBklar/von-hand' }) + assert.equal(gitea.calls.filter((c) => c.path.endsWith('/transfer') && c.path.includes('von-hand')).length, 0) + assert.equal(aw.get('websiteProjects', doc('knso/von-hand').$id).repoFullName, 'WEBklar/von-hand') +}) +test('Erkennung ohne Groß-/Kleinschreibung', async () => { + assert.equal(isOrgRepo('webklar/x'), true) + assert.equal(await isInternalRepo('andj/steuerungstechnik'), true) +}) +test('repoLockKey', () => { + assert.equal(repoLockKey('JUSN/D-ner-Nation'), 'repo:jusn/d-ner-nation') +}) +test('currentRepoFullName', async () => { + assert.equal(await currentRepoFullName('WEBklar/D-ner-Nation'), 'WEBklar/D-ner-Nation') // Eintrag vorhanden + assert.equal(await currentRepoFullName('JUSN/D-ner-Nation'), 'WEBklar/D-ner-Nation') // kein Eintrag, Weiterleitung +}) +test('Gitea nicht erreichbar', async () => { + gitea.setDown(true) + const r = await moveRepoToOrg(doc('ANDJ/unberuehrt')) + gitea.setDown(false) + assert.equal(r.moved, null) + assert.match(r.warnings[0], /^Gitea ist gerade nicht erreichbar \(.+\)\. Bitte später ‚Pushes nachtragen‘ klicken\.$/) +})