Phase-1-Vorbereitung für den Rebreak-Cutover (apps/rebreak Nuxt → backend
standalone Nitro). Alle Änderungen sind lokal verifiziert (build = 9.66 MB
gzipped 3.08 MB, node .output/server/index.mjs startet ohne ERR_MODULE_NOT_FOUND
auf :3000). Kein Push, kein Server-Eingriff in dieser Session.
Inhalt:
- backend/nitro.config.ts: 8 zusätzliche runtimeConfig-Keys (cartesia*, eleven*,
supabaseUrl/AnonKey/ServiceKey, public.supabase.{url,key}). Schließt den
Auth-500-Cascade vom 2026-05-06 (server/utils/auth.ts:32 liest
config.public.supabase ?? config.supabase — beide Pfade jetzt deklariert).
- .npmrc (NEU, root-level): node-linker=hoisted für Prisma 7 transitive
@prisma/client-runtime-utils (siehe feedback_backend_runtime_config.md).
- backend/start-staging.sh: Pfad korrigiert von /srv/rebreak-monorepo/...
→ /srv/rebreak/backend/.output-staging/server/index.mjs. infisical run
wrapper (kein NUXT_*-Mapping mehr — runtimeConfig liest process.env.X
direkt). IMAP-Services entfernt (sind Mo's Scope, separat in ecosystem).
- scripts/deploy.sh (NEU): adaptiert von /srv/rebreak/scripts/deploy.sh
für backend/-Layout. APP_DIR=backend, pnpm --filter rebreak-backend build,
.output → .output-staging atomic-move bleibt erhalten, pm2 restart
--update-env zieht neue Infisical-Secrets.
- scripts/deploy-webhook/server.mjs (NEU): 1:1-Kopie vom Server, damit
ecosystem.config.js auf die Repo-Version zeigen kann.
- ecosystem.config.js (NEU, root-level): rebreak-staging zeigt auf
backend/start-staging.sh, rebreak-webhook zeigt auf scripts/deploy-webhook.
rebreak-prod + dns-* sind kommentiert (folgen in späterer Phase).
- ops/CUTOVER_PLAN.md: Plan-Doku vom 2026-05-06 (yesterday's work).
- .gitignore: .claude/ und xgit ergänzt (lokale Agent-State, nicht versioniert).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
4.6 KiB
JavaScript
176 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Rebreak GitHub Webhook Listener
|
||
*
|
||
* Empfängt GitHub push-Events, validiert HMAC-SHA256-Signatur,
|
||
* und triggert das deploy.sh Script im Hintergrund.
|
||
*
|
||
* Port: 9000 (intern, wird von nginx reverse-proxied)
|
||
* Secret: GITHUB_WEBHOOK_SECRET in /etc/environment (via Infisical)
|
||
*/
|
||
|
||
import http from "http";
|
||
import crypto from "crypto";
|
||
import { spawn } from "child_process";
|
||
import { readFileSync } from "fs";
|
||
|
||
const REPO_ROOT = "/srv/rebreak";
|
||
const DEPLOY_SCRIPT = `${REPO_ROOT}/scripts/deploy.sh`;
|
||
const PORT = 9000;
|
||
|
||
// Lade GITHUB_WEBHOOK_SECRET aus /etc/environment
|
||
function loadEnvFile() {
|
||
try {
|
||
const content = readFileSync("/etc/environment", "utf-8");
|
||
for (const line of content.split("\n")) {
|
||
const match = line.match(/^([^=]+)="?([^"]*)"?$/);
|
||
if (match) process.env[match[1]] = match[2];
|
||
}
|
||
} catch {
|
||
// /etc/environment nicht lesbar – Env-Vars müssen anderweitig gesetzt sein
|
||
}
|
||
}
|
||
loadEnvFile();
|
||
|
||
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
|
||
if (!WEBHOOK_SECRET) {
|
||
console.error(
|
||
"[Webhook] FATAL: GITHUB_WEBHOOK_SECRET nicht in /etc/environment gesetzt",
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
function verifySignature(secret, signature, payload) {
|
||
const hmac = crypto.createHmac("sha256", secret);
|
||
hmac.update(payload, "utf-8");
|
||
const digest = `sha256=${hmac.digest("hex")}`;
|
||
try {
|
||
return crypto.timingSafeEqual(
|
||
Buffer.from(signature),
|
||
Buffer.from(digest),
|
||
);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Deploy-Queue: verhindert parallele Builds (OOM-Schutz auf 4 GB RAM)
|
||
let deployRunning = false;
|
||
let pendingDeploy = false;
|
||
|
||
function runDeploy() {
|
||
if (deployRunning) {
|
||
pendingDeploy = true;
|
||
console.log("[Webhook] Deploy already running – queued next deploy");
|
||
return;
|
||
}
|
||
|
||
deployRunning = true;
|
||
console.log("[Webhook] Starting deploy.sh...");
|
||
|
||
const proc = spawn("bash", [DEPLOY_SCRIPT], {
|
||
cwd: REPO_ROOT,
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
detached: false,
|
||
});
|
||
|
||
proc.stdout.on("data", (d) => process.stdout.write(`[deploy] ${d}`));
|
||
proc.stderr.on("data", (d) => process.stderr.write(`[deploy:err] ${d}`));
|
||
|
||
proc.on("close", (code) => {
|
||
deployRunning = false;
|
||
console.log(`[Webhook] deploy.sh exited with code ${code}`);
|
||
if (pendingDeploy) {
|
||
pendingDeploy = false;
|
||
console.log("[Webhook] Running queued deploy...");
|
||
runDeploy();
|
||
}
|
||
});
|
||
|
||
proc.on("error", (err) => {
|
||
deployRunning = false;
|
||
console.error("[Webhook] deploy.sh spawn error:", err.message);
|
||
if (pendingDeploy) {
|
||
pendingDeploy = false;
|
||
runDeploy();
|
||
}
|
||
});
|
||
}
|
||
|
||
const server = http.createServer((req, res) => {
|
||
if (req.method !== "POST" || req.url !== "/webhook") {
|
||
res.writeHead(404);
|
||
res.end("Not found");
|
||
return;
|
||
}
|
||
|
||
let body = "";
|
||
req.on("data", (chunk) => (body += chunk));
|
||
req.on("end", () => {
|
||
const sig = req.headers["x-hub-signature-256"];
|
||
if (!sig) {
|
||
res.writeHead(401);
|
||
res.end(JSON.stringify({ error: "Missing signature" }));
|
||
return;
|
||
}
|
||
|
||
if (!verifySignature(WEBHOOK_SECRET, sig, body)) {
|
||
res.writeHead(401);
|
||
res.end(JSON.stringify({ error: "Invalid signature" }));
|
||
return;
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(body);
|
||
} catch {
|
||
res.writeHead(400);
|
||
res.end(JSON.stringify({ error: "Invalid JSON" }));
|
||
return;
|
||
}
|
||
|
||
const branch = payload.ref?.split("/").pop();
|
||
if (branch !== "main") {
|
||
console.log(`[Webhook] Ignoring non-main branch: ${branch}`);
|
||
res.writeHead(200);
|
||
res.end(JSON.stringify({ ok: false, reason: "Not main branch", branch }));
|
||
return;
|
||
}
|
||
|
||
const changedFiles = (payload.commits || []).flatMap((c) => [
|
||
...(c.added || []),
|
||
...(c.modified || []),
|
||
...(c.removed || []),
|
||
]);
|
||
|
||
console.log(
|
||
`[Webhook] Push to main by ${payload.pusher?.name} – ${changedFiles.length} files changed`,
|
||
);
|
||
console.log("[Webhook] Changed files:", changedFiles.slice(0, 10));
|
||
|
||
res.writeHead(202);
|
||
res.end(
|
||
JSON.stringify({
|
||
ok: true,
|
||
message: deployRunning
|
||
? "Deploy queued (previous still running)"
|
||
: "Deploy started",
|
||
files: changedFiles.length,
|
||
}),
|
||
);
|
||
|
||
// Asynchron deployen – Response ist bereits gesendet
|
||
runDeploy();
|
||
});
|
||
});
|
||
|
||
server.listen(PORT, "127.0.0.1", () => {
|
||
console.log(`[Webhook] Listening on http://127.0.0.1:${PORT}/webhook`);
|
||
console.log(`[Webhook] Secret loaded: ${WEBHOOK_SECRET ? "yes" : "NO – FATAL"}`);
|
||
});
|
||
|
||
process.on("SIGTERM", () => {
|
||
console.log("[Webhook] SIGTERM received, shutting down");
|
||
server.close(() => process.exit(0));
|
||
});
|