实现首版 Gitea microVM runner
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
name: test
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -e '.[test]'
|
||||
- run: pytest
|
||||
- run: python -m compileall -q src
|
||||
|
||||
shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck
|
||||
- run: shellcheck scripts/*
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
@@ -1,3 +1,39 @@
|
||||
# gitea-microvm-runner
|
||||
# Gitea microVM runner
|
||||
|
||||
为 Gitea Actions 按需启动 Cloud Hypervisor microVM。适合 kind、嵌套容器和其他不应
|
||||
在常驻 Kubernetes runner 中执行的 CI 工作负载。
|
||||
|
||||
组件:
|
||||
|
||||
- `controller`:接收 Gitea `workflow_job` webhook,将指定 label 的 queued job
|
||||
发布到 NATS JetStream。
|
||||
- `worker`:在虚拟化宿主机领取任务,限制本机并发,并启动一次性 microVM。
|
||||
- `microvm-runner-launch`:为每个任务创建 COW disk、NoCloud seed 和 TAP,运行
|
||||
Cloud Hypervisor,退出后完整清理。
|
||||
- `guest-runner`:在 guest 中领取一次性 runner registration token,注册 ephemeral
|
||||
runner,执行一个 job 后关机。
|
||||
|
||||
消息流使用一个 `WorkQueuePolicy` stream。相同 runner label 的所有 worker 共享同一
|
||||
durable consumer;扩容只需要增加 worker 或提高单机 capacity。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e '.[test]'
|
||||
pytest
|
||||
```
|
||||
|
||||
## 安全边界
|
||||
|
||||
- NATS 密码、webhook secret 和 Gitea registration token 只从文件读取。
|
||||
- registration token 不写入 seed image;worker 通过单次 nonce endpoint 交给 guest。
|
||||
- guest runner 使用 `--ephemeral`,每台 VM 只执行一个 job。
|
||||
- launcher 只接受 UUID instance ID 和 URL-safe nonce,所有临时文件都位于独立目录。
|
||||
- base image 不得包含 runner identity、registration token、SSH 密码或 host key。
|
||||
|
||||
homelab 的 Kubernetes、OpenBao、LXC、bridge 和容量配置保留在
|
||||
`panxiao81/homelab-infra`。
|
||||
|
||||
按需启动 Cloud Hypervisor microVM 的 Gitea Actions runner autoscaler
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM python:3.12.11-alpine3.22 AS build
|
||||
WORKDIR /src
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
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
|
||||
USER 65532:65532
|
||||
EXPOSE 8787
|
||||
ENTRYPOINT ["/venv/bin/gitea-microvm-controller"]
|
||||
@@ -0,0 +1,24 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "gitea-microvm-runner"
|
||||
version = "0.1.0"
|
||||
description = "On-demand Cloud Hypervisor runners for Gitea Actions"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["aiohttp==3.12.15", "nats-py==2.11.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
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"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
token_url=${1:?token URL is required}
|
||||
instance=${2:?Gitea instance is required}
|
||||
runner_name=${3:?runner name is required}
|
||||
runner_label=${4:?runner label is required}
|
||||
token_file=/run/gitea-runner-registration-token
|
||||
|
||||
cleanup() {
|
||||
rm -f -- "$token_file"
|
||||
sync
|
||||
poweroff -f
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
umask 077
|
||||
curl --fail --silent --show-error --retry 10 --retry-all-errors \
|
||||
--connect-timeout 2 --max-time 30 "$token_url" >"$token_file"
|
||||
gitea-runner register \
|
||||
--no-interactive \
|
||||
--ephemeral \
|
||||
--instance "$instance" \
|
||||
--name "$runner_name" \
|
||||
--labels "$runner_label:host" \
|
||||
--token-file "$token_file"
|
||||
rm -f -- "$token_file"
|
||||
gitea-runner daemon
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
instance_id=${1:?instance ID is required}
|
||||
nonce=${2:?registration nonce is required}
|
||||
|
||||
case "$instance_id" in
|
||||
????????-????-????-????-????????????) ;;
|
||||
*) echo "invalid instance ID" >&2; exit 64 ;;
|
||||
esac
|
||||
case "$nonce" in
|
||||
*[!A-Za-z0-9_-]*|'') echo "invalid nonce" >&2; exit 64 ;;
|
||||
esac
|
||||
|
||||
state_root=${RUNNER_STATE_ROOT:-/var/lib/microvm-runner}
|
||||
base_image=${RUNNER_BASE_IMAGE:-$state_root/images/runner-base.qcow2}
|
||||
firmware=${RUNNER_FIRMWARE:-$state_root/firmware/CLOUDHV.fd}
|
||||
bridge=${RUNNER_BRIDGE:-mvrbr0}
|
||||
cloud_hypervisor=${CLOUD_HYPERVISOR:-/usr/local/bin/cloud-hypervisor}
|
||||
vm_timeout=${RUNNER_VM_TIMEOUT:-3h}
|
||||
cpus=${RUNNER_VM_CPUS:-4}
|
||||
memory=${RUNNER_VM_MEMORY:-3G}
|
||||
gitea_instance=${GITEA_INSTANCE:-https://git.ddupan.top}
|
||||
runner_label=${RUNNER_LABEL:-kind-microvm}
|
||||
token_url=${RUNNER_TOKEN_URL:-http://172.30.0.1:8787}
|
||||
|
||||
vm_dir="$state_root/instances/$instance_id"
|
||||
tap="mvr${instance_id%%-*}"
|
||||
overlay="$vm_dir/root.qcow2"
|
||||
seed="$vm_dir/seed.img"
|
||||
serial="$vm_dir/serial.log"
|
||||
|
||||
cleanup() {
|
||||
ip link delete "$tap" 2>/dev/null || true
|
||||
rm -rf -- "$vm_dir"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
test -r "$base_image"
|
||||
test -r "$firmware"
|
||||
mkdir -m 0700 -p "$vm_dir"
|
||||
qemu-img create -q -f qcow2 -F qcow2 -b "$base_image" "$overlay"
|
||||
|
||||
cat >"$vm_dir/meta-data" <<EOF
|
||||
instance-id: $instance_id
|
||||
local-hostname: gitea-runner-${instance_id%%-*}
|
||||
EOF
|
||||
cat >"$vm_dir/user-data" <<EOF
|
||||
#cloud-config
|
||||
runcmd:
|
||||
- [ /usr/local/libexec/gitea-microvm-guest-runner, "$token_url/token/$nonce", "$gitea_instance", "gitea-${instance_id%%-*}", "$runner_label" ]
|
||||
EOF
|
||||
cloud-localds "$seed" "$vm_dir/user-data" "$vm_dir/meta-data"
|
||||
|
||||
digest=$(printf '%s' "$instance_id" | sha256sum | cut -c1-10)
|
||||
mac=$(printf '02:%s:%s:%s:%s:%s' \
|
||||
"$(printf '%s' "$digest" | cut -c1-2)" \
|
||||
"$(printf '%s' "$digest" | cut -c3-4)" \
|
||||
"$(printf '%s' "$digest" | cut -c5-6)" \
|
||||
"$(printf '%s' "$digest" | cut -c7-8)" \
|
||||
"$(printf '%s' "$digest" | cut -c9-10)")
|
||||
ip tuntap add dev "$tap" mode tap
|
||||
ip link set "$tap" master "$bridge"
|
||||
ip link set "$tap" up
|
||||
|
||||
timeout --signal=TERM --kill-after=30s "$vm_timeout" "$cloud_hypervisor" \
|
||||
--firmware "$firmware" \
|
||||
--cpus "boot=$cpus" \
|
||||
--memory "size=$memory" \
|
||||
--disk "path=$overlay" \
|
||||
--disk "path=$seed,readonly=on" \
|
||||
--net "tap=$tap,mac=$mac" \
|
||||
--serial "file=$serial" \
|
||||
--console off
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
action=${1:?up or down is required}
|
||||
bridge=${RUNNER_BRIDGE:-mvrbr0}
|
||||
subnet=${RUNNER_SUBNET:-172.30.0.0/24}
|
||||
gateway=${RUNNER_GATEWAY:-172.30.0.1}
|
||||
dhcp_start=${RUNNER_DHCP_START:-172.30.0.10}
|
||||
dhcp_end=${RUNNER_DHCP_END:-172.30.0.200}
|
||||
pid_file=${RUNNER_DNSMASQ_PID:-/run/microvm-runner/dnsmasq.pid}
|
||||
|
||||
case "$action" in
|
||||
up)
|
||||
mkdir -p "$(dirname "$pid_file")"
|
||||
ip link show "$bridge" >/dev/null 2>&1 || ip link add "$bridge" type bridge
|
||||
ip address show dev "$bridge" | grep -Fq "$gateway/24" || ip address add "$gateway/24" dev "$bridge"
|
||||
ip link set "$bridge" up
|
||||
sysctl -q -w net.ipv4.ip_forward=1
|
||||
iptables -t nat -C POSTROUTING -s "$subnet" -j MASQUERADE 2>/dev/null || \
|
||||
iptables -t nat -A POSTROUTING -s "$subnet" -j MASQUERADE
|
||||
dnsmasq --interface="$bridge" --bind-interfaces --except-interface=lo \
|
||||
--dhcp-range="$dhcp_start,$dhcp_end,255.255.255.0,1h" \
|
||||
--dhcp-option="option:router,$gateway" --pid-file="$pid_file"
|
||||
;;
|
||||
down)
|
||||
if test -r "$pid_file"; then
|
||||
kill "$(cat "$pid_file")" 2>/dev/null || true
|
||||
rm -f -- "$pid_file"
|
||||
fi
|
||||
iptables -t nat -D POSTROUTING -s "$subnet" -j MASQUERADE 2>/dev/null || true
|
||||
ip link delete "$bridge" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
echo "expected up or down" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1 @@
|
||||
"""Gitea microVM runner controller and worker."""
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Gitea workflow_job webhook to NATS JetStream producer."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
import nats
|
||||
from aiohttp import web
|
||||
from nats.js.api import DiscardPolicy, RetentionPolicy, StorageType, StreamConfig
|
||||
from nats.js.errors import NotFoundError
|
||||
|
||||
LABEL = os.environ.get("RUNNER_LABEL", "kind-microvm")
|
||||
SUBJECT = os.environ.get("NATS_SUBJECT", f"ci.runner.{LABEL}")
|
||||
STREAM = os.environ.get("NATS_STREAM", "CI_RUNNER")
|
||||
NATS_URL = os.environ.get("NATS_URL", "tls://nats.ad.ddupan.top:4222")
|
||||
NATS_USER = os.environ.get("NATS_USER", "ci-producer")
|
||||
NATS_PASSWORD_FILE = Path(os.environ.get("NATS_PASSWORD_FILE", "/run/secrets/nats/password"))
|
||||
NATS_CA_FILE = os.environ.get("NATS_CA_FILE", "/etc/ssl/certs/ca-certificates.crt")
|
||||
WEBHOOK_SECRET_FILE = Path(os.environ.get("WEBHOOK_SECRET_FILE", "/run/secrets/gitea/webhook-secret"))
|
||||
|
||||
|
||||
def accepts(payload: object) -> tuple[bool, str | None]:
|
||||
if not isinstance(payload, dict) or payload.get("action") != "queued":
|
||||
return False, None
|
||||
job = payload.get("workflow_job")
|
||||
if not isinstance(job, dict) or LABEL not in job.get("labels", []):
|
||||
return False, None
|
||||
job_id = job.get("id")
|
||||
if not isinstance(job_id, int) or isinstance(job_id, bool):
|
||||
return False, None
|
||||
return True, str(job_id)
|
||||
|
||||
|
||||
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 ensure_stream(js: object) -> None:
|
||||
config = StreamConfig(
|
||||
name=STREAM,
|
||||
subjects=["ci.runner.*"],
|
||||
retention=RetentionPolicy.WORK_QUEUE,
|
||||
storage=StorageType.FILE,
|
||||
discard=DiscardPolicy.OLD,
|
||||
max_age=24 * 60 * 60,
|
||||
max_msgs=10_000,
|
||||
max_bytes=256 * 1024 * 1024,
|
||||
duplicate_window=24 * 60 * 60,
|
||||
)
|
||||
try:
|
||||
await js.stream_info(STREAM)
|
||||
except NotFoundError:
|
||||
await js.add_stream(config=config)
|
||||
else:
|
||||
await js.update_stream(config=config)
|
||||
|
||||
|
||||
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
|
||||
accepted, job_id = accepts(payload)
|
||||
if not accepted:
|
||||
return web.Response(status=204)
|
||||
await request.app["js"].publish(
|
||||
SUBJECT,
|
||||
body,
|
||||
headers={"Nats-Msg-Id": f"gitea-workflow-job-{job_id}"},
|
||||
)
|
||||
return web.Response(status=202, text="queued\n")
|
||||
|
||||
|
||||
async def health(request: web.Request) -> web.Response:
|
||||
connected = request.app["nc"].is_connected
|
||||
return web.Response(text="ok\n" if connected else "disconnected\n", status=200 if connected else 503)
|
||||
|
||||
|
||||
async def nats_context(app: web.Application):
|
||||
tls = ssl.create_default_context(cafile=NATS_CA_FILE)
|
||||
nc = await nats.connect(
|
||||
NATS_URL,
|
||||
user=NATS_USER,
|
||||
password=NATS_PASSWORD_FILE.read_text().strip(),
|
||||
tls=tls,
|
||||
name="microvm-runner-controller",
|
||||
)
|
||||
app["nc"] = nc
|
||||
app["js"] = nc.jetstream()
|
||||
await ensure_stream(app["js"])
|
||||
yield
|
||||
await nc.drain()
|
||||
|
||||
|
||||
def create_app() -> web.Application:
|
||||
app = web.Application(client_max_size=1024 * 1024)
|
||||
app.cleanup_ctx.append(nats_context)
|
||||
app.router.add_post("/webhook", webhook)
|
||||
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", "8787")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capacity-bounded JetStream consumer that launches one ephemeral VM per job."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import ssl
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import nats
|
||||
from aiohttp import web
|
||||
from nats.errors import TimeoutError
|
||||
from nats.js.api import AckPolicy, ConsumerConfig
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CAPACITY = int(os.environ.get("RUNNER_CAPACITY", "1"))
|
||||
SUBJECT = os.environ.get("NATS_SUBJECT", "ci.runner.kind-microvm")
|
||||
STREAM = os.environ.get("NATS_STREAM", "CI_RUNNER")
|
||||
DURABLE = os.environ.get("NATS_DURABLE", "kind-microvm")
|
||||
MAX_INFLIGHT = int(os.environ.get("RUNNER_MAX_INFLIGHT", "64"))
|
||||
NATS_URL = os.environ.get("NATS_URL", "tls://nats.ad.ddupan.top:4222")
|
||||
NATS_USER = os.environ.get("NATS_USER", "ci-worker")
|
||||
NATS_PASSWORD_FILE = Path(os.environ.get("NATS_PASSWORD_FILE", "/etc/microvm-runner/nats-password"))
|
||||
NATS_CA_FILE = os.environ.get("NATS_CA_FILE", "/etc/ssl/certs/ca-certificates.crt")
|
||||
REGISTRATION_TOKEN_FILE = Path(os.environ.get("REGISTRATION_TOKEN_FILE", "/etc/microvm-runner/registration-token"))
|
||||
LAUNCHER = os.environ.get("LAUNCHER", "/usr/local/libexec/microvm-runner-launch")
|
||||
TOKEN_LISTEN = os.environ.get("TOKEN_LISTEN", "172.30.0.1")
|
||||
TOKEN_PORT = int(os.environ.get("TOKEN_PORT", "8787"))
|
||||
|
||||
tokens: dict[str, bytes] = {}
|
||||
token_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def token(request: web.Request) -> web.Response:
|
||||
nonce = request.match_info["nonce"]
|
||||
async with token_lock:
|
||||
value = tokens.pop(nonce, None)
|
||||
if value is None:
|
||||
raise web.HTTPNotFound()
|
||||
return web.Response(body=value, headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
async def heartbeat(message: object, stop: asyncio.Event) -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=60)
|
||||
return
|
||||
except asyncio.TimeoutError:
|
||||
await message.in_progress()
|
||||
|
||||
|
||||
async def run_one(message: object) -> None:
|
||||
instance_id = str(uuid.uuid4())
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
async with token_lock:
|
||||
tokens[nonce] = REGISTRATION_TOKEN_FILE.read_bytes().strip()
|
||||
stop = asyncio.Event()
|
||||
pulse = asyncio.create_task(heartbeat(message, stop))
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(LAUNCHER, instance_id, nonce)
|
||||
return_code = await process.wait()
|
||||
if return_code == 0:
|
||||
await message.ack()
|
||||
else:
|
||||
LOG.error("launcher for %s exited with %d", instance_id, return_code)
|
||||
await message.nak(delay=30)
|
||||
except Exception:
|
||||
await message.nak(delay=30)
|
||||
raise
|
||||
finally:
|
||||
stop.set()
|
||||
await pulse
|
||||
async with token_lock:
|
||||
tokens.pop(nonce, None)
|
||||
|
||||
|
||||
def _report_task(task: asyncio.Task[None]) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
error = task.exception()
|
||||
if error is not None:
|
||||
LOG.error(
|
||||
"runner task failed",
|
||||
exc_info=(type(error), error, error.__traceback__),
|
||||
)
|
||||
|
||||
|
||||
async def consume() -> None:
|
||||
if CAPACITY < 1:
|
||||
raise ValueError("RUNNER_CAPACITY must be at least 1")
|
||||
tls = ssl.create_default_context(cafile=NATS_CA_FILE)
|
||||
nc = await nats.connect(
|
||||
NATS_URL,
|
||||
user=NATS_USER,
|
||||
password=NATS_PASSWORD_FILE.read_text().strip(),
|
||||
tls=tls,
|
||||
name=DURABLE,
|
||||
)
|
||||
js = nc.jetstream()
|
||||
subscription = await js.pull_subscribe(
|
||||
SUBJECT,
|
||||
durable=DURABLE,
|
||||
stream=STREAM,
|
||||
config=ConsumerConfig(
|
||||
durable_name=DURABLE,
|
||||
filter_subject=SUBJECT,
|
||||
ack_policy=AckPolicy.EXPLICIT,
|
||||
ack_wait=5 * 60,
|
||||
max_ack_pending=MAX_INFLIGHT,
|
||||
max_deliver=5,
|
||||
),
|
||||
)
|
||||
active: set[asyncio.Task[None]] = set()
|
||||
try:
|
||||
while True:
|
||||
active = {task for task in active if not task.done()}
|
||||
free = CAPACITY - len(active)
|
||||
if free == 0:
|
||||
await asyncio.wait(active, return_when=asyncio.FIRST_COMPLETED)
|
||||
continue
|
||||
try:
|
||||
messages = await subscription.fetch(batch=free, timeout=5)
|
||||
except TimeoutError:
|
||||
continue
|
||||
for message in messages:
|
||||
task = asyncio.create_task(run_one(message))
|
||||
task.add_done_callback(_report_task)
|
||||
active.add(task)
|
||||
finally:
|
||||
if active:
|
||||
await asyncio.gather(*active, return_exceptions=True)
|
||||
await nc.drain()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
app = web.Application()
|
||||
app.router.add_get("/token/{nonce}", token)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, TOKEN_LISTEN, TOKEN_PORT).start()
|
||||
try:
|
||||
await consume()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Gitea microVM runner worker
|
||||
After=network-online.target microvm-runner-network.service
|
||||
Wants=network-online.target
|
||||
Requires=microvm-runner-network.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=-/etc/microvm-runner/worker.env
|
||||
ExecStart=/opt/gitea-microvm-runner/venv/bin/gitea-microvm-worker
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
SupplementaryGroups=kvm
|
||||
PrivateTmp=yes
|
||||
ProtectHome=yes
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/var/lib/microvm-runner /run/microvm-runner
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Network bridge for Gitea microVM runners
|
||||
Before=gitea-microvm-worker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=/usr/local/libexec/microvm-runner-network up
|
||||
ExecStop=/usr/local/libexec/microvm-runner-network down
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
from gitea_microvm_runner import controller
|
||||
|
||||
|
||||
def test_accepts_matching_queued_job(monkeypatch):
|
||||
monkeypatch.setattr(controller, "LABEL", "kind-microvm")
|
||||
assert controller.accepts({
|
||||
"action": "queued",
|
||||
"workflow_job": {"id": 47, "labels": ["linux", "kind-microvm"]},
|
||||
}) == (True, "47")
|
||||
|
||||
|
||||
def test_rejects_other_actions_labels_and_boolean_id(monkeypatch):
|
||||
monkeypatch.setattr(controller, "LABEL", "kind-microvm")
|
||||
assert controller.accepts({"action": "completed", "workflow_job": {"id": 1, "labels": ["kind-microvm"]}}) == (False, None)
|
||||
assert controller.accepts({"action": "queued", "workflow_job": {"id": 1, "labels": ["host"]}}) == (False, None)
|
||||
assert controller.accepts({"action": "queued", "workflow_job": {"id": True, "labels": ["kind-microvm"]}}) == (False, None)
|
||||
|
||||
|
||||
def test_signature_accepts_gitea_and_prefixed_forms(tmp_path, monkeypatch):
|
||||
secret = b"test-secret"
|
||||
body = b'{"action":"queued"}'
|
||||
secret_file = tmp_path / "secret"
|
||||
secret_file.write_bytes(secret)
|
||||
monkeypatch.setattr(controller, "WEBHOOK_SECRET_FILE", secret_file)
|
||||
digest = hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
assert controller.valid_signature(body, digest)
|
||||
assert controller.valid_signature(body, f"sha256={digest}")
|
||||
assert not controller.valid_signature(body, "bad")
|
||||
Reference in New Issue
Block a user