simrun 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.
- simrun/__init__.py +3 -0
- simrun/argo.py +449 -0
- simrun/build_constraints.txt +20 -0
- simrun/cgroup.py +77 -0
- simrun/cli.py +350 -0
- simrun/client.py +407 -0
- simrun/config.py +63 -0
- simrun/cwl.py +287 -0
- simrun/demo.py +106 -0
- simrun/edit.py +408 -0
- simrun/environment.py +370 -0
- simrun/ledger.py +286 -0
- simrun/mcp.py +161 -0
- simrun/planner.py +303 -0
- simrun/plugins.py +149 -0
- simrun/plugins_builtin.py +33 -0
- simrun/podspec.py +166 -0
- simrun/resources.py +270 -0
- simrun/service/__init__.py +1 -0
- simrun/service/app.py +1721 -0
- simrun/service/argo_backend.py +842 -0
- simrun/service/auth.py +93 -0
- simrun/service/backend.py +170 -0
- simrun/service/compare.py +77 -0
- simrun/service/controller.py +788 -0
- simrun/service/edits.py +406 -0
- simrun/service/index.py +292 -0
- simrun/service/logs.py +91 -0
- simrun/service/submission.py +349 -0
- simrun/snapshot.py +196 -0
- simrun/store.py +190 -0
- simrun/testing.py +263 -0
- simrun/worker.py +1097 -0
- simrun/workflow.py +188 -0
- simrun-0.1.0.dist-info/METADATA +107 -0
- simrun-0.1.0.dist-info/RECORD +39 -0
- simrun-0.1.0.dist-info/WHEEL +4 -0
- simrun-0.1.0.dist-info/entry_points.txt +10 -0
- simrun-0.1.0.dist-info/licenses/LICENSE +7 -0
simrun/service/logs.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The logs of the pods of a task, kept in the run store.
|
|
2
|
+
|
|
3
|
+
The logs of a pod are gone with the pod: Kueue deletes a preempted pod, Argo deletes
|
|
4
|
+
the pods of a workflow with it after its time to live, and stopping a workflow ends
|
|
5
|
+
its pods. So the controller copies the logs of the main containers of the pods of a
|
|
6
|
+
task into `tasks/<task>/logs/` once the task is final and its pods terminated, the
|
|
7
|
+
ones which ended before it stops or cancels a workflow (the last `KEPT_TAIL_LINES`
|
|
8
|
+
lines, at most what the backend reads of a log): a file `<pod>.log` per pod (every
|
|
9
|
+
attempt the backend can still read) and `index.json`, the pods in their order,
|
|
10
|
+
written last, which marks the logs as kept. An ended run is followed until its logs
|
|
11
|
+
are kept or its workflow is gone (`RunRecord.pending_logs`); a run which never had a
|
|
12
|
+
workflow keeps none. The API serves the kept logs first and the logs of the live pods
|
|
13
|
+
otherwise.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import uuid
|
|
19
|
+
from collections import deque
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from simrun.service.backend import PodLog
|
|
23
|
+
|
|
24
|
+
LOGS_DIR = "logs"
|
|
25
|
+
INDEX_FILE = "index.json"
|
|
26
|
+
# the lines of every pod an agent or a person gets by default
|
|
27
|
+
DEFAULT_TAIL_LINES = 300
|
|
28
|
+
# the last lines of every pod kept in the store
|
|
29
|
+
KEPT_TAIL_LINES = 100_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write(path: Path, text: str) -> None:
|
|
33
|
+
"""Write a file atomically (a temporary file in its directory, renamed)."""
|
|
34
|
+
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
35
|
+
tmp.write_text(text)
|
|
36
|
+
os.replace(tmp, path)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def kept(task_dir: Path) -> bool:
|
|
40
|
+
"""Whether the logs of a task are kept in its directory."""
|
|
41
|
+
return (task_dir / LOGS_DIR / INDEX_FILE).is_file()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def keep(task_dir: Path, logs: list[PodLog]) -> None:
|
|
45
|
+
"""Keep the logs of the pods of a task in `task_dir/logs`.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
task_dir: The directory of the task.
|
|
49
|
+
logs: The logs of its pods, oldest first.
|
|
50
|
+
"""
|
|
51
|
+
directory = task_dir / LOGS_DIR
|
|
52
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
index = []
|
|
54
|
+
for log in logs:
|
|
55
|
+
name = f"{log.pod}.log"
|
|
56
|
+
_write(directory / name, log.text)
|
|
57
|
+
index.append({"node": log.node, "pod": log.pod, "file": name})
|
|
58
|
+
_write(directory / INDEX_FILE, json.dumps(index, indent=2))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tail(path: Path, lines: int | None) -> str:
|
|
62
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
63
|
+
if lines is None:
|
|
64
|
+
return handle.read()
|
|
65
|
+
return "".join(deque(handle, maxlen=lines))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_kept(task_dir: Path, tail_lines: int | None = None) -> list[PodLog] | None:
|
|
69
|
+
"""The kept logs of a task, the last `tail_lines` lines of each.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The logs, None when they are not kept (yet).
|
|
73
|
+
"""
|
|
74
|
+
directory = task_dir / LOGS_DIR
|
|
75
|
+
try:
|
|
76
|
+
index = json.loads((directory / INDEX_FILE).read_text())
|
|
77
|
+
except FileNotFoundError:
|
|
78
|
+
return None
|
|
79
|
+
logs = []
|
|
80
|
+
for entry in index:
|
|
81
|
+
try:
|
|
82
|
+
text = _tail(directory / entry["file"], tail_lines)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
text = "(the kept log is missing)\n"
|
|
85
|
+
logs.append(PodLog(node=entry["node"], pod=entry["pod"], text=text))
|
|
86
|
+
return logs
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def render(logs: list[PodLog]) -> str:
|
|
90
|
+
"""The logs of the pods of a task as one text, each pod under a header."""
|
|
91
|
+
return "\n".join(f"==> {log.node} ({log.pod}) <==\n{log.text}" for log in logs)
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""The submission of a run in the background: its environment, image and workflow.
|
|
2
|
+
|
|
3
|
+
`POST /runs` validates and plans a run, writes its record `submitting` (with the
|
|
4
|
+
deterministic name of its workflow and the settings of its tasks) and returns its id
|
|
5
|
+
at once. The controller then runs `Submitter.run` in its pool of submissions
|
|
6
|
+
(`Controller.start_submission`): it resolves the environment of the run, builds its
|
|
7
|
+
image when the registry does not have it (once per image: a second submission of the
|
|
8
|
+
same new environment waits for the first build and finds the image), records the
|
|
9
|
+
image and the time it was ready (`built`, from which the grace of a submission
|
|
10
|
+
counts), reserves the node of the run and submits its workflow; the run is queued
|
|
11
|
+
then.
|
|
12
|
+
|
|
13
|
+
A failure lands in the record: the run fails with a message, its node is released and
|
|
14
|
+
a workflow a failed submission may have created is cancelled. A run cancelled while it
|
|
15
|
+
is resolved or built is not submitted; one cancelled while its workflow is submitted
|
|
16
|
+
has the workflow cancelled; the workflow of one cancelled and deleted meanwhile is
|
|
17
|
+
cancelled as an orphan.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import logging
|
|
22
|
+
import shutil
|
|
23
|
+
import tempfile
|
|
24
|
+
import threading
|
|
25
|
+
from collections.abc import Generator
|
|
26
|
+
from contextlib import contextmanager, suppress
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import simrun
|
|
31
|
+
from simrun.argo import to_argo
|
|
32
|
+
from simrun.config import ClusterConfig
|
|
33
|
+
from simrun.environment import (
|
|
34
|
+
BUILD_CONSTRAINTS,
|
|
35
|
+
Environment,
|
|
36
|
+
dockerfile,
|
|
37
|
+
image_ref,
|
|
38
|
+
resolve_environment,
|
|
39
|
+
)
|
|
40
|
+
from simrun.planner import PodShape, TaskPlan
|
|
41
|
+
from simrun.plugins import TaskType
|
|
42
|
+
from simrun.service.backend import Backend
|
|
43
|
+
from simrun.service.controller import TERMINAL_RUN_STATES, Controller, closed
|
|
44
|
+
from simrun.snapshot import SourceProvenance
|
|
45
|
+
from simrun.store import RunRecord, RunState, RunStore
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
ENVIRONMENT_LOCK = "environment.lock"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SubmissionError(Exception):
|
|
53
|
+
"""A submission which cannot go on; the message is the one of the record."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def has_model_project(store: Path, sources: list[SourceProvenance]) -> bool:
|
|
57
|
+
"""Whether the model of a run (its first source) has a `pyproject.toml`."""
|
|
58
|
+
return bool(sources) and (store / sources[0].snapshot / "pyproject.toml").is_file()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def task_plans(record: RunRecord) -> dict[str, TaskPlan]:
|
|
62
|
+
"""The plans of the tasks of a run from its record."""
|
|
63
|
+
return {
|
|
64
|
+
task_id: dataclasses.replace(TaskPlan(**plan), shape=PodShape(**plan["shape"]))
|
|
65
|
+
for task_id, plan in record.plans.items()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _undo(current: RunRecord, record: RunRecord) -> dict[str, object]:
|
|
70
|
+
"""What a run which ended during its submission still has to undo.
|
|
71
|
+
|
|
72
|
+
The workflow is cancelled, and the node released: the submission may have
|
|
73
|
+
reserved it after the end of the run released it (a release removes only the
|
|
74
|
+
reservation of its run, so it may be repeated).
|
|
75
|
+
"""
|
|
76
|
+
return {
|
|
77
|
+
"pending_cancel": True,
|
|
78
|
+
"pending_release": current.pending_release or record.reserve,
|
|
79
|
+
"pending_logs": True,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Submitter:
|
|
84
|
+
"""Resolves, builds and submits the runs written `submitting`."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
store: RunStore,
|
|
89
|
+
backend: Backend,
|
|
90
|
+
types: dict[str, TaskType],
|
|
91
|
+
controller: Controller,
|
|
92
|
+
cluster: ClusterConfig,
|
|
93
|
+
python: str,
|
|
94
|
+
simrun_wheel: Path | None,
|
|
95
|
+
build_constraints: Path | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Create the submitter.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
store: The run store.
|
|
101
|
+
backend: The backend the workflows run on.
|
|
102
|
+
types: The task types by name.
|
|
103
|
+
controller: The controller, whose lock guards the records.
|
|
104
|
+
cluster: The cluster.
|
|
105
|
+
python: Python version of the images.
|
|
106
|
+
simrun_wheel: The simrun wheel the images install.
|
|
107
|
+
build_constraints: The pinned versions of the build backends, the ones
|
|
108
|
+
of simrun by default.
|
|
109
|
+
"""
|
|
110
|
+
self.store = store
|
|
111
|
+
self.backend = backend
|
|
112
|
+
self.types = types
|
|
113
|
+
self.controller = controller
|
|
114
|
+
self.cluster = cluster
|
|
115
|
+
self.python = python
|
|
116
|
+
self.simrun_wheel = simrun_wheel
|
|
117
|
+
self.build_constraints = build_constraints or BUILD_CONSTRAINTS
|
|
118
|
+
self._reserve_lock = threading.Lock()
|
|
119
|
+
self._builds_lock = threading.Lock()
|
|
120
|
+
self._builds: dict[str, threading.Lock] = {}
|
|
121
|
+
|
|
122
|
+
@contextmanager
|
|
123
|
+
def _building(self, ref: str) -> Generator[None]:
|
|
124
|
+
"""Hold the lock of an image, so it is built once at a time."""
|
|
125
|
+
with self._builds_lock:
|
|
126
|
+
lock = self._builds.setdefault(ref, threading.Lock())
|
|
127
|
+
with lock:
|
|
128
|
+
yield
|
|
129
|
+
|
|
130
|
+
def environment(self, record: RunRecord) -> tuple[str | None, str | None]:
|
|
131
|
+
"""The image of a run and the lock of its environment, built when missing.
|
|
132
|
+
|
|
133
|
+
A run whose model has no `pyproject.toml` keeps the image it was written
|
|
134
|
+
with (the default image of the service).
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
SubmissionError: The environment cannot be resolved, the service has no
|
|
138
|
+
simrun wheel to build with, or the build fails.
|
|
139
|
+
"""
|
|
140
|
+
if not has_model_project(self.store.root, record.sources):
|
|
141
|
+
return record.image, None
|
|
142
|
+
model, *libraries = record.sources
|
|
143
|
+
try:
|
|
144
|
+
env = resolve_environment(
|
|
145
|
+
self.store.root,
|
|
146
|
+
model,
|
|
147
|
+
libraries,
|
|
148
|
+
python=self.python,
|
|
149
|
+
simrun_version=simrun.__version__,
|
|
150
|
+
build_constraints=self.build_constraints,
|
|
151
|
+
)
|
|
152
|
+
except (ValueError, RuntimeError) as error:
|
|
153
|
+
raise SubmissionError(
|
|
154
|
+
f"the environment cannot be resolved: {error}"
|
|
155
|
+
) from error
|
|
156
|
+
ref = image_ref(env, self.cluster.registry)
|
|
157
|
+
with self._building(ref):
|
|
158
|
+
try:
|
|
159
|
+
missing = not self.backend.has_image(ref)
|
|
160
|
+
except Exception as error:
|
|
161
|
+
raise SubmissionError(
|
|
162
|
+
f"the registry cannot tell whether it has {ref}: {error}"
|
|
163
|
+
) from error
|
|
164
|
+
if missing:
|
|
165
|
+
self._build(env, ref)
|
|
166
|
+
return ref, env.lock
|
|
167
|
+
|
|
168
|
+
def _build(self, env: Environment, ref: str) -> None:
|
|
169
|
+
wheel = self.simrun_wheel
|
|
170
|
+
if wheel is None:
|
|
171
|
+
raise SubmissionError("the service has no simrun_wheel to build with")
|
|
172
|
+
with tempfile.TemporaryDirectory(prefix="simrun-build-") as tmp:
|
|
173
|
+
context = Path(tmp)
|
|
174
|
+
(context / "Dockerfile").write_text(
|
|
175
|
+
dockerfile(env, self.cluster.base_image_ref(), wheel.name)
|
|
176
|
+
)
|
|
177
|
+
shutil.copy2(wheel, context / wheel.name)
|
|
178
|
+
logger.info("building the image %s", ref)
|
|
179
|
+
try:
|
|
180
|
+
self.backend.build_image(context, ref)
|
|
181
|
+
except Exception as error:
|
|
182
|
+
raise SubmissionError(
|
|
183
|
+
f"building the image {ref} failed: {error}"
|
|
184
|
+
) from error
|
|
185
|
+
|
|
186
|
+
def run(self, run_id: str) -> None:
|
|
187
|
+
"""Resolve, build and submit a run written `submitting`.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
run_id: The run.
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
record = self.store.read(run_id)
|
|
194
|
+
except KeyError:
|
|
195
|
+
return
|
|
196
|
+
if record.state != RunState.SUBMITTING:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
image, lock = self.environment(record)
|
|
200
|
+
built = record.model_copy(
|
|
201
|
+
update={"image": image, "built": datetime.now(UTC)}
|
|
202
|
+
)
|
|
203
|
+
if not self.controller.write_if_unchanged(record, built):
|
|
204
|
+
logger.info("run %s changed while it was built: not submitted", run_id)
|
|
205
|
+
return
|
|
206
|
+
record = built
|
|
207
|
+
if lock is not None:
|
|
208
|
+
(self.store.run_dir(run_id) / ENVIRONMENT_LOCK).write_text(lock)
|
|
209
|
+
try:
|
|
210
|
+
manifest = to_argo(record, task_plans(record), self.types, self.cluster)
|
|
211
|
+
except ValueError as error:
|
|
212
|
+
raise SubmissionError(str(error)) from error
|
|
213
|
+
if record.reserve is not None:
|
|
214
|
+
self._reserve(record.reserve, run_id)
|
|
215
|
+
except SubmissionError as error:
|
|
216
|
+
self._fail(record, str(error))
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
self.backend.submit(manifest)
|
|
220
|
+
except Exception as error:
|
|
221
|
+
self._abort(record, error)
|
|
222
|
+
return
|
|
223
|
+
self._submitted(record)
|
|
224
|
+
|
|
225
|
+
def _reserve(self, node: str, run_id: str) -> None:
|
|
226
|
+
"""Reserve the node of a run, unless another run took it meanwhile.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
SubmissionError: The node is unknown, reserved by another run, or its
|
|
230
|
+
reservation failed.
|
|
231
|
+
"""
|
|
232
|
+
with self._reserve_lock:
|
|
233
|
+
try:
|
|
234
|
+
found = next((n for n in self.backend.nodes() if n.name == node), None)
|
|
235
|
+
if found is None:
|
|
236
|
+
raise SubmissionError(f"unknown node {node!r} to reserve")
|
|
237
|
+
if found.reserved_by not in (None, run_id):
|
|
238
|
+
raise SubmissionError(
|
|
239
|
+
f"node {node!r} is reserved by run {found.reserved_by}"
|
|
240
|
+
)
|
|
241
|
+
self.backend.reserve(node, run_id)
|
|
242
|
+
except SubmissionError:
|
|
243
|
+
raise
|
|
244
|
+
except Exception as error:
|
|
245
|
+
raise SubmissionError(
|
|
246
|
+
f"reserving node {node!r} failed: {error}"
|
|
247
|
+
) from error
|
|
248
|
+
|
|
249
|
+
def _fail(self, record: RunRecord, message: str) -> None:
|
|
250
|
+
"""Fail a run whose submission cannot go on; nothing was submitted."""
|
|
251
|
+
logger.error("the submission of run %s failed: %s", record.id, message)
|
|
252
|
+
failed = closed(
|
|
253
|
+
record, RunState.FAILED, record.tasks, message, pending_logs=False
|
|
254
|
+
)
|
|
255
|
+
if self.controller.write_if_unchanged(record, failed):
|
|
256
|
+
self.controller.settle(record.id)
|
|
257
|
+
|
|
258
|
+
def _abort(self, record: RunRecord, error: Exception) -> None:
|
|
259
|
+
"""Fail a run whose submission of the workflow failed, then settle it.
|
|
260
|
+
|
|
261
|
+
The workflow may have been created although the submission raised, so it is
|
|
262
|
+
cancelled like the node is released, retried by the controller on failure.
|
|
263
|
+
"""
|
|
264
|
+
logger.error("submitting run %s failed: %s", record.id, error)
|
|
265
|
+
failed = closed(
|
|
266
|
+
record,
|
|
267
|
+
RunState.FAILED,
|
|
268
|
+
record.tasks,
|
|
269
|
+
f"submitting failed: {error}",
|
|
270
|
+
pending_cancel=True,
|
|
271
|
+
)
|
|
272
|
+
try:
|
|
273
|
+
if not self.controller.write_if_unchanged(record, failed):
|
|
274
|
+
# cancelled meanwhile: the cancellation is retried by the controller
|
|
275
|
+
self._cancel_if_pending(record)
|
|
276
|
+
return
|
|
277
|
+
except OSError:
|
|
278
|
+
logger.exception("writing the failed record of run %s failed", record.id)
|
|
279
|
+
if record.reserve is not None:
|
|
280
|
+
with suppress(Exception):
|
|
281
|
+
self.backend.unreserve(record.reserve, record.id)
|
|
282
|
+
return
|
|
283
|
+
self.controller.settle(record.id)
|
|
284
|
+
|
|
285
|
+
def _cancel_if_pending(self, record: RunRecord) -> None:
|
|
286
|
+
"""Cancel the workflow of a run which ended or was deleted meanwhile."""
|
|
287
|
+
with self.controller.lock:
|
|
288
|
+
try:
|
|
289
|
+
current: RunRecord | None = self.store.read(record.id)
|
|
290
|
+
except KeyError:
|
|
291
|
+
current = None
|
|
292
|
+
if current is not None and current.state in TERMINAL_RUN_STATES:
|
|
293
|
+
self.store.write(current.model_copy(update=_undo(current, record)))
|
|
294
|
+
if current is None:
|
|
295
|
+
self._cancel_orphan(record)
|
|
296
|
+
else:
|
|
297
|
+
self.controller.settle(record.id)
|
|
298
|
+
|
|
299
|
+
def _submitted(self, record: RunRecord) -> None:
|
|
300
|
+
"""Queue a submitted run, or cancel its workflow when it ended meanwhile."""
|
|
301
|
+
ended = False
|
|
302
|
+
with self.controller.lock:
|
|
303
|
+
try:
|
|
304
|
+
current: RunRecord | None = self.store.read(record.id)
|
|
305
|
+
except KeyError:
|
|
306
|
+
current = None
|
|
307
|
+
if current is not None:
|
|
308
|
+
ended = current.state in TERMINAL_RUN_STATES
|
|
309
|
+
if ended: # cancelled while it was submitted: cancel the workflow
|
|
310
|
+
current = current.model_copy(update=_undo(current, record))
|
|
311
|
+
else:
|
|
312
|
+
current = current.model_copy(update={"state": RunState.QUEUED})
|
|
313
|
+
self.store.write(current)
|
|
314
|
+
if current is None:
|
|
315
|
+
self._cancel_orphan(record)
|
|
316
|
+
return
|
|
317
|
+
if ended:
|
|
318
|
+
self.controller.settle(record.id)
|
|
319
|
+
logger.info("run %s submitted by %s", record.id, record.submitted_by)
|
|
320
|
+
|
|
321
|
+
def _cancel_orphan(self, record: RunRecord) -> None:
|
|
322
|
+
"""Undo the submission of a run cancelled and deleted while it was submitted.
|
|
323
|
+
|
|
324
|
+
Its workflow is cancelled and its node released (the job may have reserved
|
|
325
|
+
it after the run was released and deleted); no record is left to retry
|
|
326
|
+
either, so both are best effort and logged.
|
|
327
|
+
"""
|
|
328
|
+
name = record.backend_name
|
|
329
|
+
logger.warning(
|
|
330
|
+
"run %s was cancelled and deleted while it was submitted", record.id
|
|
331
|
+
)
|
|
332
|
+
if name is not None:
|
|
333
|
+
try:
|
|
334
|
+
self.backend.cancel(name)
|
|
335
|
+
except KeyError:
|
|
336
|
+
pass
|
|
337
|
+
except Exception:
|
|
338
|
+
logger.exception("cancelling the orphan workflow %s failed", name)
|
|
339
|
+
if record.reserve is not None:
|
|
340
|
+
try:
|
|
341
|
+
self.backend.unreserve(record.reserve, record.id)
|
|
342
|
+
except KeyError:
|
|
343
|
+
pass
|
|
344
|
+
except Exception:
|
|
345
|
+
logger.exception(
|
|
346
|
+
"releasing node %s of the orphan run %s failed",
|
|
347
|
+
record.reserve,
|
|
348
|
+
record.id,
|
|
349
|
+
)
|
simrun/snapshot.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Snapshots of working trees with provenance.
|
|
2
|
+
|
|
3
|
+
A snapshot is a copy of the files git knows of a working tree (tracked and
|
|
4
|
+
untracked, not ignored, uncommitted changes included) in the run store. Files
|
|
5
|
+
unchanged since the latest snapshot of the same name are hard links to it. The files
|
|
6
|
+
of a snapshot are read-only (`a-w`, part of the hash of its content): a snapshot is
|
|
7
|
+
named by its content and never changes, and a build backend which writes through a
|
|
8
|
+
link of a shadow tree into one fails loudly instead.
|
|
9
|
+
Submodules are out of scope: their content is not copied.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SourceProvenance(BaseModel):
|
|
28
|
+
"""Where the files of a snapshot come from.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
name: Name of the source.
|
|
32
|
+
branch: Branch of the working tree, `None` for a detached HEAD.
|
|
33
|
+
commit: Commit of HEAD.
|
|
34
|
+
dirty: Whether the working tree differs from HEAD.
|
|
35
|
+
changed_files: Porcelain status lines, e.g. `M src/a.py`.
|
|
36
|
+
diff_sha256: Hash of the snapshot tree (paths, types, modes, contents);
|
|
37
|
+
the patch stays as provenance.
|
|
38
|
+
snapshot: Directory of the snapshot, relative to the store root.
|
|
39
|
+
patch: Stored `git diff HEAD --binary`, relative to the store root.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
branch: str | None
|
|
44
|
+
commit: str
|
|
45
|
+
dirty: bool
|
|
46
|
+
changed_files: list[str]
|
|
47
|
+
diff_sha256: str
|
|
48
|
+
snapshot: str
|
|
49
|
+
patch: str | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _run(cmd: list[str], input: bytes | None = None, ok: tuple[int, ...] = ()) -> bytes:
|
|
53
|
+
result = subprocess.run(cmd, input=input, capture_output=True)
|
|
54
|
+
if result.returncode == 23 and _only_missing_files(result.stderr):
|
|
55
|
+
return result.stdout # a file vanished between the listing and the copy
|
|
56
|
+
if result.returncode != 0 and result.returncode not in ok:
|
|
57
|
+
raise RuntimeError(
|
|
58
|
+
f"{cmd[0]} failed with exit code {result.returncode}: "
|
|
59
|
+
f"{result.stderr.decode(errors='replace').strip()}"
|
|
60
|
+
)
|
|
61
|
+
return result.stdout
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _only_missing_files(stderr: bytes) -> bool:
|
|
65
|
+
"""Whether every error of rsync (exit 23) is a source file which is gone."""
|
|
66
|
+
errors = [
|
|
67
|
+
line
|
|
68
|
+
for line in stderr.decode(errors="replace").splitlines()
|
|
69
|
+
if line.startswith("rsync:")
|
|
70
|
+
]
|
|
71
|
+
return bool(errors) and all(
|
|
72
|
+
"link_stat" in line and "No such file" in line for line in errors
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git(repo: Path, *args: str) -> bytes:
|
|
77
|
+
return _run(["git", "-C", str(repo), *args])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _tree_hash(root: Path) -> str:
|
|
81
|
+
"""Hash the entries of a tree: path, type, mode bits, content or target."""
|
|
82
|
+
digest = hashlib.sha256()
|
|
83
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
84
|
+
dirnames.sort()
|
|
85
|
+
# a symlink to a directory is listed in dirnames and not descended into
|
|
86
|
+
links = [d for d in dirnames if (Path(dirpath) / d).is_symlink()]
|
|
87
|
+
for fname in sorted(filenames + links):
|
|
88
|
+
path = Path(dirpath) / fname
|
|
89
|
+
st = path.lstat()
|
|
90
|
+
kind = b"l" if stat.S_ISLNK(st.st_mode) else b"f"
|
|
91
|
+
content = (
|
|
92
|
+
os.fsencode(os.readlink(path)) if kind == b"l" else path.read_bytes()
|
|
93
|
+
)
|
|
94
|
+
digest.update(os.fsencode(str(path.relative_to(root))) + b"\0")
|
|
95
|
+
digest.update(kind + b"%o" % stat.S_IMODE(st.st_mode) + b"\0")
|
|
96
|
+
digest.update(b"%d\0" % len(content) + content)
|
|
97
|
+
return digest.hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _split(data: bytes) -> list[str]:
|
|
101
|
+
return [os.fsdecode(p) for p in data.split(b"\0") if p]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write_atomic(path: Path, data: bytes, mode: int | None = None) -> None:
|
|
105
|
+
tmp = path.with_name(f".tmp-{secrets.token_hex(6)}-{path.name}")
|
|
106
|
+
tmp.write_bytes(data)
|
|
107
|
+
if mode is not None:
|
|
108
|
+
tmp.chmod(mode)
|
|
109
|
+
os.replace(tmp, path)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def snapshot(repo: Path, store: Path, name: str) -> SourceProvenance:
|
|
113
|
+
"""Copy a working tree into `store/snapshots/<name>`.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
repo: A git working tree (a worktree with a `.git` file works too).
|
|
117
|
+
store: Root of the run store.
|
|
118
|
+
name: Name of the source.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The provenance of the snapshot.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
RuntimeError: When rsync is not installed.
|
|
125
|
+
"""
|
|
126
|
+
if shutil.which("rsync") is None:
|
|
127
|
+
raise RuntimeError("rsync is required to snapshot a working tree")
|
|
128
|
+
commit = _git(repo, "rev-parse", "HEAD").decode().strip()
|
|
129
|
+
branch = _git(repo, "branch", "--show-current").decode().strip() or None
|
|
130
|
+
status = _git(repo, "status", "--porcelain", "--untracked-files=all")
|
|
131
|
+
changed = [line for line in status.decode().splitlines() if line]
|
|
132
|
+
patch = _git(repo, "diff", "HEAD", "--binary")
|
|
133
|
+
listed = _split(
|
|
134
|
+
_git(repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
|
|
135
|
+
)
|
|
136
|
+
dirty = bool(changed)
|
|
137
|
+
|
|
138
|
+
# skip deleted files and the directories of submodules
|
|
139
|
+
files = sorted(
|
|
140
|
+
{
|
|
141
|
+
f
|
|
142
|
+
for f in listed
|
|
143
|
+
if os.path.lexists(repo / f)
|
|
144
|
+
and (os.path.islink(repo / f) or not os.path.isdir(repo / f))
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
parent = store / "snapshots" / name
|
|
149
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
latest = parent / "latest"
|
|
151
|
+
tmp = Path(tempfile.mkdtemp(prefix=".tmp-", dir=parent))
|
|
152
|
+
try:
|
|
153
|
+
# --checksum: link only files of identical content; exit 24 (a file
|
|
154
|
+
# vanished before the copy) leaves it out of the snapshot and its hash
|
|
155
|
+
# --chmod: the files read-only, also the ones linked to the latest snapshot,
|
|
156
|
+
# whose modes then match
|
|
157
|
+
cmd = ["rsync", "-a", "--checksum", "--chmod=F-w", "--files-from=-"]
|
|
158
|
+
cmd.append("--from0")
|
|
159
|
+
if latest.is_dir():
|
|
160
|
+
cmd.append(f"--link-dest={latest.resolve()}")
|
|
161
|
+
cmd += [f"{repo}/", f"{tmp}/"]
|
|
162
|
+
_run(cmd, input=b"".join(os.fsencode(f) + b"\0" for f in files), ok=(24,))
|
|
163
|
+
diff_sha256 = _tree_hash(tmp)
|
|
164
|
+
dirname = f"{commit[:12]}-{diff_sha256[:12]}"
|
|
165
|
+
final = parent / dirname
|
|
166
|
+
tmp.chmod(0o755)
|
|
167
|
+
if not final.is_dir():
|
|
168
|
+
try:
|
|
169
|
+
os.rename(tmp, final)
|
|
170
|
+
except OSError:
|
|
171
|
+
if not final.is_dir():
|
|
172
|
+
raise
|
|
173
|
+
logger.info("snapshot %s was created concurrently", final)
|
|
174
|
+
finally:
|
|
175
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
176
|
+
|
|
177
|
+
patch_rel: str | None = None
|
|
178
|
+
if dirty:
|
|
179
|
+
patch_path = parent / f"{dirname}.patch"
|
|
180
|
+
if not patch_path.exists():
|
|
181
|
+
_write_atomic(patch_path, patch, mode=0o444)
|
|
182
|
+
patch_rel = str(patch_path.relative_to(store))
|
|
183
|
+
|
|
184
|
+
link = parent / f".tmp-link-{secrets.token_hex(6)}"
|
|
185
|
+
link.symlink_to(dirname)
|
|
186
|
+
os.replace(link, latest)
|
|
187
|
+
return SourceProvenance(
|
|
188
|
+
name=name,
|
|
189
|
+
branch=branch,
|
|
190
|
+
commit=commit,
|
|
191
|
+
dirty=dirty,
|
|
192
|
+
changed_files=changed,
|
|
193
|
+
diff_sha256=diff_sha256,
|
|
194
|
+
snapshot=str(final.relative_to(store)),
|
|
195
|
+
patch=patch_rel,
|
|
196
|
+
)
|