fherma-runner 0.2.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.
- fherma_runner/__init__.py +8 -0
- fherma_runner/__main__.py +129 -0
- fherma_runner/api.py +80 -0
- fherma_runner/docker.py +146 -0
- fherma_runner/job.py +226 -0
- fherma_runner/log.py +104 -0
- fherma_runner/machine.py +143 -0
- fherma_runner/measure.py +277 -0
- fherma_runner/session.py +155 -0
- fherma_runner/workspace.py +127 -0
- fherma_runner-0.2.0.dist-info/METADATA +124 -0
- fherma_runner-0.2.0.dist-info/RECORD +16 -0
- fherma_runner-0.2.0.dist-info/WHEEL +5 -0
- fherma_runner-0.2.0.dist-info/entry_points.txt +2 -0
- fherma_runner-0.2.0.dist-info/licenses/LICENSE +202 -0
- fherma_runner-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""A FHERMA runner: takes measurement jobs and reports what happened.
|
|
2
|
+
|
|
3
|
+
Nothing here imports anything that does not ship with Python, and docker is
|
|
4
|
+
called as a command rather than through a library. Installing this on a server
|
|
5
|
+
is copying a directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The command line, and the loop that outlives everything under it.
|
|
2
|
+
|
|
3
|
+
fherma-runner --api https://fherma.io --name bench-01
|
|
4
|
+
fherma-runner --once one assignment, then stop
|
|
5
|
+
fherma-runner --re-register forget the key and join again
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from importlib import metadata
|
|
15
|
+
|
|
16
|
+
from . import job, machine
|
|
17
|
+
from .api import Api, Refused, Unreachable
|
|
18
|
+
from .session import KEYFILE, describe, identify
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def version() -> str:
|
|
22
|
+
"""What is installed, asked of the installation rather than hardcoded."""
|
|
23
|
+
try:
|
|
24
|
+
return metadata.version("fherma-runner")
|
|
25
|
+
except metadata.PackageNotFoundError:
|
|
26
|
+
return "unknown (running from a checkout)"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse(argv: list[str] | None = None) -> argparse.Namespace:
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="fherma-runner",
|
|
32
|
+
description="Takes measurement jobs from a FHERMA platform and reports what happened.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--api", default=os.environ.get("FHERMA_API", "http://localhost:4000"))
|
|
35
|
+
parser.add_argument("--name", default=os.environ.get("FHERMA_RUNNER_NAME", "runner"))
|
|
36
|
+
parser.add_argument("--interval", type=float, default=3.0, help="seconds between polls")
|
|
37
|
+
parser.add_argument("--once", action="store_true", help="one assignment, then stop")
|
|
38
|
+
parser.add_argument("--token", help="an invitation, exchanged once for this machine's key")
|
|
39
|
+
parser.add_argument("--re-register", action="store_true", help="forget the key and join again")
|
|
40
|
+
parser.add_argument("--describe", action="store_true", help="print what this machine is, and stop")
|
|
41
|
+
# The first question anybody asks after an install that may not have worked.
|
|
42
|
+
# One line rather than three, because argparse reflows the text it prints
|
|
43
|
+
# and a newline here comes out as a space.
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--version",
|
|
46
|
+
action="version",
|
|
47
|
+
version=f"fherma-runner {version()} · python {sys.version.split()[0]}",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
limits = parser.add_argument_group(
|
|
51
|
+
"limits",
|
|
52
|
+
"What a solution's container may take. The platform sets the time limits; "
|
|
53
|
+
"these are the machine's own.",
|
|
54
|
+
)
|
|
55
|
+
limits.add_argument("--memory", default=os.environ.get("FHERMA_MEMORY", "8g"))
|
|
56
|
+
limits.add_argument("--cpus", default=os.environ.get("FHERMA_CPUS"))
|
|
57
|
+
|
|
58
|
+
return parser.parse_args(argv)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
options = parse(argv)
|
|
63
|
+
|
|
64
|
+
if options.describe:
|
|
65
|
+
for name, value in machine.describe().items():
|
|
66
|
+
print(f"{name:12} {value}")
|
|
67
|
+
print(f"{'can':12} {', '.join(one['name'] for one in machine.capabilities()) or 'nothing'}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
if options.re_register and KEYFILE.exists():
|
|
71
|
+
KEYFILE.unlink()
|
|
72
|
+
|
|
73
|
+
api = Api(options.api)
|
|
74
|
+
|
|
75
|
+
while True:
|
|
76
|
+
try:
|
|
77
|
+
api.key = identify(api, options.name, options.token)
|
|
78
|
+
break
|
|
79
|
+
except Refused as failure:
|
|
80
|
+
print(f"could not register: {failure}", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
except Unreachable as failure:
|
|
83
|
+
# A runner boots when its machine boots, which may be before the
|
|
84
|
+
# platform is up. Waiting is the correct answer; exiting would need
|
|
85
|
+
# somebody to notice and start it again.
|
|
86
|
+
print(f"waiting for {options.api}: {failure}", file=sys.stderr)
|
|
87
|
+
time.sleep(options.interval)
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
describe(api)
|
|
91
|
+
except Refused as failure:
|
|
92
|
+
if failure.status in (401, 403):
|
|
93
|
+
print("the key was refused — try --re-register", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
raise
|
|
96
|
+
except Unreachable as failure:
|
|
97
|
+
print(f"could not describe this machine: {failure}", file=sys.stderr)
|
|
98
|
+
|
|
99
|
+
print(f"polling {options.api} every {options.interval}s — ctrl-c to stop")
|
|
100
|
+
|
|
101
|
+
while True:
|
|
102
|
+
try:
|
|
103
|
+
did = job.take(api, options)
|
|
104
|
+
except Refused as failure:
|
|
105
|
+
# A lapsed lease is the ordinary case: stop working on it and ask
|
|
106
|
+
# again rather than reporting into a result somebody else now owns.
|
|
107
|
+
print(f" refused ({failure.status}) — dropping this one", file=sys.stderr)
|
|
108
|
+
did = False
|
|
109
|
+
except Unreachable as failure:
|
|
110
|
+
print(f" cannot reach {options.api}: {failure}", file=sys.stderr)
|
|
111
|
+
did = False
|
|
112
|
+
|
|
113
|
+
if did and options.once:
|
|
114
|
+
return 0
|
|
115
|
+
if not did:
|
|
116
|
+
print(".", end="", flush=True)
|
|
117
|
+
time.sleep(options.interval)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def run() -> None:
|
|
121
|
+
"""The console entry point. Ctrl-C is an ending, not a crash."""
|
|
122
|
+
try:
|
|
123
|
+
raise SystemExit(main())
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
print()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
run()
|
fherma_runner/api.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Talking to the platform, and the three ways it can go wrong.
|
|
2
|
+
|
|
3
|
+
The runner is the only one of the three programs on a machine that speaks to
|
|
4
|
+
the platform at all. The bundle is handed a directory; the solution is handed a
|
|
5
|
+
directory; neither has a key and neither has a network.
|
|
6
|
+
|
|
7
|
+
Nothing here is imported that does not ship with Python. A machine that runs
|
|
8
|
+
this needs nothing installed beyond an interpreter and docker.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Refused(Exception):
|
|
19
|
+
"""The platform answered, and the answer was no."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
22
|
+
super().__init__(f"{status}: {detail}")
|
|
23
|
+
self.status = status
|
|
24
|
+
self.detail = detail
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Unreachable(Exception):
|
|
28
|
+
"""The platform did not answer. Says nothing about the work."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Dropped(Exception):
|
|
32
|
+
"""This assignment is no longer ours. Stop, and send nothing."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Api:
|
|
36
|
+
"""The calls a runner makes, and the one that gets it a key.
|
|
37
|
+
|
|
38
|
+
Errors are not swallowed. A runner that carries on after a refusal is a
|
|
39
|
+
runner reporting into somebody else's result, and the refusals here mean
|
|
40
|
+
exactly that: the lease lapsed, the assignment went elsewhere.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, base: str, key: str | None = None, timeout: float = 30) -> None:
|
|
44
|
+
self.base = base.rstrip("/")
|
|
45
|
+
self.key = key
|
|
46
|
+
self.timeout = timeout
|
|
47
|
+
|
|
48
|
+
def call(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
49
|
+
raw = self._request(method, path, body)
|
|
50
|
+
return json.loads(raw) if raw else {}
|
|
51
|
+
|
|
52
|
+
def download(self, path: str) -> bytes:
|
|
53
|
+
"""A file rather than a document — the bundle archive, and only that."""
|
|
54
|
+
return self._request("GET", path, None, decode=False)
|
|
55
|
+
|
|
56
|
+
def _request(self, method: str, path: str, body: dict | None, decode: bool = True):
|
|
57
|
+
request = urllib.request.Request(
|
|
58
|
+
f"{self.base}/api{path}",
|
|
59
|
+
method=method,
|
|
60
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
61
|
+
headers={
|
|
62
|
+
"content-type": "application/json",
|
|
63
|
+
**({"x-fherma-runner": self.key} if self.key else {}),
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as answer:
|
|
69
|
+
raw = answer.read()
|
|
70
|
+
return raw.decode() if decode else raw
|
|
71
|
+
except urllib.error.HTTPError as failure:
|
|
72
|
+
detail = failure.read().decode()[:300]
|
|
73
|
+
raise Refused(failure.code, detail) from None
|
|
74
|
+
except OSError as failure:
|
|
75
|
+
# Refused connections arrive as URLError; a read that times out
|
|
76
|
+
# half-way through arrives as a bare TimeoutError, and a platform
|
|
77
|
+
# restarting mid-request produces both. They mean one thing to a
|
|
78
|
+
# runner — nobody answered — and a machine meant to sit in a rack
|
|
79
|
+
# for weeks must not die of any of them.
|
|
80
|
+
raise Unreachable(str(getattr(failure, "reason", None) or failure)) from None
|
fherma_runner/docker.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Running containers, by calling the command rather than a library.
|
|
2
|
+
|
|
3
|
+
A library would be a dependency, and the point of having none is that putting
|
|
4
|
+
this on a server is copying a directory. `docker` is already there — a machine
|
|
5
|
+
that cannot run containers cannot be a runner at all.
|
|
6
|
+
|
|
7
|
+
Everything here is one container, started and finished. Nothing is left
|
|
8
|
+
running, and nothing is reused between points: a process that outlived its
|
|
9
|
+
point would be measuring the next one with the previous one's memory.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DockerMissing(Exception):
|
|
21
|
+
"""No docker on this machine, which is not something to work around."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Timeout(Exception):
|
|
25
|
+
"""It ran past its limit and was killed."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Mount:
|
|
30
|
+
"""One path from the host, and whether the container may write to it."""
|
|
31
|
+
|
|
32
|
+
source: Path
|
|
33
|
+
target: str
|
|
34
|
+
write: bool = False
|
|
35
|
+
|
|
36
|
+
def arg(self) -> str:
|
|
37
|
+
return f"{self.source}:{self.target}{'' if self.write else ':ro'}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Limits:
|
|
42
|
+
"""What a container may take. None leaves docker's own default."""
|
|
43
|
+
|
|
44
|
+
memory: str | None = None
|
|
45
|
+
cpus: str | None = None
|
|
46
|
+
pids: int = 512
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Result:
|
|
51
|
+
code: int
|
|
52
|
+
out: str
|
|
53
|
+
err: str
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return self.code == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def available() -> bool:
|
|
61
|
+
return shutil.which("docker") is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pull(reference: str, timeout: float = 900) -> Result:
|
|
65
|
+
"""Fetch an image by digest.
|
|
66
|
+
|
|
67
|
+
By digest, not by tag: a tag is a pointer, and an image rebuilt under the
|
|
68
|
+
same tag would silently become what a past measurement claims to have run
|
|
69
|
+
in.
|
|
70
|
+
"""
|
|
71
|
+
return _run(["docker", "pull", "--quiet", reference], timeout)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run(
|
|
75
|
+
image: str,
|
|
76
|
+
command: list[str],
|
|
77
|
+
mounts: list[Mount] | None = None,
|
|
78
|
+
workdir: str | None = None,
|
|
79
|
+
limits: Limits | None = None,
|
|
80
|
+
network: bool = False,
|
|
81
|
+
timeout: float = 600,
|
|
82
|
+
name: str | None = None,
|
|
83
|
+
) -> Result:
|
|
84
|
+
"""One container, to completion.
|
|
85
|
+
|
|
86
|
+
Network is off unless asked for. The only stage that needs it is the build,
|
|
87
|
+
where dependencies are fetched; a measurement that could reach the network
|
|
88
|
+
could fetch its answer.
|
|
89
|
+
"""
|
|
90
|
+
argv = ["docker", "run", "--rm"]
|
|
91
|
+
|
|
92
|
+
if name:
|
|
93
|
+
argv += ["--name", name]
|
|
94
|
+
if not network:
|
|
95
|
+
argv += ["--network", "none"]
|
|
96
|
+
|
|
97
|
+
limits = limits or Limits()
|
|
98
|
+
if limits.memory:
|
|
99
|
+
argv += ["--memory", limits.memory]
|
|
100
|
+
if limits.cpus:
|
|
101
|
+
argv += ["--cpus", limits.cpus]
|
|
102
|
+
if limits.pids:
|
|
103
|
+
argv += ["--pids-limit", str(limits.pids)]
|
|
104
|
+
|
|
105
|
+
# Author code, even when the fleet is ours.
|
|
106
|
+
argv += ["--cap-drop", "ALL", "--security-opt", "no-new-privileges"]
|
|
107
|
+
|
|
108
|
+
# The command is complete: the bundle's comes from its record, the
|
|
109
|
+
# solution's from its language. An image with an ENTRYPOINT would prepend
|
|
110
|
+
# to it — `python` in front of `python main.py` — so it is cleared.
|
|
111
|
+
argv += ["--entrypoint", ""]
|
|
112
|
+
|
|
113
|
+
for mount in mounts or []:
|
|
114
|
+
mount.source.mkdir(parents=True, exist_ok=True) if mount.write else None
|
|
115
|
+
argv += ["-v", mount.arg()]
|
|
116
|
+
|
|
117
|
+
if workdir:
|
|
118
|
+
argv += ["-w", workdir]
|
|
119
|
+
|
|
120
|
+
argv.append(image)
|
|
121
|
+
argv += command
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return _run(argv, timeout)
|
|
125
|
+
except Timeout:
|
|
126
|
+
if name:
|
|
127
|
+
# The container outlives the client that started it, so killing the
|
|
128
|
+
# subprocess is not killing the work.
|
|
129
|
+
_run(["docker", "kill", name], timeout=30)
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _run(argv: list[str], timeout: float) -> Result:
|
|
134
|
+
if not available():
|
|
135
|
+
raise DockerMissing("docker is not on the path")
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
done = subprocess.run(
|
|
139
|
+
argv, capture_output=True, text=True, timeout=timeout, check=False
|
|
140
|
+
)
|
|
141
|
+
except subprocess.TimeoutExpired as expired:
|
|
142
|
+
raise Timeout(f"ran past {timeout:.0f}s") from expired
|
|
143
|
+
except OSError as failure:
|
|
144
|
+
raise DockerMissing(str(failure)) from failure
|
|
145
|
+
|
|
146
|
+
return Result(code=done.returncode, out=done.stdout or "", err=done.stderr or "")
|
fherma_runner/job.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""One assignment, start to finish.
|
|
2
|
+
|
|
3
|
+
Take the job, fetch what it names, build once, then a point at a time: make the
|
|
4
|
+
cases, answer them, judge the answers, report, delete. Report per point, so a
|
|
5
|
+
run that dies on the seventh of ten keeps the six it finished.
|
|
6
|
+
|
|
7
|
+
Send nothing at all if the assignment stopped being ours. Finishing the work
|
|
8
|
+
and reporting it would be reporting into a result somebody else now owns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import shutil
|
|
15
|
+
|
|
16
|
+
from . import docker, measure, workspace
|
|
17
|
+
from .api import Api, Dropped, Refused
|
|
18
|
+
from .docker import Limits
|
|
19
|
+
from .log import Log
|
|
20
|
+
from .measure import Environment, Plan
|
|
21
|
+
from .session import beat
|
|
22
|
+
from .workspace import Failed, Workspace
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def take(api: Api, options: argparse.Namespace) -> bool:
|
|
26
|
+
"""One assignment. False when there was nothing to do."""
|
|
27
|
+
answer = api.call("GET", "/runners/me/work")
|
|
28
|
+
work = answer.get("work")
|
|
29
|
+
if not work:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
run = work["run"]
|
|
33
|
+
log = Log()
|
|
34
|
+
place = Workspace.make(run)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
return _do(api, work, place, log, options)
|
|
38
|
+
except Dropped as why:
|
|
39
|
+
log.warn(f"dropped: {why}")
|
|
40
|
+
return True
|
|
41
|
+
except Failed as why:
|
|
42
|
+
log.fail(str(why))
|
|
43
|
+
_give_up(api, run, str(why), log)
|
|
44
|
+
return True
|
|
45
|
+
except docker.DockerMissing as why:
|
|
46
|
+
log.fail(f"docker: {why}")
|
|
47
|
+
_give_up(api, run, f"this machine cannot run containers: {why}", log)
|
|
48
|
+
return True
|
|
49
|
+
finally:
|
|
50
|
+
place.clear()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _do(api: Api, work: dict, place: Workspace, log: Log, options) -> bool:
|
|
54
|
+
run = work["run"]
|
|
55
|
+
points = [Plan(**{k: one[k] for k in ("id", "point", "seeds")}) for one in work.get("points") or []]
|
|
56
|
+
where = _environment(work)
|
|
57
|
+
limits = Limits(memory=options.memory, cpus=options.cpus)
|
|
58
|
+
seconds = work.get("limits") or {}
|
|
59
|
+
|
|
60
|
+
log.done(f"run {run[:8]} · {len(points)} point(s) · {sum(len(p.seeds) for p in points)} case(s)")
|
|
61
|
+
log.info(f"solution runs in {where.solution}")
|
|
62
|
+
log.info(f"bundle runs in {where.bundle}")
|
|
63
|
+
|
|
64
|
+
log.step("pulling images")
|
|
65
|
+
for image in {where.solution, where.bundle}:
|
|
66
|
+
pulled = docker.pull(image)
|
|
67
|
+
if not pulled.ok:
|
|
68
|
+
raise Failed(f"could not pull {image}: {pulled.err.strip()[:300]}")
|
|
69
|
+
log.ok("images ready")
|
|
70
|
+
|
|
71
|
+
log.step("fetching the bundle")
|
|
72
|
+
workspace.fetch_bundle(api, place, work.get("bundle") or {})
|
|
73
|
+
log.ok(f"bundle unpacked · {len(list(place.bundle.iterdir()))} file(s)")
|
|
74
|
+
|
|
75
|
+
log.step("cloning the solution")
|
|
76
|
+
source = work.get("source") or {}
|
|
77
|
+
workspace.clone(place, source)
|
|
78
|
+
log.ok(f"{source.get('repo')} at {(source.get('commit') or '')[:12]}")
|
|
79
|
+
|
|
80
|
+
laid = workspace.lay_harness(place, work.get("harness") or [])
|
|
81
|
+
if laid:
|
|
82
|
+
log.info(f"harness written over the clone: {', '.join(laid)}")
|
|
83
|
+
|
|
84
|
+
_build(place, work, where, float(seconds.get("build_s", 600)), log)
|
|
85
|
+
|
|
86
|
+
passed = counted = 0
|
|
87
|
+
for index, plan in enumerate(points, start=1):
|
|
88
|
+
beat(api, run, f"point {index} of {len(points)} · {plan.id}", "running")
|
|
89
|
+
|
|
90
|
+
measured = measure.one_point(
|
|
91
|
+
place, plan, where, limits, float(seconds.get("point_s", 300)), log
|
|
92
|
+
)
|
|
93
|
+
cases = measured.cases
|
|
94
|
+
_say(log, plan, cases)
|
|
95
|
+
|
|
96
|
+
passed += sum(1 for one in cases if one["passed"])
|
|
97
|
+
counted += len(cases)
|
|
98
|
+
last = index == len(points)
|
|
99
|
+
|
|
100
|
+
if last:
|
|
101
|
+
log.done(f"{passed} of {counted} seeds passed")
|
|
102
|
+
|
|
103
|
+
if not _report(api, run, cases, measured.init_s, log, done=last):
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
# The point is reported; the data can be made again from the bundle,
|
|
107
|
+
# the point and the seed at any time. One point on disk, whatever the
|
|
108
|
+
# plan asked for.
|
|
109
|
+
shutil.rmtree(place.point(plan.id), ignore_errors=True)
|
|
110
|
+
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _environment(work: dict) -> Environment:
|
|
115
|
+
image = work.get("image") or {}
|
|
116
|
+
bundle = work.get("bundle") or {}
|
|
117
|
+
bundle_image = bundle.get("image")
|
|
118
|
+
|
|
119
|
+
solution = image.get("reference") if isinstance(image, dict) else image
|
|
120
|
+
if not solution:
|
|
121
|
+
raise Failed("the job names no image for the solution")
|
|
122
|
+
if not bundle_image:
|
|
123
|
+
raise Failed("the job names no image for the bundle")
|
|
124
|
+
|
|
125
|
+
return Environment(
|
|
126
|
+
solution=solution,
|
|
127
|
+
bundle=bundle_image,
|
|
128
|
+
bundle_command=bundle.get("command") or "python main.py",
|
|
129
|
+
run_command=work.get("run_cmd") or "python main.py",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build(place: Workspace, work: dict, where: Environment, limit: float, log: Log) -> None:
|
|
134
|
+
"""Once for the whole job. The same binary answers every point."""
|
|
135
|
+
command = work.get("build")
|
|
136
|
+
if not command:
|
|
137
|
+
log.info("nothing to build")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
log.step(f"building · {command}")
|
|
141
|
+
try:
|
|
142
|
+
done = docker.run(
|
|
143
|
+
where.solution,
|
|
144
|
+
["sh", "-c", command],
|
|
145
|
+
mounts=[docker.Mount(place.solution, "/solution", write=True)],
|
|
146
|
+
workdir="/solution",
|
|
147
|
+
# The one stage that may reach the network: a build fetches
|
|
148
|
+
# dependencies. Measurement never does.
|
|
149
|
+
network=True,
|
|
150
|
+
timeout=limit,
|
|
151
|
+
)
|
|
152
|
+
except docker.Timeout as expired:
|
|
153
|
+
raise Failed(f"the build ran past {limit:.0f}s") from expired
|
|
154
|
+
|
|
155
|
+
if not done.ok:
|
|
156
|
+
tail = (done.err or done.out).strip().splitlines()[-12:]
|
|
157
|
+
for line in tail:
|
|
158
|
+
log.fail(line[:200])
|
|
159
|
+
raise Failed(f"the build failed with {done.code}")
|
|
160
|
+
|
|
161
|
+
log.ok("built")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _say(log: Log, plan: Plan, cases: list[dict]) -> None:
|
|
165
|
+
timed = [one["seconds"] for one in cases if one.get("seconds")]
|
|
166
|
+
good = sum(1 for one in cases if one["passed"])
|
|
167
|
+
|
|
168
|
+
if not timed:
|
|
169
|
+
log.fail(f"{plan.id}: nothing came back")
|
|
170
|
+
elif good == len(cases):
|
|
171
|
+
log.ok(f"{plan.id}: {good}/{len(cases)} · {_span(timed)}")
|
|
172
|
+
elif good == 0:
|
|
173
|
+
# Not a shade of partial success. Every seed was wrong, which is as
|
|
174
|
+
# failed as nothing coming back at all.
|
|
175
|
+
log.fail(f"{plan.id}: 0/{len(cases)} within tolerance · {_span(timed)}")
|
|
176
|
+
else:
|
|
177
|
+
log.warn(f"{plan.id}: {good}/{len(cases)} within tolerance · {_span(timed)}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _span(timed: list[float]) -> str:
|
|
181
|
+
"""The range, in a unit that has digits in it.
|
|
182
|
+
|
|
183
|
+
Two decimals of milliseconds turn a fast implementation into `0.00 ms`,
|
|
184
|
+
which reads the same as a measurement that failed.
|
|
185
|
+
"""
|
|
186
|
+
return f"{_time(min(timed))} – {_time(max(timed))}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _time(seconds: float) -> str:
|
|
190
|
+
for over, scale, name in ((60, 1 / 60, "min"), (1, 1, "s"), (1e-3, 1e3, "ms"),
|
|
191
|
+
(1e-6, 1e6, "µs"), (0, 1e9, "ns")):
|
|
192
|
+
if abs(seconds) >= over:
|
|
193
|
+
value = seconds * scale
|
|
194
|
+
digits = 0 if abs(value) >= 100 else 1 if abs(value) >= 10 else 2
|
|
195
|
+
return f"{round(value, digits):g} {name}"
|
|
196
|
+
return "0"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _report(
|
|
200
|
+
api: Api, run: str, cases: list[dict], init_s: float | None, log: Log, done: bool
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""One point's worth. False when the assignment was taken away."""
|
|
203
|
+
try:
|
|
204
|
+
api.call(
|
|
205
|
+
"POST",
|
|
206
|
+
f"/runners/me/runs/{run}/report",
|
|
207
|
+
{"cases": cases, "init_s": init_s, "build_log": log.text(), "done": done},
|
|
208
|
+
)
|
|
209
|
+
return True
|
|
210
|
+
except Refused as failure:
|
|
211
|
+
if failure.status in (401, 403, 404):
|
|
212
|
+
log.warn(f"taken away before the report landed: {failure.detail}")
|
|
213
|
+
return False
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _give_up(api: Api, run: str, reason: str, log: Log) -> None:
|
|
218
|
+
"""Say why there is nothing, so the page shows a reason and not a silence."""
|
|
219
|
+
try:
|
|
220
|
+
api.call(
|
|
221
|
+
"POST",
|
|
222
|
+
f"/runners/me/runs/{run}/fail",
|
|
223
|
+
{"reason": reason[:4000], "build_log": log.text()},
|
|
224
|
+
)
|
|
225
|
+
except (Refused, Exception): # noqa: BLE001 — already failing
|
|
226
|
+
pass
|
fherma_runner/log.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""What the runner says while it works.
|
|
2
|
+
|
|
3
|
+
Two audiences, one source. A person watching a terminal wants to see where the
|
|
4
|
+
work has got to; the platform wants a transcript it can show on a page months
|
|
5
|
+
later. Both are written here at the same moment, so the page cannot disagree
|
|
6
|
+
with what the operator saw.
|
|
7
|
+
|
|
8
|
+
Colour goes to the terminal and never into the transcript: escape codes stored
|
|
9
|
+
in a database are a page rendering `[32m` at somebody.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
#: Off when the output is not a terminal, so a log file or a pipe stays plain.
|
|
19
|
+
COLOUR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
20
|
+
|
|
21
|
+
_CODES = {
|
|
22
|
+
"grey": "\033[90m",
|
|
23
|
+
"red": "\033[31m",
|
|
24
|
+
"green": "\033[32m",
|
|
25
|
+
"amber": "\033[33m",
|
|
26
|
+
"blue": "\033[34m",
|
|
27
|
+
"bold": "\033[1m",
|
|
28
|
+
"off": "\033[0m",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
#: How each level is painted, and how wide the column is.
|
|
32
|
+
_LEVELS = {
|
|
33
|
+
"INFO": "grey",
|
|
34
|
+
"STEP": "blue",
|
|
35
|
+
"OK": "green",
|
|
36
|
+
"WARN": "amber",
|
|
37
|
+
"FAIL": "red",
|
|
38
|
+
"DONE": "bold",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def paint(text: str, colour: str) -> str:
|
|
43
|
+
if not COLOUR or colour not in _CODES:
|
|
44
|
+
return text
|
|
45
|
+
return f"{_CODES[colour]}{text}{_CODES['off']}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Log:
|
|
49
|
+
"""A transcript that prints as it is written.
|
|
50
|
+
|
|
51
|
+
Kept because a run carries points and timings and not why the build took
|
|
52
|
+
four minutes or which seed the machine was on when it stopped. Written
|
|
53
|
+
while the work happens rather than assembled afterwards, so a run that dies
|
|
54
|
+
half way still leaves everything up to the moment it died.
|
|
55
|
+
|
|
56
|
+
One line per event: the clock, a level, the sentence. The page reads the
|
|
57
|
+
three parts into three columns.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, quiet: bool = False) -> None:
|
|
61
|
+
self.lines: list[str] = []
|
|
62
|
+
self.quiet = quiet
|
|
63
|
+
|
|
64
|
+
def say(self, level: str, text: str) -> None:
|
|
65
|
+
stamp = time.strftime("%H:%M:%S")
|
|
66
|
+
self.lines.append(f"{stamp} {level} {text}")
|
|
67
|
+
|
|
68
|
+
if self.quiet:
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
colour = _LEVELS.get(level, "grey")
|
|
72
|
+
print(
|
|
73
|
+
f"{paint(stamp, 'grey')} {paint(level.ljust(4), colour)} {text}",
|
|
74
|
+
flush=True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def info(self, text: str) -> None:
|
|
78
|
+
self.say("INFO", text)
|
|
79
|
+
|
|
80
|
+
def step(self, text: str) -> None:
|
|
81
|
+
"""A stage beginning. Printed apart so the shape of the work is visible."""
|
|
82
|
+
self.say("STEP", paint(text, "bold") if COLOUR else text)
|
|
83
|
+
|
|
84
|
+
def ok(self, text: str) -> None:
|
|
85
|
+
self.say("OK", text)
|
|
86
|
+
|
|
87
|
+
def warn(self, text: str) -> None:
|
|
88
|
+
self.say("WARN", text)
|
|
89
|
+
|
|
90
|
+
def fail(self, text: str) -> None:
|
|
91
|
+
self.say("FAIL", text)
|
|
92
|
+
|
|
93
|
+
def done(self, text: str) -> None:
|
|
94
|
+
self.say("DONE", text)
|
|
95
|
+
|
|
96
|
+
def text(self) -> str:
|
|
97
|
+
"""The transcript, without colour. This is what the platform stores."""
|
|
98
|
+
return "\n".join(_plain(line) for line in self.lines)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _plain(line: str) -> str:
|
|
102
|
+
for code in _CODES.values():
|
|
103
|
+
line = line.replace(code, "")
|
|
104
|
+
return line
|