chore(deploy): nginx härten, deploy.sh mit Sperre, Zeitlimit und ohne Webhook-Geheimnis
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
436ea4f4a5
commit
144181f2c9
5 files changed
+302
-8
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+59
-6
@@ -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 ""
|
||||
|
||||
@@ -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 "<html>ok</html>" > 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(), '<html>ok</html>');
|
||||
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/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user