plane(2.2): runner -- delivery {put, inputs} in the start body, PUT to the store

StartGate.offer validates an optional `delivery` beside the credentials: https
URLs to a hostname (never an address, no userinfo; AX_RUNNER_INSECURE_DELIVERY=1
admits http in unit tests only), names ^[A-Z][A-Z0-9_]*$, a 400 that names the
field and echoes no URL. The runner PUTs its output tar to delivery.put and
GETs each declared input from delivery.inputs[NAME] (deliver() gains a method;
log lines and DeliveryError carry the URL without its query, so no presigned
signature reaches a log, a marker or a summary). OUSAST_ARTIFACT_URL/DIAL stay
honoured until task 2.6 so both paths run in the same build; the completion
marker and the retry-only boot are unchanged, and a re-start brings a fresh
URL for the retry. Router.start_task carries `delivery` in the body and
redacts its URLs like credential values. Tests: the gate's acceptance and
refusals, an end-to-end PUT/GET against the S3-shaped fake with no URL in any
log or file, the failed-PUT-then-retry-only boot, deliver() with PUT.

Gates: pytest 2024 passed, 42 skipped, exit 0; ruff check 0; ruff format 0;
mypy 0.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-10-02 21:12:19 +02:00
co-authored by Claude Fable 5.1
parent 35d8032c0a
commit 9506a18b56
4 changed files with 249 additions and 39 deletions
+1 -1
View File
@@ -78,7 +78,7 @@ go-ahead** with a ceiling; steps that need a hand on a cluster that does not exi
- `tests/test_plane_delivery.py`: against a local S3-shaped fake (PUT/GET/HEAD by key); URLs never appear in - `tests/test_plane_delivery.py`: against a local S3-shaped fake (PUT/GET/HEAD by key); URLs never appear in
logs; expiry; member rejection; declared-only re-put; state seeding. logs; expiry; member rejection; declared-only re-put; state seeding.
- _Requirements: 3.1, 2.1_ - _Requirements: 3.1, 2.1_
- [ ] 2.2 Runner: `delivery` in the start body - [x] 2.2 Runner: `delivery` in the start body
- `src/openultrasast/plane/runner.py`: `StartGate.offer` validates an optional `delivery {put, inputs}` (https, - `src/openultrasast/plane/runner.py`: `StartGate.offer` validates an optional `delivery {put, inputs}` (https,
hostname, no echo); `_deliver_output` PUTs the tar to `delivery.put` (same retry and `DeliveryError` hostname, no echo); `_deliver_output` PUTs the tar to `delivery.put` (same retry and `DeliveryError`
semantics); `fetch_inputs` GETs from `delivery.inputs[name]`; `OUSAST_ARTIFACT_URL` and `OUSAST_ARTIFACT_DIAL` semantics); `fetch_inputs` GETs from `delivery.inputs[name]`; `OUSAST_ARTIFACT_URL` and `OUSAST_ARTIFACT_DIAL`
+18 -5
View File
@@ -28,6 +28,7 @@ import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from collections.abc import Callable, Iterable, Iterator, Mapping from collections.abc import Callable, Iterable, Iterator, Mapping
from typing import Any
from .manifests import Model from .manifests import Model
@@ -77,7 +78,9 @@ class Router:
def __init__(self, url: str) -> None: def __init__(self, url: str) -> None:
self.url = url.rstrip("/") self.url = url.rstrip("/")
def starter(self, atespace: str, task: str, run: str, credentials: Mapping[str, str]) -> Callable[[], str | None]: def starter(
self, atespace: str, task: str, run: str, credentials: Mapping[str, str], delivery: Mapping[str, Any] | None = None
) -> Callable[[], str | None]:
""":meth:`start_task` bound to one task, returning the refusal text instead of raising (None once accepted). """: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 The reconciler calls it again after a re-resume, since a resumed actor may have rebooted and wait for a
@@ -90,20 +93,30 @@ class Router:
nonlocal calls nonlocal calls
calls += 1 calls += 1
try: try:
self.start_task(atespace, task, run, credentials) self.start_task(atespace, task, run, credentials, delivery)
except StartError as exc: except StartError as exc:
return None if calls > 1 and ALREADY_STARTED in str(exc) else str(exc) return None if calls > 1 and ALREADY_STARTED in str(exc) else str(exc)
return None return None
return start return start
def start_task(self, atespace: str, task: str, run: str, credentials: Mapping[str, str]) -> None: def start_task(
"""Start ``<atespace>/<task>``'s command once; :class:`StartError` on 409, another 4xx, or the timeout.""" self, atespace: str, task: str, run: str, credentials: Mapping[str, str], delivery: Mapping[str, Any] | None = None
body = json.dumps({"run": run, "task": task, "credentials": dict(credentials)}).encode("utf-8") ) -> None:
"""Start ``<atespace>/<task>``'s command once; :class:`StartError` on 409, another 4xx, or the timeout.
``delivery`` (``{put, inputs}`` presigned URLs, ``delivery.py``) rides in the body like the credentials and is
redacted like them."""
payload: dict[str, Any] = {"run": run, "task": task, "credentials": dict(credentials)}
if delivery:
payload["delivery"] = dict(delivery)
body = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json", TARGET_HEADER: f"{atespace}/{task}"} headers = {"Content-Type": "application/json", TARGET_HEADER: f"{atespace}/{task}"}
limit = float(os.environ.get("OUSAST_START_TIMEOUT") or 300) limit = float(os.environ.get("OUSAST_START_TIMEOUT") or 300)
deadline, backoff, last = time.monotonic() + limit, 0.5, "no attempt made" deadline, backoff, last = time.monotonic() + limit, 0.5, "no attempt made"
secrets = list(credentials.values()) secrets = list(credentials.values())
if delivery:
secrets.append(str(delivery.get("put") or ""))
secrets.extend(str(u) for u in (delivery.get("inputs") or {}).values())
while True: while True:
request = urllib.request.Request(self.url + START_PATH, data=body, method="POST", headers=headers) request = urllib.request.Request(self.url + START_PATH, data=body, method="POST", headers=headers)
try: try:
+110 -33
View File
@@ -10,18 +10,23 @@ 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 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 "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 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 ``POST /ousast/v1/start`` with ``{"run", "task", "credentials", "delivery"}``, 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 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 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 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 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. environment only; they are never logged or written, and their values are redacted from the command's echoed stderr.
``delivery`` (plane-on-kubernetes design section 3) is ``{"put": <presigned PUT URL for the output tar>, "inputs":
{"<NAME>": <presigned GET URL>}}``: a presigned URL is a bearer capability, so it travels where the credentials do.
Each URL must be ``https`` to a hostname (never an address; ``AX_RUNNER_INSECURE_DELIVERY=1`` admits ``http`` in unit
tests only), the answer never echoes one, and no URL is logged.
The command runs as a child process, ``python -m openultrasast.plane.tasks.<spec.command[0]> <spec.command[1:]>``, 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 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 this server. ax has no artifact channel and never reports that a command finished, so after the child exits the
runner reads ``summary.json`` (a crash without one becomes ``failed`` with the exit code and the stderr tail), runner reads ``summary.json`` (a crash without one becomes ``failed`` with the exit code and the stderr tail),
posts the whole ``OUSAST_OUTPUT_DIR`` as a tar to ``OUSAST_ARTIFACT_URL`` -- that delivery is the task's PUTs the whole ``OUSAST_OUTPUT_DIR`` as a tar to ``delivery.put`` (or POSTs it to ``OUSAST_ARTIFACT_URL``, the receiver
path kept until task 2.6 removes it) -- that delivery is the task's
completion (Requirement 4.4); a failed one is reported ``failed``, never done -- and writes the completion marker completion (Requirement 4.4); a failed one is reported ``failed``, never done -- and writes the completion marker
``<OUSAST_STATE_DIR>/<task>.done.json`` (``exit``, ``status``, ``delivered``). Then it keeps serving until SIGTERM, ``<OUSAST_STATE_DIR>/<task>.done.json`` (``exit``, ``status``, ``delivered``). Then it keeps serving until SIGTERM,
as ax requires of PID 1. A boot that finds the marker never reruns the command (a resumed actor must not repeat as ax requires of PID 1. A boot that finds the marker never reruns the command (a resumed actor must not repeat
@@ -38,6 +43,7 @@ Environment the runner reads, all optional except the two ax variables:
AX_RUNNER_AUTOSTART "1" runs the command without waiting for a start request (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) 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) AX_RUNNER_TASK_PACKAGE package the command's module is looked up in (unit tests only)
AX_RUNNER_INSECURE_DELIVERY "1" admits http delivery URLs to a hostname (unit tests only; the image never sets it)
OUSAST_STATE_DIR completion markers, default ``/workspace/.ousast-state`` (the durable volume) 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 OUSAST_TERM_GRACE seconds between SIGTERM and SIGKILL of the command's group, default 10
OUSAST_CASE_CACHE a directory of git checkouts (``benchmarks/independent`` cache layout) OUSAST_CASE_CACHE a directory of git checkouts (``benchmarks/independent`` cache layout)
@@ -47,8 +53,9 @@ Environment the runner reads, all optional except the two ax variables:
``Host`` (the egress gateway decides on and resolves that name) ``Host`` (the egress gateway decides on and resolves that name)
OUSAST_DELIVERY_BACKOFF seconds before the second delivery attempt, doubled per retry OUSAST_DELIVERY_BACKOFF seconds before the second delivery attempt, doubled per retry
OUSAST_INPUTS JSON ``{"<NAME>": "<producer>/<artifact>"}``: after the start and before the OUSAST_INPUTS JSON ``{"<NAME>": "<producer>/<artifact>"}``: after the start and before the
command each is fetched from ``<OUSAST_ARTIFACT_URL>/inputs/<ref>`` (same command each is fetched from ``delivery.inputs[NAME]`` (else from
dial, headers and retries as delivery) to ``OUSAST_INPUT_<NAME>``; a failed ``<OUSAST_ARTIFACT_URL>/inputs/<ref>`` with the receiver's dial and headers)
to ``OUSAST_INPUT_<NAME>``, with the same retries as delivery; a failed
fetch delivers a failed summary naming the input, the command never runs fetch delivers a failed summary naming the input, the command never runs
The runner exports ``OUSAST_WORKSPACE_DIR`` (the first bound workspace) when the Task's env leaves it unset, The runner exports ``OUSAST_WORKSPACE_DIR`` (the first bound workspace) when the Task's env leaves it unset,
@@ -60,6 +67,7 @@ from __future__ import annotations
import contextlib import contextlib
import http.client import http.client
import io import io
import ipaddress
import json import json
import logging import logging
import os import os
@@ -76,7 +84,7 @@ import urllib.parse
import urllib.request import urllib.request
from collections import deque from collections import deque
from collections.abc import Mapping, MutableMapping, Sequence from collections.abc import Mapping, MutableMapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -206,14 +214,58 @@ _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 _RESERVED_PREFIXES = ("AX_", "OUSAST_") # the runner's own contract; a credential must not redirect it
@dataclass(frozen=True)
class DeliveryTargets:
"""Where this task PUTs its output tar and GETs each input (presigned URLs from the start request)."""
put: str | None = None
inputs: Mapping[str, str] = field(default_factory=dict)
def __repr__(self) -> str: # a presigned URL is a credential: never print one
return f"DeliveryTargets(put={'yes' if self.put else 'no'}, inputs={sorted(self.inputs)})"
@dataclass(frozen=True) @dataclass(frozen=True)
class Start: class Start:
"""An accepted start request: the credentials go to the command's environment and nowhere else.""" """An accepted start request: the credentials go to the command's environment and nowhere else; the delivery
targets go to the artifact PUT and the input GETs and nowhere else."""
credentials: Mapping[str, str] credentials: Mapping[str, str]
delivery: DeliveryTargets | None = None
def __repr__(self) -> str: # never print a value by accident def __repr__(self) -> str: # never print a value by accident
return f"Start(credentials={sorted(self.credentials)})" return f"Start(credentials={sorted(self.credentials)}, delivery={self.delivery!r})"
def _check_url(url: object, where: str, insecure: bool) -> str:
"""``url`` is a string, ``https`` (``http`` only when ``insecure``) to a hostname that is not an address, without
userinfo; the error names the field and never echoes the value."""
if not isinstance(url, str):
raise ValueError(f"{where} must be a URL string")
parts = urlsplit(url)
allowed = ("https", "http") if insecure else ("https",)
host = parts.hostname or ""
if parts.scheme not in allowed or not host or parts.username is not None or parts.password is not None:
raise ValueError(f"{where} must be an https URL to a hostname")
try:
ipaddress.ip_address(host.strip("[]"))
except ValueError:
return url
raise ValueError(f"{where} must name a host, not an address")
def parse_delivery(raw: object, insecure: bool = False) -> DeliveryTargets | None:
"""The start body's ``delivery`` field validated: None when absent; :class:`ValueError` names the bad field."""
if raw is None:
return None
if not isinstance(raw, dict) or not set(raw) <= {"put", "inputs"}:
raise ValueError("delivery must be an object with put and/or inputs")
put = _check_url(raw["put"], "delivery.put", insecure) if raw.get("put") is not None else None
inputs_raw = raw.get("inputs") or {}
if not isinstance(inputs_raw, dict) or not all(isinstance(k, str) and _CREDENTIAL_NAME.fullmatch(k) for k in inputs_raw):
raise ValueError("delivery.inputs must map ^[A-Z][A-Z0-9_]*$ names to URLs")
inputs = {name: _check_url(url, f"delivery.inputs.{name}", insecure) for name, url in inputs_raw.items()}
return DeliveryTargets(put, inputs)
class StartGate: class StartGate:
@@ -225,7 +277,8 @@ class StartGate:
logged, and no answer echoes it. logged, and no answer echoes it.
""" """
def __init__(self) -> None: def __init__(self, insecure: bool = False) -> None:
self._insecure = insecure # AX_RUNNER_INSECURE_DELIVERY=1: http delivery URLs (unit tests only)
self._lock = threading.Lock() self._lock = threading.Lock()
self._accepted = threading.Event() self._accepted = threading.Event()
self._expected: tuple[str, str] | None = None self._expected: tuple[str, str] | None = None
@@ -267,10 +320,14 @@ class StartGate:
for k, v in creds.items() for k, v in creds.items()
): ):
return 400, "credentials must map ^[A-Z][A-Z0-9_]*$ names (not AX_/OUSAST_) to strings" return 400, "credentials must map ^[A-Z][A-Z0-9_]*$ names (not AX_/OUSAST_) to strings"
try:
delivery = parse_delivery(request.get("delivery"), self._insecure)
except ValueError as exc:
return 400, str(exc)
run, task = self._expected run, task = self._expected
if request.get("run") != run or request.get("task") != task: if request.get("run") != run or request.get("task") != task:
return 409, f"this actor runs task {task!r} of run {run!r}" return 409, f"this actor runs task {task!r} of run {run!r}"
self._start = Start(dict(creds)) self._start = Start(dict(creds), delivery)
self._accepted.set() self._accepted.set()
return 202, "started" return 202, "started"
@@ -653,6 +710,12 @@ def _dialled(url: str, headers: Mapping[str, str], dial: str | None) -> tuple[st
return parts._replace(netloc=dial).geturl(), out return parts._replace(netloc=dial).geturl(), out
def safe_url(url: str) -> str:
"""``scheme://host/path`` of a URL: what a log line or an error may carry (a presigned query is a credential)."""
parts = urlsplit(url)
return f"{parts.scheme}://{parts.netloc}{parts.path}"
def deliver( def deliver(
url: str, url: str,
payload: bytes, payload: bytes,
@@ -661,15 +724,17 @@ def deliver(
attempts: int = DELIVERY_ATTEMPTS, attempts: int = DELIVERY_ATTEMPTS,
backoff: float = 1.0, backoff: float = 1.0,
dial: str | None = None, dial: str | None = None,
method: str = "POST",
) -> int: ) -> int:
"""POST ``payload`` to ``url`` with ``headers``; retry with doubling backoff; return the 2xx status. """Send ``payload`` to ``url`` (``POST`` to the receiver, ``PUT`` to a presigned store URL) with ``headers``; retry
with doubling backoff; return the 2xx status. Log lines and errors carry the URL without its query.
``dial`` (``host:port``): connect there instead and send the URL's host as ``Host``, as the actor must -- it ``dial`` (``host:port``): connect there instead and send the URL's host as ``Host``, as the actor must -- it
cannot resolve the receiver's cluster name, and the egress gateway decides on ``Host`` and resolves it itself. cannot resolve the receiver's cluster name, and the egress gateway decides on ``Host`` and resolves it itself.
""" """
last, (target, headers) = "no attempt made", _dialled(url, headers, dial) last, (target, headers) = "no attempt made", _dialled(url, headers, dial)
for attempt in range(1, attempts + 1): for attempt in range(1, attempts + 1):
request = urllib.request.Request(target, data=payload, method="POST", headers=headers) request = urllib.request.Request(target, data=payload, method=method, headers=headers)
try: try:
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - the reconciler's URL with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - the reconciler's URL
status = int(response.status) status = int(response.status)
@@ -680,10 +745,10 @@ def deliver(
last = f"HTTP {exc.code}" last = f"HTTP {exc.code}"
except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError) as exc: except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError) as exc:
last = f"{type(exc).__name__}: {exc}" last = f"{type(exc).__name__}: {exc}"
log.warning("artifact delivery attempt %d/%d to %s failed: %s", attempt, attempts, url, last) log.warning("artifact delivery attempt %d/%d to %s failed: %s", attempt, attempts, safe_url(url), last)
if attempt < attempts: if attempt < attempts:
time.sleep(backoff * (2 ** (attempt - 1))) time.sleep(backoff * (2 ** (attempt - 1)))
raise DeliveryError(f"{last} after {attempts} attempts to {url}") raise DeliveryError(f"{last} after {attempts} attempts to {safe_url(url)}")
def fetch( def fetch(
@@ -711,14 +776,15 @@ def fetch(
except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError) as exc: except (urllib.error.URLError, http.client.HTTPException, OSError, ValueError) as exc:
last = f"{type(exc).__name__}: {exc}" last = f"{type(exc).__name__}: {exc}"
partial.unlink(missing_ok=True) partial.unlink(missing_ok=True)
log.warning("input fetch attempt %d/%d from %s failed: %s", attempt, attempts, url, last) log.warning("input fetch attempt %d/%d from %s failed: %s", attempt, attempts, safe_url(url), last)
if attempt < attempts: if attempt < attempts:
time.sleep(backoff * (2 ** (attempt - 1))) time.sleep(backoff * (2 ** (attempt - 1)))
raise DeliveryError(f"{last} after {attempts} attempts to {url}") raise DeliveryError(f"{last} after {attempts} attempts to {safe_url(url)}")
def fetch_inputs(environ: Mapping[str, str]) -> list[Path]: def fetch_inputs(environ: Mapping[str, str], delivery: DeliveryTargets | None = None) -> list[Path]:
"""Fetch every ``OUSAST_INPUTS`` entry from the receiver to its ``OUSAST_INPUT_<NAME>`` path. """Fetch every ``OUSAST_INPUTS`` entry to its ``OUSAST_INPUT_<NAME>`` path: from ``delivery.inputs[NAME]`` (a
presigned GET, no headers) when the start request carries one, else from the receiver.
:class:`RunnerError` names the input when one cannot be fetched: a task never runs with an input missing.""" :class:`RunnerError` names the input when one cannot be fetched: a task never runs with an input missing."""
raw = environ.get("OUSAST_INPUTS") raw = environ.get("OUSAST_INPUTS")
@@ -736,11 +802,15 @@ def fetch_inputs(environ: Mapping[str, str]) -> list[Path]:
fetched: list[Path] = [] fetched: list[Path] = []
for name, ref in sorted(inputs.items()): for name, ref in sorted(inputs.items()):
dest = environ.get(f"OUSAST_INPUT_{name}") dest = environ.get(f"OUSAST_INPUT_{name}")
if not url or not dest or not isinstance(ref, str): presigned = delivery.inputs.get(name) if delivery is not None else None
raise RunnerError(f"input {name} ({ref}) not fetched: OUSAST_ARTIFACT_URL or OUSAST_INPUT_{name} is unset") if not dest or not isinstance(ref, str) or not (presigned or url):
source = f"{url.rstrip('/')}/inputs/{urllib.parse.quote(ref)}" raise RunnerError(f"input {name} ({ref}) not fetched: no delivery URL, OUSAST_ARTIFACT_URL or OUSAST_INPUT_{name}")
try: try:
size = fetch(source, headers, Path(dest), backoff=backoff, dial=environ.get("OUSAST_ARTIFACT_DIAL") or None) if presigned:
size = fetch(presigned, {}, Path(dest), backoff=backoff)
else:
source = f"{str(url).rstrip('/')}/inputs/{urllib.parse.quote(ref)}"
size = fetch(source, headers, Path(dest), backoff=backoff, dial=environ.get("OUSAST_ARTIFACT_DIAL") or None)
except DeliveryError as exc: except DeliveryError as exc:
raise RunnerError(f"input {name} ({ref}) not fetched: {exc}") from None raise RunnerError(f"input {name} ({ref}) not fetched: {exc}") from None
log.info("input %s (%s): %d bytes to %s", name, ref, size, dest) log.info("input %s (%s): %d bytes to %s", name, ref, size, dest)
@@ -748,8 +818,13 @@ def fetch_inputs(environ: Mapping[str, str]) -> list[Path]:
return fetched return fetched
def _deliver_output(task: Task, output_dir: Path, environ: Mapping[str, str]) -> bool: def _deliver_output(task: Task, output_dir: Path, environ: Mapping[str, str], delivery: DeliveryTargets | None = None) -> bool:
"""POST the output directory; False when there is nowhere to deliver; :class:`DeliveryError` when it fails.""" """PUT the output tar to ``delivery.put`` when the start request carried one, else POST it to the receiver;
False when there is nowhere to deliver; :class:`DeliveryError` when it fails."""
backoff = float(environ.get("OUSAST_DELIVERY_BACKOFF", "1.0"))
if delivery is not None and delivery.put:
deliver(delivery.put, build_tar(output_dir), {"Content-Type": "application/x-tar"}, backoff=backoff, method="PUT")
return True
url = environ.get("OUSAST_ARTIFACT_URL") url = environ.get("OUSAST_ARTIFACT_URL")
if not url: if not url:
return False return False
@@ -758,7 +833,6 @@ def _deliver_output(task: Task, output_dir: Path, environ: Mapping[str, str]) ->
run = environ.get("OUSAST_RUN") run = environ.get("OUSAST_RUN")
if run: if run:
headers["X-Ousast-Run"] = run headers["X-Ousast-Run"] = run
backoff = float(environ.get("OUSAST_DELIVERY_BACKOFF", "1.0"))
deliver(url, build_tar(output_dir), headers, backoff=backoff, dial=environ.get("OUSAST_ARTIFACT_DIAL") or None) deliver(url, build_tar(output_dir), headers, backoff=backoff, dial=environ.get("OUSAST_ARTIFACT_DIAL") or None)
return True return True
@@ -810,14 +884,16 @@ def _exit_of(marker: Mapping[str, Any]) -> int:
return EXIT_BY_STATUS.get(str(marker.get("status")), EXIT_BY_STATUS["failed"]) return EXIT_BY_STATUS.get(str(marker.get("status")), EXIT_BY_STATUS["failed"])
def _finish(session: _Session, env: Mapping[str, str], exit_code: int | None, summary: Mapping[str, Any]) -> dict[str, Any]: def _finish(
session: _Session, env: Mapping[str, str], exit_code: int | None, summary: Mapping[str, Any], delivery: DeliveryTargets | None = None
) -> dict[str, Any]:
"""Deliver the output directory and write the completion marker; a failed delivery reports ``failed``. """Deliver the output directory and write the completion marker; a failed delivery reports ``failed``.
The marker keeps the task's own summary so a later boot can put it back and retry the delivery alone. The marker keeps the task's own summary so a later boot can put it back and retry the delivery alone.
""" """
marker: dict[str, Any] = {"exit": exit_code, "status": str(summary.get("status")), "delivered": False, "summary": dict(summary)} marker: dict[str, Any] = {"exit": exit_code, "status": str(summary.get("status")), "delivered": False, "summary": dict(summary)}
try: try:
marker["delivered"] = _deliver_output(session.task, session.output_dir, env) marker["delivered"] = _deliver_output(session.task, session.output_dir, env, delivery)
except DeliveryError as exc: except DeliveryError as exc:
log.error("artifact delivery failed: %s", exc) log.error("artifact delivery failed: %s", exc)
marker.update(status="failed", reason=f"artifact delivery failed: {exc}") marker.update(status="failed", reason=f"artifact delivery failed: {exc}")
@@ -857,24 +933,24 @@ def _run_once(env: MutableMapping[str, str], ready: threading.Event, stop: threa
stored = done.get("summary") stored = done.get("summary")
kept: dict[str, Any] = stored if isinstance(stored, dict) else read_summary(session.output_dir) or {} kept: dict[str, Any] = stored if isinstance(stored, dict) else read_summary(session.output_dir) or {}
write_summary(session.output_dir, kept) write_summary(session.output_dir, kept)
return _exit_of(_finish(session, env, done.get("exit"), kept)) return _exit_of(_finish(session, env, done.get("exit"), kept, start.delivery)) # a re-minted URL, if any
if stop.is_set(): if stop.is_set():
return 0 return 0
try: try:
_materialise_once(session, env) _materialise_once(session, env)
fetch_inputs(env) # after the start as well: the receiver serves a task its inputs only while it runs fetch_inputs(env, start.delivery) # after the start: the URLs arrive with it (and a receiver serves a running task only)
except RunnerError as exc: except RunnerError as exc:
log.error("runner failed: %s", exc) log.error("runner failed: %s", exc)
failed = _failed(f"runner failed: {exc}", read_summary(session.output_dir)) failed = _failed(f"runner failed: {exc}", read_summary(session.output_dir))
write_summary(session.output_dir, failed) write_summary(session.output_dir, failed)
return _exit_of(_finish(session, env, None, failed)) return _exit_of(_finish(session, env, None, failed, start.delivery))
log.info("workspaces and inputs ready; running task %s: %s", session.task.metadata.name, list(session.task.command)) log.info("workspaces and inputs ready; running task %s: %s", session.task.metadata.name, list(session.task.command))
grace = 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) code, summary = run_command(session.task, session.output_dir, env, stop, grace, start.credentials)
if code is None: if code is None:
log.info("task %s was stopped before its command finished; a resume runs it again", session.task.metadata.name) log.info("task %s was stopped before its command finished; a resume runs it again", session.task.metadata.name)
return 0 return 0
return _exit_of(_finish(session, env, code, summary)) return _exit_of(_finish(session, env, code, summary, start.delivery))
except RunnerError as exc: except RunnerError as exc:
# The failure is reported, never acted on unasked: a start request is answered with it (409 without a # 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. # session, else 202 and the failed summary is delivered), so the golden boot delivers nothing either.
@@ -887,9 +963,10 @@ def _run_once(env: MutableMapping[str, str], ready: threading.Event, stop: threa
write_summary(session.output_dir, summary) write_summary(session.output_dir, summary)
gate.arm(env.get("OUSAST_RUN", ""), session.task.metadata.name) gate.arm(env.get("OUSAST_RUN", ""), session.task.metadata.name)
ready.set() ready.set()
if env.get("AX_RUNNER_AUTOSTART") != "1" and gate.wait(stop) is None: late = gate.autostart() if env.get("AX_RUNNER_AUTOSTART") == "1" else gate.wait(stop)
if late is None:
return 0 return 0
return _exit_of(_finish(session, env, None, summary)) return _exit_of(_finish(session, env, None, summary, late.delivery))
def _materialise_once(session: _Session, env: Mapping[str, str]) -> None: def _materialise_once(session: _Session, env: Mapping[str, str]) -> None:
@@ -918,7 +995,7 @@ def main(argv: Sequence[str] | None = None, environ: MutableMapping[str, str] |
env = os.environ if environ is None else environ env = os.environ if environ is None else environ
if argv: if argv:
log.warning("ax-task-runner ignores command-line arguments %r; the Task manifest is the only input", list(argv)) log.warning("ax-task-runner ignores command-line arguments %r; the Task manifest is the only input", list(argv))
ready, stop, gate = threading.Event(), threading.Event(), StartGate() ready, stop, gate = threading.Event(), threading.Event(), StartGate(insecure=env.get("AX_RUNNER_INSECURE_DELIVERY") == "1")
previous = _on_sigterm(stop) previous = _on_sigterm(stop)
server: HealthServer | None = None server: HealthServer | None = None
if env.get("AX_RUNNER_HTTP", "1") != "0": if env.get("AX_RUNNER_HTTP", "1") != "0":
+120
View File
@@ -23,6 +23,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
import pytest import pytest
from plane_fake_store import HttpObjectStore
from openultrasast.plane import egress, runner from openultrasast.plane import egress, runner
from openultrasast.plane.manifests import GitSource from openultrasast.plane.manifests import GitSource
@@ -36,6 +37,7 @@ from openultrasast.plane.runner import (
clone_destination, clone_destination,
deliver, deliver,
find_cached_checkout, find_cached_checkout,
parse_delivery,
start_health_server, start_health_server,
) )
@@ -850,3 +852,121 @@ def test_fetch_dials_the_override_and_sends_the_url_host(tmp_path: Path) -> None
server.server_close() server.server_close()
assert size == 2 and (tmp_path / "x" / "a.json").read_bytes() == b"ok" assert size == 2 and (tmp_path / "x" / "a.json").read_bytes() == b"ok"
assert seen[0]["path"] == "/inputs/p/a.json" and seen[0]["Host"] == "receiver.example" and seen[0]["X-Ousast-Run"] == "r" assert seen[0]["path"] == "/inputs/p/a.json" and seen[0]["Host"] == "receiver.example" and seen[0]["X-Ousast-Run"] == "r"
# --- store delivery through the start body (plane-on-kubernetes 2.2, design section 3) ---------------------------
@pytest.fixture
def object_store() -> Iterator[HttpObjectStore]:
fake = HttpObjectStore()
try:
yield fake
finally:
fake.close()
def test_the_start_body_may_carry_delivery_targets_and_refuses_unsafe_urls() -> None:
"""``delivery {put, inputs}``: https to a hostname, never an address or http; the answer echoes no URL."""
gate = StartGate()
gate.arm("r", "t")
bad = [
({"put": "http://store.example/b/k?sig=1"}, "delivery.put must be an https URL"),
({"put": "https://10.0.0.7/b/k"}, "delivery.put must name a host, not an address"),
({"put": "https://user:[email protected]/k"}, "delivery.put must be an https URL"),
({"inputs": {"FACTS": "ftp://store.example/k"}}, "delivery.inputs.FACTS must be an https URL"),
({"inputs": {"lower": "https://store.example/k"}}, "delivery.inputs must map"),
({"elsewhere": "x"}, "delivery must be an object"),
("https://store.example/k", "delivery must be an object"),
]
for delivery, message in bad:
status, text = gate.offer(json.dumps({"run": "r", "task": "t", "delivery": delivery}).encode())
assert status == 400 and message in text, (delivery, status, text)
assert "store.example" not in text and "10.0.0.7" not in text, "the answer never echoes a URL"
body = {
"run": "r",
"task": "t",
"delivery": {
"put": "https://store.example/b/t/output.tar?X-Amz-Signature=s",
"inputs": {"FACTS": "https://store.example/b/p/facts.json?X-Amz-Signature=s"},
},
}
assert gate.offer(json.dumps(body).encode()) == (202, "started")
start = gate.wait(threading.Event())
assert start is not None and start.delivery is not None
assert start.delivery.put == body["delivery"]["put"] and start.delivery.inputs == body["delivery"]["inputs"]
assert "X-Amz" not in repr(start) and "store.example" not in repr(start), "a Start never prints a URL"
assert parse_delivery(None) is None and parse_delivery({"put": "http://localhost:1/k"}, insecure=True) is not None
with pytest.raises(ValueError, match="must be an https URL"):
parse_delivery({"put": "http://localhost:1/k"})
def test_output_is_put_to_the_presigned_url_and_inputs_are_fetched_from_theirs(
env: dict[str, str], object_store: HttpObjectStore, tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""End to end against the S3-shaped fake: the start carries the URLs, the runner PUTs the tar and GETs the input;
no receiver, no OUSAST_ARTIFACT_URL, no URL in any log line."""
caplog.set_level(logging.DEBUG)
object_store._put("runs/run-1/repo-facts/facts.json", b'{"a.py": ["f"]}')
put_url = object_store.presign_put("runs/run-1/stub/output.tar")
facts_url = object_store.presign_get("runs/run-1/repo-facts/facts.json")
thread, environ, codes = main_in_thread(
tmp_path,
AX_RUNNER_INSECURE_DELIVERY="1",
OUSAST_TASK="stub",
OUSAST_INPUTS=json.dumps({"FACTS": "repo-facts/facts.json"}),
OUSAST_INPUT_FACTS=str(tmp_path / "ws" / "in" / "repo-facts" / "facts.json"),
)
body = {"run": "run-1", "task": "stub", "delivery": {"put": put_url, "inputs": {"FACTS": facts_url}}}
assert post_start(environ["AX_RUNNER_BOUND_PORT"], body) == (202, "started")
thread.join(timeout=30)
assert codes == [0]
assert (tmp_path / "ws" / "in" / "repo-facts" / "facts.json").read_bytes() == b'{"a.py": ["f"]}', "the input came by its URL"
assert ("GET", "runs/run-1/repo-facts/facts.json") in object_store.requests and (
"PUT",
"runs/run-1/stub/output.tar",
) in object_store.requests
with tarfile.open(fileobj=io.BytesIO(object_store.objects["runs/run-1/stub/output.tar"]), mode="r:") as tar:
names = sorted(tar.getnames())
assert "facts.json" in names and "summary.json" in names, "the whole output directory, as a tar, in the store"
marker = json.loads((tmp_path / "state" / "stub.done.json").read_text())
assert marker["delivered"] is True and marker["status"] == "done"
assert "X-Amz" not in caplog.text and "fake-bucket" not in caplog.text, "no presigned URL in any log line"
assert files_containing(tmp_path, "X-Amz-Signature") == [], "nor in any file"
def test_a_failed_put_is_retried_alone_on_the_next_start_with_a_fresh_url(
env: dict[str, str], object_store: HttpObjectStore, tmp_path: Path
) -> None:
"""The marker and retry-only boot are unchanged: a PUT that fails (wrong bucket: 404) ends the task failed with
the URL's host and path only; the next boot's start brings a working URL and only the delivery runs again."""
counter = tmp_path / "runs.txt"
wrong = object_store.presign_put("runs/run-1/stub/output.tar").replace("/fake-bucket/", "/no-such-bucket/")
thread, environ, codes = main_in_thread(
tmp_path, AX_RUNNER_INSECURE_DELIVERY="1", STUB_COUNTER=str(counter), OUSAST_DELIVERY_BACKOFF="0"
)
assert post_start(environ["AX_RUNNER_BOUND_PORT"], {"run": "run-1", "task": "stub", "delivery": {"put": wrong}})[0] == 202
thread.join(timeout=30)
assert codes == [EXIT_BY_STATUS["failed"]]
marker = json.loads((tmp_path / "state" / "stub.done.json").read_text())
assert marker["delivered"] is False and marker["status"] == "failed" and marker["exit"] == 0
assert "HTTP 404" in marker["reason"] and "X-Amz" not in marker["reason"], "the reason names the status, not the signature"
thread, environ, codes = main_in_thread(
tmp_path, AX_RUNNER_INSECURE_DELIVERY="1", STUB_COUNTER=str(counter), OUSAST_DELIVERY_BACKOFF="0"
)
good = object_store.presign_put("runs/run-1/stub/output.tar")
assert post_start(environ["AX_RUNNER_BOUND_PORT"], {"run": "run-1", "task": "stub", "delivery": {"put": good}})[0] == 202
thread.join(timeout=30)
assert codes == [0] and len(counter.read_text().splitlines()) == 1, "only the delivery is retried"
assert "runs/run-1/stub/output.tar" in object_store.objects
assert json.loads((tmp_path / "state" / "stub.done.json").read_text())["delivered"] is True
def test_deliver_puts_and_names_only_the_safe_url_on_failure(object_store: HttpObjectStore) -> None:
url = object_store.presign_put("k/output.tar")
assert runner.deliver(url, b"tar", {"Content-Type": "application/x-tar"}, backoff=0, method="PUT") == 200
assert object_store.objects["k/output.tar"] == b"tar"
with pytest.raises(runner.DeliveryError) as caught:
runner.deliver(url.replace("/fake-bucket/", "/other/"), b"x", {}, attempts=2, backoff=0, method="PUT")
assert "HTTP 404" in str(caught.value) and "X-Amz" not in str(caught.value) and "/other/k/output.tar" in str(caught.value)
assert runner.safe_url("https://h:1/p/q?X-Amz-Signature=s") == "https://h:1/p/q"