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/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
+ )