Viewing files at commit 3d84c25e26dbCommit detailsView latest

scripts/oakbench/ci_stub.py 313 lines · 12.8 KB · python
1
"""Loopback-only CI execution backend that speaks oakspace's CI protocol.
2
3
oakspace dispatches a signed job spec to ``OAK_CI_DISPATCH_URL`` (``.../jobs``)
4
and expects the execution backend to clone the exact commit through the
5
token-scoped ``/api/ci/fetch/<repo_id>`` git endpoint, run the flattened step
6
list, and report back through HMAC-signed callbacks:
7
8
- ``POST /api/ci/runs/<id>/steps``       ``{step_id, conclusion, exit_code}``
9
- ``POST /api/ci/runs/<id>/conclusion``  ``{conclusion, error}``
10
11
This is the smallest stand-in for ``workers/oak-ci`` (Cloudflare Sandbox) that
12
lets a benchmark exercise the real hosted merge gate against a local server.
13
It binds 127.0.0.1 only, runs steps as local ``bash -c`` processes (no
14
sandbox: never point a real server or untrusted repository at it), and adds a
15
fixed, labeled ``added_seconds`` delay to every run so "CI duration" is a
16
controlled scenario parameter rather than host noise.
17
18
Every executed run is appended to a JSONL ledger (run id, commit, ref, tree
19
digest of the fetched workspace, conclusion, timings). The stale-branch land
20
lane uses the ledger as its independent record of which exact trees CI tested.
21
"""
22
23
from __future__ import annotations
24
25
import hashlib
26
import hmac
27
import json
28
import os
29
import shutil
30
import subprocess
31
import tempfile
32
import threading
33
import time
34
import urllib.request
35
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
36
from pathlib import Path
37
from typing import Any, Callable, Optional
38
39
LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
40
41
42
def sign(secret: str, body: bytes) -> str:
43
    return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
44
45
46
def verify(secret: str, body: bytes, header: Optional[str]) -> bool:
47
    expected = f"sha256={sign(secret, body)}"
48
    return hmac.compare_digest(expected.encode(), (header or "").encode())
49
50
51
def tree_digest(root: Path, exclude: tuple[str, ...] = (".git", ".oak")) -> str:
52
    """Content digest of a working tree: sorted (relpath, sha256) pairs.
53
54
    Excludes VCS metadata directories at the top level only, except that
55
    ``.oak/workflows`` is versioned content in Oak repositories and is kept.
56
    """
57
    entries: list[str] = []
58
    for dirpath, dirnames, filenames in os.walk(root):
59
        rel_dir = os.path.relpath(dirpath, root)
60
        if rel_dir == ".":
61
            dirnames[:] = sorted(d for d in dirnames if d not in exclude or d == ".oak")
62
        if rel_dir == ".oak":
63
            dirnames[:] = [d for d in dirnames if d == "workflows"]
64
            filenames = []
65
        dirnames.sort()
66
        for name in sorted(filenames):
67
            path = Path(dirpath) / name
68
            rel = os.path.relpath(path, root).replace(os.sep, "/")
69
            if rel.startswith(".oak/") and not rel.startswith(".oak/workflows/"):
70
                continue
71
            entries.append(f"{rel}\0{hashlib.sha256(path.read_bytes()).hexdigest()}")
72
    return hashlib.sha256("\n".join(entries).encode()).hexdigest()
73
74
75
class CiStub:
76
    def __init__(
77
        self,
78
        *,
79
        api_url: str,
80
        secret: str,
81
        ledger: Path,
82
        added_seconds: float = 0.0,
83
        host: str = "127.0.0.1",
84
        port: int = 0,
85
        workdir: Optional[Path] = None,
86
        git_bin: str = "git",
87
        post: Optional[Callable[[str, dict[str, Any]], bool]] = None,
88
    ) -> None:
89
        if host not in LOOPBACK_HOSTS:
90
            raise ValueError("ci_stub is local-only; refusing non-loopback bind")
91
        self.api_url = api_url.rstrip("/")
92
        self.secret = secret
93
        self.ledger = ledger
94
        self.added_seconds = float(added_seconds)
95
        self.workdir = workdir or Path(tempfile.mkdtemp(prefix="oak-ci-stub-"))
96
        self.git_bin = git_bin
97
        self._post = post or self._post_http
98
        self._lock = threading.Lock()
99
        self._threads: list[threading.Thread] = []
100
        stub = self
101
102
        class Handler(BaseHTTPRequestHandler):
103
            def log_message(self, *args: Any) -> None:  # quiet
104
                pass
105
106
            def _reply(self, code: int, payload: Any = None) -> None:
107
                body = json.dumps(payload if payload is not None else {}).encode()
108
                self.send_response(code)
109
                self.send_header("content-type", "application/json")
110
                self.send_header("content-length", str(len(body)))
111
                self.end_headers()
112
                self.wfile.write(body)
113
114
            def do_GET(self) -> None:  # noqa: N802
115
                if self.path.rstrip("/").endswith("/capabilities"):
116
                    # Unprotected executor: protected dispatch stays queued.
117
                    self._reply(200, {"protected_environment_v1": None})
118
                else:
119
                    self._reply(404, {"error": "not found"})
120
121
            def do_POST(self) -> None:  # noqa: N802
122
                length = int(self.headers.get("content-length") or 0)
123
                body = self.rfile.read(length)
124
                if not verify(stub.secret, body, self.headers.get("x-oak-signature-256")):
125
                    self._reply(401, {"error": "bad signature"})
126
                    return
127
                path = self.path.rstrip("/")
128
                if path.endswith("/jobs"):
129
                    try:
130
                        spec = json.loads(body)
131
                    except ValueError:
132
                        self._reply(400, {"error": "bad json"})
133
                        return
134
                    stub.accept(spec)
135
                    self._reply(202, {"accepted": True})
136
                elif path.endswith("/cancel"):
137
                    self._reply(200, {})
138
                else:
139
                    self._reply(404, {"error": "not found"})
140
141
        self.server = ThreadingHTTPServer((host, port), Handler)
142
        self.port = self.server.server_address[1]
143
        self._serve_thread: Optional[threading.Thread] = None
144
145
    # -- lifecycle -----------------------------------------------------------
146
    def start(self) -> "CiStub":
147
        self._serve_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
148
        self._serve_thread.start()
149
        return self
150
151
    def stop(self) -> None:
152
        self.server.shutdown()
153
        self.server.server_close()
154
155
    def join_runs(self, timeout: float = 120.0) -> None:
156
        deadline = time.monotonic() + timeout
157
        for thread in list(self._threads):
158
            thread.join(max(0.0, deadline - time.monotonic()))
159
160
    # -- execution -----------------------------------------------------------
161
    def accept(self, spec: dict[str, Any]) -> None:
162
        thread = threading.Thread(target=self.execute, args=(spec,), daemon=True)
163
        with self._lock:
164
            self._threads.append(thread)
165
        thread.start()
166
167
    def _post_http(self, path: str, payload: dict[str, Any]) -> bool:
168
        body = json.dumps(payload).encode()
169
        request = urllib.request.Request(
170
            f"{self.api_url}{path}",
171
            data=body,
172
            method="POST",
173
            headers={
174
                "content-type": "application/json",
175
                "x-oak-signature-256": f"sha256={sign(self.secret, body)}",
176
            },
177
        )
178
        try:
179
            with urllib.request.urlopen(request, timeout=30) as response:
180
                return 200 <= response.status < 300
181
        except Exception:  # noqa: BLE001 - callbacks are best effort, like the real worker
182
            return False
183
184
    def fetch(self, spec: dict[str, Any], dest: Path) -> subprocess.CompletedProcess[str]:
185
        env = dict(os.environ)
186
        env.update(
187
            {
188
                "GIT_TERMINAL_PROMPT": "0",
189
                "GIT_CONFIG_COUNT": "1",
190
                "GIT_CONFIG_KEY_0": "http.extraHeader",
191
                "GIT_CONFIG_VALUE_0": f"x-oak-ci-fetch-token: {spec.get('fetch_token', '')}",
192
            }
193
        )
194
        url = f"{self.api_url}/api/ci/fetch/{spec['repo_id']}"
195
        return subprocess.run(
196
            [self.git_bin, "clone", "--quiet", "--depth", "1", "--no-tags", url, str(dest)],
197
            env=env,
198
            capture_output=True,
199
            text=True,
200
            timeout=120,
201
        )
202
203
    def execute(self, spec: dict[str, Any]) -> dict[str, Any]:
204
        run_id = int(spec["run_id"])
205
        started = time.time()
206
        record: dict[str, Any] = {
207
            "run_id": run_id,
208
            "repo_id": spec.get("repo_id"),
209
            "commit": spec.get("commit"),
210
            "ref": spec.get("ref"),
211
            "added_seconds": self.added_seconds,
212
            "started_unix": started,
213
            "tree_digest": None,
214
            "steps": [],
215
            "conclusion": None,
216
            "error": None,
217
        }
218
        workspace = self.workdir / f"run-{run_id}"
219
        shutil.rmtree(workspace, ignore_errors=True)
220
        conclusion = "success"
221
        error: Optional[str] = None
222
        try:
223
            if spec.get("fetch_token"):
224
                fetched = self.fetch(spec, workspace)
225
                if fetched.returncode != 0:
226
                    conclusion = "failure"
227
                    error = f"source fetch failed: {(fetched.stderr or fetched.stdout).strip()[:300]}"
228
            else:
229
                workspace.mkdir(parents=True)
230
            if conclusion == "success":
231
                record["tree_digest"] = tree_digest(workspace)
232
                env = dict(os.environ)
233
                env.update({str(k): str(v) for k, v in (spec.get("env") or {}).items()})
234
                env.update({str(k): str(v) for k, v in (spec.get("secrets") or {}).items()})
235
                env["OAK_CI_RUN_ID"] = str(run_id)
236
                env["OAK_CI_COMMIT"] = str(spec.get("commit") or "")
237
                for step in spec.get("steps") or []:
238
                    if conclusion != "success":
239
                        self._post(
240
                            f"/api/ci/runs/{run_id}/steps",
241
                            {"step_id": step["step_id"], "conclusion": "skipped", "exit_code": None},
242
                        )
243
                        continue
244
                    proc = subprocess.run(
245
                        ["bash", "-c", step["run"]],
246
                        cwd=workspace,
247
                        env=env,
248
                        capture_output=True,
249
                        text=True,
250
                        timeout=float(spec.get("max_seconds") or 600),
251
                    )
252
                    step_conclusion = "success" if proc.returncode == 0 else "failure"
253
                    record["steps"].append(
254
                        {"name": step.get("name"), "exit_code": proc.returncode, "tail": (proc.stdout + proc.stderr)[-400:]}
255
                    )
256
                    self._post(
257
                        f"/api/ci/runs/{run_id}/steps",
258
                        {"step_id": step["step_id"], "conclusion": step_conclusion, "exit_code": proc.returncode},
259
                    )
260
                    if proc.returncode != 0:
261
                        conclusion = "failure"
262
                        error = f"step '{step.get('name')}' exited {proc.returncode}"
263
            if self.added_seconds > 0:
264
                time.sleep(self.added_seconds)
265
        except Exception as exc:  # noqa: BLE001 - conclude rather than wedge the run
266
            conclusion = "failure"
267
            error = f"ci_stub error: {exc}"
268
        record["conclusion"] = conclusion
269
        record["error"] = error
270
        record["callback_ok"] = self._post(
271
            f"/api/ci/runs/{run_id}/conclusion", {"conclusion": conclusion, "error": error}
272
        )
273
        record["finished_unix"] = time.time()
274
        with self._lock:
275
            self.ledger.parent.mkdir(parents=True, exist_ok=True)
276
            with self.ledger.open("a") as handle:
277
                handle.write(json.dumps(record, sort_keys=True) + "\n")
278
        shutil.rmtree(workspace, ignore_errors=True)
279
        return record
280
281
282
def read_ledger(path: Path) -> list[dict[str, Any]]:
283
    if not path.exists():
284
        return []
285
    return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
286
287
288
def main(argv: Optional[list[str]] = None) -> int:
289
    import argparse
290
291
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
292
    parser.add_argument("--api-url", required=True, help="local oakspace base URL, e.g. http://127.0.0.1:3811")
293
    parser.add_argument("--port", type=int, required=True)
294
    parser.add_argument("--secret-env", default="OAK_CI_SIGNING_SECRET")
295
    parser.add_argument("--ledger", type=Path, required=True)
296
    parser.add_argument("--added-seconds", type=float, default=0.0)
297
    args = parser.parse_args(argv)
298
    secret = os.environ.get(args.secret_env, "")
299
    if not secret:
300
        parser.error(f"${args.secret_env} is empty")
301
    stub = CiStub(api_url=args.api_url, secret=secret, ledger=args.ledger, added_seconds=args.added_seconds, port=args.port)
302
    stub.start()
303
    print(f"ci_stub listening on 127.0.0.1:{stub.port}", flush=True)
304
    try:
305
        while True:
306
            time.sleep(3600)
307
    except KeyboardInterrupt:
308
        stub.stop()
309
    return 0
310
311
312
if __name__ == "__main__":
313
    raise SystemExit(main())