simrun 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simrun/__init__.py +3 -0
- simrun/argo.py +449 -0
- simrun/build_constraints.txt +20 -0
- simrun/cgroup.py +77 -0
- simrun/cli.py +350 -0
- simrun/client.py +407 -0
- simrun/config.py +63 -0
- simrun/cwl.py +287 -0
- simrun/demo.py +106 -0
- simrun/edit.py +408 -0
- simrun/environment.py +370 -0
- simrun/ledger.py +286 -0
- simrun/mcp.py +161 -0
- simrun/planner.py +303 -0
- simrun/plugins.py +149 -0
- simrun/plugins_builtin.py +33 -0
- simrun/podspec.py +166 -0
- simrun/resources.py +270 -0
- simrun/service/__init__.py +1 -0
- simrun/service/app.py +1721 -0
- simrun/service/argo_backend.py +842 -0
- simrun/service/auth.py +93 -0
- simrun/service/backend.py +170 -0
- simrun/service/compare.py +77 -0
- simrun/service/controller.py +788 -0
- simrun/service/edits.py +406 -0
- simrun/service/index.py +292 -0
- simrun/service/logs.py +91 -0
- simrun/service/submission.py +349 -0
- simrun/snapshot.py +196 -0
- simrun/store.py +190 -0
- simrun/testing.py +263 -0
- simrun/worker.py +1097 -0
- simrun/workflow.py +188 -0
- simrun-0.1.0.dist-info/METADATA +107 -0
- simrun-0.1.0.dist-info/RECORD +39 -0
- simrun-0.1.0.dist-info/WHEEL +4 -0
- simrun-0.1.0.dist-info/entry_points.txt +10 -0
- simrun-0.1.0.dist-info/licenses/LICENSE +7 -0
simrun/service/auth.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Identities of the callers of the service and the priorities of their runs."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import BaseModel, model_validator
|
|
9
|
+
|
|
10
|
+
from simrun.workflow import Priority
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Identity(BaseModel):
|
|
14
|
+
"""Who calls the service: a person or an agent acting for a person.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
name: Name of the person or the agent.
|
|
18
|
+
kind: `person` or `agent`.
|
|
19
|
+
owner: The person responsible; a person's owner is the person.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
kind: Literal["person", "agent"]
|
|
24
|
+
owner: str
|
|
25
|
+
|
|
26
|
+
@model_validator(mode="before")
|
|
27
|
+
@classmethod
|
|
28
|
+
def _owner(cls, data: Any) -> Any:
|
|
29
|
+
if isinstance(data, dict) and data.get("kind") == "person":
|
|
30
|
+
owner = data.get("owner")
|
|
31
|
+
if owner is not None and owner != data.get("name"):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"the owner of the person {data.get('name')!r} is the person, "
|
|
34
|
+
f"not {owner!r}"
|
|
35
|
+
)
|
|
36
|
+
return {**data, "owner": data.get("name")}
|
|
37
|
+
if (
|
|
38
|
+
isinstance(data, dict)
|
|
39
|
+
and data.get("kind") == "agent"
|
|
40
|
+
and not data.get("owner")
|
|
41
|
+
):
|
|
42
|
+
raise ValueError(f"the agent {data.get('name')!r} has no owner")
|
|
43
|
+
return data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_tokens(path: Path) -> dict[str, Identity]:
|
|
47
|
+
"""Read the tokens of the service from a YAML mapping of token to identity.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
path: The YAML file, e.g. `{<token>: {name: mk, kind: person}}`.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The identities by token.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValueError: The file is no mapping, or a token is empty.
|
|
57
|
+
"""
|
|
58
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
raise ValueError(f"{path}: not a mapping of token to identity")
|
|
61
|
+
tokens: dict[str, Identity] = {}
|
|
62
|
+
for token, identity in data.items():
|
|
63
|
+
if not isinstance(token, str) or not token:
|
|
64
|
+
raise ValueError(f"{path}: a token must be a non-empty string")
|
|
65
|
+
tokens[token] = Identity.model_validate(identity)
|
|
66
|
+
return tokens
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def authenticate(tokens: dict[str, Identity], token: str) -> Identity | None:
|
|
70
|
+
"""The identity of a token, compared in constant time; None if unknown."""
|
|
71
|
+
found = None
|
|
72
|
+
for known, identity in tokens.items():
|
|
73
|
+
if hmac.compare_digest(known.encode(), token.encode()):
|
|
74
|
+
found = identity
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def priority_of(identity: Identity, owner: str) -> Priority:
|
|
79
|
+
"""The priority of the runs of an identity.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
identity: The submitting identity.
|
|
83
|
+
owner: The owner of the cluster.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
`OWNER` for the owner and the owner's agents, `STANDARD` for other people,
|
|
87
|
+
`AGENT` for their agents.
|
|
88
|
+
"""
|
|
89
|
+
if identity.owner == owner:
|
|
90
|
+
return Priority.OWNER
|
|
91
|
+
if identity.kind == "person":
|
|
92
|
+
return Priority.STANDARD
|
|
93
|
+
return Priority.AGENT
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The backend the service runs workflows on: Argo Workflows, or a fake in tests."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from simrun.planner import Node
|
|
9
|
+
|
|
10
|
+
# phases of an Argo workflow or node which do not change any more
|
|
11
|
+
FINAL_PHASES = frozenset({"Succeeded", "Failed", "Error"})
|
|
12
|
+
# the phase of a task node whose pod was evicted (or lost) and waits for its retry
|
|
13
|
+
PREEMPTED_PHASE = "Preempted"
|
|
14
|
+
# the phase of a task node whose pod failed of itself (no kill for memory, no
|
|
15
|
+
# eviction) and waits for its retry
|
|
16
|
+
RETRYING_PHASE = "Retrying"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FailedAttempt(BaseModel):
|
|
20
|
+
"""An attempt of a pod which failed.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
attempt: The number of the attempt, from 0, as the pod has it in
|
|
24
|
+
`SIMRUN_ATTEMPT` (Argo's `{{retries}}`) and in the name of its report.
|
|
25
|
+
exit_code: The exit code of its main container, None without one (e.g. a
|
|
26
|
+
pod deleted by an eviction of Kueue).
|
|
27
|
+
oom_killed: Whether the main container was killed by the out of memory
|
|
28
|
+
killer (the reason `OOMKilled`); an exit 137 alone is no evidence of it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
attempt: int
|
|
32
|
+
exit_code: int | None = None
|
|
33
|
+
oom_killed: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PodLog(BaseModel):
|
|
37
|
+
"""The log of the main container of one pod (one attempt) of a task.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
node: The node of the pod in the workflow, e.g. `squares-w0(1)`.
|
|
41
|
+
pod: The name of the pod.
|
|
42
|
+
text: The log.
|
|
43
|
+
done: Whether the pod has terminated, so its log is complete.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
node: str
|
|
47
|
+
pod: str
|
|
48
|
+
text: str
|
|
49
|
+
done: bool = True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Placement(BaseModel):
|
|
53
|
+
"""A pod of a run which has not terminated, with its node and requests.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
workflow: The backend name of the run.
|
|
57
|
+
dag_task: The DAG task of the pod: `<task>-w0`, `<task>-extra`,
|
|
58
|
+
`<task>-merge` or `<task>`.
|
|
59
|
+
pod: Name of the pod.
|
|
60
|
+
node: The node the pod is bound to, `None` while it is not.
|
|
61
|
+
phase: The phase of the pod.
|
|
62
|
+
cores: The cores the pod requests.
|
|
63
|
+
memory_gb: The GiB of memory the pod requests.
|
|
64
|
+
worker: The worker id of the pod (`SIMRUN_WORKER`, the id of its ledger claims
|
|
65
|
+
and report names), `None` for a pod without one, e.g. a merge.
|
|
66
|
+
attempt: The attempt of the pod (`SIMRUN_ATTEMPT`, the number of its
|
|
67
|
+
retries, the attempt of its report name), `None` when unknown.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
workflow: str
|
|
71
|
+
dag_task: str
|
|
72
|
+
pod: str
|
|
73
|
+
node: str | None
|
|
74
|
+
phase: str
|
|
75
|
+
cores: float
|
|
76
|
+
memory_gb: float
|
|
77
|
+
worker: str | None = None
|
|
78
|
+
attempt: int | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BackendStatus(BaseModel):
|
|
82
|
+
"""The state of a workflow in the backend.
|
|
83
|
+
|
|
84
|
+
The service, not the backend, decides the state of a run from it (see
|
|
85
|
+
`simrun.service.controller`).
|
|
86
|
+
|
|
87
|
+
Attributes:
|
|
88
|
+
phase: Phase of the workflow, e.g. `Pending`, `Running`, `Succeeded`.
|
|
89
|
+
tasks: Phase of every node of the DAG by its name: `<task>-w0`,
|
|
90
|
+
`<task>-extra(<i>:<item>)` and `<task>-merge` of a split task, `<task>` of
|
|
91
|
+
an unsplit one; a node not yet created is missing. A node whose pod was
|
|
92
|
+
evicted and whose retry waits is `Preempted` (`PREEMPTED_PHASE`), one
|
|
93
|
+
whose pod failed otherwise `Retrying` (`RETRYING_PHASE`).
|
|
94
|
+
failed: The attempts of pods which failed, in the order of their number: per
|
|
95
|
+
worker id (`<task>-w<i>`), per task id of an unsplit task and per
|
|
96
|
+
`<task>-merge` of a merge.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
phase: str
|
|
100
|
+
tasks: dict[str, str]
|
|
101
|
+
failed: dict[str, list[FailedAttempt]] = Field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Backend(Protocol):
|
|
105
|
+
"""What the service needs of a cluster."""
|
|
106
|
+
|
|
107
|
+
def submit(self, manifest: dict[str, Any]) -> str:
|
|
108
|
+
"""Submit a workflow manifest and return the name of the workflow."""
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
def status(self, name: str) -> BackendStatus:
|
|
112
|
+
"""The status of a workflow.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
KeyError: The workflow does not exist (any more).
|
|
116
|
+
"""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
def cancel(self, name: str) -> None:
|
|
120
|
+
"""Terminate a workflow and its pods."""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
def stop(self, name: str) -> None:
|
|
124
|
+
"""Stop a workflow whose run is decided: pods still pending are removed."""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
def logs(
|
|
128
|
+
self, name: str, task_id: str, tail_lines: int | None = None
|
|
129
|
+
) -> list[PodLog]:
|
|
130
|
+
"""The logs of the pods of a task of a workflow, oldest first.
|
|
131
|
+
|
|
132
|
+
Every attempt whose pod still exists; `tail_lines` limits each log to its
|
|
133
|
+
last lines.
|
|
134
|
+
"""
|
|
135
|
+
...
|
|
136
|
+
|
|
137
|
+
def nodes(self) -> list[Node]:
|
|
138
|
+
"""The nodes of the cluster."""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
def placements(self) -> list[Placement]:
|
|
142
|
+
"""The pods of the runs which have not terminated, with nodes and requests."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
def reserve(self, node: str, run_id: str) -> None:
|
|
146
|
+
"""Reserve a node for a run.
|
|
147
|
+
|
|
148
|
+
The node gets the taint `simrun/reserved=<run id>:NoExecute` and the label
|
|
149
|
+
`simrun/reserved=<run id>`.
|
|
150
|
+
"""
|
|
151
|
+
...
|
|
152
|
+
|
|
153
|
+
def unreserve(self, node: str, run_id: str) -> None:
|
|
154
|
+
"""Remove the taint and the label of the reservation of a run.
|
|
155
|
+
|
|
156
|
+
A node reserved by another run (or by none) is left as it is, so a late
|
|
157
|
+
release never removes the reservation of a later run.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
KeyError: The node does not exist.
|
|
161
|
+
"""
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
def build_image(self, context: Path, ref: str) -> None:
|
|
165
|
+
"""Build the image of a build context and push it as `ref`."""
|
|
166
|
+
...
|
|
167
|
+
|
|
168
|
+
def has_image(self, ref: str) -> bool:
|
|
169
|
+
"""Whether the registry has the image `ref`."""
|
|
170
|
+
...
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The comparison of runs: provenance and the summaries of their tasks side by side."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from simrun.store import RunStore
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SUMMARY_FILE = "summary.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CompareTable(BaseModel):
|
|
17
|
+
"""A table of runs.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
columns: The columns in order: `run`, `group`, `agent`, `state`, then per
|
|
21
|
+
source `<source>.changed_files` and `<source>.diff_sha256`, then every
|
|
22
|
+
key of `out/summary.json` of every task as `<task>.<key>`.
|
|
23
|
+
rows: One row per run, with every column (None where a run has no value).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
columns: list[str]
|
|
27
|
+
rows: list[dict[str, Any]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _summary(store: RunStore, run_id: str, task_id: str) -> dict[str, Any]:
|
|
31
|
+
path = store.run_dir(run_id) / "tasks" / task_id / "out" / SUMMARY_FILE
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(path.read_text())
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return {}
|
|
36
|
+
except ValueError as error:
|
|
37
|
+
logger.warning("summary %s is no JSON: %s", path, error)
|
|
38
|
+
return {}
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
logger.warning("summary %s is no JSON object", path)
|
|
41
|
+
return {}
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compare_runs(store: RunStore, run_ids: list[str]) -> CompareTable:
|
|
46
|
+
"""Compare runs by their provenance and the summaries their merges wrote.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
store: The run store.
|
|
50
|
+
run_ids: The runs, one row each in this order.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The table.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
KeyError: A run does not exist.
|
|
57
|
+
"""
|
|
58
|
+
rows: list[dict[str, Any]] = []
|
|
59
|
+
for run_id in run_ids:
|
|
60
|
+
record = store.read(run_id)
|
|
61
|
+
row: dict[str, Any] = {
|
|
62
|
+
"run": record.id,
|
|
63
|
+
"group": record.group,
|
|
64
|
+
"agent": record.agent,
|
|
65
|
+
"state": record.state.value,
|
|
66
|
+
}
|
|
67
|
+
for source in record.sources:
|
|
68
|
+
row[f"{source.name}.changed_files"] = len(source.changed_files)
|
|
69
|
+
row[f"{source.name}.diff_sha256"] = source.diff_sha256
|
|
70
|
+
for task in record.workflow.tasks:
|
|
71
|
+
for key, value in _summary(store, run_id, task.id).items():
|
|
72
|
+
row[f"{task.id}.{key}"] = value
|
|
73
|
+
rows.append(row)
|
|
74
|
+
columns = list(dict.fromkeys(key for row in rows for key in row))
|
|
75
|
+
return CompareTable(
|
|
76
|
+
columns=columns, rows=[{c: row.get(c) for c in columns} for row in rows]
|
|
77
|
+
)
|