From 5029c5db6f817b87ef28ced7a80aaeb8ea2ed5e1 Mon Sep 17 00:00:00 2001 From: Knowit Date: Sun, 3 May 2026 02:04:02 +0800 Subject: [PATCH] Switch nginx config to directory mount and add webhook listener - Move deploy/nginx.conf -> deploy/conf.d/default.conf and mount the directory so future config changes can be hot-reloaded with `nginx -s reload` instead of a full container restart. - Add deploy/hook.py: a tiny stdlib HMAC-validated webhook listener that runs pull.sh on Gitea push events. Bound to 127.0.0.1:9528 and fronted by openresty at /_hook/deploy. - Add the matching systemd unit at deploy/facere-deploy-hook.service. - Teach pull.sh the new layout (reload vs. restart vs. compose up -d) and self-restart the hook listener if hook.py changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- deploy/{nginx.conf => conf.d/default.conf} | 0 deploy/docker-compose.yml | 2 +- deploy/facere-deploy-hook.service | 17 ++++ deploy/hook.py | 104 +++++++++++++++++++++ deploy/pull.sh | 32 +++++-- 5 files changed, 144 insertions(+), 11 deletions(-) rename deploy/{nginx.conf => conf.d/default.conf} (100%) create mode 100644 deploy/facere-deploy-hook.service create mode 100644 deploy/hook.py diff --git a/deploy/nginx.conf b/deploy/conf.d/default.conf similarity index 100% rename from deploy/nginx.conf rename to deploy/conf.d/default.conf diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 1e000da..615658b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -7,4 +7,4 @@ services: - "9527:80" volumes: - ../:/usr/share/nginx/html:ro - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./conf.d:/etc/nginx/conf.d:ro diff --git a/deploy/facere-deploy-hook.service b/deploy/facere-deploy-hook.service new file mode 100644 index 0000000..c8749bf --- /dev/null +++ b/deploy/facere-deploy-hook.service @@ -0,0 +1,17 @@ +[Unit] +Description=Facere deploy webhook listener +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ubuntu +EnvironmentFile=/etc/facere-deploy.env +ExecStart=/usr/bin/python3 /home/ubuntu/repo/facere-website/deploy/hook.py +Restart=on-failure +RestartSec=3 +StandardOutput=append:/tmp/facere-deploy-hook.log +StandardError=append:/tmp/facere-deploy-hook.log + +[Install] +WantedBy=multi-user.target diff --git a/deploy/hook.py b/deploy/hook.py new file mode 100644 index 0000000..da8029e --- /dev/null +++ b/deploy/hook.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Tiny webhook listener that runs deploy/pull.sh when Gitea pushes main. + +Listens on 127.0.0.1:9528 — exposed publicly only via openresty at +https://web.facere.cc/_hook/deploy. Validates the Gitea HMAC-SHA256 +signature against FACERE_DEPLOY_SECRET before running anything. +""" +from __future__ import annotations + +import hmac +import hashlib +import json +import os +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +SECRET = os.environ.get("FACERE_DEPLOY_SECRET", "").encode() +PULL = os.environ.get( + "FACERE_DEPLOY_SCRIPT", + "/home/ubuntu/repo/facere-website/deploy/pull.sh", +) +LOG = os.environ.get("FACERE_DEPLOY_LOG", "/tmp/facere-deploy.log") +BIND_HOST = os.environ.get("FACERE_DEPLOY_BIND", "127.0.0.1") +BIND_PORT = int(os.environ.get("FACERE_DEPLOY_PORT", "9528")) + +_run_lock = threading.Lock() + + +class Handler(BaseHTTPRequestHandler): + def _reply(self, status: int, msg: str) -> None: + body = (msg + "\n").encode() + self.send_response(status) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + self._reply(200, "facere-deploy-hook ready") + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) if length > 0 else b"" + + sig = self.headers.get("X-Gitea-Signature", "") or self.headers.get( + "X-Hub-Signature-256", "" + ).removeprefix("sha256=") + expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest() + if not sig or not hmac.compare_digest(sig, expected): + self._reply(401, "bad signature") + return + + ref = "" + try: + ref = json.loads(body).get("ref", "") + except Exception: + pass + if ref and ref != "refs/heads/main": + self._reply(202, f"ignored ref {ref}") + return + + if not _run_lock.acquire(blocking=False): + self._reply(202, "deploy already running") + return + try: + with open(LOG, "a") as f: + f.write(f"[{self.log_date_time_string()}] webhook trigger\n") + f.flush() + proc = subprocess.run( + [PULL], stdout=f, stderr=subprocess.STDOUT, timeout=180 + ) + if proc.returncode == 0: + self._reply(200, "ok") + else: + self._reply(500, f"pull.sh exit {proc.returncode}") + except subprocess.TimeoutExpired: + self._reply(504, "pull.sh timeout") + finally: + _run_lock.release() + + def log_message(self, fmt: str, *args) -> None: + sys.stderr.write( + "[%s] %s - %s\n" + % (self.log_date_time_string(), self.address_string(), fmt % args) + ) + + +def main() -> int: + if not SECRET: + print("FACERE_DEPLOY_SECRET is not set", file=sys.stderr) + return 1 + srv = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler) + print(f"facere-deploy-hook listening on {BIND_HOST}:{BIND_PORT}", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/deploy/pull.sh b/deploy/pull.sh index 2b50090..547caa6 100755 --- a/deploy/pull.sh +++ b/deploy/pull.sh @@ -8,18 +8,30 @@ if [ "$before" = "$after" ]; then exit 0 fi -# Detect whether the bind-mounted nginx.conf changed in this update. -# Docker bind-mounts the file by inode, so a `git reset` (which replaces -# the file) requires a container restart for the new config to take effect. -nginx_changed=0 -if git diff --name-only "$before" "$after" | grep -qx 'deploy/nginx.conf'; then - nginx_changed=1 -fi +changed=$(git diff --name-only "$before" "$after") +nginx_conf_changed=0 +compose_changed=0 +hook_changed=0 +if grep -qx 'deploy/conf.d/default.conf' <<<"$changed"; then nginx_conf_changed=1; fi +if grep -qx 'deploy/docker-compose.yml' <<<"$changed"; then compose_changed=1; fi +if grep -qx 'deploy/hook.py' <<<"$changed"; then hook_changed=1; fi git reset --hard origin/main echo "[$(date -Is)] deployed $before -> $after" -if [ "$nginx_changed" = "1" ]; then - sudo -n docker restart facere-web >/dev/null - echo "[$(date -Is)] restarted facere-web (nginx.conf changed)" +if [ "$compose_changed" = "1" ]; then + ( cd /home/ubuntu/repo/facere-website/deploy && sudo -n docker compose up -d ) >/dev/null + echo "[$(date -Is)] docker compose up -d (compose changed)" +elif [ "$nginx_conf_changed" = "1" ]; then + if sudo -n docker exec facere-web nginx -t >/dev/null 2>&1; then + sudo -n docker exec facere-web nginx -s reload >/dev/null + echo "[$(date -Is)] nginx reloaded (default.conf changed)" + else + echo "[$(date -Is)] nginx config test FAILED, not reloading" >&2 + fi +fi + +if [ "$hook_changed" = "1" ]; then + sudo -n systemctl restart facere-deploy-hook >/dev/null || true + echo "[$(date -Is)] facere-deploy-hook restarted (hook.py changed)" fi