From 4fcabedbed4646cccae2d4839b2d0bd5a5f08976 Mon Sep 17 00:00:00 2001 From: GISCE Bot Date: Mon, 5 Oct 2026 10:43:45 +0200 Subject: [PATCH] feat: isolate socket-activated webhook ingress Serve GitHub deliveries through a minimal ingress process backed by a systemd-owned socket, so dashboard and ingress restarts do not create a connection-refused window. Route nginx independently and teach autoupdate and system status about the new service.\n\nRefs #191\n\nCo-authored-by: Eduard Carreras --- docs/ingestion.md | 4 + docs/installation.md | 15 ++ docs/nginx-dashboard.conf | 14 ++ docs/operations.md | 12 + pyproject.toml | 1 + src/github_agent_bridge/autoupdate.py | 39 ++- src/github_agent_bridge/backend.py | 237 +++++++++++------- src/github_agent_bridge/systemd_status.py | 2 + systemd/env.example | 5 + systemd/github-agent-bridge-dashboard.service | 2 +- systemd/github-agent-bridge-webhook.service | 17 ++ systemd/github-agent-bridge-webhook.socket | 11 + tests/test_autoupdate.py | 40 +++ tests/test_webhook.py | 18 +- tests/test_webhook_systemd.py | 33 +++ 15 files changed, 351 insertions(+), 99 deletions(-) create mode 100644 systemd/github-agent-bridge-webhook.service create mode 100644 systemd/github-agent-bridge-webhook.socket create mode 100644 tests/test_webhook_systemd.py diff --git a/docs/ingestion.md b/docs/ingestion.md index 2fa45a1..5733c07 100644 --- a/docs/ingestion.md +++ b/docs/ingestion.md @@ -42,6 +42,10 @@ accepts a possible duplicate rather than risk dropping a legitimate action. operational cycle has no unexplained IMAP-only actionable events. Phase 1 is exposed as `POST /api/webhooks/github` by the dashboard service. +For production, nginx should route that exact path to the dedicated +socket-activated `github-agent-bridge-webhook.service` on port 8766. The +dashboard keeps the route for backward compatibility, but using it couples +GitHub delivery availability to dashboard/UI restarts. For a single trusted owner, configure `GITHUB_AGENT_BRIDGE_WEBHOOK_SECRET`; during rotation, `GITHUB_AGENT_BRIDGE_WEBHOOK_PREVIOUS_SECRET` accepts the old secret as well. This legacy form accepts any repository signed with that shared diff --git a/docs/installation.md b/docs/installation.md index 9121a65..4acf98b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -230,6 +230,10 @@ cp systemd/github-agent-bridge-autoupdate.service ~/.config/systemd/user/ cp systemd/github-agent-bridge-autoupdate.timer ~/.config/systemd/user/ # Optional dashboard API for operator tooling: cp systemd/github-agent-bridge-dashboard.service ~/.config/systemd/user/ +# Recommended for public GitHub webhooks. The socket remains owned by systemd +# while the small ingress process restarts. +cp systemd/github-agent-bridge-webhook.service ~/.config/systemd/user/ +cp systemd/github-agent-bridge-webhook.socket ~/.config/systemd/user/ systemctl --user daemon-reload systemctl --user enable --now github-agent-bridge.service @@ -239,6 +243,7 @@ systemctl --user enable --now github-agent-bridge-feedback.timer systemctl --user enable --now github-agent-bridge-autoupdate.timer # Optional: # systemctl --user enable --now github-agent-bridge-dashboard.service +# systemctl --user enable --now github-agent-bridge-webhook.socket ``` The reader timer calls the packaged `github-agent-bridge-reader-run` console @@ -292,6 +297,13 @@ Set `GITHUB_AGENT_BRIDGE_GITHUB_APP_ID` or configured on the GitHub App automatically. `GITHUB_AGENT_BRIDGE_WEB_PUSH_ICON_URL` can still override the notification icon with an explicit URL. +When webhooks are enabled, start `github-agent-bridge-webhook.socket` and route +the exact `/api/webhooks/github` path to `127.0.0.1:8766`. systemd owns the +listening socket and keeps a backlog while `github-agent-bridge-webhook.service` +is replaced, so dashboard restarts and short ingress restarts do not produce a +connection-refused window. Do not enable the service directly; enabling the +socket starts it on demand. + When the dashboard is published through nginx, use the proxy settings from [`operations.md`](operations.md#dashboard-api-service) or start from [`nginx-dashboard.conf`](nginx-dashboard.conf). The example keeps live SSE @@ -301,7 +313,10 @@ briefly unavailable. ```bash systemctl --user status github-agent-bridge-dashboard.service +systemctl --user status github-agent-bridge-webhook.socket +systemctl --user status github-agent-bridge-webhook.service curl http://127.0.0.1:8765/api/health +curl http://127.0.0.1:8766/api/health ``` ## Monitor health diff --git a/docs/nginx-dashboard.conf b/docs/nginx-dashboard.conf index e3fd6a4..eb0e88c 100644 --- a/docs/nginx-dashboard.conf +++ b/docs/nginx-dashboard.conf @@ -11,6 +11,20 @@ server { # ssl_certificate /etc/letsencrypt/live/bridge.example.com/fullchain.pem; # ssl_certificate_key /etc/letsencrypt/live/bridge.example.com/privkey.pem; + # Keep webhook ingestion independent from dashboard/UI restarts. systemd + # owns this socket and queues short bursts while the ingress process swaps. + location = /api/webhooks/github { + proxy_pass http://127.0.0.1:8766; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 5s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + } + location / { proxy_pass http://127.0.0.1:8765; proxy_http_version 1.1; diff --git a/docs/operations.md b/docs/operations.md index 31451f2..85d51f0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -356,6 +356,16 @@ them in the viewer's local timezone from `Intl.DateTimeFormat`; hovering a rendered timestamp shows the UTC value. Production serves the static bundle from `src/github_agent_bridge/dashboard_static`. + +Public webhook ingestion should use the separate +`github-agent-bridge-webhook.socket` and `github-agent-bridge-webhook.service`. +The ingress app exposes only `GET /api/health` and +`POST /api/webhooks/github`; dashboard, OAuth, monitoring and administration +routes are deliberately absent. systemd owns `127.0.0.1:8766` and passes file +descriptor 3 to Uvicorn, retaining queued TCP connections across short process +restarts. Consequently a dashboard/UI deployment does not interrupt webhook +delivery, and an ingress deployment has no connection-refused gap while the +service is replaced. When VAPID keys are configured and the dashboard is exposed over HTTPS, signed-in users can enable the header bell control. The executor sends final `done` and `blocked` job notifications through those browser push subscriptions for the @@ -509,6 +519,8 @@ restart errors so browser users see a short auto-refreshing maintenance page instead of nginx's generic "Bad Gateway" response while the dashboard service is restarting. A complete example is available in [`nginx-dashboard.conf`](nginx-dashboard.conf). +The example routes the exact webhook path to the socket-activated ingress on +port 8766 before the generic dashboard location. ```nginx location / { diff --git a/pyproject.toml b/pyproject.toml index 567253c..f9da7bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ test = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27", "uvicorn>=0.29"] gab = "github_agent_bridge.cli:main" github-agent-bridge = "github_agent_bridge.cli:main" github-agent-bridge-dashboard = "github_agent_bridge.backend:main" +github-agent-bridge-webhook = "github_agent_bridge.backend:webhook_main" github-agent-bridge-reader-run = "github_agent_bridge.reader_run:main" github-agent-bridge-monitor-alert = "github_agent_bridge.monitor_alert:main" github-agent-bridge-autoupdate-run = "github_agent_bridge.autoupdate_run:main" diff --git a/src/github_agent_bridge/autoupdate.py b/src/github_agent_bridge/autoupdate.py index 62ef762..8a3b47e 100644 --- a/src/github_agent_bridge/autoupdate.py +++ b/src/github_agent_bridge/autoupdate.py @@ -36,10 +36,20 @@ "src/github_agent_bridge/dashboard_data.py", "src/github_agent_bridge/dashboard_static/", ) +WEBHOOK_PATH_PREFIXES = ( + "src/github_agent_bridge/backend.py", + "src/github_agent_bridge/models.py", + "src/github_agent_bridge/parser.py", + "src/github_agent_bridge/policy.py", + "src/github_agent_bridge/queue.py", + "src/github_agent_bridge/sql/", + "src/github_agent_bridge/webhook.py", +) SYSTEMD_PATH_PREFIXES = ("systemd/",) DEFAULT_SYSTEMD_UNITS = { "executor": "github-agent-bridge.service", "dashboard": "github-agent-bridge-dashboard.service", + "webhook": "github-agent-bridge-webhook.service", "reader": "github-agent-bridge-reader.timer", "monitor": "github-agent-bridge-monitor.timer", "feedback": "github-agent-bridge-feedback.timer", @@ -129,9 +139,15 @@ def classify_changed_files(files: Sequence[str]) -> dict[str, Any]: risky_files = [path for path in files if path.startswith(RISKY_PATH_PREFIXES)] migration_files = [path for path in files if path.startswith("src/github_agent_bridge/sql/") or "/migrations/" in path] dashboard_files = [path for path in files if path.startswith(DASHBOARD_PATH_PREFIXES)] + webhook_files = [path for path in files if path.startswith(WEBHOOK_PATH_PREFIXES)] systemd_files = [path for path in files if path.startswith(SYSTEMD_PATH_PREFIXES)] - dashboard_only = bool(files) and len(dashboard_files) == len(files) - risk = "dashboard_only" if dashboard_only else "executor_or_shared" + dashboard_only = bool(files) and len(dashboard_files) == len(files) and not webhook_files + webhook_only = bool(files) and len(webhook_files) == len(files) and not dashboard_files and not risky_files + api_only = bool(files) and all( + path.startswith(DASHBOARD_PATH_PREFIXES) or path.startswith(WEBHOOK_PATH_PREFIXES) + for path in files + ) and not risky_files + risk = "dashboard_only" if dashboard_only else "webhook_only" if webhook_only else "api_only" if api_only else "executor_or_shared" if migration_files: risk = "migration_required" elif risky_files: @@ -143,6 +159,9 @@ def classify_changed_files(files: Sequence[str]) -> dict[str, Any]: return { "risk": risk, "dashboard_only": dashboard_only, + "webhook_only": webhook_only, + "api_only": api_only, + "webhook_files": webhook_files, "risky_files": risky_files, "migration_files": migration_files, "systemd_files": systemd_files, @@ -168,15 +187,25 @@ def action(command: str, unit_key: str, reason: str) -> dict[str, str]: if decision == "stage_dashboard_reload": immediate.append(action("try-restart", "dashboard", "dashboard-only update can reload independently")) + elif decision == "stage_webhook_reload": + immediate.append(action("try-restart", "webhook", "webhook ingress update can reload independently")) + elif decision == "stage_api_reload": + immediate.append(action("try-restart", "dashboard", "dashboard API update can reload independently")) + immediate.append(action("try-restart", "webhook", "webhook ingress update can reload independently")) elif decision == "stage_defer_executor_reload": immediate.append(action("try-restart", "dashboard", "dashboard can refresh while executor jobs finish")) + if classification.get("webhook_files"): + immediate.append(action("try-restart", "webhook", "webhook ingress can refresh independently")) deferred.append(action("restart", "executor", "executor/shared update waits for active queue to drain")) elif decision == "stage_full_reload": immediate.append(action("try-restart", "dashboard", "refresh dashboard after package update")) + if classification.get("webhook_files"): + immediate.append(action("try-restart", "webhook", "refresh webhook ingress after package update")) immediate.append(action("restart", "executor", "queue is quiet, executor reload is allowed")) elif decision == "defer_migration": deferred.append(action("restart", "executor", "schema migration must wait for active queue to drain")) deferred.append(action("try-restart", "dashboard", "dashboard refresh waits for migration window")) + deferred.append(action("try-restart", "webhook", "webhook ingress refresh waits for migration window")) if daemon_reload: affected_units = sorted( @@ -186,6 +215,7 @@ def action(command: str, unit_key: str, reason: str) -> dict[str, str]: for key, filename in ( ("executor", "github-agent-bridge.service"), ("dashboard", "github-agent-bridge-dashboard.service"), + ("webhook", "github-agent-bridge-webhook.service"), ("reader", "github-agent-bridge-reader.timer"), ("monitor", "github-agent-bridge-monitor.timer"), ("feedback", "github-agent-bridge-feedback.timer"), @@ -743,6 +773,11 @@ def plan_update( elif classification["dashboard_only"]: decision = "stage_dashboard_reload" dashboard_restart_allowed = True + elif classification["webhook_only"]: + decision = "stage_webhook_reload" + elif classification["api_only"]: + decision = "stage_api_reload" + dashboard_restart_allowed = True elif active_total: decision = "stage_defer_executor_reload" executor_reload_pending = True diff --git a/src/github_agent_bridge/backend.py b/src/github_agent_bridge/backend.py index ec78c2a..595ae5e 100644 --- a/src/github_agent_bridge/backend.py +++ b/src/github_agent_bridge/backend.py @@ -373,6 +373,7 @@ def _autoupdate_systemd_units() -> dict[str, str]: return { "executor": _env("GITHUB_AGENT_BRIDGE_EXECUTOR_UNIT", "github-agent-bridge.service"), "dashboard": _env("GITHUB_AGENT_BRIDGE_DASHBOARD_UNIT", "github-agent-bridge-dashboard.service"), + "webhook": _env("GITHUB_AGENT_BRIDGE_WEBHOOK_UNIT", "github-agent-bridge-webhook.service"), "reader": _env("GITHUB_AGENT_BRIDGE_READER_TIMER_UNIT", "github-agent-bridge-reader.timer"), "monitor": _env("GITHUB_AGENT_BRIDGE_MONITOR_TIMER_UNIT", "github-agent-bridge-monitor.timer"), "feedback": _env("GITHUB_AGENT_BRIDGE_FEEDBACK_TIMER_UNIT", "github-agent-bridge-feedback.timer"), @@ -704,6 +705,124 @@ def _is_admin(config: DashboardConfig, login: str, token: str | None = None) -> return False +def _webhook_schema_initializer(config: DashboardConfig): + lock = threading.Lock() + ready = False + + def ensure() -> None: + nonlocal ready + if ready: + return + with lock: + if not ready: + JobQueue(config.db) + ready = True + + return ensure + + +async def _receive_github_webhook( + request: Request, + config: DashboardConfig, + ensure_webhook_schema, +) -> dict[str, Any]: + if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json": + raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="application_json_required") + delivery_id = request.headers.get("x-github-delivery", "").strip() + event_name = request.headers.get("x-github-event", "").strip() + hook_id = request.headers.get("x-github-hook-id", "").strip() or None + signature = request.headers.get("x-hub-signature-256", "").strip() + if not delivery_id or not event_name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="github_headers_required") + content_length = request.headers.get("content-length") + if content_length: + try: + if int(content_length) > config.webhook_max_bytes: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="payload_too_large") + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_content_length") + raw_payload = await request.body() + if len(raw_payload) > config.webhook_max_bytes: + raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="payload_too_large") + try: + payload = json.loads(raw_payload) + except (json.JSONDecodeError, UnicodeDecodeError): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_json") + repository = payload.get("repository") if isinstance(payload, dict) else None + full_name = repository.get("full_name") if isinstance(repository, dict) else None + owner = str(full_name or "").partition("/")[0].lower() + if config.webhook_secrets_by_owner: + webhook_secrets = config.webhook_secrets_by_owner.get(owner, ()) + if not webhook_secrets: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="repository_owner_not_allowed") + else: + webhook_secrets = config.webhook_secrets + if not verify_signature(raw_payload, signature, webhook_secrets): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_signature") + ensure_webhook_schema() + enqueue_status = None + job_id = None + if config.webhook_mode in {"canary", "primary"}: + policy = Policy.from_file(config.webhook_policy) + repo = str(full_name or "").lower() + sender = payload.get("sender") if isinstance(payload.get("sender"), dict) else {} + sender_login = str(sender.get("login") or "").lower() + if config.webhook_mode == "canary" and repo not in policy.webhook_canary_repos: + enqueue_status = "outside_canary" + elif sender_login in policy.bot_logins: + enqueue_status = "ignored_bot" + else: + notification = webhook_notification( + event_name, + delivery_id, + payload, + bot_logins=policy.bot_logins, + ) + if notification is None: + enqueue_status = "ignored" + else: + job, enqueue_status = JobQueue(config.db).ingest( + notification, policy, source="webhook", source_key=delivery_id, + ) + job_id = job.id if job else None + receipt = persist_shadow_delivery( + config.db, + delivery_id=delivery_id, + event_name=event_name, + raw_payload=raw_payload, + hook_id=hook_id, + retention_days=config.webhook_retention_days, + enqueue_status=enqueue_status, + job_id=job_id, + ) + response = { + "mode": config.webhook_mode, + "status": receipt.status, + "event_key": receipt.event_key, + } + if config.webhook_mode != "shadow": + response.update({"enqueue_status": enqueue_status, "job_id": job_id}) + return response + + +def create_webhook_app(config: DashboardConfig | None = None) -> FastAPI: + configure_sentry(service="webhook-ingress") + config = config or DashboardConfig() + ensure_webhook_schema = _webhook_schema_initializer(config) + app = FastAPI(title="GitHub Agent Bridge Webhook Ingress") + app.state.dashboard_config = config + + @app.get("/api/health") + def health() -> dict[str, Any]: + return {"ok": True, "service": "github-agent-bridge-webhook-ingress"} + + @app.post("/api/webhooks/github") + async def github_webhook(request: Request) -> dict[str, Any]: + return await _receive_github_webhook(request, config, ensure_webhook_schema) + + return app + + def create_app(config: DashboardConfig | None = None) -> FastAPI: configure_sentry(service="dashboard") config = config or DashboardConfig() @@ -720,17 +839,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="GitHub Agent Bridge Dashboard API", lifespan=lifespan) app.state.dashboard_config = config app.state.dashboard_shutdown_event = shutdown_event - webhook_schema_lock = threading.Lock() - webhook_schema_ready = False - - def ensure_webhook_schema() -> None: - nonlocal webhook_schema_ready - if webhook_schema_ready: - return - with webhook_schema_lock: - if not webhook_schema_ready: - JobQueue(config.db) - webhook_schema_ready = True + ensure_webhook_schema = _webhook_schema_initializer(config) assets_dir = config.static_dir / "assets" if assets_dir.exists(): @@ -798,83 +907,7 @@ def health() -> dict[str, Any]: @app.post("/api/webhooks/github") async def github_webhook_shadow(request: Request) -> dict[str, Any]: - if request.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json": - raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="application_json_required") - delivery_id = request.headers.get("x-github-delivery", "").strip() - event_name = request.headers.get("x-github-event", "").strip() - hook_id = request.headers.get("x-github-hook-id", "").strip() or None - signature = request.headers.get("x-hub-signature-256", "").strip() - if not delivery_id or not event_name: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="github_headers_required") - content_length = request.headers.get("content-length") - if content_length: - try: - if int(content_length) > config.webhook_max_bytes: - raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="payload_too_large") - except ValueError: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_content_length") - raw_payload = await request.body() - if len(raw_payload) > config.webhook_max_bytes: - raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="payload_too_large") - try: - payload = json.loads(raw_payload) - except (json.JSONDecodeError, UnicodeDecodeError): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_json") - repository = payload.get("repository") if isinstance(payload, dict) else None - full_name = repository.get("full_name") if isinstance(repository, dict) else None - owner = str(full_name or "").partition("/")[0].lower() - if config.webhook_secrets_by_owner: - webhook_secrets = config.webhook_secrets_by_owner.get(owner, ()) - if not webhook_secrets: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="repository_owner_not_allowed") - else: - webhook_secrets = config.webhook_secrets - if not verify_signature(raw_payload, signature, webhook_secrets): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_signature") - ensure_webhook_schema() - enqueue_status = None - job_id = None - if config.webhook_mode in {"canary", "primary"}: - policy = Policy.from_file(config.webhook_policy) - repo = str(full_name or "").lower() - sender = payload.get("sender") if isinstance(payload.get("sender"), dict) else {} - sender_login = str(sender.get("login") or "").lower() - if config.webhook_mode == "canary" and repo not in policy.webhook_canary_repos: - enqueue_status = "outside_canary" - elif sender_login in policy.bot_logins: - enqueue_status = "ignored_bot" - else: - notification = webhook_notification( - event_name, - delivery_id, - payload, - bot_logins=policy.bot_logins, - ) - if notification is None: - enqueue_status = "ignored" - else: - job, enqueue_status = JobQueue(config.db).ingest( - notification, policy, source="webhook", source_key=delivery_id, - ) - job_id = job.id if job else None - receipt = persist_shadow_delivery( - config.db, - delivery_id=delivery_id, - event_name=event_name, - raw_payload=raw_payload, - hook_id=hook_id, - retention_days=config.webhook_retention_days, - enqueue_status=enqueue_status, - job_id=job_id, - ) - response = { - "mode": config.webhook_mode, - "status": receipt.status, - "event_key": receipt.event_key, - } - if config.webhook_mode != "shadow": - response.update({"enqueue_status": enqueue_status, "job_id": job_id}) - return response + return await _receive_github_webhook(request, config, ensure_webhook_schema) @app.get("/api/webhooks/github/status") @app.get("/api/webhooks/github/summary") @@ -1719,25 +1752,39 @@ def callback(code: str, state: str, request: Request) -> RedirectResponse: app = create_app() -def build_parser() -> argparse.ArgumentParser: +def build_parser(*, ingress: bool = False) -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog=Path(sys.argv[0]).name) parser.add_argument("--db", default=os.getenv("GITHUB_AGENT_BRIDGE_DASHBOARD_DB", os.getenv("GITHUB_AGENT_BRIDGE_DB", DEFAULT_DB))) - parser.add_argument("--host", default=DEFAULT_HOST) - parser.add_argument("--port", type=int, default=DEFAULT_PORT) - parser.add_argument("--no-auth", action="store_true", help="disable auth for isolated local development only") + parser.add_argument("--host", default=os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_HOST", DEFAULT_HOST) if ingress else DEFAULT_HOST) + parser.add_argument("--port", type=int, default=int(os.getenv("GITHUB_AGENT_BRIDGE_WEBHOOK_PORT", "8766")) if ingress else DEFAULT_PORT) + parser.add_argument("--fd", type=int, help="serve an inherited systemd socket file descriptor") + if not ingress: + parser.add_argument("--no-auth", action="store_true", help="disable auth for isolated local development only") return parser -def main(argv: list[str] | None = None) -> int: - args = build_parser().parse_args(argv) +def _serve_uvicorn(application: FastAPI, args: argparse.Namespace) -> int: try: import uvicorn except ImportError: print("uvicorn is required; install github-agent-bridge[dashboard]", file=sys.stderr) return 2 - uvicorn.run(create_app(DashboardConfig(db=args.db, require_auth=not args.no_auth)), host=args.host, port=args.port) + if args.fd is not None: + uvicorn.run(application, fd=args.fd) + else: + uvicorn.run(application, host=args.host, port=args.port) return 0 +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return _serve_uvicorn(create_app(DashboardConfig(db=args.db, require_auth=not args.no_auth)), args) + + +def webhook_main(argv: list[str] | None = None) -> int: + args = build_parser(ingress=True).parse_args(argv) + return _serve_uvicorn(create_webhook_app(DashboardConfig(db=args.db)), args) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/github_agent_bridge/systemd_status.py b/src/github_agent_bridge/systemd_status.py index a4540b5..f5191fd 100644 --- a/src/github_agent_bridge/systemd_status.py +++ b/src/github_agent_bridge/systemd_status.py @@ -19,6 +19,8 @@ class BridgeUnit: BRIDGE_UNITS = [ BridgeUnit("executor", "service", "GITHUB_AGENT_BRIDGE_EXECUTOR_UNIT", "github-agent-bridge.service"), BridgeUnit("dashboard", "service", "GITHUB_AGENT_BRIDGE_DASHBOARD_UNIT", "github-agent-bridge-dashboard.service"), + BridgeUnit("webhook", "service", "GITHUB_AGENT_BRIDGE_WEBHOOK_UNIT", "github-agent-bridge-webhook.service"), + BridgeUnit("webhook", "socket", "GITHUB_AGENT_BRIDGE_WEBHOOK_SOCKET_UNIT", "github-agent-bridge-webhook.socket"), BridgeUnit("reader", "service", "GITHUB_AGENT_BRIDGE_READER_SERVICE_UNIT", "github-agent-bridge-reader.service"), BridgeUnit("reader", "timer", "GITHUB_AGENT_BRIDGE_READER_TIMER_UNIT", "github-agent-bridge-reader.timer"), BridgeUnit("monitor", "service", "GITHUB_AGENT_BRIDGE_MONITOR_SERVICE_UNIT", "github-agent-bridge-monitor.service"), diff --git a/systemd/env.example b/systemd/env.example index fed63f5..beffa26 100644 --- a/systemd/env.example +++ b/systemd/env.example @@ -31,6 +31,8 @@ GITHUB_AGENT_BRIDGE_SYSTEMCTL_BIN=systemctl GITHUB_AGENT_BRIDGE_JOURNALCTL_BIN=journalctl GITHUB_AGENT_BRIDGE_EXECUTOR_UNIT=github-agent-bridge.service GITHUB_AGENT_BRIDGE_DASHBOARD_UNIT=github-agent-bridge-dashboard.service +GITHUB_AGENT_BRIDGE_WEBHOOK_UNIT=github-agent-bridge-webhook.service +GITHUB_AGENT_BRIDGE_WEBHOOK_SOCKET_UNIT=github-agent-bridge-webhook.socket GITHUB_AGENT_BRIDGE_READER_SERVICE_UNIT=github-agent-bridge-reader.service GITHUB_AGENT_BRIDGE_READER_TIMER_UNIT=github-agent-bridge-reader.timer GITHUB_AGENT_BRIDGE_MONITOR_SERVICE_UNIT=github-agent-bridge-monitor.service @@ -76,6 +78,9 @@ GITHUB_AGENT_BRIDGE_DASHBOARD_ALLOWED_TEAMS= GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_USERS= GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS= GITHUB_AGENT_BRIDGE_DASHBOARD_PUBLIC_URL= +# Used only when running the dedicated ingress without socket activation. +GITHUB_AGENT_BRIDGE_WEBHOOK_HOST=127.0.0.1 +GITHUB_AGENT_BRIDGE_WEBHOOK_PORT=8766 # Optional browser Push API notifications for completed bridge jobs. The public # key is served to signed-in dashboard users; keep the private key secret. GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PUBLIC_KEY= diff --git a/systemd/github-agent-bridge-dashboard.service b/systemd/github-agent-bridge-dashboard.service index 3532468..e87a747 100644 --- a/systemd/github-agent-bridge-dashboard.service +++ b/systemd/github-agent-bridge-dashboard.service @@ -11,7 +11,7 @@ Environment=GITHUB_AGENT_BRIDGE_DASHBOARD_PORT=8765 EnvironmentFile=-%h/.config/github-agent-bridge/env ExecStart=%h/.local/bin/github-agent-bridge-dashboard Restart=always -RestartSec=5 +RestartSec=1 [Install] WantedBy=default.target diff --git a/systemd/github-agent-bridge-webhook.service b/systemd/github-agent-bridge-webhook.service new file mode 100644 index 0000000..f19eebb --- /dev/null +++ b/systemd/github-agent-bridge-webhook.service @@ -0,0 +1,17 @@ +[Unit] +Description=GitHub Agent Bridge webhook ingress +Requires=github-agent-bridge-webhook.socket +After=network-online.target github-agent-bridge-webhook.socket +Wants=network-online.target + +[Service] +Type=simple +Environment=GITHUB_AGENT_BRIDGE_DASHBOARD_DB=%h/.local/state/github-agent-bridge/bridge.sqlite3 +EnvironmentFile=-%h/.config/github-agent-bridge/env +Sockets=github-agent-bridge-webhook.socket +ExecStart=%h/.local/bin/github-agent-bridge-webhook --fd 3 +Restart=on-failure +RestartSec=500ms + +[Install] +WantedBy=default.target diff --git a/systemd/github-agent-bridge-webhook.socket b/systemd/github-agent-bridge-webhook.socket new file mode 100644 index 0000000..04d15db --- /dev/null +++ b/systemd/github-agent-bridge-webhook.socket @@ -0,0 +1,11 @@ +[Unit] +Description=GitHub Agent Bridge webhook ingress socket + +[Socket] +ListenStream=127.0.0.1:8766 +NoDelay=true +KeepAlive=true +Backlog=4096 + +[Install] +WantedBy=sockets.target diff --git a/tests/test_autoupdate.py b/tests/test_autoupdate.py index 23b21c3..b8d0f5e 100644 --- a/tests/test_autoupdate.py +++ b/tests/test_autoupdate.py @@ -157,6 +157,46 @@ def test_dashboard_only_update_can_stage_while_jobs_are_active(tmp_path, monkeyp ] +def test_webhook_only_update_restarts_only_ingress(tmp_path, monkeypatch): + monkeypatch.setattr("github_agent_bridge.actors.github_actor_details_for_context", lambda ctx, *, gh_bin="gh": None) + db = tmp_path / "bridge.sqlite3" + JobQueue(db) + + plan = plan_update( + db, + repo_dir=tmp_path, + installed_version="1.2.3", + runner=release_runner("v1.2.4", ["src/github_agent_bridge/webhook.py"]), + ) + + assert plan["decision"] == "stage_webhook_reload" + assert plan["classification"]["webhook_only"] is True + assert plan["service_plan"]["immediate"] == [{ + "command": "try-restart", + "unit": "github-agent-bridge-webhook.service", + "reason": "webhook ingress update can reload independently", + }] + + +def test_shared_dashboard_and_ingress_update_restarts_both_apis(tmp_path, monkeypatch): + monkeypatch.setattr("github_agent_bridge.actors.github_actor_details_for_context", lambda ctx, *, gh_bin="gh": None) + db = tmp_path / "bridge.sqlite3" + JobQueue(db) + + plan = plan_update( + db, + repo_dir=tmp_path, + installed_version="1.2.3", + runner=release_runner("v1.2.4", ["src/github_agent_bridge/backend.py"]), + ) + + assert plan["decision"] == "stage_api_reload" + assert [item["unit"] for item in plan["service_plan"]["immediate"]] == [ + "github-agent-bridge-dashboard.service", + "github-agent-bridge-webhook.service", + ] + + def test_executor_update_records_pending_reload_when_jobs_are_active(tmp_path, monkeypatch): monkeypatch.setattr("github_agent_bridge.actors.github_actor_details_for_context", lambda ctx, *, gh_bin="gh": None) db = tmp_path / "bridge.sqlite3" diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 5c016c0..2d8e41a 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from github_agent_bridge import backend -from github_agent_bridge.backend import DashboardConfig, _encode_session, _sign, create_app +from github_agent_bridge.backend import DashboardConfig, _encode_session, _sign, create_app, create_webhook_app from github_agent_bridge.models import Notification from github_agent_bridge.policy import Policy from github_agent_bridge.queue import JobQueue @@ -18,6 +18,22 @@ SECRET = "test-secret" +def test_dedicated_webhook_ingress_exposes_only_health_and_delivery(tmp_path): + client = TestClient(create_webhook_app(DashboardConfig( + db=tmp_path / "bridge.sqlite3", webhook_secrets=(SECRET,), + ))) + payload = issue_comment_payload() + + assert client.get("/api/health").json() == { + "ok": True, "service": "github-agent-bridge-webhook-ingress", + } + assert client.post( + "/api/webhooks/github", content=payload, headers=signed_headers(payload), + ).json()["status"] == "observed" + assert client.get("/").status_code == 404 + assert client.get("/api/webhooks/github/summary").status_code == 404 + + def signed_headers(payload: bytes, *, delivery: str = "delivery-1", event: str = "issue_comment", secret: str = SECRET) -> dict[str, str]: digest = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() return { diff --git a/tests/test_webhook_systemd.py b/tests/test_webhook_systemd.py new file mode 100644 index 0000000..e432fe3 --- /dev/null +++ b/tests/test_webhook_systemd.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from github_agent_bridge.backend import build_parser + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_webhook_cli_accepts_inherited_socket_fd(): + args = build_parser(ingress=True).parse_args(["--fd", "3"]) + + assert args.fd == 3 + assert args.port == 8766 + + +def test_webhook_systemd_service_consumes_socket_activation_fd(): + service = (ROOT / "systemd/github-agent-bridge-webhook.service").read_text() + socket = (ROOT / "systemd/github-agent-bridge-webhook.socket").read_text() + + assert "Sockets=github-agent-bridge-webhook.socket" in service + assert "github-agent-bridge-webhook --fd 3" in service + assert "RestartSec=500ms" in service + assert "ListenStream=127.0.0.1:8766" in socket + assert "Backlog=4096" in socket + + +def test_nginx_routes_webhook_to_dedicated_ingress(): + nginx = (ROOT / "docs/nginx-dashboard.conf").read_text() + + webhook_location = nginx.index("location = /api/webhooks/github") + dashboard_location = nginx.index("location / {") + assert webhook_location < dashboard_location + assert "proxy_pass http://127.0.0.1:8766;" in nginx[webhook_location:dashboard_location]