The receiver took the deploy lock but never released it, which starved the poller (flock -n failed instantly on every cycle).
227 lines
8.2 KiB
Python
227 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Gitea webhook receiver that rebuilds and republishes the docs site.
|
|
|
|
Flow: a push to main in co-op/docs fires a Gitea webhook -> this receiver
|
|
verifies it -> pulls the latest main -> runs publishing/build.sh ->
|
|
publishing/deploy.sh (which writes the build into the Surfer app).
|
|
|
|
Runs on the agent host (no new Cloudron app). Standard library only.
|
|
|
|
Security model:
|
|
* Only POSTs from the Cloudron box (git.inference.coop's host) are accepted.
|
|
* The payload must carry a valid HMAC-SHA256 signature (X-Gitea-Signature,
|
|
shared secret DEPLOY_WEBHOOK_SECRET). The secret itself is never sent.
|
|
* Only pushes to refs/heads/main of co-op/docs trigger a deploy.
|
|
* The receiver binds 0.0.0.0; an iptables rule installed by the systemd unit
|
|
narrows the port to the Cloudron box as well (defence in depth).
|
|
|
|
Secrets are read from /home/ubuntu/.hermes/.env and are never logged.
|
|
"""
|
|
|
|
import fcntl
|
|
import hashlib
|
|
import hmac
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
REPO_DIR = os.environ.get("DOCS_REPO_DIR", "/home/ubuntu/repos/docs")
|
|
ENV_FILE = os.environ.get("DOCS_ENV_FILE", "/home/ubuntu/.hermes/.env")
|
|
LOG_FILE = os.environ.get("DOCS_DEPLOY_LOG", "/home/ubuntu/docs-deploy.log")
|
|
LOCK_FILE = os.environ.get("DOCS_DEPLOY_LOCK", "/home/ubuntu/.docs-deploy.lock")
|
|
PORT = int(os.environ.get("DOCS_WEBHOOK_PORT", "8787"))
|
|
EXPECT_REPO = "co-op/docs"
|
|
EXPECT_BRANCH = "refs/heads/main"
|
|
ALLOWED_SOURCES = [
|
|
ipaddress.ip_network(n)
|
|
for n in os.environ.get(
|
|
"DOCS_WEBHOOK_SOURCES",
|
|
# The Cloudron box (hosts git.inference.coop) plus loopback for local tests.
|
|
"179.237.86.116/32,2001:1600:18:206::89/128,127.0.0.1/32,::1/128",
|
|
).split(",")
|
|
if n.strip()
|
|
]
|
|
|
|
log = logging.getLogger("docs-deploy")
|
|
deploy_lock_file = open(LOCK_FILE, "a")
|
|
|
|
|
|
def load_env_file(path):
|
|
"""Return KEY=VALUE pairs from the agent host's env file (values stay local)."""
|
|
values = {}
|
|
try:
|
|
with open(path) as handle:
|
|
for line in handle:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
|
except OSError:
|
|
pass
|
|
return values
|
|
|
|
|
|
_FILE_ENV = load_env_file(ENV_FILE)
|
|
|
|
|
|
def secret(name):
|
|
return os.environ.get(name) or _FILE_ENV.get(name) or ""
|
|
|
|
|
|
def source_allowed(address):
|
|
try:
|
|
ip = ipaddress.ip_address(address.split("%")[0])
|
|
except ValueError:
|
|
return False
|
|
return any(ip in net for net in ALLOWED_SOURCES)
|
|
|
|
|
|
def run(cmd, timeout, cwd=None):
|
|
log.info("exec: %s", " ".join(cmd))
|
|
# Hand the build/deploy scripts the credentials they need (from the env
|
|
# file, if not already set). Values are passed by environment only and are
|
|
# never logged.
|
|
env = dict(os.environ)
|
|
for key in ("CLOUDRON_TOKEN", "CLOUDRON_SERVER", "SURFER_APP", "GIT_ASKPASS"):
|
|
if not env.get(key) and secret(key):
|
|
env[key] = secret(key)
|
|
env.setdefault("GIT_ASKPASS", "/home/ubuntu/.hermes/scripts/git_askpass.sh")
|
|
env.setdefault("PATH", os.environ.get("PATH", ""))
|
|
result = subprocess.run(
|
|
cmd, cwd=cwd or REPO_DIR, capture_output=True, text=True, timeout=timeout,
|
|
env=env,
|
|
)
|
|
if result.stdout.strip():
|
|
log.info("stdout: %s", result.stdout.strip()[-2000:])
|
|
if result.returncode != 0 and result.stderr.strip():
|
|
log.error("stderr: %s", result.stderr.strip()[-2000:])
|
|
return result
|
|
|
|
|
|
def deploy(commit_hint=""):
|
|
"""Pull main, rebuild, republish. Returns (ok, log_tail)."""
|
|
try:
|
|
fcntl.flock(deploy_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except BlockingIOError:
|
|
return False, "another deploy is already running; skipped"
|
|
|
|
tail = []
|
|
try:
|
|
steps = [
|
|
(["git", "pull", "--ff-only", "origin", "main"], 120),
|
|
(["bash", "publishing/build.sh"], 900),
|
|
(["bash", "publishing/deploy.sh"], 600),
|
|
]
|
|
for cmd, timeout in steps:
|
|
result = run(cmd, timeout)
|
|
out = (result.stdout or "") + (result.stderr or "")
|
|
tail.append(f"$ {' '.join(cmd)} -> exit {result.returncode}")
|
|
tail.extend(out.strip().splitlines()[-12:])
|
|
if result.returncode != 0:
|
|
log.error("step failed: %s", " ".join(cmd))
|
|
return False, "\n".join(tail)
|
|
log.info("deploy complete%s", f" ({commit_hint})" if commit_hint else "")
|
|
return True, "\n".join(tail)
|
|
except Exception as exc: # noqa: BLE001 - report, never crash the receiver
|
|
log.exception("deploy failed")
|
|
tail.append(f"error: {exc}")
|
|
return False, "\n".join(tail)
|
|
finally:
|
|
fcntl.flock(deploy_lock_file, fcntl.LOCK_UN)
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "docs-deploy/1.0"
|
|
|
|
def log_message(self, format, *args): # noqa: A002 - stdlib signature
|
|
log.info("%s - %s", self.client_address[0], format % args)
|
|
|
|
def _json(self, code, payload):
|
|
body = json.dumps(payload).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
if self.path == "/healthz":
|
|
return self._json(200, {"status": "ok"})
|
|
return self._json(404, {"error": "not found"})
|
|
|
|
def do_POST(self):
|
|
peer = self.client_address[0]
|
|
if not source_allowed(peer):
|
|
log.warning("rejected: source %s not allowed", peer)
|
|
return self._json(403, {"error": "forbidden"})
|
|
|
|
if self.headers.get("X-Gitea-Event") != "push":
|
|
return self._json(400, {"error": "not a push event"})
|
|
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
if length <= 0 or length > 1_000_000:
|
|
return self._json(400, {"error": "bad payload size"})
|
|
body = self.rfile.read(length)
|
|
|
|
signature = self.headers.get("X-Gitea-Signature") or ""
|
|
expected = hmac.new(
|
|
secret("DEPLOY_WEBHOOK_SECRET").encode(), body, hashlib.sha256
|
|
).hexdigest()
|
|
if not signature or not hmac.compare_digest(signature, expected):
|
|
log.warning("rejected: bad signature from %s", peer)
|
|
return self._json(403, {"error": "invalid signature"})
|
|
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
return self._json(400, {"error": "invalid json"})
|
|
|
|
repo = (payload.get("repository") or {}).get("full_name", "")
|
|
ref = payload.get("ref", "")
|
|
commit = (payload.get("after") or "")[:12]
|
|
pusher = ((payload.get("pusher") or {}).get("login")) or "unknown"
|
|
if repo != EXPECT_REPO or ref != EXPECT_BRANCH:
|
|
log.info("ignored: push to %s %s (not %s %s)", repo, ref, EXPECT_REPO, EXPECT_BRANCH)
|
|
return self._json(200, {"status": "ignored", "reason": "not main on co-op/docs"})
|
|
|
|
log.info("push received: %s %s commit=%s pusher=%s", repo, ref, commit, pusher)
|
|
threading.Thread(target=self._run_deploy, args=(commit,), daemon=True).start()
|
|
return self._json(202, {"status": "accepted", "commit": commit})
|
|
|
|
def _run_deploy(self, commit):
|
|
ok, tail = deploy(commit)
|
|
log.info("deploy result for %s: %s", commit, "ok" if ok else "FAILED")
|
|
log.info("tail:\n%s", tail[-1500:])
|
|
|
|
|
|
def main():
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
handlers=[
|
|
logging.FileHandler(LOG_FILE),
|
|
logging.StreamHandler(sys.stdout),
|
|
],
|
|
)
|
|
if not secret("DEPLOY_WEBHOOK_SECRET"):
|
|
log.error("DEPLOY_WEBHOOK_SECRET is not set; refusing to start")
|
|
sys.exit(1)
|
|
if not secret("CLOUDRON_TOKEN"):
|
|
log.warning("CLOUDRON_TOKEN not set; deploys will fail")
|
|
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
|
log.info("listening on 0.0.0.0:%d, repo %s", PORT, REPO_DIR)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|