From 144181f2c959e7847d817e1c192b04a56b739e61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:08:09 +0000 Subject: [PATCH] =?UTF-8?q?chore(deploy):=20nginx=20h=C3=A4rten,=20deploy.?= =?UTF-8?q?sh=20mit=20Sperre,=20Zeitlimit=20und=20ohne=20Webhook-Geheimnis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nginx: server_tokens off, HSTS, exaktes /v1 liefert 404 (Regex-Block statt ^~ /v1/). deploy.sh: AIRCARD_WEBHOOK_TOKEN nicht an npm, Sperrverzeichnis gegen parallele Läufe (verwaiste Sperren werden übernommen), timeout 900 für npm ci und Build. Neuer Test deploy/deploy.test.js gegen das echte Skript. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + deploy/README.md | 2 + deploy/deploy.sh | 65 ++++++++- deploy/deploy.test.js | 233 ++++++++++++++++++++++++++++++ deploy/nginx/aircard-ios.org.conf | 9 +- 5 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 deploy/deploy.test.js diff --git a/README.md b/README.md index fec3bb7..6e24f09 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Im Repo-Wurzelverzeichnis: ```sh node --test deploy/webhook-server.test.js # Webhook-Server (deploy.sh wird dort durch einen Stub ersetzt) +node --test deploy/deploy.test.js # deploy.sh (echtes Skript, lokaler git-Remote, npm-Stub) node --test web/scripts/appwrite-setup.test.mjs # Appwrite-Schema-Skript ``` diff --git a/deploy/README.md b/deploy/README.md index 1ac9126..84fb2d5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -53,6 +53,8 @@ docker exec webklar-aircard-build sh /deploy.sh Ohne `DEPLOY_TRIGGER` baut das Skript immer neu, auch wenn sich der Commit nicht geändert hat. +Es läuft immer nur ein Deployment: Ist schon eines aktiv (Sperrverzeichnis `build/.git/aircard-deploy.lock`), schreibt das Skript „Deployment läuft bereits“ und endet mit Exit-Code 0. Eine verwaiste Sperre (Prozess weg oder älter als 60 Minuten) übernimmt der nächste Lauf selbst. `npm ci` und der Build brechen nach 15 Minuten ab (`timeout 900`). + ## Gitea-Webhook Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea: diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 1bae102..982bd9f 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -13,17 +13,32 @@ # DEPLOY_TRIGGER (leer = manual) # # Fehlerzeilen beginnen mit "ERROR:"; der letzte Satz eines erfolgreichen Builds ist -# "Deployment abgeschlossen". +# "Deployment abgeschlossen". Läuft schon ein Deployment (Sperrverzeichnis in .git), meldet das +# Skript "läuft bereits" und endet mit 0. npm ci und der Build sind auf 15 Minuten begrenzt (timeout). set -e # Jeder Abbruch soll eine ERROR-Zeile hinterlassen, auch wenn set -e ihn auslöst. +# Die eigene Sperre (LOCK_HELD) wird bei jedem Ende freigegeben, eine fremde nie. ERROR_PRINTED="" +LOCK_HELD="" +LOCK_DIR="" fail() { echo "ERROR: $*" ERROR_PRINTED=1 exit 1 } -trap 'status=$?; if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"; fi' EXIT +cleanup() { + status=$? + if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then + echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})" + fi + if [ -n "${LOCK_HELD}" ] && [ -n "${LOCK_DIR}" ]; then + rm -rf "${LOCK_DIR}" + fi +} +trap cleanup EXIT +# Ein Signal (z. B. Zeitlimit des Webhook-Servers) soll ebenfalls über den EXIT-Trap aufräumen. +trap 'exit 143' HUP INT TERM REPO_DIR="${REPO_DIR:-/app}" OUT_DIR="${OUT_DIR:-/out}" @@ -78,6 +93,33 @@ fi cd "${REPO_DIR}" || fail "Verzeichnis ${REPO_DIR} nicht erreichbar" +# Nur ein Deployment gleichzeitig (Webhook, Polling und der manuelle Aufruf teilen sich REPO_DIR und OUT_DIR). +# mkdir ist atomar. Eine verwaiste Sperre (Prozess weg oder über 60 Minuten alt) wird übernommen. +LOCK_DIR="${REPO_DIR}/.git/aircard-deploy.lock" +acquire_lock() { + mkdir "${LOCK_DIR}" 2>/dev/null || return 1 + echo "$$" >"${LOCK_DIR}/pid" + LOCK_HELD=1 +} +if ! acquire_lock; then + _lock_pid="$(cat "${LOCK_DIR}/pid" 2>/dev/null || true)" + _lock_stale="" + if [ -n "${_lock_pid}" ]; then + kill -0 "${_lock_pid}" 2>/dev/null || _lock_stale=1 + fi + if [ -n "$(find "${LOCK_DIR}" -maxdepth 0 -mmin +60 2>/dev/null)" ]; then + _lock_stale=1 + fi + if [ -n "${_lock_stale}" ]; then + echo "Verwaiste Sperre gefunden, übernehme sie." + rm -rf "${LOCK_DIR}" + acquire_lock || { echo "Deployment läuft bereits, dieser Lauf wird übersprungen."; exit 0; } + else + echo "Deployment läuft bereits, dieser Lauf wird übersprungen." + exit 0 + fi +fi + echo "Aktualisiere Repository..." # Remote-URL bei jedem Lauf neu setzen, damit ein getauschtes Token greift. git remote set-url origin "${REPO_URL}" || fail "git remote set-url fehlgeschlagen" @@ -111,18 +153,29 @@ echo "${CURRENT_COMMIT}" >"${MARKER}" || fail "Marker ${MARKER} nicht schreibbar [ -d "${REPO_DIR}/${APP_SUBDIR}" ] || fail "Ordner ${APP_SUBDIR} fehlt im Repository" cd "${REPO_DIR}/${APP_SUBDIR}" -# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen. -unset GITEA_TOKEN REPO_URL +# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen (auch das Webhook-Geheimnis steht im +# Container und käme sonst beim manuellen Aufruf `docker exec ... sh /deploy.sh` bei npm an). +unset GITEA_TOKEN REPO_URL AIRCARD_WEBHOOK_TOKEN + +# Ein hängender Build soll nicht ewig laufen (und alle späteren Deployments sperren): 15 Minuten. +# `timeout` gibt es in busybox (node:22-alpine); fehlt es, läuft der Build ohne Limit. +if command -v timeout >/dev/null 2>&1; then + LIMIT="timeout 900" +else + LIMIT="" +fi echo "" echo "Installiere Abhängigkeiten..." -npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen" +# shellcheck disable=SC2086 # LIMIT besteht absichtlich aus zwei Wörtern +${LIMIT} npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)" echo "" echo "Baue die Seite..." # VITE_*-Variablen kommen direkt aus der Container-Umgebung (compose). rm -rf dist -npm run build || fail "Build fehlgeschlagen" +# shellcheck disable=SC2086 +${LIMIT} npm run build || fail "Build fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)" [ -f dist/index.html ] || fail "dist/index.html fehlt nach dem Build" echo "" diff --git a/deploy/deploy.test.js b/deploy/deploy.test.js new file mode 100644 index 0000000..5c8cc62 --- /dev/null +++ b/deploy/deploy.test.js @@ -0,0 +1,233 @@ +'use strict'; + +// Tests für deploy/deploy.sh (das echte Skript, mit einem lokalen git-Remote und einem npm-Stub). +// Aufruf: node --test deploy/deploy.test.js +// Voraussetzung: git und sh auf dem Host. Der Stub-npm legt dist/index.html an und schreibt seine Umgebung weg. + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); + +const DEPLOY_SH = path.join(__dirname, 'deploy.sh'); +// Standard: das sh des Hosts. Zum Gegenprüfen mit busybox (wie im node:alpine-Container): DEPLOY_TEST_SH="busybox sh". +const [SHELL_CMD, ...SHELL_ARGS] = (process.env.DEPLOY_TEST_SH || 'sh').split(' '); +const GIT_ENV = { + GIT_AUTHOR_NAME: 't', + GIT_AUTHOR_EMAIL: 't@example.invalid', + GIT_COMMITTER_NAME: 't', + GIT_COMMITTER_EMAIL: 't@example.invalid', +}; + +const tmpDirs = []; +const children = []; + +afterEach(() => { + for (const child of children.splice(0)) { + // Prozessgruppe beenden: auch der wartende npm-Stub unter dem Skript. + if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, 'SIGKILL'); + } + for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(condition, what, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`); + await pause(20); + } +} + +function git(args, cwd) { + const res = spawnSync('git', args, { cwd, env: { ...process.env, ...GIT_ENV }, encoding: 'utf8' }); + assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`); + return res.stdout; +} + +/** Ein Arbeitsverzeichnis mit Remote (file://), Stub-npm und optional Stub-timeout. */ +function setup({ withTimeoutStub = false } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-deploy-test-')); + tmpDirs.push(dir); + + const bare = path.join(dir, 'remote', 'u', 'r.git'); + fs.mkdirSync(bare, { recursive: true }); + git(['init', '--bare', '--quiet', '-b', 'main', bare]); + const work = path.join(dir, 'seed'); + git(['clone', '--quiet', bare, work]); + fs.mkdirSync(path.join(work, 'web')); + fs.writeFileSync(path.join(work, 'web', 'package.json'), '{}\n'); + git(['add', '-A'], work); + git(['commit', '--quiet', '-m', 'seed'], work); + git(['push', '--quiet', 'origin', 'HEAD:main'], work); + + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + const npmLog = path.join(dir, 'npm.log'); + const release = path.join(dir, 'release'); + fs.writeFileSync( + path.join(bin, 'npm'), + [ + '#!/bin/sh', + `echo "npm $* | WEBHOOK=\${AIRCARD_WEBHOOK_TOKEN-unset} GITEA=\${GITEA_TOKEN-unset} VITE=\${VITE_TEST_VAR-unset}" >> "${npmLog}"`, + // Blockiert, bis die Testdatei "release" existiert (für den Test mit zwei gleichzeitigen Läufen). + `if [ -n "\${STUB_NPM_BLOCK:-}" ]; then i=0; while [ ! -f "${release}" ] && [ "$i" -lt 400 ]; do sleep 0.05; i=$((i+1)); done; fi`, + 'if [ -n "${STUB_NPM_FAIL:-}" ]; then echo "npm kaputt" >&2; exit 1; fi', + 'if [ "$1" = run ]; then mkdir -p dist && echo "ok" > dist/index.html; fi', + 'exit 0', + '', + ].join('\n'), + { mode: 0o755 }, + ); + const timeoutLog = path.join(dir, 'timeout.log'); + if (withTimeoutStub) { + fs.writeFileSync( + path.join(bin, 'timeout'), + ['#!/bin/sh', `echo "timeout $*" >> "${timeoutLog}"`, 'shift', 'exec "$@"', ''].join('\n'), + { mode: 0o755 }, + ); + } + + const repoDir = path.join(dir, 'app'); + const outDir = path.join(dir, 'out'); + fs.mkdirSync(repoDir); + fs.mkdirSync(outDir); + const env = { + ...process.env, + ...GIT_ENV, + PATH: `${bin}:${process.env.PATH}`, + GITEA_URL: `file://${path.join(dir, 'remote')}`, + GITEA_USER: 'u', + GITEA_REPO: 'r', + GITEA_TOKEN: '', + REPO_DIR: repoDir, + OUT_DIR: outDir, + DEPLOY_BRANCH: 'main', + DEPLOY_TRIGGER: 'webhook', + AIRCARD_WEBHOOK_TOKEN: 'geheim-webhook', + VITE_TEST_VAR: 'bleibt', + }; + const lockDir = path.join(repoDir, '.git', 'aircard-deploy.lock'); + const npmCalls = () => (fs.existsSync(npmLog) ? fs.readFileSync(npmLog, 'utf8').trim().split('\n') : []); + const timeoutCalls = () => (fs.existsSync(timeoutLog) ? fs.readFileSync(timeoutLog, 'utf8').trim().split('\n') : []); + return { dir, env, repoDir, outDir, lockDir, release, npmCalls, timeoutCalls }; +} + +function runSync(ctx, extraEnv = {}) { + const res = spawnSync(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { env: { ...ctx.env, ...extraEnv }, encoding: 'utf8', timeout: 60000 }); + return { status: res.status, out: `${res.stdout}${res.stderr}` }; +} + +function runAsync(ctx, extraEnv = {}) { + const child = spawn(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { + env: { ...ctx.env, ...extraEnv }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + children.push(child); + const result = { out: '', status: null, done: null }; + child.stdout.on('data', (d) => (result.out += d)); + child.stderr.on('data', (d) => (result.out += d)); + result.done = new Promise((resolve) => + child.once('exit', (code) => { + result.status = code; + resolve(result); + }), + ); + return result; +} + +test('ein Lauf baut, kopiert nach out/ und gibt das Webhook-Geheimnis nicht an npm weiter', () => { + const ctx = setup(); + const res = runSync(ctx); + assert.equal(res.status, 0, res.out); + assert.match(res.out, /Deployment abgeschlossen/); + assert.equal(fs.readFileSync(path.join(ctx.outDir, 'index.html'), 'utf8').trim(), 'ok'); + const calls = ctx.npmCalls(); + assert.equal(calls.length, 2, calls.join('\n')); + for (const call of calls) { + assert.match(call, /WEBHOOK=unset/, `AIRCARD_WEBHOOK_TOKEN darf npm nicht erreichen: ${call}`); + assert.match(call, /GITEA=unset/); + assert.match(call, /VITE=bleibt/, 'andere Variablen (VITE_*) bleiben erhalten'); + } + assert.ok(!res.out.includes('geheim-webhook')); +}); + +test('das Sperrverzeichnis wird nach einem erfolgreichen Lauf entfernt', () => { + const ctx = setup(); + assert.equal(runSync(ctx).status, 0); + assert.equal(fs.existsSync(ctx.lockDir), false); +}); + +test('das Sperrverzeichnis wird auch nach einem fehlgeschlagenen Build entfernt', () => { + const ctx = setup(); + const res = runSync(ctx, { STUB_NPM_FAIL: '1' }); + assert.equal(res.status, 1); + assert.match(res.out, /ERROR: /); + assert.equal(fs.existsSync(ctx.lockDir), false); +}); + +test('ein zweiter gleichzeitiger Lauf meldet „läuft bereits“, beendet mit 0 und baut nicht', async () => { + const ctx = setup(); + const first = runAsync(ctx, { STUB_NPM_BLOCK: '1' }); + await waitFor(() => ctx.npmCalls().length >= 1, 'npm des ersten Laufs'); + assert.equal(fs.existsSync(ctx.lockDir), true); + + const second = runSync(ctx); + assert.equal(second.status, 0, second.out); + assert.match(second.out, /läuft bereits/); + assert.doesNotMatch(second.out, /Deployment abgeschlossen/); + assert.equal(ctx.npmCalls().length, 1, 'der zweite Lauf darf npm nicht starten'); + assert.equal(fs.existsSync(ctx.lockDir), true, 'der zweite Lauf darf die Sperre des ersten nicht freigeben'); + + fs.writeFileSync(ctx.release, ''); + await first.done; + assert.equal(first.status, 0, first.out); + assert.match(first.out, /Deployment abgeschlossen/); + assert.equal(fs.existsSync(ctx.lockDir), false); +}); + +test('eine verwaiste Sperre (Prozess gibt es nicht mehr) wird übernommen', () => { + const ctx = setup(); + // Erster Lauf legt das Repo an. + assert.equal(runSync(ctx).status, 0); + fs.mkdirSync(ctx.lockDir); + fs.writeFileSync(path.join(ctx.lockDir, 'pid'), '2147483646\n'); + const res = runSync(ctx); + assert.equal(res.status, 0, res.out); + assert.match(res.out, /Deployment abgeschlossen/); + assert.equal(fs.existsSync(ctx.lockDir), false); +}); + +test('eine sehr alte Sperre ohne lebenden Besitzer-Prozess-Nachweis gilt als verwaist', () => { + const ctx = setup(); + assert.equal(runSync(ctx).status, 0); + fs.mkdirSync(ctx.lockDir); + // Kein pid-Eintrag, aber alt genug (über 60 Minuten). + const old = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(ctx.lockDir, old, old); + const res = runSync(ctx); + assert.equal(res.status, 0, res.out); + assert.match(res.out, /Deployment abgeschlossen/); +}); + +const busyboxApplets = SHELL_CMD === 'busybox' && 'busybox löst timeout als eigenes Applet auf, der Stub wird nicht gerufen'; +test('npm ci und npm run build laufen unter timeout 900, wenn timeout verfügbar ist', { skip: busyboxApplets }, () => { + const ctx = setup({ withTimeoutStub: true }); + const res = runSync(ctx); + assert.equal(res.status, 0, res.out); + const calls = ctx.timeoutCalls(); + assert.equal(calls.length, 2, calls.join('\n')); + assert.match(calls[0], /^timeout 900 npm ci /); + assert.match(calls[1], /^timeout 900 npm run build/); +}); + +test('ein Build-Fehler unter timeout bleibt ein ERROR mit Exit-Code 1', () => { + const ctx = setup({ withTimeoutStub: true }); + const res = runSync(ctx, { STUB_NPM_FAIL: '1' }); + assert.equal(res.status, 1); + assert.match(res.out, /ERROR: npm ci fehlgeschlagen/); +}); diff --git a/deploy/nginx/aircard-ios.org.conf b/deploy/nginx/aircard-ios.org.conf index f47a473..6526f47 100644 --- a/deploy/nginx/aircard-ios.org.conf +++ b/deploy/nginx/aircard-ios.org.conf @@ -7,6 +7,7 @@ server { listen 80; server_name _; + server_tokens off; root /usr/share/nginx/html; index index.html; @@ -15,6 +16,8 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # TLS endet bei Traefik; der Header läuft durch und gilt für den Browser (kein includeSubDomains). + add_header Strict-Transport-Security "max-age=31536000" always; gzip on; gzip_vary on; @@ -39,8 +42,10 @@ server { } # Die Appwrite-API liegt nur über Traefik (Header X-Appwrite-Project) auf /v1. - # Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern. - location ^~ /v1/ { + # Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern, auch nicht für genau /v1. + # Regex-Block: steht vor den anderen Regex-Blöcken (die erste passende Regel gewinnt, /v1/x.png + # soll also 404 liefern); /assets/ bleibt durch das ^~ oben unberührt. + location ~ ^/v1(/|$) { return 404; }