feat(deploy): webhook auto-deploy Forgejo → VPS Paris (TD-04)
Some checks are pending
CI / quality (push) Waiting to run

This commit is contained in:
Hermann_Kitio 2026-07-01 12:34:39 +03:00
parent 85c760abee
commit 0ae2db3d8c
6 changed files with 333 additions and 37 deletions

98
deploy/deploy.sh Normal file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Expria auto-deploy script (VPS Paris).
#
# Runs as the non-root `deploy` user, invoked by deploy/webhook-listener.mjs on
# a verified push to origin/main. Fast-forwards the checkout, installs, builds,
# and restarts expria-backend.service via ONE restricted sudo rule:
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart expria-backend.service
#
# On a build or health-check failure it auto-rolls back to the previous commit.
# All output goes to stdout -> journald (journalctl -u expria-deploy).
set -euo pipefail
REPO_DIR="/opt/expria/expria-backend"
BRANCH="main"
SERVICE="expria-backend.service"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:4000/}"
LOCK_FILE="/tmp/expria-deploy.lock"
log() { echo "$(date --iso-8601=seconds) [deploy] $*"; }
# Serialize concurrent deploys: a second webhook waits here, or bails out.
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
log "another deploy is already in progress — aborting this run"
exit 0
fi
cd "$REPO_DIR"
PREV="$(git rev-parse HEAD)"
log "start (current=${PREV:0:8})"
restart_backend() {
sudo /usr/bin/systemctl restart "$SERVICE"
}
# Poll the liveness endpoint (GET / always returns 200 when the process is up).
health_check() {
for _ in $(seq 1 10); do
if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
return 1
}
rollback() {
log "ROLLBACK to ${PREV:0:8}"
if git reset --hard "$PREV" && npm ci && npm run build; then
if restart_backend && health_check; then
log "ROLLBACK ok — service healthy on ${PREV:0:8}"
return 0
fi
fi
log "ROLLBACK FAILED — manual intervention required (was on ${PREV:0:8})"
return 1
}
# 1. Fetch + fast-forward only (refuses a diverged history, no destructive pull).
git fetch --prune origin "$BRANCH"
if ! git merge --ff-only "origin/$BRANCH"; then
log "fast-forward failed (diverged history) — aborting, no changes applied"
exit 1
fi
NEW="$(git rev-parse HEAD)"
log "pulled ${PREV:0:8} -> ${NEW:0:8}"
if [ "$PREV" = "$NEW" ]; then
log "already up to date — nothing to deploy"
exit 0
fi
# 2. Install + build (rollback on failure).
if ! npm ci; then
log "npm ci FAILED"
rollback
exit 1
fi
if ! npm run build; then
log "npm run build FAILED"
rollback
exit 1
fi
# 3. Restart + health check (rollback on failure).
if ! restart_backend; then
log "systemctl restart FAILED"
rollback
exit 1
fi
if health_check; then
log "SUCCESS — deployed ${NEW:0:8}"
else
log "health check FAILED after restart"
rollback
exit 1
fi

View file

@ -0,0 +1,34 @@
# Expria auto-deploy webhook listener — systemd unit (VPS Paris).
#
# DISTINCT from expria-backend.service: this runs the webhook listener, not the
# API. Keeping them separate is required — a deploy restarts expria-backend, so
# the listener must NOT be a child of it or the deploy would kill itself.
#
# Install (ops step, after CP1 validation):
# sudo cp deploy/expria-deploy.service /etc/systemd/system/expria-deploy.service
# sudo systemctl daemon-reload
# sudo systemctl enable --now expria-deploy
# Logs:
# journalctl -u expria-deploy -f
[Unit]
Description=Expria auto-deploy webhook listener (Forgejo push -> deploy)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/opt/expria/expria-backend/deploy
EnvironmentFile=/etc/expria/webhook.env
ExecStart=/usr/bin/node /opt/expria/expria-backend/deploy/webhook-listener.mjs
Restart=always
RestartSec=5
# NOTE: NoNewPrivileges MUST stay false (default) — deploy.sh relies on sudo for
# the single restricted rule: systemctl restart expria-backend.service.
# ProtectSystem=strict is intentionally NOT set: deploy.sh writes the checkout
# under /opt/expria/expria-backend (git pull, npm ci, build).
[Install]
WantedBy=multi-user.target

140
deploy/webhook-listener.mjs Normal file
View file

@ -0,0 +1,140 @@
#!/usr/bin/env node
// Expria auto-deploy webhook listener (VPS Paris).
//
// Zero external dependencies: Node stdlib only. Runs as the non-root `deploy`
// user under systemd (deploy/expria-deploy.service), bound to 127.0.0.1 only.
// Caddy (deploy.expria.app) terminates TLS + filters source IP, then proxies
// here. Forgejo "push" events are authenticated by HMAC-SHA256 over the raw
// body (header X-Gitea-Signature) using a DEDICATED secret (never the git token).
//
// On a verified push to refs/heads/main, it spawns deploy/deploy.sh. Concurrent
// deploys are serialized by flock inside deploy.sh.
import http from "node:http";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
const HOST = "127.0.0.1";
const PORT = Number(process.env.WEBHOOK_PORT) || 9000;
const HOOK_PATH = process.env.WEBHOOK_PATH || "/hooks/deploy";
const TARGET_REF = "refs/heads/main";
const MAX_BODY = 5 * 1024 * 1024; // 5 MB — generous for a push payload
const SECRET = process.env.WEBHOOK_SECRET;
if (!SECRET || SECRET.length < 16) {
console.error(
"[webhook] WEBHOOK_SECRET missing or too short (<16 chars) — refusing to start",
);
process.exit(1);
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEPLOY_SCRIPT = path.join(__dirname, "deploy.sh");
let deployRunning = false;
function log(...args) {
console.log(new Date().toISOString(), "[webhook]", ...args);
}
// Constant-time HMAC comparison. Never logs the secret or the signature.
function verifySignature(rawBody, headerSig) {
if (!headerSig || typeof headerSig !== "string") return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(rawBody)
.digest("hex");
let a;
let b;
try {
a = Buffer.from(expected, "hex");
b = Buffer.from(headerSig, "hex");
} catch {
return false;
}
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function runDeploy(triggerSha) {
deployRunning = true;
log("deploy launch", triggerSha ? `after=${triggerSha.slice(0, 8)}` : "");
const child = spawn("/bin/bash", [DEPLOY_SCRIPT], {
cwd: __dirname,
env: { ...process.env },
stdio: ["ignore", "inherit", "inherit"], // deploy.sh logs go to journald
});
child.on("exit", (code) => {
deployRunning = false;
log("deploy finished exit=" + code);
});
child.on("error", (err) => {
deployRunning = false;
log("deploy spawn error:", err.message);
});
}
const server = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== HOOK_PATH) {
res.writeHead(404).end("not found\n");
return;
}
const chunks = [];
let size = 0;
let aborted = false;
req.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_BODY) {
aborted = true;
res.writeHead(413).end("payload too large\n");
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (aborted) return;
const rawBody = Buffer.concat(chunks);
const sig =
req.headers["x-gitea-signature"] || req.headers["x-forgejo-signature"];
if (!verifySignature(rawBody, sig)) {
log("rejected: invalid signature from", req.socket.remoteAddress);
res.writeHead(401).end("unauthorized\n");
return;
}
let payload;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
res.writeHead(400).end("bad json\n");
return;
}
if (payload.ref !== TARGET_REF) {
log("ignored ref", payload.ref);
res.writeHead(200).end("ignored (not main)\n");
return;
}
if (deployRunning) {
log("deploy already running — new run will queue on flock");
}
res.writeHead(202).end("accepted\n");
runDeploy(payload.after);
});
req.on("error", () => {
if (!res.headersSent) res.writeHead(400).end();
});
});
server.listen(PORT, HOST, () => {
log(`listening on ${HOST}:${PORT}${HOOK_PATH}`);
});