weir-run 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weir_run/__init__.py +6 -0
- weir_run/capture.py +52 -0
- weir_run/check.py +181 -0
- weir_run/cli.py +95 -0
- weir_run/config.py +169 -0
- weir_run/flows.py +104 -0
- weir_run/judge.py +92 -0
- weir_run/ledger.py +190 -0
- weir_run/mock/__init__.py +5 -0
- weir_run/mock/model.py +303 -0
- weir_run/mock/oauth.py +179 -0
- weir_run/mock/otlp.py +122 -0
- weir_run/mock/server.py +364 -0
- weir_run/needles.py +120 -0
- weir_run/py.typed +0 -0
- weir_run/pytest_plugin.py +236 -0
- weir_run/shapes.py +123 -0
- weir_run/spec.py +219 -0
- weir_run-0.1.0.dist-info/METADATA +285 -0
- weir_run-0.1.0.dist-info/RECORD +22 -0
- weir_run-0.1.0.dist-info/WHEEL +4 -0
- weir_run-0.1.0.dist-info/entry_points.txt +5 -0
weir_run/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""The canary runner: plants fake canaries, runs a program's flows against a
|
|
2
|
+
loopback mock, and judges the captured telemetry with `weir canary judge`.
|
|
3
|
+
|
|
4
|
+
Its contract is weir's docs/canary.md. The runner is a test-time tool for a
|
|
5
|
+
developer's machine or a CI runner, never imported by production code.
|
|
6
|
+
"""
|
weir_run/capture.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The capture directory the judge reads (docs/canary.md, "The capture").
|
|
2
|
+
|
|
3
|
+
capture/otlp/<signal>.jsonl telemetry files (--otlp): one decompressed
|
|
4
|
+
OTLP/JSON export request per line
|
|
5
|
+
capture/swept/... swept files: every other telemetry body and
|
|
6
|
+
each flow's stdout and stderr, one file each
|
|
7
|
+
|
|
8
|
+
The roles are fixed by directory, never guessed from content.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
SIGNALS = ("traces", "logs", "metrics")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def safe_name(text: str) -> str:
|
|
21
|
+
"""A file-name segment: id characters other than [A-Za-z0-9._-] become `_`."""
|
|
22
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", text)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Capture:
|
|
26
|
+
def __init__(self, root: Path) -> None:
|
|
27
|
+
self.root = root
|
|
28
|
+
self.otlp = root / "otlp"
|
|
29
|
+
self.swept = root / "swept"
|
|
30
|
+
self.otlp.mkdir(parents=True)
|
|
31
|
+
self.swept.mkdir(parents=True)
|
|
32
|
+
self._lock = threading.Lock()
|
|
33
|
+
self._counts: dict[str, int] = {}
|
|
34
|
+
|
|
35
|
+
def otlp_line(self, signal: str, line: bytes) -> None:
|
|
36
|
+
if signal not in SIGNALS or b"\n" in line:
|
|
37
|
+
raise ValueError("an OTLP capture line is one export request of a known signal")
|
|
38
|
+
with self._lock, (self.otlp / f"{signal}.jsonl").open("ab") as f:
|
|
39
|
+
f.write(line + b"\n")
|
|
40
|
+
|
|
41
|
+
def swept_file(self, stem: str, data: bytes, suffix: str = ".body") -> str:
|
|
42
|
+
"""Write one swept file and return its name under capture/swept."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
n = self._counts.get(stem, 0) + 1
|
|
45
|
+
self._counts[stem] = n
|
|
46
|
+
name = f"{safe_name(stem)}-{n}{suffix}"
|
|
47
|
+
(self.swept / name).write_bytes(data)
|
|
48
|
+
return name
|
|
49
|
+
|
|
50
|
+
def flow_streams(self, index: int, flow_id: str) -> tuple[Path, Path]:
|
|
51
|
+
stem = f"flow-{index:02d}-{safe_name(flow_id)}"
|
|
52
|
+
return self.swept / f"{stem}.stdout", self.swept / f"{stem}.stderr"
|
weir_run/check.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""One check run, start to finish (docs/canary.md, "What the runner owes
|
|
2
|
+
the judge"):
|
|
3
|
+
|
|
4
|
+
1. plant one fresh unique value per placement and write the ledger before
|
|
5
|
+
any flow starts;
|
|
6
|
+
2. start the mock on 127.0.0.1 and run each flow against it, recording one
|
|
7
|
+
`flow` record per flow (the mock records minted values, credential-field
|
|
8
|
+
evidence and unexpected credentials as they happen);
|
|
9
|
+
3. stop the mock, relay each flow's output with every needle replaced;
|
|
10
|
+
4. call the judge, then delete the ledger and the capture.
|
|
11
|
+
|
|
12
|
+
The CLI runs a whole config through `run_spec`; the pytest plugin drives a
|
|
13
|
+
`CheckRun` call by call.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TextIO
|
|
25
|
+
|
|
26
|
+
from weir_run import flows
|
|
27
|
+
from weir_run import judge as judge_cli
|
|
28
|
+
from weir_run.capture import Capture
|
|
29
|
+
from weir_run.ledger import Ledger
|
|
30
|
+
from weir_run.mock import Mock
|
|
31
|
+
from weir_run.shapes import ByteSource, shaped
|
|
32
|
+
from weir_run.spec import FlowSpec, RunSpec, SpecError, validate
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FlowOutcome:
|
|
37
|
+
id: str
|
|
38
|
+
status: str
|
|
39
|
+
stdout: Path = field(repr=False)
|
|
40
|
+
stderr: Path = field(repr=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CheckRun:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
spec: RunSpec,
|
|
47
|
+
judge: Sequence[str],
|
|
48
|
+
*,
|
|
49
|
+
seed: int | None = None,
|
|
50
|
+
python: str = sys.executable,
|
|
51
|
+
validate_spec: bool = True,
|
|
52
|
+
) -> None:
|
|
53
|
+
if validate_spec:
|
|
54
|
+
validate(spec)
|
|
55
|
+
self.spec = spec
|
|
56
|
+
self.judge_command = list(judge)
|
|
57
|
+
self.python = python
|
|
58
|
+
self.source = ByteSource(seed)
|
|
59
|
+
self.outcomes: list[FlowOutcome] = []
|
|
60
|
+
self.verdict: judge_cli.Verdict | None = None
|
|
61
|
+
self.json_verdict: judge_cli.Verdict | None = None
|
|
62
|
+
# 0700, private to this user: it holds the ledger and the capture.
|
|
63
|
+
self.run_dir = Path(tempfile.mkdtemp(prefix="canary-run-"))
|
|
64
|
+
self._flow_dirs = Path(tempfile.mkdtemp(prefix="canary-flows-"))
|
|
65
|
+
try:
|
|
66
|
+
self.ledger = Ledger(self.run_dir / judge_cli.LEDGER, self.source, spec.reserved_ids())
|
|
67
|
+
self.capture = Capture(self.run_dir / "capture")
|
|
68
|
+
self.planted: dict[str, str] = {}
|
|
69
|
+
for p in spec.placements:
|
|
70
|
+
made = shaped(self.source, p.shape)
|
|
71
|
+
self.planted[p.variable] = self.ledger.plant(p.id, p.kind, made, p.required).value
|
|
72
|
+
self.mock = Mock(spec.mock, self.ledger, self.capture, spec.placements, self.source)
|
|
73
|
+
self.mock.start()
|
|
74
|
+
except BaseException:
|
|
75
|
+
for directory in (self.run_dir, self._flow_dirs):
|
|
76
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
77
|
+
raise
|
|
78
|
+
self._stopped = False
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def mock_url(self) -> str:
|
|
82
|
+
return self.mock.url
|
|
83
|
+
|
|
84
|
+
def run_flow(self, flow: FlowSpec) -> FlowOutcome:
|
|
85
|
+
if self._stopped:
|
|
86
|
+
raise RuntimeError("the check run has already been judged")
|
|
87
|
+
if any(o.id == flow.id for o in self.outcomes):
|
|
88
|
+
raise SpecError(f"flow id {flow.id} is defined twice")
|
|
89
|
+
index = len(self.outcomes) + 1
|
|
90
|
+
home = self._flow_dirs / f"{index:02d}" / "home"
|
|
91
|
+
tmp = self._flow_dirs / f"{index:02d}" / "tmp"
|
|
92
|
+
home.mkdir(parents=True)
|
|
93
|
+
tmp.mkdir()
|
|
94
|
+
env = flows.flow_environment(self.mock_url, self.planted, flow.env, home, tmp)
|
|
95
|
+
stdout, stderr = self.capture.flow_streams(index, flow.id)
|
|
96
|
+
status = flows.run(flow, env, stdout, stderr, self.python)
|
|
97
|
+
self.ledger.flow(flow.id, status)
|
|
98
|
+
outcome = FlowOutcome(flow.id, status, stdout, stderr)
|
|
99
|
+
self.outcomes.append(outcome)
|
|
100
|
+
return outcome
|
|
101
|
+
|
|
102
|
+
def stop(self) -> None:
|
|
103
|
+
if not self._stopped:
|
|
104
|
+
self._stopped = True
|
|
105
|
+
self.mock.stop()
|
|
106
|
+
self.ledger.close()
|
|
107
|
+
|
|
108
|
+
def safe(self, text: str) -> str:
|
|
109
|
+
"""Program-derived text, printed only if it holds no trace of a value."""
|
|
110
|
+
return "[withheld]" if self.ledger.withheld(text) else text
|
|
111
|
+
|
|
112
|
+
def relay(self, out: TextIO) -> None:
|
|
113
|
+
"""Each flow's own output, every needle replaced by `<canary:ID>`."""
|
|
114
|
+
for o in self.outcomes:
|
|
115
|
+
out.write(f"flow {o.id}: {o.status}\n")
|
|
116
|
+
for name, path in (("stdout", o.stdout), ("stderr", o.stderr)):
|
|
117
|
+
data = path.read_bytes() if path.exists() else b""
|
|
118
|
+
if not data:
|
|
119
|
+
continue
|
|
120
|
+
out.write(f"--- flow {o.id} {name} (canaries redacted) ---\n")
|
|
121
|
+
text = self.ledger.redact(data).decode("utf-8", "replace")
|
|
122
|
+
out.write(text if text.endswith("\n") else text + "\n")
|
|
123
|
+
out.write(f"--- end of flow {o.id} {name} ---\n")
|
|
124
|
+
mock = self.mock
|
|
125
|
+
for host, count in sorted(mock.egress.items()):
|
|
126
|
+
out.write(f"mock: refused egress to {self.safe(host)} ({count}x)\n")
|
|
127
|
+
for request, count in sorted(mock.unmocked.items()):
|
|
128
|
+
out.write(f"mock: no mock for {self.safe(request)} ({count}x), answered 404\n")
|
|
129
|
+
if mock.errors:
|
|
130
|
+
out.write(f"mock: {mock.errors} request(s) failed inside the mock, answered 500\n")
|
|
131
|
+
|
|
132
|
+
def judge(self, *, json_report: bool = False) -> judge_cli.Verdict:
|
|
133
|
+
self.stop()
|
|
134
|
+
self.verdict = judge_cli.judge(self.judge_command, self.run_dir)
|
|
135
|
+
if json_report:
|
|
136
|
+
self.json_verdict = judge_cli.judge(self.judge_command, self.run_dir, json_report=True)
|
|
137
|
+
return self.verdict
|
|
138
|
+
|
|
139
|
+
def redacted(self, data: bytes) -> str:
|
|
140
|
+
"""Judge output is value-free by contract; redact it all the same."""
|
|
141
|
+
return self.ledger.redact(data).decode("utf-8", "replace")
|
|
142
|
+
|
|
143
|
+
def cleanup(self) -> list[str]:
|
|
144
|
+
"""Delete the ledger, the capture and the flows' homes. Returns the
|
|
145
|
+
directories that could not be removed."""
|
|
146
|
+
self.stop()
|
|
147
|
+
failed: list[str] = []
|
|
148
|
+
for directory in (self.run_dir, self._flow_dirs):
|
|
149
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
150
|
+
if directory.exists():
|
|
151
|
+
failed.append(str(directory))
|
|
152
|
+
return failed
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def run_spec(
|
|
156
|
+
spec: RunSpec,
|
|
157
|
+
judge: Sequence[str],
|
|
158
|
+
*,
|
|
159
|
+
out: TextIO,
|
|
160
|
+
err: TextIO,
|
|
161
|
+
seed: int | None = None,
|
|
162
|
+
json_report: Path | None = None,
|
|
163
|
+
python: str = sys.executable,
|
|
164
|
+
) -> int:
|
|
165
|
+
"""Run every flow of spec, judge, clean up. Returns the exit code."""
|
|
166
|
+
run = CheckRun(spec, judge, seed=seed, python=python)
|
|
167
|
+
try:
|
|
168
|
+
for flow in spec.flows:
|
|
169
|
+
run.run_flow(flow)
|
|
170
|
+
run.stop()
|
|
171
|
+
run.relay(err)
|
|
172
|
+
verdict = run.judge(json_report=json_report is not None)
|
|
173
|
+
out.write(run.redacted(verdict.stdout))
|
|
174
|
+
if verdict.stderr:
|
|
175
|
+
err.write(run.redacted(verdict.stderr))
|
|
176
|
+
if json_report is not None and run.json_verdict is not None:
|
|
177
|
+
json_report.write_text(run.redacted(run.json_verdict.stdout), encoding="utf-8")
|
|
178
|
+
return verdict.code
|
|
179
|
+
finally:
|
|
180
|
+
for directory in run.cleanup():
|
|
181
|
+
err.write(f"weir-run: could not delete {directory}; delete it by hand\n")
|
weir_run/cli.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""`weir-run run`: one check run from a maintainer's config.
|
|
2
|
+
|
|
3
|
+
Exit codes are the judge's (0 clean, 1 leak, 3 incomplete), and 2 for a
|
|
4
|
+
usage, configuration or input error, the runner's own or the judge's, and
|
|
5
|
+
for any other failure of the run (docs/canary.md, "Exit codes"). A runner
|
|
6
|
+
fault never exits 1, which would read as a leak.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from types import FrameType
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from weir_run import config
|
|
20
|
+
from weir_run import judge as judge_cli
|
|
21
|
+
from weir_run.check import run_spec
|
|
22
|
+
from weir_run.ledger import LedgerError
|
|
23
|
+
from weir_run.spec import SpecError
|
|
24
|
+
|
|
25
|
+
USAGE_ERROR = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group()
|
|
29
|
+
def main() -> None:
|
|
30
|
+
# Flow output and judge reports can hold any text the program wrote.
|
|
31
|
+
for stream in (sys.stdout, sys.stderr):
|
|
32
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
33
|
+
if reconfigure is not None:
|
|
34
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@main.command("run")
|
|
38
|
+
@click.option(
|
|
39
|
+
"--config",
|
|
40
|
+
"config_path",
|
|
41
|
+
required=True,
|
|
42
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
43
|
+
help="The check's TOML config: placements, flows and mocked endpoints.",
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--report",
|
|
47
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
48
|
+
help="Also write the judge's JSON report (canary-report/1) here.",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--seed",
|
|
52
|
+
type=int,
|
|
53
|
+
help="Draw canary values from a seeded stream, for local reproduction only.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--weir",
|
|
57
|
+
"weir_path",
|
|
58
|
+
help="The weir executable to judge with. Default: the one beside this Python, else PATH.",
|
|
59
|
+
)
|
|
60
|
+
def run_command(
|
|
61
|
+
config_path: Path, report: Path | None, seed: int | None, weir_path: str | None
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Plant canaries, run every flow against the loopback mock, judge the capture."""
|
|
64
|
+
# A CI cancel or timeout sends SIGTERM: stop the same way as Ctrl-C, so
|
|
65
|
+
# the flow's processes are stopped and the ledger and capture deleted.
|
|
66
|
+
previous = signal.signal(signal.SIGTERM, _terminate)
|
|
67
|
+
try:
|
|
68
|
+
if report is not None and not report.parent.is_dir():
|
|
69
|
+
raise SpecError(f"--report {report}: no such directory {report.parent}")
|
|
70
|
+
spec = config.load(config_path)
|
|
71
|
+
command = judge_cli.resolve(weir_path)
|
|
72
|
+
judge_cli.check_available(command)
|
|
73
|
+
code = run_spec(
|
|
74
|
+
spec, command, out=sys.stdout, err=sys.stderr, seed=seed, json_report=report
|
|
75
|
+
)
|
|
76
|
+
except (SpecError, LedgerError, OSError, subprocess.SubprocessError, RuntimeError) as exc:
|
|
77
|
+
click.echo(f"weir-run: {exc}", err=True)
|
|
78
|
+
sys.exit(USAGE_ERROR)
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
click.echo("weir-run: interrupted", err=True)
|
|
81
|
+
sys.exit(USAGE_ERROR)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
click.echo(f"weir-run: the run failed ({type(exc).__name__})", err=True)
|
|
84
|
+
sys.exit(USAGE_ERROR)
|
|
85
|
+
finally:
|
|
86
|
+
signal.signal(signal.SIGTERM, previous)
|
|
87
|
+
sys.exit(code)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _terminate(signum: int, frame: FrameType | None) -> None:
|
|
91
|
+
raise KeyboardInterrupt
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
weir_run/config.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The maintainer's config file: TOML in, a validated `RunSpec` out.
|
|
2
|
+
|
|
3
|
+
Strict on purpose: an unknown table or key is a usage error, never ignored,
|
|
4
|
+
so a typo cannot quietly drop a placement or a flow from the check. Paths
|
|
5
|
+
are relative to the config file's directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import tomllib
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
from weir_run.spec import (
|
|
16
|
+
CredentialField,
|
|
17
|
+
EndpointSpec,
|
|
18
|
+
FlowSpec,
|
|
19
|
+
MockSpec,
|
|
20
|
+
PlacementSpec,
|
|
21
|
+
RunSpec,
|
|
22
|
+
SpecError,
|
|
23
|
+
default_variable,
|
|
24
|
+
validate,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_TOP = {"mock", "placement", "flow"}
|
|
28
|
+
_MOCK = {"oauth", "model_reply", "tool_calls", "credential_fields", "endpoint"}
|
|
29
|
+
_ENDPOINT = {"id", "credential_fields", "status", "reply", "content_type", "capture"}
|
|
30
|
+
_PLACEMENT = {"id", "kind", "variable", "shape", "required", "provider_host"}
|
|
31
|
+
_FLOW = {"id", "command", "cwd", "timeout_seconds", "env", "skip"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _table(value: object, where: str) -> dict[str, object]:
|
|
35
|
+
if not isinstance(value, dict):
|
|
36
|
+
raise SpecError(f"{where}: expected a table")
|
|
37
|
+
return cast(dict[str, object], value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tables(value: object, where: str) -> list[dict[str, object]]:
|
|
41
|
+
if not isinstance(value, list):
|
|
42
|
+
raise SpecError(f"{where}: expected an array of tables ([[{where}]])")
|
|
43
|
+
return [_table(v, where) for v in cast(list[object], value)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _keys(table: Mapping[str, object], allowed: set[str], where: str) -> None:
|
|
47
|
+
unknown = sorted(set(table) - allowed)
|
|
48
|
+
if unknown:
|
|
49
|
+
raise SpecError(f"{where}: unknown key {unknown[0]!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _str(table: Mapping[str, object], key: str, where: str, default: str | None = None) -> str:
|
|
53
|
+
value = table.get(key, default)
|
|
54
|
+
if not isinstance(value, str):
|
|
55
|
+
raise SpecError(f"{where}: {key} must be a string")
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _opt_str(table: Mapping[str, object], key: str, where: str) -> str | None:
|
|
60
|
+
return None if key not in table else _str(table, key, where)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bool(table: Mapping[str, object], key: str, where: str, default: bool) -> bool:
|
|
64
|
+
value = table.get(key, default)
|
|
65
|
+
if not isinstance(value, bool):
|
|
66
|
+
raise SpecError(f"{where}: {key} must be true or false")
|
|
67
|
+
return value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _str_list(table: Mapping[str, object], key: str, where: str) -> list[str]:
|
|
71
|
+
value = table.get(key, [])
|
|
72
|
+
if not isinstance(value, list) or not all(
|
|
73
|
+
isinstance(v, str) for v in cast(list[object], value)
|
|
74
|
+
):
|
|
75
|
+
raise SpecError(f"{where}: {key} must be an array of strings")
|
|
76
|
+
return cast(list[str], value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fields(table: Mapping[str, object], key: str, where: str) -> tuple[CredentialField, ...]:
|
|
80
|
+
return tuple(CredentialField.parse(f) for f in _str_list(table, key, where))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _endpoint(t: dict[str, object]) -> EndpointSpec:
|
|
84
|
+
where = f"mock.endpoint {t.get('id', '?')}"
|
|
85
|
+
_keys(t, _ENDPOINT, where)
|
|
86
|
+
status = t.get("status", 200)
|
|
87
|
+
if not isinstance(status, int) or isinstance(status, bool):
|
|
88
|
+
raise SpecError(f"{where}: status must be an integer")
|
|
89
|
+
return EndpointSpec(
|
|
90
|
+
id=_str(t, "id", where),
|
|
91
|
+
credential_fields=_fields(t, "credential_fields", where),
|
|
92
|
+
status=status,
|
|
93
|
+
reply=_str(t, "reply", where, "{}"),
|
|
94
|
+
content_type=_str(t, "content_type", where, "application/json"),
|
|
95
|
+
capture=_bool(t, "capture", where, False),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _mock(t: dict[str, object]) -> MockSpec:
|
|
100
|
+
_keys(t, _MOCK, "mock")
|
|
101
|
+
fields = _table(t.get("credential_fields", {}), "mock.credential_fields")
|
|
102
|
+
return MockSpec(
|
|
103
|
+
oauth=_bool(t, "oauth", "mock", False),
|
|
104
|
+
model_reply=_str(t, "model_reply", "mock", "Done."),
|
|
105
|
+
tool_calls=_str(t, "tool_calls", "mock", "each-offered-tool-once"),
|
|
106
|
+
credential_fields={
|
|
107
|
+
name: _fields(fields, name, "mock.credential_fields") for name in fields
|
|
108
|
+
},
|
|
109
|
+
endpoints=tuple(_endpoint(e) for e in _tables(t.get("endpoint", []), "mock.endpoint")),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _placement(t: dict[str, object]) -> PlacementSpec:
|
|
114
|
+
where = f"placement {t.get('id', '?')}"
|
|
115
|
+
_keys(t, _PLACEMENT, where)
|
|
116
|
+
pid = _str(t, "id", where)
|
|
117
|
+
kind = _str(t, "kind", where)
|
|
118
|
+
variable = _opt_str(t, "variable", where)
|
|
119
|
+
if variable is None:
|
|
120
|
+
if kind == "env" and not pid.startswith("env."):
|
|
121
|
+
raise SpecError(
|
|
122
|
+
f"{where}: an env placement whose id does not start with env. names its variable"
|
|
123
|
+
)
|
|
124
|
+
variable = default_variable(pid, kind)
|
|
125
|
+
return PlacementSpec(
|
|
126
|
+
id=pid,
|
|
127
|
+
kind=kind,
|
|
128
|
+
variable=variable,
|
|
129
|
+
shape=_str(t, "shape", where, "generic"),
|
|
130
|
+
required=_bool(t, "required", where, False),
|
|
131
|
+
provider_host=_opt_str(t, "provider_host", where),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _flow(t: dict[str, object], base: Path) -> FlowSpec:
|
|
136
|
+
where = f"flow {t.get('id', '?')}"
|
|
137
|
+
_keys(t, _FLOW, where)
|
|
138
|
+
timeout = t.get("timeout_seconds", 300)
|
|
139
|
+
if not isinstance(timeout, int | float) or isinstance(timeout, bool):
|
|
140
|
+
raise SpecError(f"{where}: timeout_seconds must be a number")
|
|
141
|
+
env = _table(t.get("env", {}), f"{where} env")
|
|
142
|
+
if not all(isinstance(v, str) for v in env.values()):
|
|
143
|
+
raise SpecError(f"{where}: env values must be strings")
|
|
144
|
+
return FlowSpec(
|
|
145
|
+
id=_str(t, "id", where),
|
|
146
|
+
command=tuple(_str_list(t, "command", where)),
|
|
147
|
+
cwd=(base / _str(t, "cwd", where, ".")).resolve(),
|
|
148
|
+
timeout_seconds=float(timeout),
|
|
149
|
+
env=cast(dict[str, str], env),
|
|
150
|
+
skip=_opt_str(t, "skip", where),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load(path: Path) -> RunSpec:
|
|
155
|
+
try:
|
|
156
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
157
|
+
except OSError as exc:
|
|
158
|
+
raise SpecError(f"cannot read config {path}: {exc.strerror}") from None
|
|
159
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
160
|
+
raise SpecError(f"config {path} is not valid TOML: {exc}") from None
|
|
161
|
+
_keys(raw, _TOP, str(path))
|
|
162
|
+
base = path.resolve().parent
|
|
163
|
+
spec = RunSpec(
|
|
164
|
+
placements=tuple(_placement(p) for p in _tables(raw.get("placement", []), "placement")),
|
|
165
|
+
flows=tuple(_flow(f, base) for f in _tables(raw.get("flow", []), "flow")),
|
|
166
|
+
mock=_mock(_table(raw.get("mock", {}), "mock")),
|
|
167
|
+
)
|
|
168
|
+
validate(spec)
|
|
169
|
+
return spec
|
weir_run/flows.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Running one flow: an allowlisted environment and a bounded subprocess.
|
|
2
|
+
|
|
3
|
+
A flow starts from an empty environment, the `env -i` of the contract, and
|
|
4
|
+
gets only what is listed here: PATH, a private HOME and temporary
|
|
5
|
+
directory, a fixed locale, its planted canaries, the mock's URLs and its
|
|
6
|
+
own non-secret settings from the config. Nothing else from the job's
|
|
7
|
+
environment reaches it, so a real provider key in CI secrets never does.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from weir_run.spec import FlowSpec
|
|
21
|
+
|
|
22
|
+
PYTHON_TOKEN = "{python}"
|
|
23
|
+
MOCK_TOKEN = "{mock}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def flow_environment(
|
|
27
|
+
mock_url: str,
|
|
28
|
+
planted: Mapping[str, str],
|
|
29
|
+
extra: Mapping[str, str],
|
|
30
|
+
home: Path,
|
|
31
|
+
tmp: Path,
|
|
32
|
+
) -> dict[str, str]:
|
|
33
|
+
"""The whole environment of a flow: variable name -> value."""
|
|
34
|
+
env = {
|
|
35
|
+
"PATH": os.environ.get("PATH", os.defpath),
|
|
36
|
+
"HOME": str(home),
|
|
37
|
+
"TMPDIR": str(tmp),
|
|
38
|
+
"TEMP": str(tmp),
|
|
39
|
+
"TMP": str(tmp),
|
|
40
|
+
"LANG": "C.UTF-8",
|
|
41
|
+
"PYTHONUTF8": "1",
|
|
42
|
+
"CANARY_MOCK_URL": mock_url,
|
|
43
|
+
"OPENAI_BASE_URL": f"{mock_url}/v1",
|
|
44
|
+
"OPENAI_API_BASE": f"{mock_url}/v1",
|
|
45
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT": mock_url,
|
|
46
|
+
"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
|
|
47
|
+
}
|
|
48
|
+
for signal_name in ("TRACES", "LOGS", "METRICS"):
|
|
49
|
+
env[f"OTEL_EXPORTER_OTLP_{signal_name}_ENDPOINT"] = f"{mock_url}/v1/{signal_name.lower()}"
|
|
50
|
+
env[f"OTEL_EXPORTER_OTLP_{signal_name}_PROTOCOL"] = "http/protobuf"
|
|
51
|
+
# Every client that honours the proxy variables reaches other hosts only
|
|
52
|
+
# through the mock, which refuses them. The mock itself is reached direct.
|
|
53
|
+
for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
|
|
54
|
+
env[name] = env[name.lower()] = mock_url
|
|
55
|
+
env["NO_PROXY"] = env["no_proxy"] = "127.0.0.1,localhost"
|
|
56
|
+
if sys.platform == "win32":
|
|
57
|
+
env["USERPROFILE"] = str(home)
|
|
58
|
+
# Windows processes cannot start without these; neither holds a secret.
|
|
59
|
+
for name in ("SYSTEMROOT", "PATHEXT"):
|
|
60
|
+
if name in os.environ:
|
|
61
|
+
env[name] = os.environ[name]
|
|
62
|
+
env.update(planted)
|
|
63
|
+
env.update({k: v.replace(MOCK_TOKEN, mock_url) for k, v in extra.items()})
|
|
64
|
+
return env
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def command_line(flow: FlowSpec, python: str) -> list[str]:
|
|
68
|
+
return [python if part == PYTHON_TOKEN else part for part in flow.command]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run(flow: FlowSpec, env: Mapping[str, str], stdout: Path, stderr: Path, python: str) -> str:
|
|
72
|
+
"""Run a flow to completion; return its ledger status."""
|
|
73
|
+
if flow.skip is not None:
|
|
74
|
+
return "skipped"
|
|
75
|
+
with stdout.open("wb") as out, stderr.open("wb") as err:
|
|
76
|
+
# Its own process group, so a timeout or the end of the flow can
|
|
77
|
+
# stop everything it started.
|
|
78
|
+
options: dict[str, Any] = {"cwd": flow.cwd, "env": dict(env), "stdout": out, "stderr": err}
|
|
79
|
+
if sys.platform == "win32":
|
|
80
|
+
options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
81
|
+
else:
|
|
82
|
+
options["start_new_session"] = True
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.Popen(command_line(flow, python), stdin=subprocess.DEVNULL, **options)
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
err.write(f"weir-run: could not start the flow: {exc.strerror}\n".encode())
|
|
87
|
+
return "failed"
|
|
88
|
+
try:
|
|
89
|
+
code = proc.wait(timeout=flow.timeout_seconds)
|
|
90
|
+
status = "ok" if code == 0 else "failed"
|
|
91
|
+
except subprocess.TimeoutExpired:
|
|
92
|
+
status = "timeout"
|
|
93
|
+
finally:
|
|
94
|
+
# A flow's leftover children do not outlive it: the mock and the
|
|
95
|
+
# telemetry receiver exist only for the length of the run.
|
|
96
|
+
if sys.platform == "win32":
|
|
97
|
+
proc.kill()
|
|
98
|
+
else:
|
|
99
|
+
try:
|
|
100
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
101
|
+
except (ProcessLookupError, PermissionError):
|
|
102
|
+
pass
|
|
103
|
+
proc.wait()
|
|
104
|
+
return status
|