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:
Marius Ciepluch
2026-09-29 19:41:35 +02:00
co-authored by Claude Opus 5.5
parent ac296ee3a4
commit 14ea48b1ea
10 changed files with 919 additions and 95 deletions
+61 -61
View File
@@ -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)
+170
View File
@@ -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()
+160 -17
View File
@@ -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":