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/client.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""The client of the service: snapshots working trees, then calls the API.
|
|
2
|
+
|
|
3
|
+
The CLI (`simrun.cli`) and the MCP server (`simrun.mcp`) both go through `Client`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from simrun.plugins import TaskType, load_task_types, resolve
|
|
16
|
+
from simrun.service.app import (
|
|
17
|
+
CleanupResult,
|
|
18
|
+
OutputFile,
|
|
19
|
+
RunSummary,
|
|
20
|
+
TaskEstimate,
|
|
21
|
+
TaskProgress,
|
|
22
|
+
Usage,
|
|
23
|
+
ValidationResult,
|
|
24
|
+
)
|
|
25
|
+
from simrun.service.compare import CompareTable
|
|
26
|
+
from simrun.service.controller import TERMINAL_RUN_STATES
|
|
27
|
+
from simrun.service.edits import (
|
|
28
|
+
EditRequest,
|
|
29
|
+
EditResult,
|
|
30
|
+
EditSession,
|
|
31
|
+
EditState,
|
|
32
|
+
OpenEdit,
|
|
33
|
+
)
|
|
34
|
+
from simrun.service.logs import DEFAULT_TAIL_LINES
|
|
35
|
+
from simrun.snapshot import snapshot
|
|
36
|
+
from simrun.store import RunRecord, RunState
|
|
37
|
+
from simrun.workflow import SplitMode, Workflow, load_workflow
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
URL_ENV = "SIMRUN_URL"
|
|
42
|
+
TOKEN_ENV = "SIMRUN_TOKEN"
|
|
43
|
+
STORE_ENV = "SIMRUN_STORE"
|
|
44
|
+
# one long poll of the service, below its maximum of 300 s
|
|
45
|
+
POLL_S = 30.0
|
|
46
|
+
REQUEST_TIMEOUT_S = 60.0
|
|
47
|
+
BACKOFF_START_S = 1.0
|
|
48
|
+
BACKOFF_MAX_S = 10.0
|
|
49
|
+
# answers of a proxy or a restarting service, retried by `wait` and `simrun edit`
|
|
50
|
+
RETRY_STATUS = (502, 503, 504)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def plan_tasks(workflow: Workflow, types: dict[str, TaskType]) -> Workflow:
|
|
54
|
+
"""The workflow with the settings of its tasks planned by their types.
|
|
55
|
+
|
|
56
|
+
A task whose type is in `types` and has a `plan` gets the settings the plan
|
|
57
|
+
returns for its validated settings, the fields which were given or which the
|
|
58
|
+
plan set, dumped as JSON: the defaults stay with the plugin of the snapshot,
|
|
59
|
+
which validates the settings in the pods. Every other task is kept as written,
|
|
60
|
+
e.g. a task of a type which is not installed locally.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
workflow: The workflow, which is not changed.
|
|
64
|
+
types: The task types installed locally, by name.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A copy of the workflow with the planned settings.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: The settings of a task are invalid for its type.
|
|
71
|
+
"""
|
|
72
|
+
tasks = []
|
|
73
|
+
for task in workflow.tasks:
|
|
74
|
+
task_type = types.get(task.type)
|
|
75
|
+
if task_type is None or task_type.plan is None:
|
|
76
|
+
tasks.append(task)
|
|
77
|
+
continue
|
|
78
|
+
try:
|
|
79
|
+
settings = task_type.settings.model_validate(task.settings)
|
|
80
|
+
except ValidationError as error:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"task {task.id!r} of type {task.type!r}: {error}"
|
|
83
|
+
) from error
|
|
84
|
+
planned = resolve(task_type.plan)(settings)
|
|
85
|
+
logger.debug("task %s planned by %s", task.id, task_type.plan)
|
|
86
|
+
tasks.append(
|
|
87
|
+
task.model_copy(
|
|
88
|
+
update={"settings": planned.model_dump(mode="json", exclude_unset=True)}
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return workflow.model_copy(update={"tasks": tasks})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ClientError(Exception):
|
|
95
|
+
"""The service refused a request.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
status: The HTTP status.
|
|
99
|
+
detail: The message of the service.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, status: int, detail: str) -> None:
|
|
103
|
+
"""Create the error."""
|
|
104
|
+
super().__init__(f"{status}: {detail}")
|
|
105
|
+
self.status = status
|
|
106
|
+
self.detail = detail
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _detail(response: httpx.Response) -> str:
|
|
110
|
+
try:
|
|
111
|
+
detail = response.json()["detail"]
|
|
112
|
+
except ValueError, KeyError, TypeError:
|
|
113
|
+
return response.text or response.reason_phrase
|
|
114
|
+
if isinstance(detail, list):
|
|
115
|
+
return "; ".join(
|
|
116
|
+
f"{'.'.join(str(p) for p in d.get('loc', []))}: {d.get('msg', d)}"
|
|
117
|
+
if isinstance(d, dict)
|
|
118
|
+
else str(d)
|
|
119
|
+
for d in detail
|
|
120
|
+
)
|
|
121
|
+
return str(detail)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Client:
|
|
125
|
+
"""A client of the service of one user or agent.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
url: Base URL of the service.
|
|
129
|
+
token: Bearer token.
|
|
130
|
+
store: The run store as mounted locally, where snapshots are written.
|
|
131
|
+
transport: An HTTP transport, e.g. for tests.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
url: str,
|
|
137
|
+
token: str,
|
|
138
|
+
store: Path,
|
|
139
|
+
transport: httpx.BaseTransport | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Create the client."""
|
|
142
|
+
self.url = url.rstrip("/")
|
|
143
|
+
self.store = store
|
|
144
|
+
self._http = httpx.Client(
|
|
145
|
+
base_url=url,
|
|
146
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
147
|
+
transport=transport,
|
|
148
|
+
timeout=REQUEST_TIMEOUT_S,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_env(cls) -> Client:
|
|
153
|
+
"""The client of `SIMRUN_URL`, `SIMRUN_TOKEN` and `SIMRUN_STORE`.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
RuntimeError: When one of them is not set.
|
|
157
|
+
"""
|
|
158
|
+
values: dict[str, str] = {}
|
|
159
|
+
for name in (URL_ENV, TOKEN_ENV, STORE_ENV):
|
|
160
|
+
value = os.environ.get(name)
|
|
161
|
+
if not value:
|
|
162
|
+
raise RuntimeError(f"{name} is not set")
|
|
163
|
+
values[name] = value
|
|
164
|
+
return cls(values[URL_ENV], values[TOKEN_ENV], Path(values[STORE_ENV]))
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
"""Close the connections."""
|
|
168
|
+
self._http.close()
|
|
169
|
+
|
|
170
|
+
def _call(self, method: str, path: str, **kw: Any) -> httpx.Response:
|
|
171
|
+
response = self._http.request(method, path, **kw)
|
|
172
|
+
if response.is_error:
|
|
173
|
+
raise ClientError(response.status_code, _detail(response))
|
|
174
|
+
return response
|
|
175
|
+
|
|
176
|
+
def _request(
|
|
177
|
+
self,
|
|
178
|
+
workflow: Path | Workflow,
|
|
179
|
+
sources: dict[str, Path],
|
|
180
|
+
group: str | None,
|
|
181
|
+
split: SplitMode | None,
|
|
182
|
+
reserve: str | None,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
if isinstance(workflow, Path):
|
|
185
|
+
workflow = load_workflow(workflow)
|
|
186
|
+
workflow = plan_tasks(workflow, load_task_types())
|
|
187
|
+
provenance = [
|
|
188
|
+
snapshot(path, self.store, name) for name, path in sources.items()
|
|
189
|
+
]
|
|
190
|
+
return {
|
|
191
|
+
"workflow": workflow.model_dump(mode="json"),
|
|
192
|
+
"sources": [p.model_dump(mode="json") for p in provenance],
|
|
193
|
+
"group": group,
|
|
194
|
+
"split": split.value if split else None,
|
|
195
|
+
"reserve": reserve,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def submit(
|
|
199
|
+
self,
|
|
200
|
+
workflow: Path | Workflow,
|
|
201
|
+
sources: dict[str, Path],
|
|
202
|
+
group: str | None = None,
|
|
203
|
+
split: SplitMode | None = None,
|
|
204
|
+
reserve: str | None = None,
|
|
205
|
+
) -> RunSummary:
|
|
206
|
+
"""Plan the tasks, snapshot the sources and submit a workflow.
|
|
207
|
+
|
|
208
|
+
The settings of a task whose type is installed locally and has a `plan`
|
|
209
|
+
are replaced by the planned ones, see `plan_tasks`.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
workflow: The workflow or its YAML file.
|
|
213
|
+
sources: Working trees by source name, the model first.
|
|
214
|
+
group: Group of the run.
|
|
215
|
+
split: Split mode of every task.
|
|
216
|
+
reserve: A node to reserve.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
The summary of the run, `submitting` while the service resolves its
|
|
220
|
+
environment, builds its image and submits its workflow.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ClientError: When the service refuses the submission.
|
|
224
|
+
"""
|
|
225
|
+
body = self._request(workflow, sources, group, split, reserve)
|
|
226
|
+
return RunSummary.model_validate(self._call("POST", "/runs", json=body).json())
|
|
227
|
+
|
|
228
|
+
def estimate(
|
|
229
|
+
self,
|
|
230
|
+
workflow: Path | Workflow,
|
|
231
|
+
sources: dict[str, Path],
|
|
232
|
+
group: str | None = None,
|
|
233
|
+
split: SplitMode | None = None,
|
|
234
|
+
reserve: str | None = None,
|
|
235
|
+
) -> dict[str, TaskEstimate]:
|
|
236
|
+
"""The resources, plans and durations of the tasks of a submission."""
|
|
237
|
+
body = self._request(workflow, sources, group, split, reserve)
|
|
238
|
+
data = self._call("POST", "/estimate", json=body).json()
|
|
239
|
+
return {k: TaskEstimate.model_validate(v) for k, v in data.items()}
|
|
240
|
+
|
|
241
|
+
def validate(self, workflow: Path | Workflow) -> ValidationResult:
|
|
242
|
+
"""Validate a workflow with its tasks planned, as `submit` submits it.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
ValueError: The settings of a task are invalid for its local type.
|
|
246
|
+
"""
|
|
247
|
+
if isinstance(workflow, Path):
|
|
248
|
+
workflow = load_workflow(workflow)
|
|
249
|
+
body = plan_tasks(workflow, load_task_types()).model_dump(mode="json")
|
|
250
|
+
return ValidationResult.model_validate(
|
|
251
|
+
self._call("POST", "/validate", json=body).json()
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def open_edit(self, body: OpenEdit) -> EditSession:
|
|
255
|
+
"""Open an edit session for the workflow file of this machine."""
|
|
256
|
+
data = self._call("POST", "/edits", json=body.model_dump(mode="json")).json()
|
|
257
|
+
return EditSession.model_validate(data)
|
|
258
|
+
|
|
259
|
+
def edit_state(self, session_id: str, state: EditState) -> EditSession:
|
|
260
|
+
"""Send what was read from disk to an edit session."""
|
|
261
|
+
response = self._call(
|
|
262
|
+
"PUT",
|
|
263
|
+
f"/edits/{session_id}/state",
|
|
264
|
+
json=state.model_dump(mode="json", exclude_none=True),
|
|
265
|
+
)
|
|
266
|
+
return EditSession.model_validate(response.json())
|
|
267
|
+
|
|
268
|
+
def edit_requests(self, session_id: str, timeout: float) -> list[EditRequest]:
|
|
269
|
+
"""Long poll the open requests of the browser, for up to `timeout` s."""
|
|
270
|
+
response = self._call(
|
|
271
|
+
"GET", f"/edits/{session_id}/requests", params={"timeout": timeout}
|
|
272
|
+
)
|
|
273
|
+
return [EditRequest.model_validate(item) for item in response.json()]
|
|
274
|
+
|
|
275
|
+
def edit_result(self, session_id: str, request_id: str, result: EditResult) -> None:
|
|
276
|
+
"""Answer a request of the browser."""
|
|
277
|
+
self._call(
|
|
278
|
+
"POST",
|
|
279
|
+
f"/edits/{session_id}/requests/{request_id}/result",
|
|
280
|
+
json=result.model_dump(mode="json"),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def close_edit(self, session_id: str, timeout: float = REQUEST_TIMEOUT_S) -> None:
|
|
284
|
+
"""Close an edit session, the command stopped, waiting up to `timeout` s."""
|
|
285
|
+
self._call("POST", f"/edits/{session_id}/close", timeout=timeout)
|
|
286
|
+
|
|
287
|
+
def wait(self, run_id: str, timeout_s: float | None = None) -> RunSummary:
|
|
288
|
+
"""Wait for a run to end, or for `timeout_s` seconds (None: forever).
|
|
289
|
+
|
|
290
|
+
A dropped connection or a restarting service is retried with a backoff
|
|
291
|
+
of 1 s doubling to 10 s, until the timeout.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The summary of the run, not terminal when the timeout passed; when the
|
|
295
|
+
timeout passes during an outage, the last summary obtained.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ClientError: For an unknown run or another refusal.
|
|
299
|
+
httpx.TransportError: When the service was never reached before the
|
|
300
|
+
timeout.
|
|
301
|
+
"""
|
|
302
|
+
last: RunSummary | None = None
|
|
303
|
+
deadline = None if timeout_s is None else time.monotonic() + timeout_s
|
|
304
|
+
backoff = BACKOFF_START_S
|
|
305
|
+
while True:
|
|
306
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
307
|
+
poll = POLL_S if remaining is None else min(POLL_S, max(remaining, 0.0))
|
|
308
|
+
try:
|
|
309
|
+
response = self._http.get(
|
|
310
|
+
f"/runs/{run_id}/wait", params={"timeout": poll}
|
|
311
|
+
)
|
|
312
|
+
if response.status_code in RETRY_STATUS:
|
|
313
|
+
raise httpx.TransportError(f"status {response.status_code}")
|
|
314
|
+
except httpx.TransportError as error:
|
|
315
|
+
remaining = None if deadline is None else deadline - time.monotonic()
|
|
316
|
+
if remaining is not None and remaining <= 0:
|
|
317
|
+
if last is None:
|
|
318
|
+
raise
|
|
319
|
+
return last
|
|
320
|
+
logger.warning(
|
|
321
|
+
"wait for %s: %s, retrying in %.0f s", run_id, error, backoff
|
|
322
|
+
)
|
|
323
|
+
time.sleep(backoff if remaining is None else min(backoff, remaining))
|
|
324
|
+
backoff = min(backoff * 2, BACKOFF_MAX_S)
|
|
325
|
+
continue
|
|
326
|
+
backoff = BACKOFF_START_S
|
|
327
|
+
if response.is_error:
|
|
328
|
+
raise ClientError(response.status_code, _detail(response))
|
|
329
|
+
summary = last = RunSummary.model_validate(response.json())
|
|
330
|
+
if summary.state in TERMINAL_RUN_STATES:
|
|
331
|
+
return summary
|
|
332
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
333
|
+
return summary
|
|
334
|
+
|
|
335
|
+
def status(self, run_id: str) -> RunRecord:
|
|
336
|
+
"""The record of a run."""
|
|
337
|
+
return RunRecord.model_validate(self._call("GET", f"/runs/{run_id}").json())
|
|
338
|
+
|
|
339
|
+
def progress(self, run_id: str) -> dict[str, TaskProgress]:
|
|
340
|
+
"""The progress of the tasks of a run."""
|
|
341
|
+
data = self._call("GET", f"/runs/{run_id}/progress").json()
|
|
342
|
+
return {k: TaskProgress.model_validate(v) for k, v in data.items()}
|
|
343
|
+
|
|
344
|
+
def list_runs(
|
|
345
|
+
self,
|
|
346
|
+
group: str | None = None,
|
|
347
|
+
agent: str | None = None,
|
|
348
|
+
state: RunState | None = None,
|
|
349
|
+
owner: str | None = None,
|
|
350
|
+
) -> list[RunSummary]:
|
|
351
|
+
"""The runs, filtered by group, agent, state and owner."""
|
|
352
|
+
filters = {"group": group, "agent": agent, "state": state, "owner": owner}
|
|
353
|
+
params = {k: v for k, v in filters.items() if v is not None}
|
|
354
|
+
data = self._call("GET", "/runs", params=params).json()
|
|
355
|
+
return [RunSummary.model_validate(r) for r in data]
|
|
356
|
+
|
|
357
|
+
def compare(self, run_ids: list[str]) -> CompareTable:
|
|
358
|
+
"""Provenance and summaries of runs side by side."""
|
|
359
|
+
data = self._call("POST", "/compare", json={"runs": run_ids}).json()
|
|
360
|
+
return CompareTable.model_validate(data)
|
|
361
|
+
|
|
362
|
+
def cancel(self, run_id: str) -> RunSummary:
|
|
363
|
+
"""Cancel a run."""
|
|
364
|
+
return RunSummary.model_validate(
|
|
365
|
+
self._call("POST", f"/runs/{run_id}/cancel").json()
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def logs(
|
|
369
|
+
self, run_id: str, task_id: str, tail_lines: int = DEFAULT_TAIL_LINES
|
|
370
|
+
) -> str:
|
|
371
|
+
"""The logs of the pods of a task, the last `tail_lines` of each (0: all)."""
|
|
372
|
+
return self._call(
|
|
373
|
+
"GET", f"/runs/{run_id}/logs/{task_id}", params={"tail_lines": tail_lines}
|
|
374
|
+
).text
|
|
375
|
+
|
|
376
|
+
def outputs(self, run_id: str, task_id: str) -> list[OutputFile]:
|
|
377
|
+
"""The output files of a task, paths relative to the store."""
|
|
378
|
+
data = self._call("GET", f"/runs/{run_id}/outputs/{task_id}").json()
|
|
379
|
+
return [OutputFile.model_validate(f) for f in data]
|
|
380
|
+
|
|
381
|
+
def delete(self, run_id: str) -> CleanupResult:
|
|
382
|
+
"""Delete an ended run."""
|
|
383
|
+
return CleanupResult.model_validate(
|
|
384
|
+
self._call("DELETE", f"/runs/{run_id}").json()
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def cleanup(
|
|
388
|
+
self,
|
|
389
|
+
older_than_days: float,
|
|
390
|
+
group: str | None = None,
|
|
391
|
+
failed_only: bool = False,
|
|
392
|
+
dry_run: bool = False,
|
|
393
|
+
) -> CleanupResult:
|
|
394
|
+
"""Remove ended runs and the snapshots nothing refers to any more."""
|
|
395
|
+
body = {
|
|
396
|
+
"older_than_days": older_than_days,
|
|
397
|
+
"group": group,
|
|
398
|
+
"failed_only": failed_only,
|
|
399
|
+
"dry_run": dry_run,
|
|
400
|
+
}
|
|
401
|
+
return CleanupResult.model_validate(
|
|
402
|
+
self._call("POST", "/cleanup", json=body).json()
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def usage(self) -> Usage:
|
|
406
|
+
"""Bytes of the store per run and group."""
|
|
407
|
+
return Usage.model_validate(self._call("GET", "/usage").json())
|
simrun/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The configuration of the cluster the runs are submitted to."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
# the base image of the images of the environments: uv with the python of the
|
|
6
|
+
# service, pinned by the digest of its index, which the registry mirrors (the tag
|
|
7
|
+
# `0.13.7-python3.14-trixie-slim` of `ghcr.io/astral-sh/uv`)
|
|
8
|
+
DEFAULT_BASE_IMAGE = (
|
|
9
|
+
"astral-sh/uv:0.13.7-python3.14-trixie-slim"
|
|
10
|
+
"@sha256:e80c05a310400cfc90af00b5b7271ef9ad24816e3265e1162af8a678e1aad099"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ClusterConfig(BaseModel):
|
|
15
|
+
"""Where and how the pods of a run are placed in the cluster.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
namespace: Namespace of the workflows and their pods.
|
|
19
|
+
local_queue: Kueue LocalQueue of the pods.
|
|
20
|
+
store_claim: PersistentVolumeClaim of the NFS run store.
|
|
21
|
+
store_mount: Path the run store is mounted at in a pod.
|
|
22
|
+
scratch_path: Local scratch, a hostPath on the local disk of a node, mounted
|
|
23
|
+
at the same path in a pod.
|
|
24
|
+
registry: Host of the image registry inside the network, as the nodes and
|
|
25
|
+
BuildKit name it in image references.
|
|
26
|
+
registry_url: URL of the API of the registry as the service reaches it,
|
|
27
|
+
`http://<registry>` by default.
|
|
28
|
+
service_account: Service account of the pods of the workflows, allowed to
|
|
29
|
+
write the results of the Argo executor.
|
|
30
|
+
buildkit: Address of the BuildKit daemon the builds of images run on.
|
|
31
|
+
buildctl_image: Image of the job which sends a build to BuildKit.
|
|
32
|
+
base_image: The base image of the images of the environments, an image with
|
|
33
|
+
uv and the python of the service (`ServiceSettings.python`); a
|
|
34
|
+
repository of `registry` (the mirror of an image of another registry)
|
|
35
|
+
unless it names a registry host itself.
|
|
36
|
+
grace_s: Seconds a worker has to stop after SIGTERM.
|
|
37
|
+
ttl_after_finished_s: Seconds a finished workflow is kept.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
namespace: str = "simrun"
|
|
41
|
+
local_queue: str = "simrun"
|
|
42
|
+
store_claim: str = "simrun-store"
|
|
43
|
+
store_mount: str = "/store"
|
|
44
|
+
scratch_path: str = "/scratch"
|
|
45
|
+
registry: str
|
|
46
|
+
registry_url: str | None = None
|
|
47
|
+
service_account: str = "simrun-workflow"
|
|
48
|
+
buildkit: str = "tcp://buildkitd.simrun:1234"
|
|
49
|
+
buildctl_image: str = "moby/buildkit:v0.34.2-rootless"
|
|
50
|
+
base_image: str = DEFAULT_BASE_IMAGE
|
|
51
|
+
grace_s: int = 60
|
|
52
|
+
ttl_after_finished_s: int = 86400
|
|
53
|
+
|
|
54
|
+
def base_image_ref(self) -> str:
|
|
55
|
+
"""The base image of the environments as the builds name it.
|
|
56
|
+
|
|
57
|
+
`base_image` below `registry`, unless its first component is a registry
|
|
58
|
+
host (it has a `.` or a `:`, or is `localhost`).
|
|
59
|
+
"""
|
|
60
|
+
first, slash, _ = self.base_image.partition("/")
|
|
61
|
+
if slash and ("." in first or ":" in first or first == "localhost"):
|
|
62
|
+
return self.base_image
|
|
63
|
+
return f"{self.registry}/{self.base_image}"
|