puremethod 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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
executor/ssh.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""`ssh` backend (05-executor.md §6): a machine the user owns, reached over SSH.
|
|
2
|
+
|
|
3
|
+
Isolation is declared from the host configuration: `container` when jobs run in Docker on
|
|
4
|
+
the host, otherwise `none` (§6). The deadline is enforced on the host by `timeout`, so it
|
|
5
|
+
holds if the engine or the connection goes away (INV-2, §9). Credentials reach the job as
|
|
6
|
+
`export` lines on the remote shell's stdin: never on a command line, never on disk (INV-7).
|
|
7
|
+
Host requirements (§9, KB-015): a POSIX shell, `tar`, `timeout`; Docker for containers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import io
|
|
14
|
+
import shlex
|
|
15
|
+
import stat
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import paramiko
|
|
21
|
+
|
|
22
|
+
from .base import CredentialLookup, Executor
|
|
23
|
+
from .common import TAG_FILE, cap, check_tag, cost, process_text, redact, sanitized_tar
|
|
24
|
+
from .common import classify as classify_status
|
|
25
|
+
from .local_process import _tar
|
|
26
|
+
from .types import (
|
|
27
|
+
DEFAULT_IMAGE,
|
|
28
|
+
METRICS_FILE,
|
|
29
|
+
EnvRef,
|
|
30
|
+
EnvSpec,
|
|
31
|
+
ExecutorInfo,
|
|
32
|
+
JobResult,
|
|
33
|
+
JobSpec,
|
|
34
|
+
Network,
|
|
35
|
+
PreflightReport,
|
|
36
|
+
Resources,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
LOST_AFTER = 3 # consecutive failed polls before a job is `lost` (§4)
|
|
40
|
+
|
|
41
|
+
# Kills every process in a session: `pkill -s` where available, else a /proc scan (Linux).
|
|
42
|
+
KILL_SESSION = (
|
|
43
|
+
'_pm_kill_session() { [ -n "$1" ] || return 0; '
|
|
44
|
+
'if command -v pkill >/dev/null 2>&1; then pkill -KILL -s "$1" 2>/dev/null; '
|
|
45
|
+
'else for s in /proc/[0-9]*/stat; do set -- "$1" $(cat "$s" 2>/dev/null); '
|
|
46
|
+
'[ "$7" = "$1" ] && kill -KILL "$2" 2>/dev/null; done; fi; return 0; }; '
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class _Remote:
|
|
52
|
+
job_id: str
|
|
53
|
+
dir: str
|
|
54
|
+
deadline_s: float
|
|
55
|
+
killed: bool = False
|
|
56
|
+
failures: int = 0
|
|
57
|
+
lost: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_key(text: str) -> paramiko.PKey:
|
|
61
|
+
last: Exception | None = None
|
|
62
|
+
for cls in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
|
|
63
|
+
try:
|
|
64
|
+
return cls.from_private_key(io.StringIO(text))
|
|
65
|
+
except (paramiko.SSHException, ValueError) as exc:
|
|
66
|
+
last = exc
|
|
67
|
+
raise ValueError(f"unsupported or invalid private key ({type(last).__name__})")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _JumpFailed(OSError):
|
|
71
|
+
"""A jump host failed (§5 step 10). The message names the hop, never server text."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, host: str, port: int, user: str, cause: Exception,
|
|
74
|
+
onward: str | None = None) -> None:
|
|
75
|
+
if onward is not None:
|
|
76
|
+
reason = f"jump host {host} cannot reach {onward} ({type(cause).__name__})"
|
|
77
|
+
else:
|
|
78
|
+
reason = "jump host " + _connect_problem(cause, host, port, user, None)
|
|
79
|
+
super().__init__(reason)
|
|
80
|
+
self.reason = reason
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _connect_problem(exc: Exception, host: str, port: int, user: str,
|
|
84
|
+
known_hosts: str | None) -> str:
|
|
85
|
+
"""Why the connection failed, in words the user can act on. Built from the exception's
|
|
86
|
+
type and our own settings, never its text beyond a fixed marker: server messages are
|
|
87
|
+
not shown."""
|
|
88
|
+
if isinstance(exc, _JumpFailed):
|
|
89
|
+
return exc.reason
|
|
90
|
+
if isinstance(exc, paramiko.BadHostKeyException):
|
|
91
|
+
return (f"{host}'s host key does not match the trusted one; the server may have been "
|
|
92
|
+
"replaced or impersonated")
|
|
93
|
+
if isinstance(exc, paramiko.AuthenticationException):
|
|
94
|
+
return f"{host} refused the SSH key for user {user!r}"
|
|
95
|
+
if isinstance(exc, FileNotFoundError) and known_hosts:
|
|
96
|
+
return f"the trusted host keys file {known_hosts!r} does not exist"
|
|
97
|
+
if isinstance(exc, paramiko.SSHException) and "not found in known_hosts" in str(exc):
|
|
98
|
+
where = f"in {known_hosts!r}" if known_hosts else "(no trusted host keys file is set)"
|
|
99
|
+
return (f"{host}'s host key is not trusted {where}; connect once with "
|
|
100
|
+
f"`ssh -p {port} {user}@{host}` to verify and trust it")
|
|
101
|
+
if isinstance(exc, ValueError):
|
|
102
|
+
return "the SSH key credential is missing or is not a supported private key"
|
|
103
|
+
if isinstance(exc, OSError):
|
|
104
|
+
return f"cannot reach {host}:{port} ({type(exc).__name__})"
|
|
105
|
+
return type(exc).__name__
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SSHExecutor(Executor):
|
|
109
|
+
def __init__(self, credentials: CredentialLookup, *, executor_id: str = "ssh", host: str,
|
|
110
|
+
user: str, key_credential: str, port: int = 22, remote_root: str = "puremethod",
|
|
111
|
+
use_docker: bool = False, network: Network = "on", concurrency: int = 1,
|
|
112
|
+
rate_usd_per_hour: float | None = None, grace_s: float = 30.0,
|
|
113
|
+
known_hosts: str | None = None, accept_unknown_host: bool = False,
|
|
114
|
+
host_key: str | None = None, jumps: list[dict[str, Any]] | None = None,
|
|
115
|
+
connect_timeout_s: float = 15.0, resources: Resources | None = None) -> None:
|
|
116
|
+
super().__init__(credentials)
|
|
117
|
+
self._info = ExecutorInfo(id=executor_id, kind="ssh",
|
|
118
|
+
isolation="container" if use_docker else "none",
|
|
119
|
+
network=network, concurrency=concurrency,
|
|
120
|
+
resources=resources or Resources(),
|
|
121
|
+
rate_usd_per_hour=rate_usd_per_hour)
|
|
122
|
+
self._host, self._port, self._user = host, port, user
|
|
123
|
+
self._key_credential = key_credential
|
|
124
|
+
self._root = remote_root.strip("/")
|
|
125
|
+
self._docker = use_docker
|
|
126
|
+
self._grace = grace_s
|
|
127
|
+
self._known_hosts = known_hosts
|
|
128
|
+
self._accept_unknown = accept_unknown_host
|
|
129
|
+
self._host_key = host_key # "<type> <base64>": the one key this host may present
|
|
130
|
+
self._timeout = connect_timeout_s
|
|
131
|
+
# §5 step 10 (KB-036): hops before the host, each {host, user, port, key_credential,
|
|
132
|
+
# host_key}; every hop trusts exactly its confirmed key.
|
|
133
|
+
self._jumps = list(jumps or [])
|
|
134
|
+
self._client: paramiko.SSHClient | None = None
|
|
135
|
+
self._hop_clients: list[paramiko.SSHClient] = []
|
|
136
|
+
|
|
137
|
+
def describe(self) -> ExecutorInfo:
|
|
138
|
+
return self._info
|
|
139
|
+
|
|
140
|
+
# ---- connection --------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def _connect(self) -> paramiko.SSHClient:
|
|
143
|
+
if self._client is not None:
|
|
144
|
+
transport = self._client.get_transport()
|
|
145
|
+
if transport is not None and transport.is_active():
|
|
146
|
+
return self._client
|
|
147
|
+
self.close()
|
|
148
|
+
sock: Any = None
|
|
149
|
+
for hop in self._jumps:
|
|
150
|
+
hop_client = self._trusting(str(hop["host"]), int(hop.get("port", 22)),
|
|
151
|
+
str(hop["host_key"]))
|
|
152
|
+
self._hop_clients.append(hop_client)
|
|
153
|
+
try:
|
|
154
|
+
hop_client.connect(str(hop["host"]), port=int(hop.get("port", 22)),
|
|
155
|
+
username=str(hop["user"]), sock=sock,
|
|
156
|
+
pkey=_load_key(self._credentials(str(hop["key_credential"]))),
|
|
157
|
+
timeout=self._timeout, banner_timeout=self._timeout,
|
|
158
|
+
auth_timeout=self._timeout, allow_agent=False,
|
|
159
|
+
look_for_keys=False)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
self.close()
|
|
162
|
+
raise _JumpFailed(str(hop["host"]), int(hop.get("port", 22)),
|
|
163
|
+
str(hop["user"]), exc) from exc
|
|
164
|
+
nxt = [*self._jumps, {"host": self._host, "port": self._port}][
|
|
165
|
+
len(self._hop_clients)]
|
|
166
|
+
transport = hop_client.get_transport()
|
|
167
|
+
try:
|
|
168
|
+
if transport is None:
|
|
169
|
+
raise paramiko.SSHException("no transport")
|
|
170
|
+
sock = transport.open_channel("direct-tcpip",
|
|
171
|
+
(str(nxt["host"]), int(nxt.get("port", 22))),
|
|
172
|
+
("127.0.0.1", 0), timeout=self._timeout)
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
self.close()
|
|
175
|
+
raise _JumpFailed(str(hop["host"]), int(hop.get("port", 22)),
|
|
176
|
+
str(hop["user"]), exc, onward=str(nxt["host"])) from exc
|
|
177
|
+
client = self._trusting(self._host, self._port, self._host_key)
|
|
178
|
+
# Unknown host keys are refused unless the configuration explicitly accepts them.
|
|
179
|
+
client.set_missing_host_key_policy(paramiko.AutoAddPolicy() if self._accept_unknown
|
|
180
|
+
else paramiko.RejectPolicy())
|
|
181
|
+
try:
|
|
182
|
+
pkey = _load_key(self._credentials(self._key_credential))
|
|
183
|
+
client.connect(self._host, port=self._port, username=self._user, pkey=pkey,
|
|
184
|
+
sock=sock, timeout=self._timeout, banner_timeout=self._timeout,
|
|
185
|
+
auth_timeout=self._timeout, allow_agent=False, look_for_keys=False)
|
|
186
|
+
except Exception:
|
|
187
|
+
client.close()
|
|
188
|
+
self.close()
|
|
189
|
+
raise
|
|
190
|
+
self._client = client
|
|
191
|
+
return client
|
|
192
|
+
|
|
193
|
+
def _trusting(self, host: str, port: int, host_key: str | None) -> paramiko.SSHClient:
|
|
194
|
+
"""A client that trusts this host's confirmed key (and, for the target only, a
|
|
195
|
+
known_hosts file from before KB-035), and refuses any other."""
|
|
196
|
+
client = paramiko.SSHClient()
|
|
197
|
+
if self._known_hosts and host == self._host:
|
|
198
|
+
client.load_host_keys(self._known_hosts)
|
|
199
|
+
if host_key:
|
|
200
|
+
kind, b64 = host_key.split(None, 1)
|
|
201
|
+
name = host if port == 22 else f"[{host}]:{port}"
|
|
202
|
+
client.get_host_keys().add(name, kind, paramiko.PKey.from_type_string(
|
|
203
|
+
kind, base64.b64decode(b64.strip())))
|
|
204
|
+
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
|
205
|
+
return client
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
if self._client is not None:
|
|
209
|
+
self._client.close()
|
|
210
|
+
self._client = None
|
|
211
|
+
for c in reversed(self._hop_clients):
|
|
212
|
+
c.close()
|
|
213
|
+
self._hop_clients = []
|
|
214
|
+
|
|
215
|
+
def _exec(self, command: str, stdin: str | None = None,
|
|
216
|
+
timeout: float = 120) -> tuple[int, str, str]:
|
|
217
|
+
client = self._connect()
|
|
218
|
+
chan_in, out, err = client.exec_command(command, timeout=timeout)
|
|
219
|
+
if stdin is not None:
|
|
220
|
+
chan_in.write(stdin)
|
|
221
|
+
chan_in.channel.shutdown_write()
|
|
222
|
+
rc = out.channel.recv_exit_status()
|
|
223
|
+
return rc, out.read().decode(errors="replace"), err.read().decode(errors="replace")
|
|
224
|
+
|
|
225
|
+
# ---- interface ---------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def preflight(self) -> PreflightReport:
|
|
228
|
+
problems: list[str] = []
|
|
229
|
+
try:
|
|
230
|
+
for tool in ("sh", "tar", "timeout") + (("docker",) if self._docker else ()):
|
|
231
|
+
rc, _, _ = self._exec(f"command -v {tool}")
|
|
232
|
+
if rc != 0:
|
|
233
|
+
problems.append(f"host lacks `{tool}`")
|
|
234
|
+
if self._docker and not problems:
|
|
235
|
+
rc, _, err = self._exec("docker version --format '{{.Server.Version}}'")
|
|
236
|
+
if rc != 0:
|
|
237
|
+
problems.append(f"docker on the host does not answer: {err.strip()[:200]}")
|
|
238
|
+
except (OSError, paramiko.SSHException, ValueError) as exc:
|
|
239
|
+
problem = _connect_problem(exc, self._host, self._port, self._user,
|
|
240
|
+
self._known_hosts)
|
|
241
|
+
return PreflightReport(False, [f"ssh connection failed: {problem}"])
|
|
242
|
+
if problems:
|
|
243
|
+
return PreflightReport(False, problems)
|
|
244
|
+
env = self.build_env(EnvSpec(base_image=DEFAULT_IMAGE if self._docker else None))
|
|
245
|
+
if env.status != "ok":
|
|
246
|
+
return PreflightReport(False, [f"environment: {env.log[-500:]}"])
|
|
247
|
+
handle = self.submit(JobSpec(code=_tar({"ok.sh": b"echo ok\n"}), entrypoint="sh ok.sh",
|
|
248
|
+
env=env, seed=0, deadline_s=60))
|
|
249
|
+
deadline = time.monotonic() + 120
|
|
250
|
+
while self.poll(handle) != "done" and time.monotonic() < deadline:
|
|
251
|
+
time.sleep(0.5)
|
|
252
|
+
if self.poll(handle) != "done" or self.result(handle).status != "ok":
|
|
253
|
+
problems.append("a trivial job did not run")
|
|
254
|
+
return PreflightReport(not problems, problems)
|
|
255
|
+
|
|
256
|
+
def build_env(self, spec: EnvSpec) -> EnvRef:
|
|
257
|
+
digest = spec.digest()
|
|
258
|
+
short = digest.removeprefix("sha256:")[:24]
|
|
259
|
+
t0 = time.monotonic()
|
|
260
|
+
try:
|
|
261
|
+
if self._docker:
|
|
262
|
+
tag = f"pm-env:{short}"
|
|
263
|
+
rc, _, _ = self._exec(f"docker image inspect {tag} >/dev/null 2>&1")
|
|
264
|
+
if rc == 0:
|
|
265
|
+
return EnvRef(digest=digest, status="ok", ref=tag)
|
|
266
|
+
ctx = f"{self._root}/envs/{short}"
|
|
267
|
+
lines = [f"FROM {spec.base_image or DEFAULT_IMAGE}"]
|
|
268
|
+
if spec.requirements_lock.strip():
|
|
269
|
+
lines += ["COPY requirements.lock /tmp/requirements.lock",
|
|
270
|
+
"RUN pip install --no-cache-dir -r /tmp/requirements.lock"]
|
|
271
|
+
self._put_files(ctx, {"Dockerfile": "\n".join(lines) + "\n",
|
|
272
|
+
"requirements.lock": spec.requirements_lock})
|
|
273
|
+
rc, out, err = self._exec(f"docker build -t {tag} {shlex.quote(ctx)}",
|
|
274
|
+
timeout=3600)
|
|
275
|
+
ok = rc == 0
|
|
276
|
+
return EnvRef(digest=digest, status="ok" if ok else "failed",
|
|
277
|
+
ref=tag if ok else "", log=(out + err)[-20000:],
|
|
278
|
+
build_seconds=time.monotonic() - t0, built=ok)
|
|
279
|
+
if not spec.requirements_lock.strip() and spec.python_version is None:
|
|
280
|
+
return EnvRef(digest=digest, status="ok", ref="")
|
|
281
|
+
venv = f"{self._root}/envs/{short}"
|
|
282
|
+
self._put_files(venv, {"requirements.lock": spec.requirements_lock})
|
|
283
|
+
python = (spec.python_version and f"python{spec.python_version}") or "python3"
|
|
284
|
+
cmd = (f"test -f {venv}/.pm-ready || ({python} -m venv {venv}/venv && "
|
|
285
|
+
f"{venv}/venv/bin/pip install -r {venv}/requirements.lock && "
|
|
286
|
+
f"touch {venv}/.pm-ready)")
|
|
287
|
+
rc, out, err = self._exec(cmd, timeout=3600)
|
|
288
|
+
ok = rc == 0
|
|
289
|
+
return EnvRef(digest=digest, status="ok" if ok else "failed",
|
|
290
|
+
ref=f"{venv}/venv/bin" if ok else "", log=(out + err)[-20000:],
|
|
291
|
+
build_seconds=time.monotonic() - t0, built=ok)
|
|
292
|
+
except (OSError, paramiko.SSHException, ValueError) as exc:
|
|
293
|
+
return EnvRef(digest=digest, status="failed", ref="",
|
|
294
|
+
log=f"ssh error: {type(exc).__name__}",
|
|
295
|
+
build_seconds=time.monotonic() - t0)
|
|
296
|
+
|
|
297
|
+
def _put_files(self, remote_dir: str, files: dict[str, str]) -> None:
|
|
298
|
+
self._exec(f"mkdir -p {shlex.quote(remote_dir)}")
|
|
299
|
+
sftp = self._connect().open_sftp()
|
|
300
|
+
try:
|
|
301
|
+
for name, text in files.items():
|
|
302
|
+
with sftp.open(f"{remote_dir}/{name}", "w") as f:
|
|
303
|
+
f.write(text.encode())
|
|
304
|
+
finally:
|
|
305
|
+
sftp.close()
|
|
306
|
+
|
|
307
|
+
# ---- backend hooks -----------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _start(self, job_id: str, spec: JobSpec, secrets: dict[str, str]) -> _Remote:
|
|
310
|
+
d = f"{self._root}/jobs/{job_id}"
|
|
311
|
+
self._exec(f"mkdir -p {d}/ws/outputs")
|
|
312
|
+
if spec.tag is not None: # validated by `submit`: safe in a shell word
|
|
313
|
+
self._exec(f"printf %s {shlex.quote(spec.tag)} > {d}/{TAG_FILE}")
|
|
314
|
+
sftp = self._connect().open_sftp()
|
|
315
|
+
try:
|
|
316
|
+
with sftp.open(f"{d}/code.tar", "wb") as f:
|
|
317
|
+
f.write(sanitized_tar(spec.code))
|
|
318
|
+
finally:
|
|
319
|
+
sftp.close()
|
|
320
|
+
rc, _, err = self._exec(f"tar -xf {d}/code.tar -C {d}/ws && mkdir -p {d}/ws/outputs")
|
|
321
|
+
if rc != 0:
|
|
322
|
+
raise RuntimeError(f"unpacking the code bundle failed: {err.strip()[:200]}")
|
|
323
|
+
grace = str(int(max(self._grace, 1)))
|
|
324
|
+
deadline = str(max(int(spec.deadline_s), 1))
|
|
325
|
+
exports = [f"export {name}={shlex.quote(value)}" for name, value in secrets.items()]
|
|
326
|
+
exports += [f"export PM_SEED={spec.seed}",
|
|
327
|
+
f"export PM_ENTRY={shlex.quote(spec.entrypoint)}"]
|
|
328
|
+
if self._docker:
|
|
329
|
+
flags = " ".join(f"-e {name}" for name in secrets)
|
|
330
|
+
net = "--network none" if self._info.network == "off" else ""
|
|
331
|
+
inner = (f'docker run --name pm-job-{job_id} -v "$PWD":/work -w /work '
|
|
332
|
+
f'-e PM_SEED {flags} {net} {spec.env.ref} '
|
|
333
|
+
f'timeout -s TERM -k {grace} {deadline} sh -c "$PM_ENTRY"')
|
|
334
|
+
else:
|
|
335
|
+
path = f'export PATH="$HOME/{spec.env.ref}:$PATH"; ' if spec.env.ref else ""
|
|
336
|
+
inner = f'{path}timeout -s TERM -k {grace} {deadline} sh -c "$PM_ENTRY"'
|
|
337
|
+
# The job's session is the handle for the whole tree: `timeout` moves the job into a
|
|
338
|
+
# process group of its own, but never out of the session. After the exit code is
|
|
339
|
+
# written, anything the job left behind in its session is killed (INV-3).
|
|
340
|
+
wrapper = (f"{KILL_SESSION}"
|
|
341
|
+
"sid=$(cut -d' ' -f6 /proc/$$/stat 2>/dev/null); [ -n \"$sid\" ] || sid=$$; "
|
|
342
|
+
"echo \"$sid\" > ../sid; "
|
|
343
|
+
f"{inner} > ../stdout 2> ../stderr; echo $? > ../exit_code.tmp; "
|
|
344
|
+
"mv ../exit_code.tmp ../exit_code; _pm_kill_session \"$sid\"")
|
|
345
|
+
script = "\n".join([
|
|
346
|
+
*exports,
|
|
347
|
+
f'cd "$HOME/{d}/ws"',
|
|
348
|
+
"date +%s > ../started",
|
|
349
|
+
"if command -v setsid >/dev/null 2>&1; then",
|
|
350
|
+
f" setsid sh -c {shlex.quote(wrapper)} </dev/null >/dev/null 2>&1 &",
|
|
351
|
+
"else",
|
|
352
|
+
f" nohup sh -c {shlex.quote(wrapper)} </dev/null >/dev/null 2>&1 &",
|
|
353
|
+
"fi",
|
|
354
|
+
"echo $! > ../pid",
|
|
355
|
+
]) + "\n"
|
|
356
|
+
rc, _, err = self._exec("sh -s", stdin=script)
|
|
357
|
+
if rc != 0:
|
|
358
|
+
raise RuntimeError(f"starting the job failed: {err.strip()[:200]}")
|
|
359
|
+
return _Remote(job_id=job_id, dir=d, deadline_s=spec.deadline_s)
|
|
360
|
+
|
|
361
|
+
def _done(self, backend: Any) -> bool:
|
|
362
|
+
r: _Remote = backend
|
|
363
|
+
try:
|
|
364
|
+
rc, _, _ = self._exec(f"test -f {r.dir}/exit_code", timeout=30)
|
|
365
|
+
r.failures = 0
|
|
366
|
+
return rc == 0
|
|
367
|
+
except (OSError, paramiko.SSHException, EOFError):
|
|
368
|
+
self.close()
|
|
369
|
+
r.failures += 1
|
|
370
|
+
if r.failures >= LOST_AFTER:
|
|
371
|
+
r.lost = True
|
|
372
|
+
return True
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
def _read(self, sftp: paramiko.SFTPClient, path: str, limit: int | None = None) -> bytes:
|
|
376
|
+
try:
|
|
377
|
+
with sftp.open(path, "rb") as f:
|
|
378
|
+
return bytes(f.read(limit + 1 if limit else -1))
|
|
379
|
+
except OSError:
|
|
380
|
+
return b""
|
|
381
|
+
|
|
382
|
+
def _collect(self, backend: Any, spec: JobSpec, secrets: dict[str, str], *,
|
|
383
|
+
killed: bool) -> JobResult:
|
|
384
|
+
r: _Remote = backend
|
|
385
|
+
if r.lost:
|
|
386
|
+
return JobResult(status="lost", exit_code=None, stdout=b"", stderr=b"", outputs={},
|
|
387
|
+
metrics=None, started_at=None, ended_at=None, duration_s=None,
|
|
388
|
+
cost_usd=None, metered=False, env_digest=spec.env.digest,
|
|
389
|
+
code_digest=spec.code_digest, redactions=[],
|
|
390
|
+
output_cap_bytes=2 * 1024 * 1024)
|
|
391
|
+
limit = 2 * 1024 * 1024
|
|
392
|
+
sftp = self._connect().open_sftp()
|
|
393
|
+
try:
|
|
394
|
+
stdout, n1 = redact(self._read(sftp, f"{r.dir}/stdout", limit), secrets)
|
|
395
|
+
stderr, n2 = redact(self._read(sftp, f"{r.dir}/stderr", limit), secrets)
|
|
396
|
+
code_text = self._read(sftp, f"{r.dir}/exit_code").decode().strip()
|
|
397
|
+
started_text = self._read(sftp, f"{r.dir}/started").decode().strip()
|
|
398
|
+
ended_mtime = sftp.stat(f"{r.dir}/exit_code").st_mtime
|
|
399
|
+
outputs, n3, truncated = self._outputs(sftp, f"{r.dir}/ws/outputs", secrets)
|
|
400
|
+
finally:
|
|
401
|
+
sftp.close()
|
|
402
|
+
stdout, t1 = cap(stdout)
|
|
403
|
+
stderr, t2 = cap(stderr)
|
|
404
|
+
truncated += [n for n, t in (("stdout", t1), ("stderr", t2)) if t]
|
|
405
|
+
exit_code = int(code_text) if code_text.lstrip("-").isdigit() else None
|
|
406
|
+
duration = None
|
|
407
|
+
if started_text.isdigit() and ended_mtime:
|
|
408
|
+
duration = max(float(ended_mtime) - float(started_text), 0.0)
|
|
409
|
+
timed_out = duration is not None and duration >= r.deadline_s and exit_code != 0
|
|
410
|
+
status = classify_status(exit_code, killed=killed or r.killed, timed_out=timed_out,
|
|
411
|
+
crashed=False)
|
|
412
|
+
if self._docker:
|
|
413
|
+
self._exec(f"docker rm -f pm-job-{r.job_id} >/dev/null 2>&1", timeout=60)
|
|
414
|
+
# The remote workspace may hold what the job wrote, credentials included.
|
|
415
|
+
self._exec(f"rm -rf {shlex.quote(r.dir)}", timeout=120)
|
|
416
|
+
usd, metered = cost(self._info.rate_usd_per_hour, duration)
|
|
417
|
+
return JobResult(status=status, exit_code=exit_code, stdout=stdout, stderr=stderr,
|
|
418
|
+
outputs=outputs, metrics=outputs.get(METRICS_FILE), started_at=None,
|
|
419
|
+
ended_at=None, duration_s=duration, cost_usd=usd, metered=metered,
|
|
420
|
+
env_digest=spec.env.digest, code_digest=spec.code_digest,
|
|
421
|
+
redactions=sorted(set(n1 + n2 + n3)), output_cap_bytes=limit,
|
|
422
|
+
truncated=truncated)
|
|
423
|
+
|
|
424
|
+
def _outputs(self, sftp: paramiko.SFTPClient, root: str, secrets: dict[str, str]
|
|
425
|
+
) -> tuple[dict[str, bytes], list[str], list[str]]:
|
|
426
|
+
files: dict[str, bytes] = {}
|
|
427
|
+
names: list[str] = []
|
|
428
|
+
truncated: list[str] = []
|
|
429
|
+
stack = [""]
|
|
430
|
+
while stack:
|
|
431
|
+
rel = stack.pop()
|
|
432
|
+
try:
|
|
433
|
+
entries = sftp.listdir_attr(f"{root}/{rel}" if rel else root)
|
|
434
|
+
except OSError:
|
|
435
|
+
continue
|
|
436
|
+
for e in entries:
|
|
437
|
+
sub = f"{rel}/{e.filename}" if rel else e.filename
|
|
438
|
+
mode = e.st_mode or 0
|
|
439
|
+
if stat.S_ISDIR(mode):
|
|
440
|
+
stack.append(sub)
|
|
441
|
+
elif stat.S_ISREG(mode):
|
|
442
|
+
data, hit, cut = process_text(self._read(sftp, f"{root}/{sub}"), secrets)
|
|
443
|
+
files[f"outputs/{sub}"] = data
|
|
444
|
+
names += hit
|
|
445
|
+
if cut:
|
|
446
|
+
truncated.append(f"outputs/{sub}")
|
|
447
|
+
return files, names, truncated
|
|
448
|
+
|
|
449
|
+
def _kill(self, backend: Any) -> None:
|
|
450
|
+
r: _Remote = backend
|
|
451
|
+
r.killed = True
|
|
452
|
+
docker = f"docker kill pm-job-{r.job_id} >/dev/null 2>&1; " if self._docker else ""
|
|
453
|
+
script = (KILL_SESSION + docker
|
|
454
|
+
+ f"sid=$(cat {r.dir}/sid 2>/dev/null); pid=$(cat {r.dir}/pid 2>/dev/null); "
|
|
455
|
+
+ f"test -f {r.dir}/exit_code || echo 137 > {r.dir}/exit_code; "
|
|
456
|
+
+ '_pm_kill_session "$sid"; '
|
|
457
|
+
+ 'if [ -n "$pid" ]; then kill -KILL -- -"$pid" 2>/dev/null '
|
|
458
|
+
+ '|| kill -KILL "$pid" 2>/dev/null; fi; true')
|
|
459
|
+
self._exec(script, timeout=60)
|
|
460
|
+
|
|
461
|
+
def reap(self, tags: list[str]) -> list[str]:
|
|
462
|
+
"""Jobs on the host from any engine process, found by their tag file (INV-9): kill
|
|
463
|
+
the session and the process group, remove the container and the job directory."""
|
|
464
|
+
wanted = [check_tag(t) for t in tags]
|
|
465
|
+
if not wanted:
|
|
466
|
+
return []
|
|
467
|
+
docker = ('docker rm -f "pm-job-$(basename "$d")" >/dev/null 2>&1; '
|
|
468
|
+
if self._docker else "")
|
|
469
|
+
listed = " ".join(wanted) # check_tag: no spaces, quotes or shell characters
|
|
470
|
+
script = (KILL_SESSION
|
|
471
|
+
+ f"for d in {self._root}/jobs/*; do "
|
|
472
|
+
+ f'[ -f "$d/{TAG_FILE}" ] || continue; t=$(cat "$d/{TAG_FILE}"); '
|
|
473
|
+
+ f'case " {listed} " in *" $t "*) ;; *) continue;; esac; '
|
|
474
|
+
+ docker
|
|
475
|
+
+ 'sid=$(cat "$d/sid" 2>/dev/null); pid=$(cat "$d/pid" 2>/dev/null); '
|
|
476
|
+
+ '_pm_kill_session "$sid"; '
|
|
477
|
+
+ 'if [ -n "$pid" ]; then kill -KILL -- -"$pid" 2>/dev/null '
|
|
478
|
+
+ '|| kill -KILL "$pid" 2>/dev/null; fi; '
|
|
479
|
+
+ 'rm -rf "$d"; echo "$t"; done; true')
|
|
480
|
+
_, out, _ = self._exec(script, timeout=120)
|
|
481
|
+
return sorted({line.strip() for line in out.splitlines() if line.strip() in wanted})
|
executor/types.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Executor types (05-executor.md §3, §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
Isolation = Literal["container", "vm", "none"]
|
|
11
|
+
Network = Literal["on", "off"]
|
|
12
|
+
JobState = Literal["queued", "running", "done"]
|
|
13
|
+
Status = Literal["ok", "failed", "crashed", "timeout", "killed", "lost"]
|
|
14
|
+
|
|
15
|
+
DEFAULT_IMAGE = "python:3.11-slim"
|
|
16
|
+
METRICS_FILE = "outputs/pm_metrics.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Resources:
|
|
21
|
+
cpus: float | None = None
|
|
22
|
+
memory_mb: int | None = None
|
|
23
|
+
gpus: int = 0
|
|
24
|
+
gpu_model: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ExecutorInfo:
|
|
29
|
+
id: str
|
|
30
|
+
kind: str
|
|
31
|
+
isolation: Isolation
|
|
32
|
+
network: Network
|
|
33
|
+
concurrency: int
|
|
34
|
+
resources: Resources
|
|
35
|
+
rate_usd_per_hour: float | None # None: unmetered
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def metered(self) -> bool:
|
|
39
|
+
return self.rate_usd_per_hour is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class EnvSpec:
|
|
44
|
+
base_image: str | None = DEFAULT_IMAGE # container kinds
|
|
45
|
+
python_version: str | None = None # local-process and ssh without Docker
|
|
46
|
+
requirements_lock: str = ""
|
|
47
|
+
system_packages: tuple[str, ...] = ()
|
|
48
|
+
|
|
49
|
+
def digest(self) -> str:
|
|
50
|
+
canonical = json.dumps(asdict(self), sort_keys=True, separators=(",", ":"))
|
|
51
|
+
return "sha256:" + hashlib.sha256(canonical.encode()).hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class EnvRef:
|
|
56
|
+
digest: str
|
|
57
|
+
status: Literal["ok", "failed"]
|
|
58
|
+
ref: str # image tag, or interpreter path
|
|
59
|
+
log: str = ""
|
|
60
|
+
build_seconds: float = 0.0
|
|
61
|
+
built: bool = False # built by this call (not reused)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class DataMount:
|
|
66
|
+
name: str
|
|
67
|
+
source: str # host path the user configured; mounted read-only at /data/<name>
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class JobSpec:
|
|
72
|
+
code: bytes # a tar archive
|
|
73
|
+
entrypoint: str
|
|
74
|
+
env: EnvRef
|
|
75
|
+
seed: int
|
|
76
|
+
deadline_s: float
|
|
77
|
+
resources: Resources = field(default_factory=Resources)
|
|
78
|
+
credentials: tuple[str, ...] = ()
|
|
79
|
+
data_mounts: tuple[DataMount, ...] = ()
|
|
80
|
+
tag: str | None = None # the caller's id for the job, for `reap` (KB-020)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def code_digest(self) -> str:
|
|
84
|
+
return "sha256:" + hashlib.sha256(self.code).hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class JobHandle:
|
|
89
|
+
job_id: str
|
|
90
|
+
executor_id: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class JobResult:
|
|
95
|
+
status: Status
|
|
96
|
+
exit_code: int | None
|
|
97
|
+
stdout: bytes
|
|
98
|
+
stderr: bytes
|
|
99
|
+
outputs: dict[str, bytes]
|
|
100
|
+
metrics: bytes | None
|
|
101
|
+
started_at: str | None
|
|
102
|
+
ended_at: str | None
|
|
103
|
+
duration_s: float | None
|
|
104
|
+
cost_usd: float | None
|
|
105
|
+
metered: bool
|
|
106
|
+
env_digest: str
|
|
107
|
+
code_digest: str
|
|
108
|
+
redactions: list[str]
|
|
109
|
+
output_cap_bytes: int
|
|
110
|
+
truncated: list[str] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class PreflightReport:
|
|
115
|
+
ok: bool
|
|
116
|
+
problems: list[str] = field(default_factory=list)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ExecutorError(Exception):
|
|
120
|
+
"""Base error."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class UnknownJob(ExecutorError):
|
|
124
|
+
"""No job with this handle on this executor."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NotDone(ExecutorError):
|
|
128
|
+
"""`result` called before `poll` reported `done`."""
|