Compare commits

...
Author SHA1 Message Date
panxiao81 5348ca9891 将 OpenSandbox 调度改为持久事件循环
test / shell (pull_request) Successful in 14s
test / python (pull_request) Successful in 10s
2026-09-18 19:25:09 +00:00
panxiao81 0890bc76d9 Merge pull request '按 guest Agent 格式恢复 SPIRE 父身份' (#26) from fix/opensandbox-guest-parent into main
test / python (push) Successful in 21s
test / shell (push) Successful in 26s
publish images / publish-images (push) Canceled after 0s
2026-09-18 18:56:50 +00:00
panxiao81 fe0703fe5c 按 guest Agent 格式恢复 SPIRE 父身份
test / python (pull_request) Successful in 27s
test / shell (pull_request) Successful in 29s
2026-09-18 18:55:49 +00:00
panxiao81 66f7d797f3 Merge pull request '修正 OpenSandbox SPIRE 父身份格式' (#25) from fix/opensandbox-spire-parent into main
test / shell (push) Successful in 24s
test / python (push) Successful in 17s
publish images / publish-images (push) Failing after 1s
2026-09-18 18:29:48 +00:00
6 changed files with 227 additions and 38 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Lifecycle 请求把稳定的 repository/task SPIFFE ID 放入 task environment
集群内的 `opensandbox-identity` controller 读取 BatchSandbox allocation 得到实际
Pod UID,然后创建:
- parent`spiffe://ddupan.top/spire/agent/k8s_psat/sandbox-kata/<pod-uid>`
- parent`spiffe://ddupan.top/spire/agent/k8s_psat/sandbox-kata/pod/<pod-uid>`
- workload`spiffe://ddupan.top/ci/<owner>/<repository>/<task>`
- selector`unix:uid:2000`
+165 -20
View File
@@ -3,14 +3,27 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import json
import logging
import os
import ssl
from pathlib import Path
import nats
from aiohttp import web
from nats.errors import TimeoutError as NatsTimeoutError
from nats.js.api import (
AckPolicy,
ConsumerConfig,
DiscardPolicy,
RetentionPolicy,
StorageType,
StreamConfig,
)
from nats.js.errors import NotFoundError
from .models import RunnerRequest
from .opensandbox import OpenSandboxClient
@@ -22,19 +35,28 @@ 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"
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",
)
)
NATS_URL = os.environ.get("NATS_URL", "tls://nats.ad.ddupan.top:4222")
NATS_CA_FILE = os.environ.get("NATS_CA_FILE", "/etc/ssl/certs/ca-certificates.crt")
NATS_PRODUCER_USER = os.environ.get("NATS_PRODUCER_USER", "ci-producer")
NATS_PRODUCER_PASSWORD_FILE = Path(
os.environ.get("NATS_PRODUCER_PASSWORD_FILE", "/run/secrets/nats/producer-password")
)
NATS_WORKER_USER = os.environ.get("NATS_WORKER_USER", "ci-worker")
NATS_WORKER_PASSWORD_FILE = Path(
os.environ.get("NATS_WORKER_PASSWORD_FILE", "/run/secrets/nats/worker-password")
)
NATS_STREAM = os.environ.get("NATS_STREAM", "CI_RUNNER")
NATS_SUBJECT_PREFIX = os.environ.get("NATS_SUBJECT_PREFIX", "ci.runner")
POD_CONSUMER_ENABLED = os.environ.get("POD_CONSUMER_ENABLED", "true") == "true"
def accepts(payload: object) -> tuple[bool, str | None]:
@@ -60,21 +82,42 @@ async def webhook(request: web.Request) -> web.Response:
context = _webhook_context(payload)
LOG.info("workflow_job webhook received %s", context)
scheduler: OpenSandboxScheduler = request.app["scheduler"]
if isinstance(payload, dict) and payload.get("action") == "completed":
job = payload.get("workflow_job")
runner_name = job.get("runner_name") if isinstance(job, dict) else None
if isinstance(runner_name, str) and runner_name.startswith(
("gitea-pod-", "gitea-vm-")
):
cleaned = await scheduler.complete(runner_name)
await request.app["js"].publish(
f"{NATS_SUBJECT_PREFIX}.lifecycle.completed",
body,
headers={
"Nats-Msg-Id": (f"gitea-workflow-job-{job.get('id')}-completed")
},
)
LOG.info(
"completed runner cleanup runner=%r cleaned=%s %s",
runner_name,
cleaned,
context,
)
return web.Response(status=204)
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")
subject = f"{NATS_SUBJECT_PREFIX}.{runner_request.backend}"
await request.app["js"].publish(
subject,
runner_request.to_json(),
headers={"Nats-Msg-Id": f"gitea-workflow-job-{runner_request.job_id}-queued"},
)
LOG.info("runner request persisted subject=%s %s", subject, context)
return web.Response(status=202, text="queued\n")
async def registration_token(request: web.Request) -> web.Response:
@@ -108,8 +151,97 @@ async def health(request: web.Request) -> web.Response:
)
async def opensandbox_context(app: web.Application):
async def ensure_stream(js: object) -> None:
config = StreamConfig(
name=NATS_STREAM,
subjects=[f"{NATS_SUBJECT_PREFIX}.>"],
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(NATS_STREAM)
except NotFoundError:
await js.add_stream(config=config)
else:
await js.update_stream(config=config)
async def run_message(message: object, scheduler: OpenSandboxScheduler) -> None:
try:
runner_request = RunnerRequest.from_json(message.data)
await scheduler.create(runner_request)
task = scheduler.active[runner_request.job_id]
while not task.done():
try:
await asyncio.wait_for(asyncio.shield(task), timeout=30)
except asyncio.TimeoutError:
await message.in_progress()
await task
except ValueError as error:
LOG.info("runner request already active: %s", error)
await message.nak(delay=5)
except Exception:
LOG.exception("persistent runner request failed")
await message.nak(delay=15)
else:
await message.ack()
async def consume_pod_requests(js: object, scheduler: OpenSandboxScheduler) -> None:
subscription = await js.pull_subscribe(
f"{NATS_SUBJECT_PREFIX}.pod",
# Reuse the existing durable so no pending Pod request is stranded
# during the in-process consumer migration.
durable="pod",
stream=NATS_STREAM,
config=ConsumerConfig(
durable_name="pod",
filter_subject=f"{NATS_SUBJECT_PREFIX}.pod",
ack_policy=AckPolicy.EXPLICIT,
ack_wait=5 * 60,
max_ack_pending=4,
max_deliver=20,
),
)
active: set[asyncio.Task[None]] = set()
while True:
active = {task for task in active if not task.done()}
try:
messages = await subscription.fetch(
batch=max(1, 4 - len(active)), timeout=5
)
except NatsTimeoutError:
continue
for message in messages:
task = asyncio.create_task(run_message(message, scheduler))
active.add(task)
async def runtime_context(app: web.Application):
tokens = RegistrationTokens(REGISTRATION_TOKEN_FILE.read_bytes())
tls = ssl.create_default_context(cafile=NATS_CA_FILE)
producer = await nats.connect(
NATS_URL,
user=NATS_PRODUCER_USER,
password=NATS_PRODUCER_PASSWORD_FILE.read_text().strip(),
tls=tls,
name="opensandbox-runner-controller-producer",
)
worker = await nats.connect(
NATS_URL,
user=NATS_WORKER_USER,
password=NATS_WORKER_PASSWORD_FILE.read_text().strip(),
tls=ssl.create_default_context(cafile=NATS_CA_FILE),
name="opensandbox-runner-controller-worker",
)
app["nc"] = producer
app["js"] = producer.jetstream()
await ensure_stream(app["js"])
async with OpenSandboxClient(
api_url=OPENSANDBOX_API,
api_key_file=OPENSANDBOX_API_KEY_FILE,
@@ -117,13 +249,26 @@ async def opensandbox_context(app: web.Application):
scheduler = OpenSandboxScheduler(client, tokens)
app["opensandbox_client"] = client
app["scheduler"] = scheduler
yield
await scheduler.close()
consumer = None
if POD_CONSUMER_ENABLED:
consumer = asyncio.create_task(
consume_pod_requests(worker.jetstream(), scheduler),
name="opensandbox-pod-consumer",
)
try:
yield
finally:
if consumer is not None:
consumer.cancel()
await asyncio.gather(consumer, return_exceptions=True)
await scheduler.close()
await worker.drain()
await producer.drain()
def create_app() -> web.Application:
app = web.Application(client_max_size=1024 * 1024)
app.cleanup_ctx.append(opensandbox_context)
app.cleanup_ctx.append(runtime_context)
app.router.add_post("/webhook", webhook)
app.router.add_get("/token/{nonce}", registration_token)
app.router.add_get("/healthz", health)
@@ -91,7 +91,7 @@ def identity_entry(
"className": SPIRE_CLASS_NAME,
"parentID": (
f"spiffe://{SPIFFE_TRUST_DOMAIN}/spire/agent/k8s_psat/"
f"{SPIRE_CLUSTER_NAME}/{pod_uid}"
f"{SPIRE_CLUSTER_NAME}/pod/{pod_uid}"
),
"spiffeID": spiffe_id,
"selectors": [f"unix:uid:{RUNNER_UID}"],
+40 -9
View File
@@ -88,6 +88,7 @@ class OpenSandboxScheduler:
self.tokens = tokens
self.on_finished = on_finished
self.active: dict[int, asyncio.Task[None]] = {}
self.runners: dict[str, tuple[int, str, str]] = {}
async def create(self, request: RunnerRequest) -> str:
if request.job_id in self.active:
@@ -106,11 +107,12 @@ class OpenSandboxScheduler:
raise
task = asyncio.create_task(
self._monitor(request, sandbox_id, nonce),
self._monitor(request, sandbox_id, nonce, runner_name),
name=f"opensandbox-{sandbox_id}",
)
task.add_done_callback(self._report)
self.active[request.job_id] = task
self.runners[runner_name] = (request.job_id, sandbox_id, nonce)
LOG.info(
"OpenSandbox runner created sandbox=%s runner=%s job_id=%d "
"repository=%s job_name=%r pool=ci-%s",
@@ -124,7 +126,11 @@ class OpenSandboxScheduler:
return sandbox_id
async def _monitor(
self, request: RunnerRequest, sandbox_id: str, nonce: str
self,
request: RunnerRequest,
sandbox_id: str,
nonce: str,
runner_name: str,
) -> None:
try:
deadline = asyncio.get_running_loop().time() + SANDBOX_TIMEOUT
@@ -139,13 +145,38 @@ class OpenSandboxScheduler:
await asyncio.sleep(2)
raise asyncio.TimeoutError(f"OpenSandbox {sandbox_id} timed out")
finally:
await self.tokens.revoke(nonce)
try:
await self.client.delete(sandbox_id)
finally:
self.active.pop(request.job_id, None)
if self.on_finished is not None:
self.on_finished(request.job_id)
await self._cleanup(request.job_id, sandbox_id, nonce, runner_name)
async def complete(self, runner_name: str) -> bool:
"""Delete the sandbox which actually ran a completed Gitea job."""
state = self.runners.get(runner_name)
if state is None:
return False
job_id, sandbox_id, nonce = state
task = self.active.get(job_id)
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await self._cleanup(job_id, sandbox_id, nonce, runner_name)
return True
async def _cleanup(
self,
job_id: int,
sandbox_id: str,
nonce: str,
runner_name: str,
) -> None:
"""Revoke and delete once, including cancellation-before-start races."""
if self.runners.pop(runner_name, None) is None:
return
await self.tokens.revoke(nonce)
try:
await self.client.delete(sandbox_id)
finally:
self.active.pop(job_id, None)
if self.on_finished is not None:
self.on_finished(job_id)
@staticmethod
def _report(task: asyncio.Task[None]) -> None:
+1 -1
View File
@@ -49,5 +49,5 @@ def test_entry_binds_exact_pod_agent_and_runner_uid():
pod_uid="pod-uid",
spiffe_id="spiffe://ddupan.top/ci/org/repo/test",
)
assert entry["spec"]["parentID"].endswith("/sandbox-kata/pod-uid")
assert entry["spec"]["parentID"].endswith("/sandbox-kata/pod/pod-uid")
assert entry["spec"]["selectors"] == ["unix:uid:2000"]
+19 -6
View File
@@ -26,13 +26,9 @@ def test_sandbox_request_uses_pool_identity_and_one_time_token_url():
request(), "gitea-vm-abcd", "http://scheduler/token/nonce"
)
assert document["pool"] == "ci-vm"
assert document["entrypoint"] == [
"/usr/local/libexec/gitea-opensandbox-runner"
]
assert document["entrypoint"] == ["/usr/local/libexec/gitea-opensandbox-runner"]
assert document["env"]["GITEA_RUNNER_LABELS"] == "self-hosted:host,vm:host"
assert document["env"]["GITEA_RUNNER_REGISTRATION_TOKEN_URL"].endswith(
"/nonce"
)
assert document["env"]["GITEA_RUNNER_REGISTRATION_TOKEN_URL"].endswith("/nonce")
assert document["env"]["CI_SPIFFE_ID"] == (
"spiffe://ddupan.top/ci/panxiao81/example/publish-image-3e72cdc4a97e"
)
@@ -86,3 +82,20 @@ async def test_scheduler_rejects_duplicate_active_job():
scheduler.active[42] = asyncio.Future()
with pytest.raises(ValueError, match="already"):
await scheduler.create(request())
async def test_scheduler_deletes_sandbox_for_completed_runner():
class RunningOpenSandbox(FakeOpenSandbox):
async def get(self, sandbox_id):
return {"status": {"state": "Running"}}
client = RunningOpenSandbox()
scheduler = OpenSandboxScheduler(client, RegistrationTokens(b"secret"))
await scheduler.create(request())
runner_name = next(iter(scheduler.runners))
assert await scheduler.complete(runner_name) is True
assert client.deleted == ["sandbox-1"]
assert scheduler.active == {}
assert scheduler.runners == {}
assert await scheduler.complete(runner_name) is False