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.
@@ -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)