diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 46b2b88..69a4592 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -4,6 +4,7 @@ import fs from 'node:fs/promises' import path from 'node:path' import { config } from '../config.js' import { listDocuments, Query } from './appwriteAdmin.js' +import { withLocks } from './keyedLock.js' const execFileAsync = promisify(execFile) @@ -428,8 +429,17 @@ export async function runDeploy(repoFullName, branch, previewConfig) { } const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() - const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git` const workDir = previewWorkDir(repoFullName) + // Deploys desselben Repos oder derselben Subdomain nacheinander: Projektaenderung und + // Gitea-Webhook kamen gleichzeitig, beide git clone liefen ins selbe workDir und + // brachen ab (Kirschner Gebaeudeservice, 30.09.2026). + return withLocks([`preview-work:${workDir}`, `preview-site:${subdomain}`], () => + deployLocked(repoFullName, branch, previewConfig, subdomain, workDir) + ) +} + +async function deployLocked(repoFullName, branch, previewConfig, subdomain, workDir) { + const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git` const targetDir = path.join(config.preview.deployRoot, subdomain) await cloneRepo(cloneUrl, workDir, branch, config.gitea.apiToken) diff --git a/test/previewDeployLock.test.js b/test/previewDeployLock.test.js new file mode 100644 index 0000000..3af586b --- /dev/null +++ b/test/previewDeployLock.test.js @@ -0,0 +1,56 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +// 2026-09-30 (Kirschner Gebäudeservice): Projektänderung und Gitea-Webhook deployten dasselbe +// Repo gleichzeitig, beide git clone liefen ins selbe Arbeitsverzeichnis und brachen ab. +let tmp +let previewDeploy +const cwd = process.cwd() + +before(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'preview-lock-')) + const repo = path.join(tmp, 'gitea', 'WEBklar', 'kunde.git') + const src = path.join(tmp, 'src') + await fs.mkdir(src, { recursive: true }) + // Genug Inhalt, dass ein Klon Zeit braucht und sich zwei Klone überschneiden + for (let i = 0; i < 300; i += 1) await fs.writeFile(path.join(src, `seite-${i}.html`), `

${i}

`.repeat(200)) + await fs.writeFile(path.join(src, 'index.html'), 'Kunde') + const git = (args, dir) => execFileSync('git', args, { cwd: dir, stdio: 'ignore' }) + git(['init', '-q', '-b', 'main'], src) + git(['-c', 'user.name=t', '-c', 'user.email=t@t', 'add', '.'], src) + git(['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init'], src) + git(['clone', '-q', '--bare', src, repo], tmp) + + await fs.mkdir(path.join(tmp, 'work')) + process.chdir(path.join(tmp, 'work')) // previewWorkDir() liegt unter process.cwd() + Object.assign(process.env, { + APPWRITE_ENDPOINT: 'http://127.0.0.1:9/v1', + APPWRITE_API_KEY: 'test-key', + GITEA_BASE_URL: `file://${path.join(tmp, 'gitea')}`, + GITEA_API_TOKEN: '', + PREVIEW_DEPLOY_ROOT: path.join(tmp, 'previews'), + TRAEFIK_DYNAMIC_DIR: '', + }) + previewDeploy = await import('../server/services/previewDeploy.js') +}) + +after(async () => { + process.chdir(cwd) + await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('gleichzeitige Deploys desselben Repos laufen nacheinander und gelingen beide', async () => { + const cfg = { subdomain: 'kunde', type: 'static' } + const results = await Promise.allSettled([ + previewDeploy.runDeploy('WEBklar/kunde', 'main', cfg), + previewDeploy.runDeploy('WEBklar/kunde', 'main', cfg), + previewDeploy.runDeploy('WEBklar/kunde', 'main', cfg), + ]) + for (const r of results) assert.equal(r.status, 'fulfilled', r.reason?.message) + assert.match(await fs.readFile(path.join(tmp, 'previews', 'kunde', 'index.html'), 'utf8'), /Kunde/) + assert.equal((await fs.readdir(path.join(tmp, 'previews', 'kunde'))).filter((f) => f.startsWith('seite-')).length, 300) +})