#!/usr/bin/env python3 """Gitea workflow_job webhook to the OpenSandbox Lifecycle API.""" from __future__ import annotations import hashlib import hmac import json import logging import os from pathlib import Path from aiohttp import web from .models import RunnerRequest from .opensandbox import OpenSandboxClient from .opensandbox_worker import OpenSandboxScheduler, RegistrationTokens LOG = logging.getLogger(__name__) WEBHOOK_SECRET_FILE = Path( os.environ.get("WEBHOOK_SECRET_FILE", "/run/secrets/gitea/webhook-secret") ) REGISTRATION_TOKEN_FILE = Path( os.environ.get( "REGISTRATION_TOKEN_FILE", "/run/secrets/gitea/registration-token" ) ) OPENSANDBOX_API = os.environ.get( "OPENSANDBOX_API", "http://10.60.0.13:8080" ) OPENSANDBOX_API_KEY_FILE = Path( os.environ.get( "OPENSANDBOX_API_KEY_FILE", "/run/secrets/opensandbox/api-key", ) ) def accepts(payload: object) -> tuple[bool, str | None]: request = RunnerRequest.from_webhook(payload) return (request is not None, str(request.job_id) if request else None) def valid_signature(body: bytes, signature: str) -> bool: expected = hmac.new( WEBHOOK_SECRET_FILE.read_bytes().strip(), body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature.removeprefix("sha256="), expected) async def webhook(request: web.Request) -> web.Response: body = await request.read() if not valid_signature(body, request.headers.get("X-Gitea-Signature", "")): raise web.HTTPUnauthorized() try: payload = json.loads(body) except json.JSONDecodeError as error: raise web.HTTPBadRequest(text="invalid JSON\n") from error context = _webhook_context(payload) LOG.info("workflow_job webhook received %s", context) runner_request = RunnerRequest.from_webhook(payload) if runner_request is None: LOG.info("workflow_job webhook ignored %s", context) return web.Response(status=204) scheduler: OpenSandboxScheduler = request.app["scheduler"] try: sandbox_id = await scheduler.create(runner_request) except ValueError as error: LOG.info("duplicate workflow_job webhook ignored: %s", error) return web.Response(status=202, text="already scheduled\n") except Exception: LOG.exception("failed to create OpenSandbox runner %s", context) raise web.HTTPServiceUnavailable(text="sandbox unavailable\n") return web.Response(status=202, text=f"sandbox={sandbox_id}\n") async def registration_token(request: web.Request) -> web.Response: scheduler: OpenSandboxScheduler = request.app["scheduler"] value = await scheduler.tokens.consume(request.match_info["nonce"]) if value is None: raise web.HTTPNotFound() return web.Response(body=value, headers={"Cache-Control": "no-store"}) def _webhook_context(payload: object) -> str: if not isinstance(payload, dict): return f"payload_type={type(payload).__name__}" job = payload.get("workflow_job") repository = payload.get("repository") job = job if isinstance(job, dict) else {} repository = repository if isinstance(repository, dict) else {} return ( f"action={payload.get('action')!r} job_id={job.get('id')!r} " f"run_id={job.get('run_id')!r} runner_name={job.get('runner_name')!r} " f"repository={repository.get('full_name')!r} job_name={job.get('name')!r} " f"labels={job.get('labels')!r}" ) async def health(request: web.Request) -> web.Response: client: OpenSandboxClient = request.app["opensandbox_client"] return web.Response( text="ok\n" if client.session is not None else "disconnected\n", status=200 if client.session is not None else 503, ) async def opensandbox_context(app: web.Application): tokens = RegistrationTokens(REGISTRATION_TOKEN_FILE.read_bytes()) async with OpenSandboxClient( api_url=OPENSANDBOX_API, api_key_file=OPENSANDBOX_API_KEY_FILE, ) as client: scheduler = OpenSandboxScheduler(client, tokens) app["opensandbox_client"] = client app["scheduler"] = scheduler yield await scheduler.close() def create_app() -> web.Application: app = web.Application(client_max_size=1024 * 1024) app.cleanup_ctx.append(opensandbox_context) app.router.add_post("/webhook", webhook) app.router.add_get("/token/{nonce}", registration_token) app.router.add_get("/healthz", health) return app def main() -> None: logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO")) web.run_app( create_app(), host=os.environ.get("LISTEN", "0.0.0.0"), port=int(os.environ.get("PORT", "8787")), ) if __name__ == "__main__": main()