Compare commits

..
Author SHA1 Message Date
panxiao81 bfe9a1be58 记录动态 Runner 设计原则
test / python (pull_request) Successful in 8s
test / shell (pull_request) Successful in 17s
2026-09-16 14:10:01 +00:00
panxiao81 4d6ae62abe Merge pull request:添加嵌套 CI 的 SPIRE JWT broker
test / python (push) Successful in 8s
test / shell (push) Successful in 15s
2026-09-16 13:38:48 +00:00
panxiao81 7c3cd6a921 添加嵌套 CI 的 SPIRE JWT broker
test / python (pull_request) Successful in 8s
test / shell (pull_request) Successful in 17s
2026-09-16 13:37:40 +00:00
panxiao81 0785d9a403 Merge pull request:添加 SPIRE JWT-SVID 冒烟测试
test / shell (push) Successful in 18s
test / python (push) Successful in 7s
2026-09-16 13:31:27 +00:00
panxiao81 dfdcfd3b50 添加 SPIRE JWT-SVID 冒烟测试
test / shell (pull_request) Successful in 1m11s
test / python (pull_request) Successful in 1m27s
2026-09-16 13:29:04 +00:00
7 changed files with 243 additions and 5 deletions
+33
View File
@@ -0,0 +1,33 @@
name: SPIRE identity smoke
on:
workflow_dispatch:
jobs:
jwt-svid:
runs-on: self-hosted
container:
volumes:
- /run/spire/agent-sockets:/run/spire/agent-sockets:ro
steps:
- name: Fetch pinned SPIRE CLI
shell: bash
run: |
set -euo pipefail
archive=/tmp/spire.tar.gz
curl --fail --location --silent --show-error \
--output "$archive" \
https://github.com/spiffe/spire/releases/download/v1.15.3/spire-1.15.3-linux-amd64-musl.tar.gz
printf '%s %s\n' \
ca1a4d1155317bdd2afc7f36663828a10410c7c840e54725b90b4064b0a301c7 \
"$archive" | sha256sum --check --status
tar -xzf "$archive" -C /tmp spire-1.15.3/bin/spire-agent
- name: Fetch short-lived zot JWT-SVID
shell: bash
run: |
set -euo pipefail
/tmp/spire-1.15.3/bin/spire-agent api fetch jwt \
-audience zot \
-socketPath /run/spire/agent-sockets/spire-agent.sock \
>/dev/null
+18 -4
View File
@@ -1,17 +1,31 @@
# Gitea microVM runner # Gitea dynamic runner
为 Gitea Actions 按需启动 Cloud Hypervisor microVM。适合 kind、嵌套容器和其他不应 为 Gitea Actions 按需创建一次性执行环境。对 workflow 提供两种稳定的 runner
在常驻 Kubernetes runner 中执行的 CI 工作负载。 接口:
```yaml
runs-on: [self-hosted, pod]
```
```yaml
runs-on: [self-hosted, vm]
```
`pod` 使用动态 Kubernetes Pod,`vm` 使用动态 Cloud Hypervisor microVM。每个环境
只执行一个 job,并在 job 结束后连同本地状态一起销毁。完整的设计约束见
[`docs/design-principles.md`](docs/design-principles.md)。
组件: 组件:
- `controller`:接收 Gitea `workflow_job` webhook,将指定 label 的 queued job - `controller`:接收 Gitea `workflow_job` webhook,将指定 label 的 queued job
发布到 NATS JetStream。 发布到 NATS JetStream。
- `worker`:在虚拟化宿主机领取任务,限制本机并发,并启动一次性 microVM。 - `worker`:领取任务、限制并发,并通过 Pod 或 microVM backend 创建一次性环境。
- `microvm-runner-launch`:为每个任务创建 COW disk、NoCloud seed 和 TAP,运行 - `microvm-runner-launch`:为每个任务创建 COW disk、NoCloud seed 和 TAP,运行
Cloud Hypervisor,退出后完整清理。 Cloud Hypervisor,退出后完整清理。
- `guest-runner`:在 guest 中领取一次性 runner registration token,注册 ephemeral - `guest-runner`:在 guest 中领取一次性 runner registration token,注册 ephemeral
runner,执行一个 job 后关机。 runner,执行一个 job 后关机。
- `jwt-broker`:早期共享 Kubernetes runner 的过渡实验;目标架构不部署它,每个
动态 Pod 或 VM 直接取得自己的 SPIFFE 身份。
消息流使用一个 `WorkQueuePolicy` stream。相同 runner label 的所有 worker 共享同一 消息流使用一个 `WorkQueuePolicy` stream。相同 runner label 的所有 worker 共享同一
durable consumer;扩容只需要增加 worker 或提高单机 capacity。 durable consumer;扩容只需要增加 worker 或提高单机 capacity。
+3
View File
@@ -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 FROM python:3.12.11-alpine3.22 AS build
WORKDIR /src WORKDIR /src
COPY pyproject.toml README.md ./ 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 FROM python:3.12.11-alpine3.22
RUN addgroup -S -g 65532 runner && adduser -S -D -H -u 65532 -G runner runner RUN addgroup -S -g 65532 runner && adduser -S -D -H -u 65532 -G runner runner
COPY --from=build /venv /venv COPY --from=build /venv /venv
COPY --from=spire /opt/spire/bin/spire-agent /opt/spire/bin/spire-agent
USER 65532:65532 USER 65532:65532
EXPOSE 8787 EXPOSE 8787
ENTRYPOINT ["/venv/bin/gitea-microvm-controller"] ENTRYPOINT ["/venv/bin/gitea-microvm-controller"]
+81
View File
@@ -0,0 +1,81 @@
# 动态 Runner 设计原则
## 对 workflow 的接口
Runner 只向 workflow 暴露两个执行环境:
```yaml
runs-on: [self-hosted, pod]
```
```yaml
runs-on: [self-hosted, vm]
```
- `self-hosted` 是固定前缀。
- `pod` 表示一次性 Kubernetes Pod,承担常规 CI、镜像构建和 kind 等任务。
- `vm` 表示一次性 microVM,承担需要独立内核、KVM、systemd 或更强隔离的任务。
执行后端是基础设施选择,不是权限角色。workflow 不需要额外声明由 controller
维护的 role 或权限 label。
## 一个 job,一个环境
Controller 根据 Gitea `workflow_job` webhook 创建执行环境。每个 Pod 或 VM 注册一个
ephemeral runner,只执行一个 job;任务结束后注销 runner,并删除计算环境及其全部
本地状态。
`job_id` 仅用于消息去重、状态追踪、实例关联和失败清理,不进入 workload 身份,也
不参与资源授权。
## 环境只提供运行边界
基础镜像只提供启动 runner 和执行 workflow 所需的最小环境。Docker、BuildKit、
kind 等工具由 pipeline 按需安装和启动,而不是由 controller 预制成常驻服务。
例如 Pod job 可以在 Pod 内启动仅供本次任务使用的 Docker daemon。该 daemon 及其
镜像、容器和缓存属于当前 job 的临时状态,随 Pod 一起销毁。Docker 创建的容器不是
独立的身份边界;需要访问凭据的操作由 Pod 中的 workflow 进程完成,并通过环境变量
或标准输入把短期凭据交给具体工具。
## Workload 身份
动态 Pod 和 VM 都直接拥有自己的 SPIFFE 身份,不继承常驻 runner 的共享身份:
- Pod 通过 Kubernetes workload attestation 取得身份。
- VM 通过 VM 内的 SPIRE Agent 取得身份。
SPIFFE ID 由具有业务意义且稳定的 workflow 上下文派生:
```text
spiffe://ddupan.top/ci/<owner>/<repository>/<workflow>/<job-name>
```
同一种任务在不同运行中使用相同的逻辑 SPIFFE ID;每次运行取得独立、短期的 SVID。
Pod 与 VM 是可替换的执行实现,因此默认不写入 SPIFFE ID。
workflow 和 job 名称必须经过确定性的路径规范化。规范化结果必须保留仓库边界,并在
发生冲突时拒绝创建环境,不能静默地让两个任务共享身份。
## Self-service 与授权边界
新增 workflow 或 job 时,controller 自动为它派生身份,不维护第二份任务或角色
allowlist。能够修改仓库 CI 的主体本来就能修改该仓库已有任务,因此 controller 的
重复审批不能形成额外的安全边界,只会破坏 self-service。
身份不等于权限。新任务可以立即取得自己的 SPIFFE ID,但默认不会因此获得 Zot、
OpenBao 或其他资源的特殊权限。资源所有者在资源端按照有意义的 workflow/job 身份
配置授权策略。
## 非目标设计
目标架构不依赖以下机制:
- 多个 job 共享的常驻 Docker daemon。
- 常驻 runner Pod 的共享 SPIFFE 身份。
- 为嵌套 CI 容器转发共享身份的 JWT broker。
- 将 Gitea 数字 job ID 编入 SPIFFE ID。
- controller 维护的仓库任务权限 allowlist。
仓库中的 `jwt-broker` 是早期方案的实验实现,在 Pod/VM 动态执行环境完成迁移后不应
部署。
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "gitea-microvm-runner" name = "gitea-microvm-runner"
version = "0.1.0" version = "0.2.0"
description = "On-demand Cloud Hypervisor runners for Gitea Actions" description = "On-demand Cloud Hypervisor runners for Gitea Actions"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["aiohttp==3.12.15", "nats-py==2.11.0"] 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] [project.scripts]
gitea-microvm-controller = "gitea_microvm_runner.controller:main" gitea-microvm-controller = "gitea_microvm_runner.controller:main"
gitea-microvm-worker = "gitea_microvm_runner.worker:cli" gitea-microvm-worker = "gitea_microvm_runner.worker:cli"
gitea-spire-jwt-broker = "gitea_microvm_runner.jwt_broker:main"
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
+90
View File
@@ -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()
+16
View File
@@ -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)