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
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
"""The backend of the service on a cluster: Argo Workflows, Kueue and BuildKit.
|
|
2
|
+
|
|
3
|
+
A run is an Argo `Workflow` (`simrun.argo.to_argo`) in the namespace of the cluster;
|
|
4
|
+
Kueue admits its pods (plain pods with a queue label) and preempts lower priorities.
|
|
5
|
+
The status of a workflow is read from the nodes of its DAG (`workflow_status`): a node
|
|
6
|
+
retried by Argo (a `Retry` node) is `Pending` while its attempt waits for admission,
|
|
7
|
+
`Preempted` while the attempt after an eviction waits (a pod deleted by Kueue or lost,
|
|
8
|
+
or a worker stopped on SIGTERM), `Retrying` while the attempt after another failure
|
|
9
|
+
waits (a crash, a pod killed after its grace period), `Running` while its attempt
|
|
10
|
+
runs; the failed attempts are listed per worker with their exit
|
|
11
|
+
codes and whether the kernel killed them for memory (`OOMKilled`).
|
|
12
|
+
|
|
13
|
+
The nodes are the ones labelled `simrun/worker=true` (the nodes of the ResourceFlavor
|
|
14
|
+
of Kueue) without a taint which keeps pods off, a reservation of simrun aside; their
|
|
15
|
+
cores and memory are the allocatable ones minus the wait container of the Argo
|
|
16
|
+
executor and minus the requests of the pods on them which are no pods of runs (the
|
|
17
|
+
DaemonSets of the cluster, e.g. of the NFS driver and of the monitoring), rounded
|
|
18
|
+
down to whole cores and GiB, so a pod the planner sizes for a node fits it beside
|
|
19
|
+
them.
|
|
20
|
+
|
|
21
|
+
A reservation is the taint `simrun/reserved=<run id>:NoExecute`, which evicts the
|
|
22
|
+
pods of other runs from the node, and the label `simrun/reserved=<run id>`, which the
|
|
23
|
+
pods of the run select.
|
|
24
|
+
|
|
25
|
+
An image is built by a Job of `buildctl`, which sends the build context (a ConfigMap
|
|
26
|
+
of its files, owned by the Job) to the BuildKit daemon of the cluster and pushes the
|
|
27
|
+
image to the registry with the credentials of the optional Secret
|
|
28
|
+
`simrun-registry-auth`; whether the registry has an image is asked its API.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import base64
|
|
32
|
+
import hashlib
|
|
33
|
+
import logging
|
|
34
|
+
import math
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
import httpx
|
|
43
|
+
from kubernetes import client
|
|
44
|
+
from kubernetes import config as kube_config
|
|
45
|
+
from kubernetes.client.exceptions import ApiException
|
|
46
|
+
from kubernetes.config.config_exception import ConfigException
|
|
47
|
+
from kubernetes.utils import parse_quantity
|
|
48
|
+
|
|
49
|
+
from simrun.argo import CONTAINER, GPU_RESOURCE, RESERVED_KEY
|
|
50
|
+
from simrun.config import ClusterConfig
|
|
51
|
+
from simrun.planner import Node
|
|
52
|
+
from simrun.service.app import CONFIG_ENV, load_settings
|
|
53
|
+
from simrun.service.backend import (
|
|
54
|
+
FINAL_PHASES,
|
|
55
|
+
PREEMPTED_PHASE,
|
|
56
|
+
RETRYING_PHASE,
|
|
57
|
+
BackendStatus,
|
|
58
|
+
FailedAttempt,
|
|
59
|
+
Placement,
|
|
60
|
+
PodLog,
|
|
61
|
+
)
|
|
62
|
+
from simrun.worker import EXIT_TERMINATED
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
GROUP = "argoproj.io"
|
|
67
|
+
VERSION = "v1alpha1"
|
|
68
|
+
PLURAL = "workflows"
|
|
69
|
+
WORKFLOW_LABEL = "workflows.argoproj.io/workflow"
|
|
70
|
+
NODE_ANNOTATION = "workflows.argoproj.io/node-name"
|
|
71
|
+
# the label of the nodes the pods of the runs run on, the nodes of the ResourceFlavor
|
|
72
|
+
WORKER_LABEL = "simrun/worker"
|
|
73
|
+
# the affinity of the pods of the infrastructure: off the nodes of the runs
|
|
74
|
+
OFF_WORKERS = {
|
|
75
|
+
"nodeAffinity": {
|
|
76
|
+
"requiredDuringSchedulingIgnoredDuringExecution": {
|
|
77
|
+
"nodeSelectorTerms": [
|
|
78
|
+
{
|
|
79
|
+
"matchExpressions": [
|
|
80
|
+
{"key": WORKER_LABEL, "operator": "DoesNotExist"}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
# the resources of a container of the Argo executor, see
|
|
88
|
+
# deploy/cluster/argo/controller-config.yaml
|
|
89
|
+
EXECUTOR_CPU = 0.1
|
|
90
|
+
EXECUTOR_MEMORY_GB = 0.25
|
|
91
|
+
GIB = 2**30
|
|
92
|
+
# the name of an attempt of a retried node ends in `(<attempt>)`
|
|
93
|
+
ATTEMPT = re.compile(r"\((\d+)\)$")
|
|
94
|
+
EXIT_CODE = re.compile(r"\(exit code (-?\d+)\)")
|
|
95
|
+
# node types which are no task of the DAG
|
|
96
|
+
GROUP_TYPES = frozenset({"DAG", "TaskGroup", "Steps", "StepGroup"})
|
|
97
|
+
FAILED_PHASES = frozenset({"Failed", "Error"})
|
|
98
|
+
OOM_REASON = "OOMKilled"
|
|
99
|
+
# the message of an attempt whose container of the executor failed starts so
|
|
100
|
+
EXECUTOR_CONTAINERS = ("init: ", "wait: ")
|
|
101
|
+
REGISTRY_AUTH_SECRET = "simrun-registry-auth"
|
|
102
|
+
# a ConfigMap holds at most 1 MiB
|
|
103
|
+
MAX_CONTEXT_BYTES = 1_000_000
|
|
104
|
+
BUILD_TIMEOUT_S = 3600.0
|
|
105
|
+
BUILD_POLL_S = 2.0
|
|
106
|
+
BUILD_TTL_S = 600
|
|
107
|
+
# the bytes of a log of a pod read at most, into the memory of the service
|
|
108
|
+
LOG_LIMIT_BYTES = 8 * 2**20
|
|
109
|
+
MANIFEST_TYPES = (
|
|
110
|
+
"application/vnd.oci.image.index.v1+json",
|
|
111
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
112
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
113
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _killed_for_memory(node: dict[str, Any]) -> bool:
|
|
118
|
+
"""Whether the main container of an attempt was killed by the out of memory killer.
|
|
119
|
+
|
|
120
|
+
Argo v4 names the container in the message of the node, e.g.
|
|
121
|
+
`main: OOMKilled (exit code 137)`; a kill of a container of the executor
|
|
122
|
+
(`init: ...`, `wait: ...`) is no kill of the worker. An exit 137 alone is no
|
|
123
|
+
evidence (Ruling 39): a pod which did not stop within its grace period is killed
|
|
124
|
+
with SIGKILL, and a worker which exits 137 itself (Ruling 7) reports in its
|
|
125
|
+
report whether the out of memory killer killed a process of its pool.
|
|
126
|
+
"""
|
|
127
|
+
message = node.get("message") or ""
|
|
128
|
+
if message.startswith(EXECUTOR_CONTAINERS):
|
|
129
|
+
return False
|
|
130
|
+
return message.removeprefix(f"{CONTAINER}: ").startswith(OOM_REASON)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _exit_code(node: dict[str, Any]) -> int | None:
|
|
134
|
+
"""The exit code of the main container of an attempt, None without one.
|
|
135
|
+
|
|
136
|
+
The output of Argo, else the one its message names, e.g. `Error (exit code
|
|
137
|
+
137)`; an attempt whose container of the executor failed has none of the main
|
|
138
|
+
container, and a pod deleted none at all (`-1`).
|
|
139
|
+
"""
|
|
140
|
+
message = node.get("message") or ""
|
|
141
|
+
if message.startswith(EXECUTOR_CONTAINERS):
|
|
142
|
+
return None
|
|
143
|
+
text = str((node.get("outputs") or {}).get("exitCode") or "")
|
|
144
|
+
if not text:
|
|
145
|
+
found = EXIT_CODE.search(message)
|
|
146
|
+
text = found.group(1) if found else ""
|
|
147
|
+
try:
|
|
148
|
+
code = int(text)
|
|
149
|
+
except ValueError:
|
|
150
|
+
return None
|
|
151
|
+
return code if code >= 0 else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parameter(node: dict[str, Any], name: str) -> str | None:
|
|
155
|
+
"""The value of an input parameter of a node, None without it."""
|
|
156
|
+
for parameter in (node.get("inputs") or {}).get("parameters") or []:
|
|
157
|
+
if parameter.get("name") == name:
|
|
158
|
+
return parameter.get("value")
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _phase(node: dict[str, Any], nodes: dict[str, dict[str, Any]]) -> str:
|
|
163
|
+
"""The phase of a task node, from its last attempt when Argo retries it."""
|
|
164
|
+
phase = node.get("phase") or "Pending"
|
|
165
|
+
if node.get("type") != "Retry" or phase in FINAL_PHASES:
|
|
166
|
+
return phase
|
|
167
|
+
attempts = [nodes[c] for c in node.get("children") or [] if c in nodes]
|
|
168
|
+
if not attempts:
|
|
169
|
+
return "Pending"
|
|
170
|
+
last = attempts[-1]
|
|
171
|
+
last_phase = last.get("phase") or "Pending"
|
|
172
|
+
if last_phase in ("Running", "Succeeded"):
|
|
173
|
+
return "Running"
|
|
174
|
+
failed = last if last_phase in FAILED_PHASES else None
|
|
175
|
+
if failed is None and len(attempts) > 1:
|
|
176
|
+
before = attempts[-2]
|
|
177
|
+
failed = before if before.get("phase") in FAILED_PHASES else None
|
|
178
|
+
if failed is None or _killed_for_memory(failed):
|
|
179
|
+
return "Pending"
|
|
180
|
+
if _evicted(failed):
|
|
181
|
+
return PREEMPTED_PHASE
|
|
182
|
+
return RETRYING_PHASE
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _evicted(node: dict[str, Any]) -> bool:
|
|
186
|
+
"""Whether a failed attempt was evicted.
|
|
187
|
+
|
|
188
|
+
Its pod was deleted (no exit code of the main container) or the worker stopped
|
|
189
|
+
on SIGTERM (143).
|
|
190
|
+
"""
|
|
191
|
+
return _exit_code(node) in (None, EXIT_TERMINATED)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def workflow_status(workflow: dict[str, Any]) -> BackendStatus:
|
|
195
|
+
"""The status of a workflow from its Argo object.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
workflow: The workflow as the API returns it.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
The phase of the workflow, of every task node of its DAG by its name relative
|
|
202
|
+
to the workflow, and the failed attempts of the pods per worker id, per task
|
|
203
|
+
id of an unsplit task and per `<task>-merge` of a merge.
|
|
204
|
+
"""
|
|
205
|
+
name = workflow["metadata"]["name"]
|
|
206
|
+
status = workflow.get("status") or {}
|
|
207
|
+
nodes: dict[str, dict[str, Any]] = status.get("nodes") or {}
|
|
208
|
+
prefix = f"{name}."
|
|
209
|
+
tasks: dict[str, str] = {}
|
|
210
|
+
failed: dict[str, list[FailedAttempt]] = {}
|
|
211
|
+
for node in nodes.values():
|
|
212
|
+
full = node.get("name") or ""
|
|
213
|
+
if not full.startswith(prefix):
|
|
214
|
+
continue
|
|
215
|
+
relative = full.removeprefix(prefix)
|
|
216
|
+
kind = node.get("type")
|
|
217
|
+
attempt = ATTEMPT.search(relative)
|
|
218
|
+
if kind == "Pod" and node.get("phase") in FAILED_PHASES:
|
|
219
|
+
# a worker by its id, an unsplit task or a merge by its DAG task
|
|
220
|
+
key = _parameter(node, "worker") or ATTEMPT.sub("", relative)
|
|
221
|
+
failed.setdefault(key, []).append(
|
|
222
|
+
FailedAttempt(
|
|
223
|
+
attempt=int(attempt.group(1)) if attempt else 0,
|
|
224
|
+
exit_code=_exit_code(node),
|
|
225
|
+
oom_killed=_killed_for_memory(node),
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
if kind in GROUP_TYPES or attempt:
|
|
229
|
+
continue
|
|
230
|
+
tasks[relative] = _phase(node, nodes)
|
|
231
|
+
for attempts in failed.values():
|
|
232
|
+
attempts.sort(key=lambda a: a.attempt)
|
|
233
|
+
return BackendStatus(
|
|
234
|
+
phase=status.get("phase") or "Pending", tasks=tasks, failed=failed
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _requests(container: dict[str, Any]) -> tuple[float, float]:
|
|
239
|
+
"""The cores and GiB a container requests."""
|
|
240
|
+
requests = (container.get("resources") or {}).get("requests") or {}
|
|
241
|
+
cpu = float(parse_quantity(requests.get("cpu", "0")))
|
|
242
|
+
memory = float(parse_quantity(requests.get("memory", "0"))) / GIB
|
|
243
|
+
return cpu, memory
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _env_of(pod: dict[str, Any], name: str) -> str | None:
|
|
247
|
+
"""A variable of the environment of the main container of a pod, None without it."""
|
|
248
|
+
for container in (pod.get("spec") or {}).get("containers") or []:
|
|
249
|
+
if container.get("name") != CONTAINER:
|
|
250
|
+
continue
|
|
251
|
+
for var in container.get("env") or []:
|
|
252
|
+
if var.get("name") == name:
|
|
253
|
+
return var.get("value")
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _worker_of(pod: dict[str, Any]) -> str | None:
|
|
258
|
+
"""The `SIMRUN_WORKER` of the main container of a pod, None without it."""
|
|
259
|
+
return _env_of(pod, "SIMRUN_WORKER")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _attempt_of(pod: dict[str, Any]) -> int | None:
|
|
263
|
+
"""The `SIMRUN_ATTEMPT` of the main container of a pod, the retries Argo set.
|
|
264
|
+
|
|
265
|
+
None without it or when it is no number, e.g. a `{{retries}}` Argo did not
|
|
266
|
+
substitute.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
return int(_env_of(pod, "SIMRUN_ATTEMPT") or "")
|
|
270
|
+
except ValueError:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def pod_requests(pod: dict[str, Any]) -> tuple[float, float]:
|
|
275
|
+
"""The cores and GiB a pod requests, as the scheduler counts them.
|
|
276
|
+
|
|
277
|
+
The containers together, or an init container alone when it requests more (the
|
|
278
|
+
init containers run before the containers, one at a time; a sidecar, an init
|
|
279
|
+
container which keeps running, counts with the containers and the init
|
|
280
|
+
containers after it), plus the overhead of the pod.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
pod: The pod as the API serialises it.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The cores and the GiB.
|
|
287
|
+
"""
|
|
288
|
+
spec = pod.get("spec") or {}
|
|
289
|
+
cpu, memory = 0.0, 0.0
|
|
290
|
+
for container in spec.get("containers") or []:
|
|
291
|
+
c, m = _requests(container)
|
|
292
|
+
cpu, memory = cpu + c, memory + m
|
|
293
|
+
sidecar_cpu, sidecar_memory = 0.0, 0.0
|
|
294
|
+
init_cpu, init_memory = 0.0, 0.0
|
|
295
|
+
for container in spec.get("initContainers") or []:
|
|
296
|
+
c, m = _requests(container)
|
|
297
|
+
if container.get("restartPolicy") == "Always":
|
|
298
|
+
sidecar_cpu, sidecar_memory = sidecar_cpu + c, sidecar_memory + m
|
|
299
|
+
init_cpu = max(init_cpu, sidecar_cpu + c)
|
|
300
|
+
init_memory = max(init_memory, sidecar_memory + m)
|
|
301
|
+
overhead = spec.get("overhead") or {}
|
|
302
|
+
return (
|
|
303
|
+
max(cpu + sidecar_cpu, init_cpu)
|
|
304
|
+
+ float(parse_quantity(overhead.get("cpu", "0"))),
|
|
305
|
+
max(memory + sidecar_memory, init_memory)
|
|
306
|
+
+ float(parse_quantity(overhead.get("memory", "0"))) / GIB,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def node_from(
|
|
311
|
+
node: dict[str, Any], used: tuple[float, float] = (0.0, 0.0)
|
|
312
|
+
) -> Node | None:
|
|
313
|
+
"""The node of the planner of a Kubernetes node, None when pods cannot run on it.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
node: The node as the API serialises it.
|
|
317
|
+
used: The cores and GiB the pods on the node request which are no pods of
|
|
318
|
+
runs (e.g. of DaemonSets).
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
The node with its allocatable cores and memory minus one executor container
|
|
322
|
+
and minus `used`, rounded down, for the planner, the allocatable cores and
|
|
323
|
+
memory minus `used` as the scheduler counts the requests of the pods of the
|
|
324
|
+
runs, and its reservation; None for a node which is unschedulable or has a
|
|
325
|
+
taint other than a reservation which keeps pods off.
|
|
326
|
+
"""
|
|
327
|
+
metadata = node.get("metadata") or {}
|
|
328
|
+
spec = node.get("spec") or {}
|
|
329
|
+
if spec.get("unschedulable"):
|
|
330
|
+
return None
|
|
331
|
+
reserved = (metadata.get("labels") or {}).get(RESERVED_KEY)
|
|
332
|
+
for taint in spec.get("taints") or []:
|
|
333
|
+
if taint.get("key") == RESERVED_KEY:
|
|
334
|
+
reserved = reserved or taint.get("value")
|
|
335
|
+
elif taint.get("effect") in ("NoSchedule", "NoExecute"):
|
|
336
|
+
return None
|
|
337
|
+
allocatable = (node.get("status") or {}).get("allocatable") or {}
|
|
338
|
+
cpu = float(parse_quantity(allocatable.get("cpu", "0")))
|
|
339
|
+
memory = float(parse_quantity(allocatable.get("memory", "0"))) / GIB
|
|
340
|
+
gpus = int(parse_quantity(allocatable.get(GPU_RESOURCE, "0")))
|
|
341
|
+
used_cpu, used_memory = used
|
|
342
|
+
return Node(
|
|
343
|
+
name=metadata["name"],
|
|
344
|
+
# the tolerance keeps an exact difference from rounding down below it
|
|
345
|
+
cores=max(0, math.floor(cpu - EXECUTOR_CPU - used_cpu + 1e-9)),
|
|
346
|
+
memory_gb=float(
|
|
347
|
+
max(0, math.floor(memory - EXECUTOR_MEMORY_GB - used_memory + 1e-9))
|
|
348
|
+
),
|
|
349
|
+
gpus=gpus,
|
|
350
|
+
reserved_by=reserved,
|
|
351
|
+
allocatable_cores=max(0.0, cpu - used_cpu),
|
|
352
|
+
allocatable_memory_gb=max(0.0, memory - used_memory),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def manifest_url(ref: str, config: ClusterConfig) -> str:
|
|
357
|
+
"""The URL of the manifest of an image of the registry of the cluster.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
ref: The image, `<registry>/<repository>[:<tag>|@<digest>]`.
|
|
361
|
+
config: The cluster.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The URL of the manifest in the API of the registry.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
ValueError: The image is not in the registry of the cluster.
|
|
368
|
+
"""
|
|
369
|
+
host, _, rest = ref.partition("/")
|
|
370
|
+
if host != config.registry or not rest:
|
|
371
|
+
raise ValueError(f"the image {ref!r} is not in the registry {config.registry}")
|
|
372
|
+
if "@" in rest:
|
|
373
|
+
repository, _, reference = rest.partition("@")
|
|
374
|
+
else:
|
|
375
|
+
repository, colon, reference = rest.rpartition(":")
|
|
376
|
+
if not colon:
|
|
377
|
+
repository, reference = rest, "latest"
|
|
378
|
+
base = (config.registry_url or f"http://{config.registry}").rstrip("/")
|
|
379
|
+
return f"{base}/v2/{repository}/manifests/{reference}"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _task_node(relative: str, task_id: str) -> bool:
|
|
383
|
+
"""Whether a node name relative to its workflow is a pod of a task."""
|
|
384
|
+
pattern = rf"{re.escape(task_id)}(-w0|-extra\(\d+:\d+\)|-merge)?(\(\d+\))?"
|
|
385
|
+
return re.fullmatch(pattern, relative) is not None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _api_client(api: client.ApiClient | None) -> client.ApiClient:
|
|
389
|
+
"""The client of the API.
|
|
390
|
+
|
|
391
|
+
The given one, else in a pod its service account, else the kubeconfig of
|
|
392
|
+
`KUBECONFIG` or `~/.kube/config`.
|
|
393
|
+
"""
|
|
394
|
+
if api is not None:
|
|
395
|
+
return api
|
|
396
|
+
configuration = client.Configuration()
|
|
397
|
+
try:
|
|
398
|
+
kube_config.load_incluster_config(client_configuration=configuration)
|
|
399
|
+
except ConfigException:
|
|
400
|
+
# read at the call: the client reads `KUBECONFIG` once, at its import
|
|
401
|
+
kube_config.load_kube_config(
|
|
402
|
+
config_file=os.environ.get("KUBECONFIG"),
|
|
403
|
+
client_configuration=configuration,
|
|
404
|
+
)
|
|
405
|
+
return client.ApiClient(configuration)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class ArgoBackend:
|
|
409
|
+
"""The backend of the service on a cluster of Argo Workflows and Kueue."""
|
|
410
|
+
|
|
411
|
+
def __init__(
|
|
412
|
+
self, config: ClusterConfig, api: client.ApiClient | None = None
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Create the backend.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
config: The cluster.
|
|
418
|
+
api: The client of the Kubernetes API; by default the service account of
|
|
419
|
+
the pod, else the kubeconfig.
|
|
420
|
+
"""
|
|
421
|
+
self.config = config
|
|
422
|
+
self.api = _api_client(api)
|
|
423
|
+
self.core = client.CoreV1Api(self.api)
|
|
424
|
+
self.batch = client.BatchV1Api(self.api)
|
|
425
|
+
self.custom = client.CustomObjectsApi(self.api)
|
|
426
|
+
|
|
427
|
+
# workflows
|
|
428
|
+
|
|
429
|
+
def submit(self, manifest: dict[str, Any]) -> str:
|
|
430
|
+
"""Create the workflow; returns its name.
|
|
431
|
+
|
|
432
|
+
A workflow of the name which exists already is taken as submitted: the name
|
|
433
|
+
of the workflow of a run is fixed, so a resumed submission creates it once.
|
|
434
|
+
"""
|
|
435
|
+
try:
|
|
436
|
+
created = self.custom.create_namespaced_custom_object(
|
|
437
|
+
GROUP, VERSION, self.config.namespace, PLURAL, manifest
|
|
438
|
+
)
|
|
439
|
+
except ApiException as error:
|
|
440
|
+
if error.status == 409:
|
|
441
|
+
return manifest["metadata"]["name"]
|
|
442
|
+
raise
|
|
443
|
+
return created["metadata"]["name"] # ty: ignore[not-subscriptable]
|
|
444
|
+
|
|
445
|
+
def _workflow(self, name: str) -> dict[str, Any]:
|
|
446
|
+
try:
|
|
447
|
+
return self.custom.get_namespaced_custom_object( # ty: ignore[invalid-return-type]
|
|
448
|
+
GROUP, VERSION, self.config.namespace, PLURAL, name
|
|
449
|
+
)
|
|
450
|
+
except ApiException as error:
|
|
451
|
+
if error.status == 404:
|
|
452
|
+
raise KeyError(name) from None
|
|
453
|
+
raise
|
|
454
|
+
|
|
455
|
+
def status(self, name: str) -> BackendStatus:
|
|
456
|
+
"""The status of a workflow; KeyError when it does not exist."""
|
|
457
|
+
return workflow_status(self._workflow(name))
|
|
458
|
+
|
|
459
|
+
def _shutdown(self, name: str, strategy: str) -> None:
|
|
460
|
+
"""Shut a workflow down and delete its pods still pending.
|
|
461
|
+
|
|
462
|
+
Argo stops the running pods; a pod waiting for admission by Kueue never runs,
|
|
463
|
+
so it is deleted here.
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
self.custom.patch_namespaced_custom_object(
|
|
467
|
+
GROUP,
|
|
468
|
+
VERSION,
|
|
469
|
+
self.config.namespace,
|
|
470
|
+
PLURAL,
|
|
471
|
+
name,
|
|
472
|
+
{"spec": {"shutdown": strategy}},
|
|
473
|
+
)
|
|
474
|
+
except ApiException as error:
|
|
475
|
+
if error.status == 404:
|
|
476
|
+
raise KeyError(name) from None
|
|
477
|
+
raise
|
|
478
|
+
for pod in self._pods(f"{WORKFLOW_LABEL}={name}", "status.phase=Pending"):
|
|
479
|
+
try:
|
|
480
|
+
self.core.delete_namespaced_pod(
|
|
481
|
+
pod["metadata"]["name"], self.config.namespace
|
|
482
|
+
)
|
|
483
|
+
except ApiException as error:
|
|
484
|
+
if error.status != 404:
|
|
485
|
+
raise
|
|
486
|
+
|
|
487
|
+
def _pods(self, labels: str, fields: str | None = None) -> list[dict[str, Any]]:
|
|
488
|
+
"""The pods of the namespace with the labels and fields, serialised."""
|
|
489
|
+
pods = self.core.list_namespaced_pod(
|
|
490
|
+
self.config.namespace, label_selector=labels, field_selector=fields
|
|
491
|
+
)
|
|
492
|
+
return [self.api.sanitize_for_serialization(p) for p in pods.items]
|
|
493
|
+
|
|
494
|
+
def cancel(self, name: str) -> None:
|
|
495
|
+
"""Terminate a workflow and its pods; KeyError when it does not exist."""
|
|
496
|
+
self._shutdown(name, "Terminate")
|
|
497
|
+
|
|
498
|
+
def stop(self, name: str) -> None:
|
|
499
|
+
"""Stop a workflow whose run is decided; KeyError when it does not exist."""
|
|
500
|
+
self._shutdown(name, "Stop")
|
|
501
|
+
|
|
502
|
+
def logs(
|
|
503
|
+
self, name: str, task_id: str, tail_lines: int | None = None
|
|
504
|
+
) -> list[PodLog]:
|
|
505
|
+
"""The logs of the main containers of the pods of a task, oldest first.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
name: The workflow.
|
|
509
|
+
task_id: The task.
|
|
510
|
+
tail_lines: The last lines of every log, all without.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
A log per pod which still exists, every attempt, with whether the pod
|
|
514
|
+
terminated; a log which cannot be read says why, e.g. that its pod
|
|
515
|
+
has not started. At most
|
|
516
|
+
`LOG_LIMIT_BYTES` of a log are read (the first of its last `tail_lines`
|
|
517
|
+
lines).
|
|
518
|
+
"""
|
|
519
|
+
prefix = f"{name}."
|
|
520
|
+
found: list[PodLog] = []
|
|
521
|
+
options: dict[str, Any] = {
|
|
522
|
+
"container": CONTAINER,
|
|
523
|
+
"limit_bytes": LOG_LIMIT_BYTES,
|
|
524
|
+
}
|
|
525
|
+
if tail_lines is not None:
|
|
526
|
+
options["tail_lines"] = tail_lines
|
|
527
|
+
for pod in sorted(
|
|
528
|
+
self._pods(f"{WORKFLOW_LABEL}={name}"),
|
|
529
|
+
key=lambda p: p["metadata"].get("creationTimestamp") or "",
|
|
530
|
+
):
|
|
531
|
+
annotations = pod["metadata"].get("annotations") or {}
|
|
532
|
+
node = annotations.get(NODE_ANNOTATION, "").removeprefix(prefix)
|
|
533
|
+
if not _task_node(node, task_id):
|
|
534
|
+
continue
|
|
535
|
+
pod_name = pod["metadata"]["name"]
|
|
536
|
+
try:
|
|
537
|
+
# a container which has not started has no log: an empty body
|
|
538
|
+
text = (
|
|
539
|
+
self.core.read_namespaced_pod_log(
|
|
540
|
+
pod_name, self.config.namespace, **options
|
|
541
|
+
)
|
|
542
|
+
or ""
|
|
543
|
+
)
|
|
544
|
+
except ApiException as error:
|
|
545
|
+
# or 400, while the container waits, e.g. for its image or a node
|
|
546
|
+
text = (
|
|
547
|
+
"(the pod has not started)\n"
|
|
548
|
+
if error.status == 400
|
|
549
|
+
else f"(no logs: {error.reason})\n"
|
|
550
|
+
)
|
|
551
|
+
phase = (pod.get("status") or {}).get("phase")
|
|
552
|
+
done = phase in ("Succeeded", "Failed")
|
|
553
|
+
found.append(PodLog(node=node, pod=pod_name, text=text, done=done))
|
|
554
|
+
return found
|
|
555
|
+
|
|
556
|
+
# nodes
|
|
557
|
+
|
|
558
|
+
def _run_pod(self, pod: dict[str, Any]) -> bool:
|
|
559
|
+
"""Whether a pod is one of a run: a pod of a workflow in the namespace."""
|
|
560
|
+
metadata = pod.get("metadata") or {}
|
|
561
|
+
return metadata.get("namespace") == self.config.namespace and (
|
|
562
|
+
WORKFLOW_LABEL in (metadata.get("labels") or {})
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
def nodes(self) -> list[Node]:
|
|
566
|
+
"""The worker nodes on which pods can run, with their reservations.
|
|
567
|
+
|
|
568
|
+
Their capacity is the allocatable one less the requests of the pods bound to
|
|
569
|
+
them which have not terminated and are no pods of runs, e.g. of DaemonSets;
|
|
570
|
+
Kueue counts the pods of the runs itself.
|
|
571
|
+
"""
|
|
572
|
+
listed = [
|
|
573
|
+
self.api.sanitize_for_serialization(n)
|
|
574
|
+
for n in self.core.list_node(label_selector=f"{WORKER_LABEL}=true").items
|
|
575
|
+
]
|
|
576
|
+
names = {n["metadata"]["name"] for n in listed}
|
|
577
|
+
used: dict[str, tuple[float, float]] = {}
|
|
578
|
+
pods = self.core.list_pod_for_all_namespaces(
|
|
579
|
+
field_selector="status.phase!=Succeeded,status.phase!=Failed"
|
|
580
|
+
)
|
|
581
|
+
for item in pods.items:
|
|
582
|
+
pod = self.api.sanitize_for_serialization(item)
|
|
583
|
+
node = (pod.get("spec") or {}).get("nodeName")
|
|
584
|
+
if node is None or node not in names or self._run_pod(pod):
|
|
585
|
+
continue
|
|
586
|
+
cpu, memory = pod_requests(pod)
|
|
587
|
+
before = used.get(node, (0.0, 0.0))
|
|
588
|
+
used[node] = (before[0] + cpu, before[1] + memory)
|
|
589
|
+
nodes = (
|
|
590
|
+
node_from(n, used.get(n["metadata"]["name"], (0.0, 0.0))) for n in listed
|
|
591
|
+
)
|
|
592
|
+
return [n for n in nodes if n is not None]
|
|
593
|
+
|
|
594
|
+
def placements(self) -> list[Placement]:
|
|
595
|
+
"""The pods of the runs which have not terminated, with nodes and requests."""
|
|
596
|
+
found: list[Placement] = []
|
|
597
|
+
for pod in self._pods(
|
|
598
|
+
WORKFLOW_LABEL, "status.phase!=Succeeded,status.phase!=Failed"
|
|
599
|
+
):
|
|
600
|
+
metadata = pod.get("metadata") or {}
|
|
601
|
+
workflow = (metadata.get("labels") or {}).get(WORKFLOW_LABEL)
|
|
602
|
+
if workflow is None:
|
|
603
|
+
continue
|
|
604
|
+
annotated = (metadata.get("annotations") or {}).get(NODE_ANNOTATION, "")
|
|
605
|
+
task = annotated.partition(".")[2]
|
|
606
|
+
cores, memory = pod_requests(pod)
|
|
607
|
+
found.append(
|
|
608
|
+
Placement(
|
|
609
|
+
workflow=workflow,
|
|
610
|
+
dag_task=re.sub(r"\(.*\)$", "", task),
|
|
611
|
+
pod=metadata.get("name", ""),
|
|
612
|
+
node=(pod.get("spec") or {}).get("nodeName"),
|
|
613
|
+
phase=(pod.get("status") or {}).get("phase", "Unknown"),
|
|
614
|
+
cores=cores,
|
|
615
|
+
memory_gb=memory,
|
|
616
|
+
worker=_worker_of(pod),
|
|
617
|
+
attempt=_attempt_of(pod),
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
return found
|
|
621
|
+
|
|
622
|
+
def _node(self, node: str) -> dict[str, Any]:
|
|
623
|
+
try:
|
|
624
|
+
return self.api.sanitize_for_serialization(self.core.read_node(node))
|
|
625
|
+
except ApiException as error:
|
|
626
|
+
if error.status == 404:
|
|
627
|
+
raise KeyError(node) from None
|
|
628
|
+
raise
|
|
629
|
+
|
|
630
|
+
def _patch_node(self, node: str, body: dict[str, Any]) -> None:
|
|
631
|
+
self.core.patch_node(node, body)
|
|
632
|
+
|
|
633
|
+
def reserve(self, node: str, run_id: str) -> None:
|
|
634
|
+
"""Taint and label a node for a run; KeyError for an unknown node.
|
|
635
|
+
|
|
636
|
+
The taint replaces a reservation the node had; the resource version of the
|
|
637
|
+
node guards the list of taints against a concurrent change.
|
|
638
|
+
"""
|
|
639
|
+
current = self._node(node)
|
|
640
|
+
taints = [
|
|
641
|
+
t
|
|
642
|
+
for t in (current.get("spec") or {}).get("taints") or []
|
|
643
|
+
if t.get("key") != RESERVED_KEY
|
|
644
|
+
]
|
|
645
|
+
taints.append({"key": RESERVED_KEY, "value": run_id, "effect": "NoExecute"})
|
|
646
|
+
self._patch_node(
|
|
647
|
+
node,
|
|
648
|
+
{
|
|
649
|
+
"metadata": {
|
|
650
|
+
"labels": {RESERVED_KEY: run_id},
|
|
651
|
+
"resourceVersion": current["metadata"]["resourceVersion"],
|
|
652
|
+
},
|
|
653
|
+
"spec": {"taints": taints},
|
|
654
|
+
},
|
|
655
|
+
)
|
|
656
|
+
logger.info("node %s reserved for run %s", node, run_id)
|
|
657
|
+
|
|
658
|
+
def unreserve(self, node: str, run_id: str) -> None:
|
|
659
|
+
"""Remove the taint and the label of the reservation of a run.
|
|
660
|
+
|
|
661
|
+
Raises:
|
|
662
|
+
KeyError: The node does not exist.
|
|
663
|
+
"""
|
|
664
|
+
current = self._node(node)
|
|
665
|
+
labels = (current["metadata"].get("labels")) or {}
|
|
666
|
+
all_taints = (current.get("spec") or {}).get("taints") or []
|
|
667
|
+
taints = [
|
|
668
|
+
t
|
|
669
|
+
for t in all_taints
|
|
670
|
+
if not (t.get("key") == RESERVED_KEY and t.get("value") == run_id)
|
|
671
|
+
]
|
|
672
|
+
body: dict[str, Any] = {
|
|
673
|
+
"metadata": {"resourceVersion": current["metadata"]["resourceVersion"]}
|
|
674
|
+
}
|
|
675
|
+
if labels.get(RESERVED_KEY) == run_id:
|
|
676
|
+
body["metadata"]["labels"] = {RESERVED_KEY: None}
|
|
677
|
+
if len(taints) != len(all_taints):
|
|
678
|
+
body["spec"] = {"taints": taints}
|
|
679
|
+
if len(body) == 1 and "labels" not in body["metadata"]:
|
|
680
|
+
return
|
|
681
|
+
self._patch_node(node, body)
|
|
682
|
+
logger.info("node %s released by run %s", node, run_id)
|
|
683
|
+
|
|
684
|
+
# images
|
|
685
|
+
|
|
686
|
+
def build_image(self, context: Path, ref: str) -> None:
|
|
687
|
+
"""Build the image of a flat build context with BuildKit and push it.
|
|
688
|
+
|
|
689
|
+
Raises:
|
|
690
|
+
ValueError: The context has a directory or is too large for a ConfigMap.
|
|
691
|
+
RuntimeError: The build failed.
|
|
692
|
+
TimeoutError: The build took longer than `BUILD_TIMEOUT_S`.
|
|
693
|
+
"""
|
|
694
|
+
files: dict[str, str] = {}
|
|
695
|
+
for path in sorted(context.iterdir()):
|
|
696
|
+
if not path.is_file():
|
|
697
|
+
raise ValueError(f"the build context has a directory: {path.name}")
|
|
698
|
+
files[path.name] = base64.b64encode(path.read_bytes()).decode()
|
|
699
|
+
if sum(len(v) for v in files.values()) > MAX_CONTEXT_BYTES:
|
|
700
|
+
raise ValueError(f"the build context {context} is larger than 1 MB")
|
|
701
|
+
digest = hashlib.sha256(ref.encode()).hexdigest()[:10]
|
|
702
|
+
name = f"simrun-build-{digest}-{uuid.uuid4().hex[:6]}"
|
|
703
|
+
namespace = self.config.namespace
|
|
704
|
+
job = self.batch.create_namespaced_job(
|
|
705
|
+
namespace, client.V1Job.model_validate(self._build_job(name, ref))
|
|
706
|
+
)
|
|
707
|
+
# deleted in every case: its pod would wait for a context never stored
|
|
708
|
+
try:
|
|
709
|
+
owner = {
|
|
710
|
+
"apiVersion": "batch/v1",
|
|
711
|
+
"kind": "Job",
|
|
712
|
+
"name": name,
|
|
713
|
+
"uid": self.api.sanitize_for_serialization(job)["metadata"]["uid"],
|
|
714
|
+
}
|
|
715
|
+
context_map = {
|
|
716
|
+
"metadata": {"name": name, "ownerReferences": [owner]},
|
|
717
|
+
"binaryData": files,
|
|
718
|
+
}
|
|
719
|
+
self.core.create_namespaced_config_map(
|
|
720
|
+
namespace, client.V1ConfigMap.model_validate(context_map)
|
|
721
|
+
)
|
|
722
|
+
logger.info("building %s in the job %s", ref, name)
|
|
723
|
+
self._wait_for_build(name, ref)
|
|
724
|
+
finally:
|
|
725
|
+
try:
|
|
726
|
+
self.batch.delete_namespaced_job(
|
|
727
|
+
name, namespace, propagation_policy="Background"
|
|
728
|
+
)
|
|
729
|
+
except ApiException as error:
|
|
730
|
+
if error.status != 404:
|
|
731
|
+
logger.warning("deleting the build job %s failed: %s", name, error)
|
|
732
|
+
|
|
733
|
+
def _build_job(self, name: str, ref: str) -> dict[str, Any]:
|
|
734
|
+
resources = {"cpu": "200m", "memory": "256Mi"}
|
|
735
|
+
return {
|
|
736
|
+
"metadata": {"name": name, "labels": {"app": "simrun-build"}},
|
|
737
|
+
"spec": {
|
|
738
|
+
"backoffLimit": 0,
|
|
739
|
+
"ttlSecondsAfterFinished": BUILD_TTL_S,
|
|
740
|
+
"template": {
|
|
741
|
+
"metadata": {"labels": {"app": "simrun-build"}},
|
|
742
|
+
"spec": {
|
|
743
|
+
"restartPolicy": "Never",
|
|
744
|
+
"affinity": OFF_WORKERS,
|
|
745
|
+
"containers": [
|
|
746
|
+
{
|
|
747
|
+
"name": "buildctl",
|
|
748
|
+
"image": self.config.buildctl_image,
|
|
749
|
+
"command": [
|
|
750
|
+
"buildctl",
|
|
751
|
+
"--addr",
|
|
752
|
+
self.config.buildkit,
|
|
753
|
+
"build",
|
|
754
|
+
"--frontend",
|
|
755
|
+
"dockerfile.v0",
|
|
756
|
+
"--local",
|
|
757
|
+
"context=/context",
|
|
758
|
+
"--local",
|
|
759
|
+
"dockerfile=/context",
|
|
760
|
+
"--output",
|
|
761
|
+
f"type=image,name={ref},push=true",
|
|
762
|
+
],
|
|
763
|
+
"env": [{"name": "DOCKER_CONFIG", "value": "/docker"}],
|
|
764
|
+
"resources": {
|
|
765
|
+
"requests": resources,
|
|
766
|
+
"limits": resources,
|
|
767
|
+
},
|
|
768
|
+
"volumeMounts": [
|
|
769
|
+
{"name": "context", "mountPath": "/context"},
|
|
770
|
+
{"name": "docker", "mountPath": "/docker"},
|
|
771
|
+
],
|
|
772
|
+
}
|
|
773
|
+
],
|
|
774
|
+
"volumes": [
|
|
775
|
+
{"name": "context", "configMap": {"name": name}},
|
|
776
|
+
{
|
|
777
|
+
"name": "docker",
|
|
778
|
+
"secret": {
|
|
779
|
+
"secretName": REGISTRY_AUTH_SECRET,
|
|
780
|
+
"optional": True,
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
def _wait_for_build(self, name: str, ref: str) -> None:
|
|
790
|
+
deadline = time.monotonic() + BUILD_TIMEOUT_S
|
|
791
|
+
while time.monotonic() < deadline:
|
|
792
|
+
# the Job with its status: `jobs/status` would need a right of its own
|
|
793
|
+
job = self.api.sanitize_for_serialization(
|
|
794
|
+
self.batch.read_namespaced_job(name, self.config.namespace)
|
|
795
|
+
)
|
|
796
|
+
status = job.get("status") or {}
|
|
797
|
+
if status.get("succeeded"):
|
|
798
|
+
logger.info("built %s", ref)
|
|
799
|
+
return
|
|
800
|
+
if status.get("failed"):
|
|
801
|
+
raise RuntimeError(
|
|
802
|
+
f"the build of {ref} failed:\n{self._job_logs(name)}"
|
|
803
|
+
)
|
|
804
|
+
time.sleep(BUILD_POLL_S)
|
|
805
|
+
raise TimeoutError(f"the build of {ref} took longer than {BUILD_TIMEOUT_S} s")
|
|
806
|
+
|
|
807
|
+
def _job_logs(self, name: str) -> str:
|
|
808
|
+
texts = []
|
|
809
|
+
for pod in self._pods(f"job-name={name}"):
|
|
810
|
+
pod_name = pod["metadata"]["name"]
|
|
811
|
+
try:
|
|
812
|
+
texts.append(
|
|
813
|
+
self.core.read_namespaced_pod_log(
|
|
814
|
+
pod_name, self.config.namespace, tail_lines=50
|
|
815
|
+
)
|
|
816
|
+
or ""
|
|
817
|
+
)
|
|
818
|
+
except ApiException as error:
|
|
819
|
+
texts.append(f"(no logs of {pod_name}: {error.reason})")
|
|
820
|
+
return "\n".join(texts)
|
|
821
|
+
|
|
822
|
+
def has_image(self, ref: str) -> bool:
|
|
823
|
+
"""Whether the registry has the image; asks the API of the registry.
|
|
824
|
+
|
|
825
|
+
Raises:
|
|
826
|
+
ValueError: The image is not in the registry of the cluster.
|
|
827
|
+
RuntimeError: The registry answered neither found nor not found.
|
|
828
|
+
"""
|
|
829
|
+
url = manifest_url(ref, self.config)
|
|
830
|
+
response = httpx.head(
|
|
831
|
+
url, headers={"Accept": ", ".join(MANIFEST_TYPES)}, timeout=30.0
|
|
832
|
+
)
|
|
833
|
+
if response.status_code == 200:
|
|
834
|
+
return True
|
|
835
|
+
if response.status_code == 404:
|
|
836
|
+
return False
|
|
837
|
+
raise RuntimeError(f"the registry answered {response.status_code} for {url}")
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def backend() -> ArgoBackend:
|
|
841
|
+
"""The backend of the settings named by `SIMRUN_CONFIG`, for `serve`."""
|
|
842
|
+
return ArgoBackend(load_settings(Path(os.environ[CONFIG_ENV])).cluster)
|