diff --git a/README.md b/README.md index a4f0274..ad241b1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ Cloud Hypervisor,退出后完整清理。 - `guest-runner`:在 guest 中领取一次性 runner registration token,注册 ephemeral runner,执行一个 job 后关机。 +- `jwt-broker`:运行在 Kubernetes runner 外层 Pod 中,以可被 SPIRE attestation + 的 PID 获取固定 `aud=zot` JWT-SVID;DinD job 通过受限 HTTP endpoint 获取短期 + token。broker 不记录响应、不缓存 token,也不接受调用方指定 audience。 消息流使用一个 `WorkQueuePolicy` stream。相同 runner label 的所有 worker 共享同一 durable consumer;扩容只需要增加 worker 或提高单机 capacity。 diff --git a/container/controller.Dockerfile b/container/controller.Dockerfile index 993de1c..b4e2a44 100644 --- a/container/controller.Dockerfile +++ b/container/controller.Dockerfile @@ -1,3 +1,5 @@ +FROM ghcr.io/spiffe/spire-agent:1.15.3@sha256:41b0dcd8b258a69db9e2768292a060766fb76fd866e4bc925849981ea1b825ff AS spire + FROM python:3.12.11-alpine3.22 AS build WORKDIR /src COPY pyproject.toml README.md ./ @@ -7,6 +9,7 @@ RUN python -m venv /venv && /venv/bin/pip install --no-cache-dir . FROM python:3.12.11-alpine3.22 RUN addgroup -S -g 65532 runner && adduser -S -D -H -u 65532 -G runner runner COPY --from=build /venv /venv +COPY --from=spire /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent USER 65532:65532 EXPOSE 8787 ENTRYPOINT ["/venv/bin/gitea-microvm-controller"] diff --git a/pyproject.toml b/pyproject.toml index ac088c0..8c1e745 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gitea-microvm-runner" -version = "0.1.0" +version = "0.2.0" description = "On-demand Cloud Hypervisor runners for Gitea Actions" requires-python = ">=3.11" dependencies = ["aiohttp==3.12.15", "nats-py==2.11.0"] @@ -15,6 +15,7 @@ test = ["pytest==8.4.2", "pytest-asyncio==1.2.0"] [project.scripts] gitea-microvm-controller = "gitea_microvm_runner.controller:main" gitea-microvm-worker = "gitea_microvm_runner.worker:cli" +gitea-spire-jwt-broker = "gitea_microvm_runner.jwt_broker:main" [tool.pytest.ini_options] asyncio_mode = "auto" diff --git a/src/gitea_microvm_runner/jwt_broker.py b/src/gitea_microvm_runner/jwt_broker.py new file mode 100644 index 0000000..742cf3c --- /dev/null +++ b/src/gitea_microvm_runner/jwt_broker.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Fixed-audience JWT-SVID broker for nested Gitea job containers.""" + +import asyncio +import json +import os + +from aiohttp import web + +SPIRE_AGENT = os.environ.get("SPIRE_AGENT", "/opt/spire/bin/spire-agent") +SPIRE_SOCKET = os.environ.get( + "SPIRE_SOCKET", "/run/spire/agent-sockets/spire-agent.sock" +) +AUDIENCE = os.environ.get("JWT_AUDIENCE", "zot") +MAX_CONCURRENCY = int(os.environ.get("MAX_CONCURRENCY", "4")) +semaphore = asyncio.Semaphore(MAX_CONCURRENCY) + + +def extract_svid(document: object) -> str: + if not isinstance(document, list): + raise ValueError("unexpected SPIRE response") + for item in document: + if not isinstance(item, dict): + continue + svids = item.get("svids") + if not isinstance(svids, list): + continue + for svid in svids: + if isinstance(svid, dict) and isinstance(svid.get("svid"), str): + return svid["svid"] + raise ValueError("SPIRE response contains no JWT-SVID") + + +async def fetch_svid() -> str: + async with semaphore: + process = await asyncio.create_subprocess_exec( + SPIRE_AGENT, + "api", + "fetch", + "jwt", + "-output", + "json", + "-audience", + AUDIENCE, + "-socketPath", + SPIRE_SOCKET, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) + if process.returncode != 0: + message = stderr.decode(errors="replace").strip() + raise RuntimeError(f"SPIRE agent failed: {message}") + return extract_svid(json.loads(stdout)) + + +async def token(_: web.Request) -> web.Response: + try: + value = await fetch_svid() + except (RuntimeError, ValueError, json.JSONDecodeError, asyncio.TimeoutError): + raise web.HTTPServiceUnavailable(text="identity unavailable\n") + return web.Response( + text=f"{value}\n", + content_type="text/plain", + headers={"Cache-Control": "no-store"}, + ) + + +async def health(_: web.Request) -> web.Response: + return web.Response(text="ok\n") + + +def create_app() -> web.Application: + app = web.Application(client_max_size=1024) + app.router.add_post("/token", token) + app.router.add_get("/healthz", health) + return app + + +def main() -> None: + web.run_app( + create_app(), + host=os.environ.get("LISTEN", "0.0.0.0"), + port=int(os.environ.get("PORT", "8788")), + access_log=None, + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_jwt_broker.py b/tests/test_jwt_broker.py new file mode 100644 index 0000000..2a999b5 --- /dev/null +++ b/tests/test_jwt_broker.py @@ -0,0 +1,16 @@ +import pytest + +from gitea_microvm_runner.jwt_broker import extract_svid + + +def test_extract_svid_from_spire_json(): + assert extract_svid([ + {"svids": [{"hint": "", "spiffe_id": "spiffe://example/ci", "svid": "jwt"}]}, + {"bundles": {}}, + ]) == "jwt" + + +@pytest.mark.parametrize("document", [{}, [], [{"bundles": {}}]]) +def test_extract_svid_rejects_unexpected_response(document): + with pytest.raises(ValueError): + extract_svid(document)