Files
gitea-dynamic-runner/src/gitea_dynamic_runner/controller.py
T
panxiao81 f3a199e7ba
test / python (pull_request) Successful in 13s
test / shell (pull_request) Failing after 21s
重构为直接 OpenSandbox 生命周期调度
2026-09-18 18:12:10 +00:00

144 lines
4.7 KiB
Python

#!/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()