mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(plane): router-addressed start signal; credentials only in the start request
Agent Substrate's golden-snapshot boot runs the same image with the same task env, so the runner no longer starts its command by itself: after the workspaces are ready it waits for POST /ousast/v1/start (run/task checked, once per boot, refused after a delivered run). The reconciler sends it through svc/atenet-router with ate-target-actor, which never addresses the golden actor (plane/router.py), and the bound Model's secretKey variable travels only in that body, into the child's env. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ac296ee3a4
commit
14ea48b1ea
@@ -1,26 +1,25 @@
|
||||
"""ax-backed reconciler of a ``Run``: submission, resume, completion by delivery (ai-service-plane Req 3, 4.4, 7).
|
||||
"""ax-backed reconciler of a ``Run``: submission, resume, start, completion by delivery (ai-service-plane Req 3, 4, 7).
|
||||
|
||||
Every task of a Run is executed by ax on this host's kind cluster; nothing here runs a task as a local subprocess
|
||||
and nothing here knows a prompt, a score or a model call. For each ready task the reconciler renders one YAML
|
||||
file (the Task with its env extended, its Workspaces, one generated Workspace carrying the consumed artifacts as
|
||||
``files`` entries, and the bound Model) and runs ``ax apply -f``. ax creates a Task ``Suspended``; the reconciler
|
||||
resumes it (``ax resume task``), retrying with backoff while ax answers DeadlineExceeded/Unavailable (the first
|
||||
resume of a new image waits for its golden snapshot) for up to ``OUSAST_RESUME_TIMEOUT`` s (default 900). ax has no
|
||||
Completed phase and never reports that a command exited; completion is the runner's artifact delivery, and
|
||||
``Failed``, the task vanishing, or ``OUSAST_TASK_TIMEOUT`` s (default 7200) without delivery fail the task with
|
||||
ax's condition message. ``ax delete task`` follows either way. The outcome is the delivered ``summary.json``.
|
||||
ax executes every task on this host's kind cluster; nothing here runs a task locally or knows a prompt, a score or
|
||||
a model call. Per ready task: one rendered YAML file (the Task with its env extended, its Workspaces, a generated
|
||||
Workspace with the consumed artifacts as ``files``, the bound Model), ``ax apply -f``, ``ax resume task`` (retried
|
||||
on DeadlineExceeded/Unavailable -- a new image's first resume waits for Agent Substrate's golden snapshot -- for
|
||||
up to ``OUSAST_RESUME_TIMEOUT`` s, 900), and once ax says ``Running`` one start request through Substrate's router
|
||||
(``router.py``, Req 4.5) carrying the bound Model's credential from this process's environment, never from a file
|
||||
(Req 4.6). Completion is the runner's artifact delivery (ax has no Completed phase); ``Failed``, the task
|
||||
vanishing, a refused start or ``OUSAST_TASK_TIMEOUT`` s (7200) without delivery fail it; ``ax delete task`` follows.
|
||||
|
||||
ax's API server rejects unknown fields, so rendered documents carry only ax's fields; ``openultrasast.io/*``
|
||||
annotations reach the task as env: ``openultrasast.io/model`` as ``OUSAST_MODEL`` plus ``OUSAST_MODEL_PARAMS`` (Req
|
||||
7.1), each Workspace's ``openultrasast.io/git-commits`` as ``OUSAST_GIT_PINS``. ``OUSAST_INPUT_<NAME>`` points into
|
||||
a generated Workspace at ``/workspace/.ousast-in/<task>``. The receiver takes the runner's tar (``X-Ousast-Run``,
|
||||
``X-Ousast-Task``) on ``OUSAST_ARTIFACT_PORT`` and advertises ``OUSAST_ARTIFACT_HOST`` (default ``172.17.0.1``, the
|
||||
docker bridge kind nodes reach). Run state: ``~/ousast-results/plane/<run>/`` (``OUSAST_RESULTS``), polls every
|
||||
``OUSAST_POLL_SECONDS`` (3).
|
||||
ax rejects unknown fields, so rendered documents carry only ax's fields; ``openultrasast.io/model`` reaches the
|
||||
task as ``OUSAST_MODEL`` plus ``OUSAST_MODEL_PARAMS`` (Req 7.1), each Workspace's ``openultrasast.io/git-commits``
|
||||
as ``OUSAST_GIT_PINS``; ``OUSAST_INPUT_<NAME>`` points into ``/workspace/.ousast-in/<task>``. The receiver takes
|
||||
the runner's tar (``X-Ousast-Run``, ``X-Ousast-Task``) on ``OUSAST_ARTIFACT_PORT``, advertised as
|
||||
``OUSAST_ARTIFACT_HOST`` (default ``172.17.0.1``, the docker bridge). State: ``~/ousast-results/plane/<run>/``
|
||||
(``OUSAST_RESULTS``), polled every ``OUSAST_POLL_SECONDS`` (3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -41,6 +40,7 @@ import yaml
|
||||
|
||||
from .doctor import doctor
|
||||
from .manifests import AX_API_VERSION, Manifests, Model, Run, RunTask, Task, Workspace, load_manifests
|
||||
from .router import Router, credentials, open_router
|
||||
|
||||
MODEL_ANNOTATION = "openultrasast.io/model"
|
||||
OUTPUT_ROOT = "/workspace/.ousast-out"
|
||||
@@ -297,27 +297,22 @@ def _acquire_lock(base: Path) -> Path:
|
||||
lock = base / "lock"
|
||||
pid = _read_json(lock)
|
||||
if isinstance(pid, int) and pid != os.getpid():
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
raise RuntimeError(f"run is held by pid {pid} ({lock})")
|
||||
lock.write_text(json.dumps(os.getpid()), encoding="utf-8")
|
||||
return lock
|
||||
|
||||
|
||||
def _await(ax: Ax, name: str, delivered: Callable[[], bool], poll: float) -> str | None:
|
||||
"""Resume ``name`` and wait for its delivery: None once delivered, else why the task failed.
|
||||
|
||||
ax sets ``Failed`` when a resume call fails, so ``Failed`` is final only once a resume went through; until then
|
||||
a DeadlineExceeded/Unavailable resume is retried with doubling backoff inside ``OUSAST_RESUME_TIMEOUT``.
|
||||
"""
|
||||
task_limit = float(os.environ.get("OUSAST_TASK_TIMEOUT") or 7200)
|
||||
def _await(ax: Ax, name: str, delivered: Callable[[], bool], start: Callable[[], str | None]) -> str | None:
|
||||
"""Resume ``name``, ``start`` it once Running (again after a re-resume), await delivery: None, else why it failed.
|
||||
``Failed`` is final only once a resume went through (ax sets it when a resume call fails); until then a
|
||||
DeadlineExceeded/Unavailable resume is retried with doubling backoff inside ``OUSAST_RESUME_TIMEOUT``."""
|
||||
task_limit, poll = float(os.environ.get("OUSAST_TASK_TIMEOUT") or 7200), float(os.environ.get("OUSAST_POLL_SECONDS") or 3)
|
||||
resume_limit = float(os.environ.get("OUSAST_RESUME_TIMEOUT") or 900)
|
||||
started = time.monotonic()
|
||||
resuming: float | None = started # when the current resume spell began; None while ax runs the task
|
||||
retry_at, backoff, last, unknown, phase = started, poll, "", 0, "Suspended"
|
||||
retry_at, backoff, last, unknown, phase, sent = started, poll, "", 0, "Suspended", False
|
||||
while not delivered():
|
||||
now = time.monotonic()
|
||||
if now - started > task_limit:
|
||||
@@ -344,22 +339,28 @@ def _await(ax: Ax, name: str, delivered: Callable[[], bool], poll: float) -> str
|
||||
if resuming is None and phase == "Failed":
|
||||
return f"ax phase Failed: {message}"
|
||||
if resuming is None and phase == "Suspended":
|
||||
resuming, retry_at, backoff = time.monotonic(), 0.0, poll
|
||||
resuming, retry_at, backoff, sent = time.monotonic(), 0.0, poll, False
|
||||
if resuming is None and phase == "Running" and not sent:
|
||||
sent, refused = True, start() # through Substrate's router: the golden actor is never addressed (Req 4.5)
|
||||
if refused and not delivered():
|
||||
return refused
|
||||
return None
|
||||
|
||||
|
||||
def _execute(ax: Ax, run: Run, entry: RunTask, manifests: Manifests, base: Path, receiver: Receiver, poll: float) -> tuple[str, str]:
|
||||
ax_name = _ax_name(run.metadata.name, entry.name)
|
||||
def _execute(ax: Ax, run: Run, entry: RunTask, manifests: Manifests, base: Path, receiver: Receiver, router: Router) -> tuple[str, str]:
|
||||
ax_name, task = _ax_name(run.metadata.name, entry.name), manifests.tasks[entry.task]
|
||||
manifest = base / entry.name / "task.yaml"
|
||||
manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
docs = render_task(run, entry, manifests, base, receiver.url())
|
||||
manifest.write_text(yaml.safe_dump_all(docs, sort_keys=False), encoding="utf-8")
|
||||
try:
|
||||
try: # Req 4.6: the bound Model's credential is read here and only ever sent in the start request
|
||||
secrets = credentials(manifests.models.get(task.metadata.annotations.get(MODEL_ANNOTATION) or ""))
|
||||
start = router.starter(task.metadata.atespace or "default", ax_name, run.metadata.name, secrets)
|
||||
ax.apply(manifest)
|
||||
except RuntimeError as exc:
|
||||
except RuntimeError as exc: # StartError included: a Model-bound task whose secret is unset
|
||||
return "failed", str(exc)
|
||||
try:
|
||||
failure = _await(ax, ax_name, lambda: entry.name in receiver.delivered, poll)
|
||||
failure = _await(ax, ax_name, lambda: entry.name in receiver.delivered, start)
|
||||
finally:
|
||||
ax.delete(ax_name)
|
||||
return ("failed", failure) if failure else outcome_of(_read_json(base / entry.name / "summary.json"))
|
||||
@@ -371,9 +372,7 @@ def run(run_manifest: Path, *, workers: int = 1, ax: str = "ax", results_root: P
|
||||
base = run_dir(run_spec.metadata.name, results_root)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
lock = _acquire_lock(base)
|
||||
poll = float(os.environ.get("OUSAST_POLL_SECONDS") or 3)
|
||||
fresh = {"run": run_spec.metadata.name, "started": _now(), "tasks": {}}
|
||||
state = _State(base / "state.json", _read_json(base / "state.json") or fresh)
|
||||
state = _State(base / "state.json", _read_json(base / "state.json") or {"run": run_spec.metadata.name, "started": _now(), "tasks": {}})
|
||||
receiver = Receiver(run_spec.metadata.name, base, int(os.environ.get("OUSAST_ARTIFACT_PORT") or 0))
|
||||
threading.Thread(target=receiver.serve_forever, daemon=True).start()
|
||||
cli, finished = Ax(ax), queue.Queue[tuple[str, str, str]]()
|
||||
@@ -387,31 +386,32 @@ def run(run_manifest: Path, *, workers: int = 1, ax: str = "ax", results_root: P
|
||||
if state.status(entry.name) in ("running", "suspended"):
|
||||
cli.delete(_ax_name(run_spec.metadata.name, entry.name)) # an interrupted attempt; ax must not keep it
|
||||
state.set(entry.name, status="running", started=_now(), finished=None)
|
||||
finished.put((entry.name, *_execute(cli, run_spec, entry, manifests, base, receiver, poll)))
|
||||
finished.put((entry.name, *_execute(cli, run_spec, entry, manifests, base, receiver, router)))
|
||||
|
||||
try:
|
||||
while pending or running:
|
||||
blocked = {t.serialize for t in running.values() if t.serialize}
|
||||
for entry in list(pending):
|
||||
if halted or len(running) >= max(workers, 1) or (entry.serialize and entry.serialize in blocked):
|
||||
continue
|
||||
if not all(state.status(p) == "done" for p in entry.producers):
|
||||
continue
|
||||
pending.remove(entry)
|
||||
running[entry.name] = entry
|
||||
blocked.add(entry.serialize or "")
|
||||
with receiver.lock:
|
||||
receiver.running.add(entry.name)
|
||||
threading.Thread(target=worker, args=(entry,), daemon=True).start()
|
||||
if not running:
|
||||
break # nothing can start: a producer stopped short
|
||||
name, status, reason = finished.get()
|
||||
del running[name]
|
||||
summary = _read_json(base / name / "summary.json") or {}
|
||||
state.set(name, status=status, finished=_now(), usd=summary.get("usd"), model=summary.get("model"), reason=reason or None)
|
||||
halted = halted or status == "failed"
|
||||
if status == "unfinished":
|
||||
pending = [t for t in pending if name not in t.producers]
|
||||
with open_router() as router: # one port-forward to Substrate's router for the whole run
|
||||
while pending or running:
|
||||
blocked = {t.serialize for t in running.values() if t.serialize}
|
||||
for entry in list(pending):
|
||||
if halted or len(running) >= max(workers, 1) or (entry.serialize and entry.serialize in blocked):
|
||||
continue
|
||||
if not all(state.status(p) == "done" for p in entry.producers):
|
||||
continue
|
||||
pending.remove(entry)
|
||||
running[entry.name] = entry
|
||||
blocked.add(entry.serialize or "")
|
||||
with receiver.lock:
|
||||
receiver.running.add(entry.name)
|
||||
threading.Thread(target=worker, args=(entry,), daemon=True).start()
|
||||
if not running:
|
||||
break # nothing can start: a producer stopped short
|
||||
name, status, reason = finished.get()
|
||||
del running[name]
|
||||
summary = _read_json(base / name / "summary.json") or {}
|
||||
state.set(name, status=status, finished=_now(), usd=summary.get("usd"), model=summary.get("model"), reason=reason or None)
|
||||
halted = halted or status == "failed"
|
||||
if status == "unfinished":
|
||||
pending = [t for t in pending if name not in t.producers]
|
||||
finally:
|
||||
receiver.shutdown()
|
||||
lock.unlink(missing_ok=True)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""The start signal through Agent Substrate's router (ai-service-plane Requirements 4.5, 4.6).
|
||||
|
||||
Agent Substrate boots every new ActorTemplate once as a temporary "golden" actor to capture a snapshot, with the
|
||||
same image and the same task environment ax baked into the template, so a runner cannot tell that boot from the
|
||||
real one. The runner therefore never starts its command by itself: it prepares the workspaces, reports ready and
|
||||
waits for ``POST /ousast/v1/start``. The reconciler sends that request through Substrate's router
|
||||
(``svc/atenet-router`` in ``ate-system``), which routes on ``ate-target-actor: <atespace>/<task>`` to the task's
|
||||
actor only -- never to the golden one -- and resumes a suspended actor before proxying.
|
||||
|
||||
:func:`open_router` port-forwards the router to a random local port for the duration of a Run (or uses
|
||||
``OUSAST_ROUTER_URL`` when set, as the tests do); :meth:`Router.start_task` posts the start body, retrying 502/503/504
|
||||
and connection errors with backoff for up to ``OUSAST_START_TIMEOUT`` s (default 300). Provider credentials travel
|
||||
only in that body: :func:`credentials` reads the variable a bound Model's ``secretKey`` names from this process's
|
||||
environment (``ousast`` loads ``.env`` at startup); nothing here writes or logs a value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Iterable, Iterator, Mapping
|
||||
|
||||
from .manifests import Model
|
||||
|
||||
__all__ = ["ALREADY_STARTED", "START_PATH", "Router", "StartError", "credentials", "open_router", "redact"]
|
||||
|
||||
START_PATH = "/ousast/v1/start"
|
||||
ALREADY_STARTED = "already started on this boot" # the runner's 409 when its command is running
|
||||
NAMESPACE = "ate-system"
|
||||
SERVICE = "svc/atenet-router"
|
||||
TARGET_HEADER = "ate-target-actor"
|
||||
RETRY_STATUS = frozenset({502, 503, 504})
|
||||
_FORWARDING = re.compile(r"Forwarding from 127\.0\.0\.1:(\d+)")
|
||||
|
||||
|
||||
class StartError(RuntimeError):
|
||||
"""The start request was refused, or no actor answered it in time; the message carries the response text."""
|
||||
|
||||
|
||||
def redact(text: str, secrets: Iterable[str]) -> str:
|
||||
"""``text`` with every non-empty secret value replaced by ``[redacted]``."""
|
||||
for secret in secrets:
|
||||
if secret:
|
||||
text = text.replace(secret, "[redacted]")
|
||||
return text
|
||||
|
||||
|
||||
def credentials(model: Model | None, environ: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
"""The provider credentials of a task bound to ``model``: ``{secretKey.key: value}`` from the environment.
|
||||
|
||||
A task without a Model, or a Model without ``secretKey``, gets none. A named variable that is unset or empty
|
||||
raises :class:`StartError` naming the variable (never a value).
|
||||
"""
|
||||
if model is None or model.secret_key is None:
|
||||
return {}
|
||||
env = os.environ if environ is None else environ
|
||||
name = model.secret_key.key
|
||||
value = env.get(name, "")
|
||||
if not value:
|
||||
where = f"secretKey {model.secret_key.name}; it is not set in this environment or .env"
|
||||
raise StartError(f"Model/{model.metadata.name} needs {name} ({where})")
|
||||
return {name: value}
|
||||
|
||||
|
||||
class Router:
|
||||
"""Posts start requests to one task actor at a time through the router at ``url``."""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self.url = url.rstrip("/")
|
||||
|
||||
def starter(self, atespace: str, task: str, run: str, credentials: Mapping[str, str]) -> Callable[[], str | None]:
|
||||
""":meth:`start_task` bound to one task, returning the refusal text instead of raising (None once accepted).
|
||||
|
||||
The reconciler calls it again after a re-resume, since a resumed actor may have rebooted and wait for a
|
||||
start; on those later calls a 409 saying the command is already started on this boot means the process
|
||||
tree survived and the command is still running, which is not a failure.
|
||||
"""
|
||||
calls = 0
|
||||
|
||||
def start() -> str | None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
try:
|
||||
self.start_task(atespace, task, run, credentials)
|
||||
except StartError as exc:
|
||||
return None if calls > 1 and ALREADY_STARTED in str(exc) else str(exc)
|
||||
return None
|
||||
|
||||
return start
|
||||
|
||||
def start_task(self, atespace: str, task: str, run: str, credentials: Mapping[str, str]) -> None:
|
||||
"""Start ``<atespace>/<task>``'s command once; :class:`StartError` on 409, another 4xx, or the timeout."""
|
||||
body = json.dumps({"run": run, "task": task, "credentials": dict(credentials)}).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json", TARGET_HEADER: f"{atespace}/{task}"}
|
||||
limit = float(os.environ.get("OUSAST_START_TIMEOUT") or 300)
|
||||
deadline, backoff, last = time.monotonic() + limit, 0.5, "no attempt made"
|
||||
secrets = list(credentials.values())
|
||||
while True:
|
||||
request = urllib.request.Request(self.url + START_PATH, data=body, method="POST", headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - the router's local URL
|
||||
if 200 <= int(response.status) < 300:
|
||||
return
|
||||
last = f"HTTP {response.status}"
|
||||
except urllib.error.HTTPError as exc:
|
||||
text = redact(exc.read().decode("utf-8", errors="replace").strip()[:400], secrets)
|
||||
if exc.code not in RETRY_STATUS:
|
||||
raise StartError(f"start of {atespace}/{task} refused (HTTP {exc.code}): {text}") from None
|
||||
last = f"HTTP {exc.code}: {text}"
|
||||
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
|
||||
last = redact(f"{type(exc).__name__}: {exc}", secrets)
|
||||
if time.monotonic() + backoff > deadline:
|
||||
raise StartError(f"start of {atespace}/{task} not accepted within {limit:g}s (OUSAST_START_TIMEOUT): {last}")
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 10.0)
|
||||
|
||||
|
||||
def _drain(stream: Iterable[str], lines: queue.Queue[str | None]) -> None:
|
||||
for line in stream: # kubectl logs a line per proxied connection; an unread pipe would block it
|
||||
lines.put(line)
|
||||
lines.put(None)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def open_router(context: str | None = None, *, kubectl: str = "kubectl", ready_timeout: float = 30.0) -> Iterator[Router]:
|
||||
"""A :class:`Router` for the duration of the block.
|
||||
|
||||
``OUSAST_ROUTER_URL`` set: that URL, nothing started. Otherwise ``kubectl port-forward svc/atenet-router :80``
|
||||
in ``ate-system`` of ``context`` (default ``kind-$KIND_CLUSTER_NAME``, ``kind-ousast``), the local port read
|
||||
from kubectl's ``Forwarding from 127.0.0.1:NNNN`` line; on exit that child process is terminated.
|
||||
"""
|
||||
override = os.environ.get("OUSAST_ROUTER_URL")
|
||||
if override:
|
||||
yield Router(override)
|
||||
return
|
||||
context = context or "kind-" + (os.environ.get("KIND_CLUSTER_NAME") or "ousast")
|
||||
argv = [kubectl, "--context", context, "-n", NAMESPACE, "port-forward", SERVICE, ":80"]
|
||||
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
lines: queue.Queue[str | None] = queue.Queue()
|
||||
threading.Thread(target=_drain, args=(proc.stdout, lines), name="router-port-forward", daemon=True).start()
|
||||
deadline, seen, port = time.monotonic() + ready_timeout, list[str](), None
|
||||
while port is None:
|
||||
try:
|
||||
line = lines.get(timeout=max(deadline - time.monotonic(), 0.01))
|
||||
except queue.Empty:
|
||||
raise StartError(f"kubectl port-forward printed no port within {ready_timeout:g}s: {''.join(seen)[-400:]}") from None
|
||||
if line is None:
|
||||
raise StartError(f"kubectl port-forward exited ({proc.wait()}): {''.join(seen)[-400:]}")
|
||||
seen.append(line)
|
||||
match = _FORWARDING.search(line)
|
||||
port = int(match.group(1)) if match else None
|
||||
yield Router(f"http://127.0.0.1:{port}")
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
@@ -7,6 +7,16 @@ metadata paths on port 80, ``/readyz`` answering 503 until every workspace is ma
|
||||
Workspaces are prepared on the first boot only: ``/workspace`` survives suspend and resume, the process tree does
|
||||
not (ax docs/runner.md).
|
||||
|
||||
The command never starts by itself (Requirements 4.5, 4.6). Agent Substrate boots every new ActorTemplate once as a
|
||||
"golden" actor to snapshot it, with this image and the same ``AX_TASK_YAML``/``AX_WORKSPACES_YAML``/env, and inside
|
||||
the sandbox nothing tells that boot from the real one. So after the workspaces are ready the runner waits for
|
||||
``POST /ousast/v1/start`` with ``{"run", "task", "credentials"}``, which the reconciler sends through Substrate's
|
||||
router to the task's actor only (``plane/router.py``). ``run`` and ``task`` must match ``OUSAST_RUN`` and the Task's
|
||||
name (409 otherwise); one request per boot is accepted (202), later ones are 409, and a boot whose completion marker
|
||||
records a delivered run refuses every request (409 with the reason). Before the workspaces are ready the answer is
|
||||
503, which the reconciler retries. The credentials live in this process's memory and go into the command's
|
||||
environment only; they are never logged or written, and their values are redacted from the command's echoed stderr.
|
||||
|
||||
The command runs as a child process, ``python -m openultrasast.plane.tasks.<spec.command[0]> <spec.command[1:]>``,
|
||||
in its own process group, with the first workspace as its working directory and ``AX_METADATA_URL`` pointing at
|
||||
this server. ax has no artifact channel and never reports that a command finished, so after the child exits the
|
||||
@@ -25,6 +35,8 @@ Environment the runner reads, all optional except the two ax variables:
|
||||
AX_RUNNER_HTTP "0" disables the health server (unit tests only)
|
||||
AX_RUNNER_PORT port of the health server, default 80; "0" picks a free port
|
||||
AX_RUNNER_EXIT_AFTER_COMMAND "1" exits after delivery with 0 done, 2 failed, 3 unfinished (unit tests only)
|
||||
AX_RUNNER_AUTOSTART "1" runs the command without waiting for a start request (unit tests only;
|
||||
required with AX_RUNNER_HTTP=0, where no request could arrive)
|
||||
AX_RUNNER_TASK_PACKAGE package the command's module is looked up in (unit tests only)
|
||||
OUSAST_STATE_DIR completion markers, default ``/workspace/.ousast-state`` (the durable volume)
|
||||
OUSAST_TERM_GRACE seconds between SIGTERM and SIGKILL of the command's group, default 10
|
||||
@@ -64,17 +76,22 @@ from typing import Any
|
||||
import yaml
|
||||
|
||||
from openultrasast.plane.manifests import GitSource, ManifestError, Task, Workspace, parse_manifest
|
||||
from openultrasast.plane.router import ALREADY_STARTED
|
||||
from openultrasast.plane.router import redact as _redact
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_PORT",
|
||||
"DELIVERY_ATTEMPTS",
|
||||
"EXIT_BY_STATUS",
|
||||
"START_PATH",
|
||||
"STATE_DIR",
|
||||
"TASK_PACKAGE",
|
||||
"Child",
|
||||
"DeliveryError",
|
||||
"HealthServer",
|
||||
"RunnerError",
|
||||
"Start",
|
||||
"StartGate",
|
||||
"build_tar",
|
||||
"deliver",
|
||||
"clone_destination",
|
||||
@@ -173,9 +190,105 @@ def load_workspaces(text: str) -> dict[str, Workspace]:
|
||||
# --- health server -----------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
START_PATH = "/ousast/v1/start"
|
||||
_CREDENTIAL_NAME = re.compile(r"[A-Z][A-Z0-9_]*")
|
||||
_RESERVED_PREFIXES = ("AX_", "OUSAST_") # the runner's own contract; a credential must not redirect it
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Start:
|
||||
"""An accepted start request: the credentials go to the command's environment and nowhere else."""
|
||||
|
||||
credentials: Mapping[str, str]
|
||||
|
||||
def __repr__(self) -> str: # never print a value by accident
|
||||
return f"Start(credentials={sorted(self.credentials)})"
|
||||
|
||||
|
||||
class StartGate:
|
||||
"""The one start request per boot (Requirements 4.5, 4.6).
|
||||
|
||||
Before :meth:`arm` (workspaces not ready) a request is answered 503, which the reconciler retries; afterwards
|
||||
one request whose ``run`` and ``task`` match the Task is accepted (202), every other one is 409, as is any
|
||||
request once :meth:`refuse` recorded why this boot runs nothing (a delivered run). The request body is never
|
||||
logged, and no answer echoes it.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._accepted = threading.Event()
|
||||
self._expected: tuple[str, str] | None = None
|
||||
self._refusal: str | None = None
|
||||
self._start: Start | None = None
|
||||
|
||||
def arm(self, run: str, task: str) -> None:
|
||||
with self._lock:
|
||||
self._expected = (run, task)
|
||||
|
||||
def refuse(self, reason: str) -> None:
|
||||
with self._lock:
|
||||
self._refusal = reason
|
||||
|
||||
def autostart(self) -> Start:
|
||||
"""The test escape ``AX_RUNNER_AUTOSTART=1``: start without a request; a later request is refused."""
|
||||
with self._lock:
|
||||
self._start = Start({})
|
||||
self._accepted.set()
|
||||
return self._start
|
||||
|
||||
def offer(self, body: bytes) -> tuple[int, str]:
|
||||
with self._lock:
|
||||
if self._refusal is not None:
|
||||
return 409, self._refusal
|
||||
if self._accepted.is_set():
|
||||
return 409, f"this task was {ALREADY_STARTED}"
|
||||
if self._expected is None:
|
||||
return 503, "workspaces not materialised"
|
||||
try:
|
||||
request = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return 400, "the start body is not JSON"
|
||||
if not isinstance(request, dict):
|
||||
return 400, "the start body must be a JSON object"
|
||||
creds = request.get("credentials") or {}
|
||||
if not isinstance(creds, dict) or not all(
|
||||
isinstance(k, str) and _CREDENTIAL_NAME.fullmatch(k) and not k.startswith(_RESERVED_PREFIXES) and isinstance(v, str)
|
||||
for k, v in creds.items()
|
||||
):
|
||||
return 400, "credentials must map ^[A-Z][A-Z0-9_]*$ names (not AX_/OUSAST_) to strings"
|
||||
run, task = self._expected
|
||||
if request.get("run") != run or request.get("task") != task:
|
||||
return 409, f"this actor runs task {task!r} of run {run!r}"
|
||||
self._start = Start(dict(creds))
|
||||
self._accepted.set()
|
||||
return 202, "started"
|
||||
|
||||
def wait(self, stop: threading.Event) -> Start | None:
|
||||
"""The accepted start, or None once ``stop`` is set first."""
|
||||
while not self._accepted.wait(0.1):
|
||||
if stop.is_set():
|
||||
return None
|
||||
return self._start
|
||||
|
||||
|
||||
class _HealthHandler(BaseHTTPRequestHandler):
|
||||
server: HealthServer
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - http.server's naming
|
||||
if self.path.split("?", 1)[0] != START_PATH:
|
||||
self._answer(404, "not found")
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = -1
|
||||
if not 0 <= length <= 1 << 20:
|
||||
self._answer(400, "bad Content-Length")
|
||||
return
|
||||
status, text = self.server.gate.offer(self.rfile.read(length))
|
||||
log.info("start request: %d %s", status, text)
|
||||
self._answer(status, text)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - http.server's naming
|
||||
path = self.path.split("?", 1)[0] # ax's controller probes /readyz?check=workspace
|
||||
if path == "/healthz":
|
||||
@@ -202,14 +315,15 @@ class _HealthHandler(BaseHTTPRequestHandler):
|
||||
|
||||
class HealthServer(ThreadingHTTPServer):
|
||||
"""``/healthz`` always 200; ``/readyz`` 503 until ``ready`` is set; the Task and Workspaces YAML under
|
||||
``/metadata/v1alpha1/ax/``. Serves in a daemon thread."""
|
||||
``/metadata/v1alpha1/ax/``; ``POST /ousast/v1/start`` goes to ``gate``. Serves in a daemon thread."""
|
||||
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, port: int, ready: threading.Event, metadata: Mapping[str, str] | None = None) -> None:
|
||||
def __init__(self, port: int, ready: threading.Event, metadata: Mapping[str, str] | None = None, gate: StartGate | None = None) -> None:
|
||||
super().__init__(("0.0.0.0", port), _HealthHandler)
|
||||
self.ready = ready
|
||||
self.metadata = dict(metadata or {})
|
||||
self.gate = gate or StartGate()
|
||||
self._thread = threading.Thread(target=self.serve_forever, name="ax-health", daemon=True)
|
||||
|
||||
@property
|
||||
@@ -225,9 +339,11 @@ class HealthServer(ThreadingHTTPServer):
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
|
||||
def start_health_server(port: int, ready: threading.Event, metadata: Mapping[str, str] | None = None) -> HealthServer:
|
||||
"""Serve ``/healthz``, ``/readyz`` and ax's metadata paths (``metadata`` keyed ``task``/``workspaces``)."""
|
||||
server = HealthServer(port, ready, metadata)
|
||||
def start_health_server(
|
||||
port: int, ready: threading.Event, metadata: Mapping[str, str] | None = None, gate: StartGate | None = None
|
||||
) -> HealthServer:
|
||||
"""Serve ``/healthz``, ``/readyz``, ax's metadata paths (``metadata`` keyed ``task``/``workspaces``) and the start."""
|
||||
server = HealthServer(port, ready, metadata, gate)
|
||||
server.start()
|
||||
return server
|
||||
|
||||
@@ -426,7 +542,8 @@ def task_module(command: Sequence[str], package: str = TASK_PACKAGE) -> str:
|
||||
class Child:
|
||||
"""``spec.command`` as a child in its own process group; its stderr is echoed and the tail kept."""
|
||||
|
||||
def __init__(self, argv: Sequence[str], cwd: str | None, env: Mapping[str, str]) -> None:
|
||||
def __init__(self, argv: Sequence[str], cwd: str | None, env: Mapping[str, str], secrets: Sequence[str] = ()) -> None:
|
||||
self.secrets = [s for s in secrets if s]
|
||||
self.proc = subprocess.Popen(list(argv), cwd=cwd, env=dict(env), stderr=subprocess.PIPE, start_new_session=True)
|
||||
self.tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
|
||||
self._reader = threading.Thread(target=self._echo, name="task-stderr", daemon=True)
|
||||
@@ -435,7 +552,7 @@ class Child:
|
||||
def _echo(self) -> None:
|
||||
assert self.proc.stderr is not None
|
||||
for raw in self.proc.stderr:
|
||||
line = raw.decode("utf-8", errors="replace")
|
||||
line = _redact(raw.decode("utf-8", errors="replace"), self.secrets)
|
||||
sys.stderr.write(line)
|
||||
sys.stderr.flush()
|
||||
self.tail.append(line.rstrip("\n"))
|
||||
@@ -484,15 +601,18 @@ def summary_after(code: int, output_dir: Path, name: str, stderr_tail: Sequence[
|
||||
|
||||
|
||||
def run_command(
|
||||
task: Task, output_dir: Path, env: Mapping[str, str], stop: threading.Event, grace: float
|
||||
task: Task, output_dir: Path, env: Mapping[str, str], stop: threading.Event, grace: float, credentials: Mapping[str, str] | None = None
|
||||
) -> tuple[int | None, dict[str, Any]]:
|
||||
"""Run ``python -m <package>.<command[0]> <command[1:]>`` in the first workspace; (exit code, summary).
|
||||
|
||||
(None, {}) when ``stop`` interrupted it: the command did not finish and a resume runs it again.
|
||||
``credentials`` (from the start request) are added to the child's environment only, and their values are
|
||||
redacted from its echoed stderr and from the stderr tail a crash summary keeps. (None, {}) when ``stop``
|
||||
interrupted it: the command did not finish and a resume runs it again.
|
||||
"""
|
||||
credentials = dict(credentials or {})
|
||||
module = task_module(task.command, env.get("AX_RUNNER_TASK_PACKAGE") or TASK_PACKAGE)
|
||||
cwd = task.workspaces[0].path if task.workspaces else None
|
||||
child = Child([sys.executable, "-m", module, *task.command[1:]], cwd, env)
|
||||
child = Child([sys.executable, "-m", module, *task.command[1:]], cwd, {**env, **credentials}, list(credentials.values()))
|
||||
code = child.wait(stop, grace)
|
||||
if code is None:
|
||||
return None, {}
|
||||
@@ -611,8 +731,9 @@ def _finish(session: _Session, env: Mapping[str, str], exit_code: int | None, su
|
||||
return marker
|
||||
|
||||
|
||||
def _run_once(env: MutableMapping[str, str], ready: threading.Event, stop: threading.Event) -> int:
|
||||
"""Materialise, run the command once, deliver; on a resumed volume, only what the marker says is left."""
|
||||
def _run_once(env: MutableMapping[str, str], ready: threading.Event, stop: threading.Event, gate: StartGate) -> int:
|
||||
"""Materialise, wait for the start request, run the command once, deliver; on a resumed volume, only what the
|
||||
marker says is left. A boot whose marker records a delivered run refuses every start request."""
|
||||
session: _Session | None = None
|
||||
try:
|
||||
session = _prepare(env)
|
||||
@@ -622,11 +743,21 @@ def _run_once(env: MutableMapping[str, str], ready: threading.Event, stop: threa
|
||||
cache_dir = env.get("OUSAST_CASE_CACHE")
|
||||
materialise(session.task, session.workspaces, Path(cache_dir) if cache_dir else None, load_pins(env.get("OUSAST_GIT_PINS", "")))
|
||||
session.prepared.touch()
|
||||
ready.set()
|
||||
done = _read_marker(session.marker)
|
||||
if done is not None and done.get("delivered"):
|
||||
gate.refuse(f"task {session.task.metadata.name} already ran and was delivered ({done.get('status')})")
|
||||
ready.set()
|
||||
log.info("task %s already ran and was delivered (%s); not running it again", session.task.metadata.name, done.get("status"))
|
||||
return _exit_of(done)
|
||||
gate.arm(env.get("OUSAST_RUN", ""), session.task.metadata.name)
|
||||
ready.set()
|
||||
if env.get("AX_RUNNER_AUTOSTART") == "1":
|
||||
start: Start | None = gate.autostart()
|
||||
else:
|
||||
log.info("workspaces ready; waiting for the start request on %s", START_PATH)
|
||||
start = gate.wait(stop)
|
||||
if start is None:
|
||||
return 0
|
||||
if done is not None:
|
||||
log.info("task %s already ran; retrying the delivery only", session.task.metadata.name)
|
||||
stored = done.get("summary")
|
||||
@@ -636,17 +767,26 @@ def _run_once(env: MutableMapping[str, str], ready: threading.Event, stop: threa
|
||||
if stop.is_set():
|
||||
return 0
|
||||
log.info("workspaces ready; running task %s: %s", session.task.metadata.name, list(session.task.command))
|
||||
code, summary = run_command(session.task, session.output_dir, env, stop, float(env.get("OUSAST_TERM_GRACE") or TERM_GRACE))
|
||||
grace = float(env.get("OUSAST_TERM_GRACE") or TERM_GRACE)
|
||||
code, summary = run_command(session.task, session.output_dir, env, stop, grace, start.credentials)
|
||||
if code is None:
|
||||
log.info("task %s was stopped before its command finished; a resume runs it again", session.task.metadata.name)
|
||||
return 0
|
||||
return _exit_of(_finish(session, env, code, summary))
|
||||
except RunnerError as exc:
|
||||
# The failure is reported, never acted on unasked: a start request is answered with it (409 without a
|
||||
# session, else 202 and the failed summary is delivered), so the golden boot delivers nothing either.
|
||||
log.error("runner failed: %s", exc)
|
||||
if session is None:
|
||||
gate.refuse(f"runner failed: {exc}")
|
||||
ready.set()
|
||||
return EXIT_BY_STATUS["failed"]
|
||||
summary = _failed(f"runner failed: {exc}", read_summary(session.output_dir))
|
||||
write_summary(session.output_dir, summary)
|
||||
gate.arm(env.get("OUSAST_RUN", ""), session.task.metadata.name)
|
||||
ready.set()
|
||||
if env.get("AX_RUNNER_AUTOSTART") != "1" and gate.wait(stop) is None:
|
||||
return 0
|
||||
return _exit_of(_finish(session, env, None, summary))
|
||||
|
||||
|
||||
@@ -667,17 +807,20 @@ def main(argv: Sequence[str] | None = None, environ: MutableMapping[str, str] |
|
||||
env = os.environ if environ is None else environ
|
||||
if argv:
|
||||
log.warning("ax-task-runner ignores command-line arguments %r; the Task manifest is the only input", list(argv))
|
||||
ready, stop = threading.Event(), threading.Event()
|
||||
ready, stop, gate = threading.Event(), threading.Event(), StartGate()
|
||||
previous = _on_sigterm(stop)
|
||||
server: HealthServer | None = None
|
||||
if env.get("AX_RUNNER_HTTP", "1") != "0":
|
||||
metadata = {"task": env.get("AX_TASK_YAML", ""), "workspaces": env.get("AX_WORKSPACES_YAML", "")}
|
||||
server = start_health_server(int(env.get("AX_RUNNER_PORT", str(DEFAULT_PORT))), ready, metadata)
|
||||
server = start_health_server(int(env.get("AX_RUNNER_PORT", str(DEFAULT_PORT))), ready, metadata, gate)
|
||||
env["AX_RUNNER_BOUND_PORT"] = str(server.port)
|
||||
env["AX_METADATA_URL"] = f"http://127.0.0.1:{server.port}"
|
||||
log.info("health and metadata server on port %d", server.port)
|
||||
try:
|
||||
code = _run_once(env, ready, stop)
|
||||
if server is None and env.get("AX_RUNNER_AUTOSTART") != "1":
|
||||
log.error("AX_RUNNER_HTTP=0 without AX_RUNNER_AUTOSTART=1: no start request could ever arrive")
|
||||
return EXIT_BY_STATUS["failed"]
|
||||
code = _run_once(env, ready, stop, gate)
|
||||
if stop.is_set():
|
||||
return 0
|
||||
if env.get("AX_RUNNER_EXIT_AFTER_COMMAND") == "1":
|
||||
|
||||
Reference in New Issue
Block a user