diff --git a/JOURNAL.md b/JOURNAL.md index 6c49392..d1ba103 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -266,3 +266,22 @@ clone success exit 0; hello success exit 0 — log shows `whoami=root`, `abra 0. **Next:** M3 — comment-bridge service: Gitea issue_comment webhook → verify HMAC + `!testme` exact + collaborator → resolve PR head repo/SHA → trigger a parameterized Drone build; post a PR comment with the run link. Need a Drone API token for the bridge (mint from the bot's Drone account). + +## 2026-05-26 — M3 start: bridge secrets + comment-bridge source + +**Secrets (sops):** minted a Gitea API token (`cc-ci-bridge`, scopes read:org/user, write:repo/issue), +a Drone API token (`POST /api/user/token`, the stable personal token; rotates on call), and a webhook +HMAC (urandom hex64). Stored as bridge_gitea_token / bridge_drone_token / bridge_webhook_hmac via +`sops set` (host age identity). secrets.yaml now holds 6 secrets. + +**bridge/bridge.py** (Python stdlib only, §4.1): POST /hook handler — verifies Gitea HMAC +(`X-Gitea-Signature` sha256), requires `X-Gitea-Event: issue_comment`, action=created, body trimmed +== `!testme`, issue is a PR; checks commenter is a collaborator (Gitea collaborators endpoint, 204); +resolves PR head sha+repo; triggers a parameterized Drone build +(`POST /api/repos//builds?branch=main&RECIPE&REF&PR&SRC`, custom params → pipeline env); +posts a PR comment linking the run. Secrets read from mounted files; config via env. `/healthz` GET. + +**Next:** package the bridge as a swarm service (dockerTools image, no Docker Hub pull) behind +traefik at `ci.commoninternet.net/hook` via a reconcile oneshot (modules/bridge.nix); register a +per-repo webhook with the HMAC; demo on a scratch PR (!testme triggers; non-!testme + non-collab +rejected). That's the M3 gate. diff --git a/bridge/.gitkeep b/bridge/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/bridge/bridge.py b/bridge/bridge.py new file mode 100644 index 0000000..33f2e78 --- /dev/null +++ b/bridge/bridge.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""cc-ci comment-bridge (§4.1). + +Receives Gitea `issue_comment` webhooks; when a *collaborator* comments exactly `!testme` on an +open PR, triggers a parameterized Drone build of the cc-ci pipeline for that PR's head commit and +posts a PR comment linking the run. Everything else is ignored. Python stdlib only. + +Config (env): + BRIDGE_LISTEN host:port to bind (default 0.0.0.0:8080) + GITEA_API e.g. https://git.autonomic.zone/api/v1 + DRONE_URL e.g. https://drone.ci.commoninternet.net + CI_REPO the pipeline repo, e.g. recipe-maintainers/cc-ci + HMAC_FILE file with the webhook HMAC secret + DRONE_TOKEN_FILE file with the Drone API token + GITEA_TOKEN_FILE file with the Gitea API token +""" +import hashlib +import hmac +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +GITEA_API = os.environ.get("GITEA_API", "https://git.autonomic.zone/api/v1") +DRONE_URL = os.environ.get("DRONE_URL", "https://drone.ci.commoninternet.net") +CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci") +TRIGGER = "!testme" + + +def _read(path): + with open(path) as fh: + return fh.read().strip() + + +HMAC_SECRET = _read(os.environ["HMAC_FILE"]).encode() +DRONE_TOKEN = _read(os.environ["DRONE_TOKEN_FILE"]) +GITEA_TOKEN = _read(os.environ["GITEA_TOKEN_FILE"]) + + +def log(*a): + print(*a, file=sys.stderr, flush=True) + + +def _api(url, token, method="GET", data=None): + headers = {"Authorization": "token " + token} if token else {} + body = None + if data is not None: + body = json.dumps(data).encode() + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read() + return resp.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as e: + return e.code, None + + +def is_collaborator(full_name, user): + # 204 => the user has push access (collaborator or org member with access). + status, _ = _api(f"{GITEA_API}/repos/{full_name}/collaborators/{user}", GITEA_TOKEN) + return status == 204 + + +def pr_head(owner, repo, number): + status, pr = _api(f"{GITEA_API}/repos/{owner}/{repo}/pulls/{number}", GITEA_TOKEN) + if status != 200 or not pr: + return None + head = pr.get("head", {}) + return {"sha": head.get("sha"), "repo": (head.get("repo") or {}).get("full_name")} + + +def trigger_build(recipe, ref, pr, src): + # Drone "create build" with custom params -> exposed to the pipeline as env vars. + q = urllib.parse.urlencode( + {"branch": "main", "RECIPE": recipe, "REF": ref, "PR": str(pr), "SRC": src} + ) + url = f"{DRONE_URL}/api/repos/{CI_REPO}/builds?{q}" + status, build = _api(url, DRONE_TOKEN, method="POST") + if status in (200, 201) and build: + return build.get("number") + log("drone trigger failed", status) + return None + + +def post_comment(owner, repo, number, body): + _api( + f"{GITEA_API}/repos/{owner}/{repo}/issues/{number}/comments", + GITEA_TOKEN, + method="POST", + data={"body": body}, + ) + + +class Handler(BaseHTTPRequestHandler): + def _send(self, code, msg=""): + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(msg.encode()) + + def do_GET(self): + # health endpoint + if self.path.rstrip("/") in ("/hook/healthz", "/healthz"): + return self._send(200, "ok") + return self._send(404, "not found") + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + + # 1) verify HMAC (Gitea sends hex sha256 in X-Gitea-Signature) + sig = self.headers.get("X-Gitea-Signature", "") + expected = hmac.new(HMAC_SECRET, body, hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + log("rejected: bad signature") + return self._send(401, "bad signature") + + if self.headers.get("X-Gitea-Event") != "issue_comment": + return self._send(204, "ignored") + + try: + payload = json.loads(body) + except ValueError: + return self._send(400, "bad json") + + action = payload.get("action") + comment = (payload.get("comment") or {}).get("body", "") + issue = payload.get("issue") or {} + repo = payload.get("repository") or {} + user = (payload.get("comment") or {}).get("user", {}).get("login", "") + full_name = repo.get("full_name", "") + owner = (repo.get("owner") or {}).get("login", "") + name = repo.get("name", "") + number = issue.get("number") + + # 2) only a created comment, exactly "!testme", on a PR + if action != "created" or comment.strip() != TRIGGER: + return self._send(204, "ignored") + if not issue.get("pull_request"): + return self._send(204, "not a PR") + + # 3) commenter must be a collaborator / org member with access + if not is_collaborator(full_name, user): + log(f"rejected: {user} not a collaborator on {full_name}") + return self._send(403, "not authorized") + + # 4) resolve PR head (test the code at the PR head commit) + head = pr_head(owner, name, number) + if not head or not head["sha"]: + return self._send(502, "cannot resolve PR head") + + # 5) trigger the parameterized Drone build + num = trigger_build(name, head["sha"], number, head["repo"] or full_name) + if not num: + post_comment(owner, name, number, "cc-ci: failed to start a CI run (see bridge logs).") + return self._send(502, "trigger failed") + + run_url = f"{DRONE_URL}/{CI_REPO}/{num}" + post_comment( + owner, name, number, + f"cc-ci: started CI run for `{name}` @ `{head['sha'][:8]}` → {run_url}", + ) + log(f"triggered build {num} for {name}@{head['sha'][:8]} (PR #{number}) by {user}") + return self._send(201, run_url) + + def log_message(self, *a): # quiet default access logging + pass + + +def main(): + host, _, port = os.environ.get("BRIDGE_LISTEN", "0.0.0.0:8080").rpartition(":") + srv = ThreadingHTTPServer((host or "0.0.0.0", int(port)), Handler) + log(f"comment-bridge listening on {host or '0.0.0.0'}:{port}") + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index 8a4c022..c55a651 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -1,6 +1,9 @@ test_secret: ENC[AES256_GCM,data:VOxNiRyeSQQPKeF2PUK9AtezhzX+Hdm9ji5ZYm+gNd2NJ+wwXc67En8=,iv:Bn1oQeBN98E2/To1KRAw3wsLUF0/HsQFBm8s28L5aqo=,tag:KPS5Y+25Elf3alSF2H6npw==,type:str] drone_rpc_secret: ENC[AES256_GCM,data:pE+6nTpFaclRAQDBQZfki5WiMTOC6NkhBMcAkEfma9QjfOyBx9BbfyCyAB3a0ICo8NZj6kMHyw78GbrowU6RAg==,iv:3YgoLsXEQh6bOVMyVpSGopZFTP/Kxi20QdajNX9heVI=,tag:SNhRwTVzkygxZ10bN1yHlQ==,type:str] drone_gitea_client_secret: ENC[AES256_GCM,data:kBYptCmAdFmAuZNa1moK3o5faYrKFDr5KEjDHzfZOKyrz47awRX9LwNejyWlej1ybE8rvv075Yc=,iv:fESd6OYoHKjoZS4YBajon0dibt0BuHD2f/WqjbljmiY=,tag:f7skoA5fizTXjYGI3u6Uig==,type:str] +bridge_drone_token: ENC[AES256_GCM,data:5n7x6S9a/OIoq2AyPX4iKNDmoQsk+WT8za2/4qVhZFQ=,iv:r0fAs1cAj/YEOq6AGPsjJnsApYM8bMimdJ6e5zzKIw0=,tag:YMyt5FNR9yd0Mj/i+FmmFQ==,type:str] +bridge_gitea_token: ENC[AES256_GCM,data:zyGrnq36o2RfIBEGsCGFpOg/wJucNDviEUdaJjJkOXJkAdV0+CYZhw==,iv:y5G+cBu/Aaghn1ORcFT0wkj5ZJZ3UjzREmiES/dPosE=,tag:5KKANpOgf3+0rvspNCjYrg==,type:str] +bridge_webhook_hmac: ENC[AES256_GCM,data:0qD6BtZSoQx6pKUf8sz2Zcp656TKrzjNvb5zBtoGnLalg4lX7Nm715xtoaqdgAnXOEeJ5e8LfDgLIhwtxYHJxQ==,iv:hvXNzF1dXviGZae0hMhqn/pBwbv/LosHPl/BL6V3ZvU=,tag:B8V4HKqs/+gigQfTmBBCkw==,type:str] sops: kms: [] gcp_kms: [] @@ -25,8 +28,8 @@ sops: a2RKRWVaTGhNb0d3TFlnL2NtejZOaEUK2dQaAzlYk4Z7aBej77cO4Ug9Afkka6wg G1SumwxX0wMocpgz4WhDUPkBC66uWlaR3u1AWzwpzRseuwAZ94gAxA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-05-26T21:39:48Z" - mac: ENC[AES256_GCM,data:+O8ZV1d1csy8XVM+c2+feD/qH35t+qgnqZtQaP1jMCLIVQ6j/o+BT3Oo8bgMePrSml6hVJQ865LPF/sIT/Y3Ap5veGngR9DVzbMkhnqY1vEqjwa3cn+apX9cCarDHL9sY6iwGy4P9s3f8QgMUn0VRqkEJxnQ+dUfaTrlPNiizxc=,iv:ez7zjR+2jzTOdOOAJoA/u99Yh5b1q6RED45VMfZIqlE=,tag:7fF3qQYTTC3iMsxWFYII/w==,type:str] + lastmodified: "2026-05-26T22:15:49Z" + mac: ENC[AES256_GCM,data:ulV/ounh9Qt6+ZEK5R6eqLi++VwLjGajgpCdl9q3CjNvNinXL19TMIrmezellsVt9njsVqgsKbYkwTAhYN0t7MHcS2ObNe6Gw0EeZBsABTu8fHuyopW4wxFC3I6O4TaEJm4ALOxuqCUFtZoRs5Cnrez4MhzVzZjFoqlVtYj3FCE=,iv:Y6TJTUaeU2rRKP9gd9vByqM2TfX0eZw15Gbz40RzY7M=,tag:f9e8LPbCymrYeL0kDCKzvA==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.9.4