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: [] }
|
||||
}
|
||||
Reference in new issue
Block a user