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) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 02:04:02 +08:00
parent 942a0096ba
commit 5029c5db6f
5 changed files with 144 additions and 11 deletions

View File

@@ -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

View File

@@ -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

104
deploy/hook.py Normal file
View File

@@ -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())

View File

@@ -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