Umzug zugewiesener Repos nach WEBklar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51c4852488
commit
6e6c9a4c24
4 files changed
+369
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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: [] }
|
||||
}
|
||||
@@ -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<string, string>} [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 --- */
|
||||
|
||||
|
||||
@@ -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\.$/)
|
||||
})
|
||||
Reference in new issue
Block a user