specimux-cloud 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.
- specimux_cloud/__init__.py +19 -0
- specimux_cloud/backends/__init__.py +5 -0
- specimux_cloud/backends/aws.py +487 -0
- specimux_cloud/backends/base.py +196 -0
- specimux_cloud/backends/local.py +463 -0
- specimux_cloud/cli.py +225 -0
- specimux_cloud/console/__init__.py +0 -0
- specimux_cloud/console/app.py +555 -0
- specimux_cloud/dorado/__init__.py +0 -0
- specimux_cloud/dorado/wrapper.py +270 -0
- specimux_cloud/engine/__init__.py +0 -0
- specimux_cloud/engine/plugin.py +119 -0
- specimux_cloud/engine/wrapper.py +452 -0
- specimux_cloud/packages.py +50 -0
- specimux_cloud/runapi/__init__.py +0 -0
- specimux_cloud/runapi/app.py +547 -0
- specimux_cloud/runapi/auth.py +95 -0
- specimux_cloud/runapi/events.py +120 -0
- specimux_cloud/runapi/service.py +1727 -0
- specimux_cloud/stopsignal.py +49 -0
- specimux_cloud/uploader/__init__.py +0 -0
- specimux_cloud/uploader/cli.py +228 -0
- specimux_cloud/uploader/runctl.py +71 -0
- specimux_cloud/uploader/submit.py +169 -0
- specimux_cloud-0.1.0.dist-info/METADATA +462 -0
- specimux_cloud-0.1.0.dist-info/RECORD +30 -0
- specimux_cloud-0.1.0.dist-info/WHEEL +5 -0
- specimux_cloud-0.1.0.dist-info/entry_points.txt +5 -0
- specimux_cloud-0.1.0.dist-info/licenses/LICENSE +28 -0
- specimux_cloud-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""specimux-cloud: the hosted form of specimux-suite.
|
|
2
|
+
|
|
3
|
+
Four parts, matching docs/DESIGN.md:
|
|
4
|
+
|
|
5
|
+
- ``runapi``: the always-on service the browser, the uploader and hosts
|
|
6
|
+
(mycomap.org, the console) talk to — event store and fan-out, per-run
|
|
7
|
+
dashboard, job control, uploads, results, run sessions.
|
|
8
|
+
- ``console``: a built-in host — login with a service key, job page,
|
|
9
|
+
dashboard links, downloads; talks to the run API over HTTP only.
|
|
10
|
+
- ``engine``: what runs inside a compute job — a wrapper around
|
|
11
|
+
``specimux-suite`` plus the cloud plugin that forwards events and
|
|
12
|
+
applies commands.
|
|
13
|
+
- ``backends``: storage, command queue, job launcher and control-plane
|
|
14
|
+
store behind small interfaces, with local implementations (a
|
|
15
|
+
directory, memory, subprocesses, SQLite) and AWS ones (S3, SQS,
|
|
16
|
+
Batch, DynamoDB). The run API's logic is the same over both.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""AWS backends: S3, SQS, Batch and DynamoDB behind the same interfaces as
|
|
2
|
+
the local ones, so the run API's logic does not change.
|
|
3
|
+
|
|
4
|
+
Conventions:
|
|
5
|
+
|
|
6
|
+
- One S3 bucket, keys exactly as the local DirectoryStorage lays them out.
|
|
7
|
+
Presigned URLs are S3's own; ETags are S3's (MD5 for single-part PUTs,
|
|
8
|
+
which is what the uploader does today).
|
|
9
|
+
- One SQS standard queue per run, named after the run id, created on
|
|
10
|
+
first use; at-least-once delivery with a visibility timeout, like
|
|
11
|
+
MemoryQueue.
|
|
12
|
+
- Batch jobs named ``<run>-<kind>-<generation>``; ``find_by_name`` lists
|
|
13
|
+
the queue's jobs by that name, which is how a lost submission is
|
|
14
|
+
adopted.
|
|
15
|
+
- One DynamoDB table with a composite key (``pk``, ``sk``): runs
|
|
16
|
+
(``RUN#<id>`` / ``RUN``), a client-token index row, archives, intents,
|
|
17
|
+
commands (``RUN#<id>`` / ``CMD#<id>``) and stage slots
|
|
18
|
+
(``STAGE#<name>`` / ``SLOT#<n>``), all written with condition
|
|
19
|
+
expressions.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from decimal import Decimal
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Iterable, Optional
|
|
29
|
+
|
|
30
|
+
from .base import ConflictError, JobHandle, JobSpec, JobStatus, Message, ObjectInfo
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _boto3():
|
|
36
|
+
try:
|
|
37
|
+
import boto3
|
|
38
|
+
return boto3
|
|
39
|
+
except ImportError as e: # pragma: no cover
|
|
40
|
+
raise RuntimeError("AWS backends need boto3: pip install 'specimux-cloud[aws]'") from e
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- Storage ---
|
|
44
|
+
|
|
45
|
+
class S3Storage:
|
|
46
|
+
def __init__(self, bucket: str, region: Optional[str] = None, client=None):
|
|
47
|
+
self.bucket = bucket
|
|
48
|
+
if client is None:
|
|
49
|
+
# SigV4 with a regional endpoint: the legacy signer produces
|
|
50
|
+
# global-endpoint URLs that S3 answers with a 307 the uploader
|
|
51
|
+
# must not follow (the redirect drops the body on some clients)
|
|
52
|
+
from botocore.config import Config
|
|
53
|
+
client = _boto3().client(
|
|
54
|
+
"s3", region_name=region,
|
|
55
|
+
endpoint_url=f"https://s3.{region}.amazonaws.com" if region else None,
|
|
56
|
+
config=Config(signature_version="s3v4", s3={"addressing_style": "virtual"}))
|
|
57
|
+
self.client = client
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _etag(raw: str) -> str:
|
|
61
|
+
return (raw or "").strip('"')
|
|
62
|
+
|
|
63
|
+
def put(self, key: str, data: bytes) -> ObjectInfo:
|
|
64
|
+
r = self.client.put_object(Bucket=self.bucket, Key=key, Body=data)
|
|
65
|
+
return ObjectInfo(key, len(data), self._etag(r.get("ETag", "")))
|
|
66
|
+
|
|
67
|
+
def put_file(self, key: str, path: Path) -> ObjectInfo:
|
|
68
|
+
self.client.upload_file(str(path), self.bucket, key)
|
|
69
|
+
return self.head(key)
|
|
70
|
+
|
|
71
|
+
def get(self, key: str) -> bytes:
|
|
72
|
+
return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
|
|
73
|
+
|
|
74
|
+
def download(self, key: str, dest: Path) -> ObjectInfo:
|
|
75
|
+
dest = Path(dest)
|
|
76
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
self.client.download_file(self.bucket, key, str(dest))
|
|
78
|
+
return self.head(key)
|
|
79
|
+
|
|
80
|
+
def head(self, key: str) -> Optional[ObjectInfo]:
|
|
81
|
+
try:
|
|
82
|
+
r = self.client.head_object(Bucket=self.bucket, Key=key)
|
|
83
|
+
except self.client.exceptions.ClientError as e:
|
|
84
|
+
if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey", "NotFound"):
|
|
85
|
+
return None
|
|
86
|
+
raise
|
|
87
|
+
return ObjectInfo(key, int(r["ContentLength"]), self._etag(r.get("ETag", "")))
|
|
88
|
+
|
|
89
|
+
def list(self, prefix: str) -> list[ObjectInfo]:
|
|
90
|
+
out = []
|
|
91
|
+
paginator = self.client.get_paginator("list_objects_v2")
|
|
92
|
+
for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
|
|
93
|
+
for o in page.get("Contents", []):
|
|
94
|
+
out.append(ObjectInfo(o["Key"], int(o["Size"]), self._etag(o.get("ETag", ""))))
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
def delete(self, key: str) -> None:
|
|
98
|
+
self.client.delete_object(Bucket=self.bucket, Key=key)
|
|
99
|
+
|
|
100
|
+
def delete_prefix(self, prefix: str) -> int:
|
|
101
|
+
objs = self.list(prefix)
|
|
102
|
+
for i in range(0, len(objs), 1000):
|
|
103
|
+
chunk = objs[i:i + 1000]
|
|
104
|
+
self.client.delete_objects(Bucket=self.bucket,
|
|
105
|
+
Delete={"Objects": [{"Key": o.key} for o in chunk], "Quiet": True})
|
|
106
|
+
return len(objs)
|
|
107
|
+
|
|
108
|
+
def presign_put(self, key: str, expires_s: int = 3600) -> str:
|
|
109
|
+
return self.client.generate_presigned_url("put_object", Params={"Bucket": self.bucket, "Key": key},
|
|
110
|
+
ExpiresIn=expires_s, HttpMethod="PUT")
|
|
111
|
+
|
|
112
|
+
def presign_get(self, key: str, expires_s: int = 3600) -> str:
|
|
113
|
+
return self.client.generate_presigned_url("get_object", Params={"Bucket": self.bucket, "Key": key},
|
|
114
|
+
ExpiresIn=expires_s)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- Command queue ---
|
|
118
|
+
|
|
119
|
+
class SqsQueue:
|
|
120
|
+
def __init__(self, prefix: str = "specimux-cloud", region: Optional[str] = None,
|
|
121
|
+
visibility_s: int = 30, client=None):
|
|
122
|
+
self.prefix = prefix
|
|
123
|
+
self.visibility_s = visibility_s
|
|
124
|
+
self.client = client or _boto3().client("sqs", region_name=region)
|
|
125
|
+
self._urls: dict[str, str] = {}
|
|
126
|
+
|
|
127
|
+
def _url(self, run_id: str, create: bool = True) -> Optional[str]:
|
|
128
|
+
url = self._urls.get(run_id)
|
|
129
|
+
if url:
|
|
130
|
+
return url
|
|
131
|
+
name = f"{self.prefix}-{run_id}"
|
|
132
|
+
try:
|
|
133
|
+
url = self.client.get_queue_url(QueueName=name)["QueueUrl"]
|
|
134
|
+
except self.client.exceptions.QueueDoesNotExist:
|
|
135
|
+
if not create:
|
|
136
|
+
return None
|
|
137
|
+
url = self.client.create_queue(QueueName=name, Attributes={
|
|
138
|
+
"VisibilityTimeout": str(self.visibility_s),
|
|
139
|
+
"MessageRetentionPeriod": str(4 * 24 * 3600),
|
|
140
|
+
})["QueueUrl"]
|
|
141
|
+
self._urls[run_id] = url
|
|
142
|
+
return url
|
|
143
|
+
|
|
144
|
+
def send(self, run_id: str, body: dict) -> str:
|
|
145
|
+
r = self.client.send_message(QueueUrl=self._url(run_id), MessageBody=json.dumps(body))
|
|
146
|
+
return r["MessageId"]
|
|
147
|
+
|
|
148
|
+
def receive(self, run_id: str, wait_s: float = 0.0, max_messages: int = 10) -> list[Message]:
|
|
149
|
+
url = self._url(run_id, create=False)
|
|
150
|
+
if url is None:
|
|
151
|
+
return []
|
|
152
|
+
r = self.client.receive_message(QueueUrl=url, MaxNumberOfMessages=max(1, min(10, max_messages)),
|
|
153
|
+
WaitTimeSeconds=int(min(20, max(0, wait_s))))
|
|
154
|
+
out = []
|
|
155
|
+
for m in r.get("Messages", []):
|
|
156
|
+
try:
|
|
157
|
+
body = json.loads(m["Body"])
|
|
158
|
+
except ValueError:
|
|
159
|
+
body = {"raw": m["Body"]}
|
|
160
|
+
# the receipt handle is what deletes the message: it is the id we hand out
|
|
161
|
+
out.append(Message(m["ReceiptHandle"], body))
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
def ack(self, run_id: str, message_id: str) -> None:
|
|
165
|
+
url = self._url(run_id, create=False)
|
|
166
|
+
if url:
|
|
167
|
+
self.client.delete_message(QueueUrl=url, ReceiptHandle=message_id)
|
|
168
|
+
|
|
169
|
+
def purge(self, run_id: str) -> None:
|
|
170
|
+
url = self._url(run_id, create=False)
|
|
171
|
+
if url:
|
|
172
|
+
self.client.delete_queue(QueueUrl=url)
|
|
173
|
+
self._urls.pop(run_id, None)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- Launcher ---
|
|
177
|
+
|
|
178
|
+
class BatchLauncher:
|
|
179
|
+
"""AWS Batch. ``job_queues`` and ``job_definitions`` map a job kind
|
|
180
|
+
(``engine``, ``dorado``) to the queue and definition to submit to."""
|
|
181
|
+
|
|
182
|
+
_STATES = {
|
|
183
|
+
"SUBMITTED": "pending", "PENDING": "pending", "RUNNABLE": "pending",
|
|
184
|
+
"STARTING": "pending", "RUNNING": "running",
|
|
185
|
+
"SUCCEEDED": "succeeded", "FAILED": "failed",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def __init__(self, job_queues: dict, job_definitions: dict, region: Optional[str] = None, client=None):
|
|
189
|
+
self.job_queues = dict(job_queues)
|
|
190
|
+
self.job_definitions = dict(job_definitions)
|
|
191
|
+
self.client = client or _boto3().client("batch", region_name=region)
|
|
192
|
+
|
|
193
|
+
def submit(self, spec: JobSpec) -> JobHandle:
|
|
194
|
+
r = self.client.submit_job(
|
|
195
|
+
jobName=spec.name,
|
|
196
|
+
jobQueue=self.job_queues[spec.kind],
|
|
197
|
+
jobDefinition=self.job_definitions[spec.kind],
|
|
198
|
+
containerOverrides={
|
|
199
|
+
"environment": [{"name": k, "value": str(v)} for k, v in spec.env.items()],
|
|
200
|
+
**({"command": list(spec.args)} if spec.args else {}),
|
|
201
|
+
**({"resourceRequirements": [
|
|
202
|
+
{"type": "VCPU", "value": str(spec.vcpus)},
|
|
203
|
+
{"type": "MEMORY", "value": str(spec.memory_mib or spec.vcpus * 1900)},
|
|
204
|
+
]} if spec.vcpus else {}),
|
|
205
|
+
},
|
|
206
|
+
tags={"run_id": spec.run_id, "generation": str(spec.generation), "kind": spec.kind},
|
|
207
|
+
propagateTags=True,
|
|
208
|
+
**({"timeout": {"attemptDurationSeconds": int(spec.timeout_s)}} if spec.timeout_s else {}),
|
|
209
|
+
)
|
|
210
|
+
return JobHandle(r["jobId"], spec.name)
|
|
211
|
+
|
|
212
|
+
def describe(self, job_id: str) -> JobStatus:
|
|
213
|
+
jobs = self.client.describe_jobs(jobs=[job_id]).get("jobs", [])
|
|
214
|
+
if not jobs:
|
|
215
|
+
return JobStatus("unknown", reason="no such job")
|
|
216
|
+
j = jobs[0]
|
|
217
|
+
state = self._STATES.get(j.get("status"), "unknown")
|
|
218
|
+
container = j.get("container") or {}
|
|
219
|
+
code = container.get("exitCode")
|
|
220
|
+
reason = j.get("statusReason") or container.get("reason") or ""
|
|
221
|
+
return JobStatus(state, exit_code=int(code) if code is not None else None, reason=reason)
|
|
222
|
+
|
|
223
|
+
def find_by_name(self, name: str) -> Optional[JobHandle]:
|
|
224
|
+
for queue in set(self.job_queues.values()):
|
|
225
|
+
r = self.client.list_jobs(jobQueue=queue, filters=[{"name": "JOB_NAME", "values": [name]}])
|
|
226
|
+
summaries = r.get("jobSummaryList", [])
|
|
227
|
+
if summaries:
|
|
228
|
+
# the most recent submission of that name
|
|
229
|
+
latest = max(summaries, key=lambda s: s.get("createdAt", 0))
|
|
230
|
+
return JobHandle(latest["jobId"], name)
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def cancel(self, job_id: str, reason: str = "") -> None:
|
|
234
|
+
self.client.terminate_job(jobId=job_id, reason=reason or "cancelled by run API")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --- Store ---
|
|
238
|
+
|
|
239
|
+
def _to_ddb(value):
|
|
240
|
+
"""JSON → DynamoDB-safe (floats become Decimal)."""
|
|
241
|
+
return json.loads(json.dumps(value), parse_float=Decimal)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _from_ddb(value):
|
|
245
|
+
if isinstance(value, list):
|
|
246
|
+
return [_from_ddb(v) for v in value]
|
|
247
|
+
if isinstance(value, dict):
|
|
248
|
+
return {k: _from_ddb(v) for k, v in value.items()}
|
|
249
|
+
if isinstance(value, Decimal):
|
|
250
|
+
return int(value) if value == value.to_integral_value() else float(value)
|
|
251
|
+
return value
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class DynamoStore:
|
|
255
|
+
def __init__(self, table: str, region: Optional[str] = None, resource=None):
|
|
256
|
+
self.table_name = table
|
|
257
|
+
self.resource = resource or _boto3().resource("dynamodb", region_name=region)
|
|
258
|
+
self.table = self.resource.Table(table)
|
|
259
|
+
|
|
260
|
+
@staticmethod
|
|
261
|
+
def create_table(table: str, region: Optional[str] = None, client=None) -> None:
|
|
262
|
+
"""Create the table (pay per request); for CDK-less setups and tests."""
|
|
263
|
+
client = client or _boto3().client("dynamodb", region_name=region)
|
|
264
|
+
client.create_table(
|
|
265
|
+
TableName=table,
|
|
266
|
+
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"},
|
|
267
|
+
{"AttributeName": "sk", "AttributeType": "S"}],
|
|
268
|
+
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}, {"AttributeName": "sk", "KeyType": "RANGE"}],
|
|
269
|
+
BillingMode="PAY_PER_REQUEST",
|
|
270
|
+
)
|
|
271
|
+
client.get_waiter("table_exists").wait(TableName=table)
|
|
272
|
+
|
|
273
|
+
def _cond_failed(self, e) -> bool:
|
|
274
|
+
return e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException"
|
|
275
|
+
|
|
276
|
+
# runs
|
|
277
|
+
def create_run(self, run: dict, client_token: Optional[str] = None) -> dict:
|
|
278
|
+
if client_token:
|
|
279
|
+
existing = self.table.get_item(Key={"pk": f"TOKEN#{client_token}", "sk": "TOKEN"}).get("Item")
|
|
280
|
+
if existing:
|
|
281
|
+
return self.get_run(existing["run_id"])
|
|
282
|
+
now = time.time()
|
|
283
|
+
run = {**run, "created": run.get("created", now), "updated": now}
|
|
284
|
+
self.table.put_item(Item={"pk": f"RUN#{run['id']}", "sk": "RUN", "kind": "run",
|
|
285
|
+
"user_id": run.get("user_id"), "host": run.get("host"),
|
|
286
|
+
"state": run.get("state"),
|
|
287
|
+
"created": _to_ddb(run["created"]), "doc": _to_ddb(run)},
|
|
288
|
+
ConditionExpression="attribute_not_exists(pk)")
|
|
289
|
+
if client_token:
|
|
290
|
+
try:
|
|
291
|
+
self.table.put_item(Item={"pk": f"TOKEN#{client_token}", "sk": "TOKEN", "run_id": run["id"]},
|
|
292
|
+
ConditionExpression="attribute_not_exists(pk)")
|
|
293
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
|
294
|
+
# lost a race with an identical create: hand back the winner
|
|
295
|
+
self.table.delete_item(Key={"pk": f"RUN#{run['id']}", "sk": "RUN"})
|
|
296
|
+
return self.create_run(run, client_token)
|
|
297
|
+
return run
|
|
298
|
+
|
|
299
|
+
def get_run(self, run_id: str) -> Optional[dict]:
|
|
300
|
+
item = self.table.get_item(Key={"pk": f"RUN#{run_id}", "sk": "RUN"}).get("Item")
|
|
301
|
+
return _from_ddb(item["doc"]) if item else None
|
|
302
|
+
|
|
303
|
+
def update_run(self, run_id: str, updates: dict, expected_state: Optional[Iterable[str]] = None) -> dict:
|
|
304
|
+
for attempt in range(5):
|
|
305
|
+
run = self.get_run(run_id)
|
|
306
|
+
if run is None:
|
|
307
|
+
raise ConflictError(f"no run {run_id}")
|
|
308
|
+
if expected_state is not None and run.get("state") not in set(expected_state):
|
|
309
|
+
raise ConflictError(f"run {run_id} is {run.get('state')}, expected {list(expected_state)}")
|
|
310
|
+
new = {**run, **(updates(dict(run)) if callable(updates) else updates), "updated": time.time()}
|
|
311
|
+
try:
|
|
312
|
+
# optimistic: the stored doc must still be the one we read
|
|
313
|
+
self.table.put_item(
|
|
314
|
+
Item={"pk": f"RUN#{run_id}", "sk": "RUN", "kind": "run", "user_id": new.get("user_id"),
|
|
315
|
+
"host": new.get("host"), "state": new.get("state"),
|
|
316
|
+
"created": _to_ddb(new.get("created")), "doc": _to_ddb(new)},
|
|
317
|
+
ConditionExpression="attribute_exists(pk) AND #d.updated = :u",
|
|
318
|
+
ExpressionAttributeNames={"#d": "doc"},
|
|
319
|
+
ExpressionAttributeValues={":u": _to_ddb(run.get("updated"))},
|
|
320
|
+
)
|
|
321
|
+
return new
|
|
322
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
|
323
|
+
continue # someone else wrote first: re-read and retry
|
|
324
|
+
raise ConflictError(f"run {run_id}: too much contention")
|
|
325
|
+
|
|
326
|
+
def list_runs(self, user_id: Optional[str] = None, states: Optional[Iterable[str]] = None,
|
|
327
|
+
host: Optional[str] = None) -> list[dict]:
|
|
328
|
+
from boto3.dynamodb.conditions import Attr
|
|
329
|
+
expr = Attr("kind").eq("run")
|
|
330
|
+
if user_id is not None:
|
|
331
|
+
expr = expr & Attr("user_id").eq(user_id)
|
|
332
|
+
if host is not None:
|
|
333
|
+
expr = expr & Attr("host").eq(host)
|
|
334
|
+
items = []
|
|
335
|
+
kwargs = {"FilterExpression": expr}
|
|
336
|
+
while True:
|
|
337
|
+
r = self.table.scan(**kwargs)
|
|
338
|
+
items.extend(r.get("Items", []))
|
|
339
|
+
if "LastEvaluatedKey" not in r:
|
|
340
|
+
break
|
|
341
|
+
kwargs["ExclusiveStartKey"] = r["LastEvaluatedKey"]
|
|
342
|
+
runs = sorted((_from_ddb(i["doc"]) for i in items), key=lambda r: r.get("created", 0))
|
|
343
|
+
if states is not None:
|
|
344
|
+
wanted = set(states)
|
|
345
|
+
runs = [r for r in runs if r.get("state") in wanted]
|
|
346
|
+
return runs
|
|
347
|
+
|
|
348
|
+
def delete_run(self, run_id: str) -> None:
|
|
349
|
+
from boto3.dynamodb.conditions import Key
|
|
350
|
+
r = self.table.query(KeyConditionExpression=Key("pk").eq(f"RUN#{run_id}"))
|
|
351
|
+
with self.table.batch_writer() as batch:
|
|
352
|
+
for item in r.get("Items", []):
|
|
353
|
+
batch.delete_item(Key={"pk": item["pk"], "sk": item["sk"]})
|
|
354
|
+
|
|
355
|
+
# hosts
|
|
356
|
+
def put_host(self, host: dict) -> dict:
|
|
357
|
+
self.table.put_item(Item={"pk": f"HOST#{host['id']}", "sk": "HOST", "kind": "host",
|
|
358
|
+
"doc": _to_ddb(host)})
|
|
359
|
+
return host
|
|
360
|
+
|
|
361
|
+
def get_host(self, host_id: str) -> Optional[dict]:
|
|
362
|
+
item = self.table.get_item(Key={"pk": f"HOST#{host_id}", "sk": "HOST"}).get("Item")
|
|
363
|
+
return _from_ddb(item["doc"]) if item else None
|
|
364
|
+
|
|
365
|
+
def list_hosts(self) -> list[dict]:
|
|
366
|
+
from boto3.dynamodb.conditions import Attr
|
|
367
|
+
items, kwargs = [], {"FilterExpression": Attr("kind").eq("host")}
|
|
368
|
+
while True:
|
|
369
|
+
r = self.table.scan(**kwargs)
|
|
370
|
+
items.extend(r.get("Items", []))
|
|
371
|
+
if "LastEvaluatedKey" not in r:
|
|
372
|
+
break
|
|
373
|
+
kwargs["ExclusiveStartKey"] = r["LastEvaluatedKey"]
|
|
374
|
+
return sorted((_from_ddb(i["doc"]) for i in items), key=lambda h: h["id"])
|
|
375
|
+
|
|
376
|
+
# archives
|
|
377
|
+
def put_archive(self, archive: dict) -> dict:
|
|
378
|
+
self.table.put_item(Item={"pk": f"ARCHIVE#{archive['id']}", "sk": "ARCHIVE", "kind": "archive",
|
|
379
|
+
"user_id": archive.get("user_id"), "doc": _to_ddb(archive)})
|
|
380
|
+
return archive
|
|
381
|
+
|
|
382
|
+
def get_archive(self, archive_id: str) -> Optional[dict]:
|
|
383
|
+
item = self.table.get_item(Key={"pk": f"ARCHIVE#{archive_id}", "sk": "ARCHIVE"}).get("Item")
|
|
384
|
+
return _from_ddb(item["doc"]) if item else None
|
|
385
|
+
|
|
386
|
+
# intents
|
|
387
|
+
def open_intent(self, run_id: str, kind: str, payload: dict) -> str:
|
|
388
|
+
iid = uuid.uuid4().hex
|
|
389
|
+
self.table.put_item(Item={"pk": "INTENTS", "sk": f"OPEN#{iid}", "kind": "intent", "id": iid,
|
|
390
|
+
"run_id": run_id, "intent_kind": kind, "payload": _to_ddb(payload),
|
|
391
|
+
"opened": _to_ddb(time.time())})
|
|
392
|
+
return iid
|
|
393
|
+
|
|
394
|
+
def resolve_intent(self, intent_id: str, result: dict) -> None:
|
|
395
|
+
item = self.table.get_item(Key={"pk": "INTENTS", "sk": f"OPEN#{intent_id}"}).get("Item")
|
|
396
|
+
if not item:
|
|
397
|
+
return
|
|
398
|
+
self.table.put_item(Item={**item, "pk": f"RUN#{item['run_id']}", "sk": f"INTENT#{intent_id}",
|
|
399
|
+
"result": _to_ddb(result), "resolved": _to_ddb(time.time())})
|
|
400
|
+
self.table.delete_item(Key={"pk": "INTENTS", "sk": f"OPEN#{intent_id}"})
|
|
401
|
+
|
|
402
|
+
def list_open_intents(self, run_id: Optional[str] = None) -> list[dict]:
|
|
403
|
+
from boto3.dynamodb.conditions import Key
|
|
404
|
+
r = self.table.query(KeyConditionExpression=Key("pk").eq("INTENTS"))
|
|
405
|
+
out = []
|
|
406
|
+
for item in sorted(r.get("Items", []), key=lambda i: i.get("opened", 0)):
|
|
407
|
+
if run_id is not None and item.get("run_id") != run_id:
|
|
408
|
+
continue
|
|
409
|
+
out.append({"id": item["id"], "run_id": item["run_id"], "kind": item["intent_kind"],
|
|
410
|
+
"payload": _from_ddb(item.get("payload", {})), "opened": _from_ddb(item.get("opened"))})
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
# commands
|
|
414
|
+
def put_command(self, run_id: str, command: dict) -> dict:
|
|
415
|
+
now = time.time()
|
|
416
|
+
doc = {**command, "run_id": run_id, "outcome": "pending", "created": now}
|
|
417
|
+
try:
|
|
418
|
+
self.table.put_item(Item={"pk": f"RUN#{run_id}", "sk": f"CMD#{command['id']}", "kind": "command",
|
|
419
|
+
"outcome": "pending", "created": _to_ddb(now), "doc": _to_ddb(doc)},
|
|
420
|
+
ConditionExpression="attribute_not_exists(pk)")
|
|
421
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
|
422
|
+
pass
|
|
423
|
+
return doc
|
|
424
|
+
|
|
425
|
+
def get_command(self, run_id: str, command_id: str) -> Optional[dict]:
|
|
426
|
+
item = self.table.get_item(Key={"pk": f"RUN#{run_id}", "sk": f"CMD#{command_id}"}).get("Item")
|
|
427
|
+
if not item:
|
|
428
|
+
return None
|
|
429
|
+
return {**_from_ddb(item["doc"]), "outcome": item.get("outcome"), "reason": item.get("reason")}
|
|
430
|
+
|
|
431
|
+
def mark_command(self, run_id: str, command_id: str, outcome: str, reason: Optional[str] = None) -> None:
|
|
432
|
+
try:
|
|
433
|
+
self.table.update_item(Key={"pk": f"RUN#{run_id}", "sk": f"CMD#{command_id}"},
|
|
434
|
+
UpdateExpression="SET outcome = :o, reason = :r, updated = :u",
|
|
435
|
+
ConditionExpression="attribute_exists(pk)",
|
|
436
|
+
ExpressionAttributeValues={":o": outcome, ":r": reason, ":u": _to_ddb(time.time())})
|
|
437
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
|
438
|
+
pass
|
|
439
|
+
|
|
440
|
+
def list_commands(self, run_id: str, pending_only: bool = False) -> list[dict]:
|
|
441
|
+
from boto3.dynamodb.conditions import Key
|
|
442
|
+
r = self.table.query(KeyConditionExpression=Key("pk").eq(f"RUN#{run_id}") & Key("sk").begins_with("CMD#"))
|
|
443
|
+
cmds = [{**_from_ddb(i["doc"]), "outcome": i.get("outcome"), "reason": i.get("reason")}
|
|
444
|
+
for i in sorted(r.get("Items", []), key=lambda i: i.get("created", 0))]
|
|
445
|
+
if pending_only:
|
|
446
|
+
cmds = [c for c in cmds if c["outcome"] == "pending"]
|
|
447
|
+
return cmds
|
|
448
|
+
|
|
449
|
+
# reservations: one item per taken slot, SLOT#0 .. SLOT#<slots-1>
|
|
450
|
+
def _slot_items(self, stage: str) -> list[dict]:
|
|
451
|
+
from boto3.dynamodb.conditions import Key
|
|
452
|
+
r = self.table.query(KeyConditionExpression=Key("pk").eq(f"STAGE#{stage}") & Key("sk").begins_with("SLOT#"),
|
|
453
|
+
ConsistentRead=True)
|
|
454
|
+
return sorted(r.get("Items", []), key=lambda i: int(i["sk"][5:]))
|
|
455
|
+
|
|
456
|
+
def reserve_stage(self, stage: str, run_id: str, slots: int = 1) -> bool:
|
|
457
|
+
errors = self.table.meta.client.exceptions
|
|
458
|
+
for _ in range(3):
|
|
459
|
+
items = self._slot_items(stage)
|
|
460
|
+
if any(i.get("run_id") == run_id for i in items):
|
|
461
|
+
return True
|
|
462
|
+
taken = {int(i["sk"][5:]) for i in items}
|
|
463
|
+
free = [n for n in range(slots) if n not in taken]
|
|
464
|
+
if not free:
|
|
465
|
+
return False
|
|
466
|
+
try:
|
|
467
|
+
self.table.put_item(Item={"pk": f"STAGE#{stage}", "sk": f"SLOT#{free[0]}", "run_id": run_id,
|
|
468
|
+
"since": _to_ddb(time.time())},
|
|
469
|
+
ConditionExpression="attribute_not_exists(pk)")
|
|
470
|
+
return True
|
|
471
|
+
except errors.ConditionalCheckFailedException:
|
|
472
|
+
continue # another run took that slot first; look again
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
def release_stage(self, stage: str, run_id: str) -> None:
|
|
476
|
+
for item in self._slot_items(stage):
|
|
477
|
+
if item.get("run_id") != run_id:
|
|
478
|
+
continue
|
|
479
|
+
try:
|
|
480
|
+
self.table.delete_item(Key={"pk": item["pk"], "sk": item["sk"]},
|
|
481
|
+
ConditionExpression="run_id = :r",
|
|
482
|
+
ExpressionAttributeValues={":r": run_id})
|
|
483
|
+
except self.table.meta.client.exceptions.ConditionalCheckFailedException:
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
def stage_holders(self, stage: str) -> list[str]:
|
|
487
|
+
return [i["run_id"] for i in self._slot_items(stage)]
|