labtasker-server 2.0.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.
- labtasker_server/__init__.py +3 -0
- labtasker_server/__main__.py +3 -0
- labtasker_server/app.py +416 -0
- labtasker_server/cli.py +371 -0
- labtasker_server/config.py +42 -0
- labtasker_server/database.py +176 -0
- labtasker_server/errors.py +27 -0
- labtasker_server/filtering.py +528 -0
- labtasker_server/local.py +453 -0
- labtasker_server/logging.py +47 -0
- labtasker_server/middleware.py +73 -0
- labtasker_server/migrations/__init__.py +1 -0
- labtasker_server/migrations/env.py +20 -0
- labtasker_server/migrations/versions/0001_initial.py +135 -0
- labtasker_server/migrations/versions/__init__.py +1 -0
- labtasker_server/models.py +139 -0
- labtasker_server/pagination.py +126 -0
- labtasker_server/py.typed +1 -0
- labtasker_server/schemas.py +265 -0
- labtasker_server/services/__init__.py +1 -0
- labtasker_server/services/queues.py +66 -0
- labtasker_server/services/tasks.py +859 -0
- labtasker_server/validation.py +150 -0
- labtasker_server-2.0.0.dist-info/METADATA +16 -0
- labtasker_server-2.0.0.dist-info/RECORD +28 -0
- labtasker_server-2.0.0.dist-info/WHEEL +4 -0
- labtasker_server-2.0.0.dist-info/entry_points.txt +2 -0
- labtasker_server-2.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import socket
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Literal, cast
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import fcntl
|
|
21
|
+
except ImportError: # pragma: no cover - local mode is rejected off POSIX
|
|
22
|
+
fcntl = None # type: ignore[assignment]
|
|
23
|
+
|
|
24
|
+
LOCAL_GITIGNORE = "*\n!.gitignore\n"
|
|
25
|
+
LAUNCH_THROTTLE_SECONDS = 10.0
|
|
26
|
+
STARTUP_WAIT_SECONDS = 30.0
|
|
27
|
+
STARTUP_PUBLICATION_SECONDS = 1.0
|
|
28
|
+
HEALTH_POLL_SECONDS = 0.05
|
|
29
|
+
METADATA_VERSION = 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class LocalPaths:
|
|
34
|
+
directory: Path
|
|
35
|
+
database: Path
|
|
36
|
+
log: Path
|
|
37
|
+
runtime_directory: Path
|
|
38
|
+
socket: Path
|
|
39
|
+
metadata: Path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class RuntimeMetadata:
|
|
44
|
+
metadata_version: int
|
|
45
|
+
generation: str
|
|
46
|
+
role: Literal["coordinator", "daemon"]
|
|
47
|
+
pid: int
|
|
48
|
+
process_start_marker: str
|
|
49
|
+
directory: str
|
|
50
|
+
database: str
|
|
51
|
+
database_device: int
|
|
52
|
+
database_inode: int
|
|
53
|
+
automatic_attempt_at: float
|
|
54
|
+
server_version: str | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_local_capabilities() -> None:
|
|
58
|
+
if os.name != "posix" or fcntl is None or not hasattr(socket, "AF_UNIX"):
|
|
59
|
+
raise RuntimeError("Local mode requires POSIX flock and Unix-domain sockets.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def local_paths(directory: Path | None = None) -> LocalPaths:
|
|
63
|
+
require_local_capabilities()
|
|
64
|
+
canonical = (Path.cwd() if directory is None else directory).resolve()
|
|
65
|
+
digest = hashlib.sha256(os.fsencode(canonical)).hexdigest()
|
|
66
|
+
runtime_directory = Path("/tmp") / f"labtasker-{os.geteuid()}"
|
|
67
|
+
local_directory = canonical / ".labtasker"
|
|
68
|
+
return LocalPaths(
|
|
69
|
+
directory=canonical,
|
|
70
|
+
database=local_directory / "server.db",
|
|
71
|
+
log=local_directory / "server.log",
|
|
72
|
+
runtime_directory=runtime_directory,
|
|
73
|
+
socket=runtime_directory / f"{digest}.sock",
|
|
74
|
+
metadata=runtime_directory / f"{digest}.json",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ensure_local_storage(paths: LocalPaths) -> None:
|
|
79
|
+
paths.database.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
try:
|
|
81
|
+
with (paths.database.parent / ".gitignore").open(
|
|
82
|
+
"x", encoding="utf-8", newline="\n"
|
|
83
|
+
) as stream:
|
|
84
|
+
stream.write(LOCAL_GITIGNORE)
|
|
85
|
+
except FileExistsError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ensure_runtime_directory(paths: LocalPaths) -> None:
|
|
90
|
+
with suppress(FileExistsError):
|
|
91
|
+
paths.runtime_directory.mkdir(mode=0o700)
|
|
92
|
+
info = paths.runtime_directory.lstat()
|
|
93
|
+
if (
|
|
94
|
+
not stat.S_ISDIR(info.st_mode)
|
|
95
|
+
or stat.S_ISLNK(info.st_mode)
|
|
96
|
+
or info.st_uid != os.geteuid()
|
|
97
|
+
or stat.S_IMODE(info.st_mode) & 0o077
|
|
98
|
+
):
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"Runtime directory must be an owner-only real directory: {paths.runtime_directory}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def try_acquire_database(paths: LocalPaths, *, create: bool = True) -> int | None:
|
|
105
|
+
require_local_capabilities()
|
|
106
|
+
if create:
|
|
107
|
+
ensure_local_storage(paths)
|
|
108
|
+
elif not paths.database.exists():
|
|
109
|
+
return os.open(os.devnull, os.O_RDONLY)
|
|
110
|
+
flags = os.O_RDWR | (os.O_CREAT if create else 0)
|
|
111
|
+
fd = os.open(paths.database, flags, 0o600)
|
|
112
|
+
try:
|
|
113
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
114
|
+
except BlockingIOError:
|
|
115
|
+
os.close(fd)
|
|
116
|
+
return None
|
|
117
|
+
return fd
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def database_is_free(paths: LocalPaths) -> bool:
|
|
121
|
+
fd = try_acquire_database(paths, create=False)
|
|
122
|
+
if fd is None:
|
|
123
|
+
return False
|
|
124
|
+
os.close(fd)
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def database_identity(fd: int) -> tuple[int, int]:
|
|
129
|
+
info = os.fstat(fd)
|
|
130
|
+
return info.st_dev, info.st_ino
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def process_start_marker(pid: int) -> str | None:
|
|
134
|
+
proc_stat = Path(f"/proc/{pid}/stat")
|
|
135
|
+
try:
|
|
136
|
+
suffix = proc_stat.read_text(encoding="utf-8").rsplit(")", 1)[1].split()
|
|
137
|
+
return f"proc:{suffix[19]}"
|
|
138
|
+
except (OSError, IndexError):
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
result = subprocess.run(
|
|
142
|
+
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
143
|
+
check=False,
|
|
144
|
+
capture_output=True,
|
|
145
|
+
text=True,
|
|
146
|
+
timeout=1,
|
|
147
|
+
)
|
|
148
|
+
except (OSError, subprocess.SubprocessError):
|
|
149
|
+
return None
|
|
150
|
+
marker = result.stdout.strip()
|
|
151
|
+
return f"ps:{marker}" if result.returncode == 0 and marker else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def make_metadata(
|
|
155
|
+
paths: LocalPaths,
|
|
156
|
+
*,
|
|
157
|
+
generation: str,
|
|
158
|
+
role: Literal["coordinator", "daemon"],
|
|
159
|
+
pid: int,
|
|
160
|
+
automatic_attempt_at: float,
|
|
161
|
+
database_fd: int,
|
|
162
|
+
server_version: str | None,
|
|
163
|
+
) -> RuntimeMetadata:
|
|
164
|
+
marker = process_start_marker(pid)
|
|
165
|
+
if marker is None:
|
|
166
|
+
raise RuntimeError(f"Could not determine process identity for PID {pid}.")
|
|
167
|
+
device, inode = database_identity(database_fd)
|
|
168
|
+
return RuntimeMetadata(
|
|
169
|
+
metadata_version=METADATA_VERSION,
|
|
170
|
+
generation=generation,
|
|
171
|
+
role=role,
|
|
172
|
+
pid=pid,
|
|
173
|
+
process_start_marker=marker,
|
|
174
|
+
directory=str(paths.directory),
|
|
175
|
+
database=str(paths.database),
|
|
176
|
+
database_device=device,
|
|
177
|
+
database_inode=inode,
|
|
178
|
+
automatic_attempt_at=automatic_attempt_at,
|
|
179
|
+
server_version=server_version,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def write_metadata(paths: LocalPaths, metadata: RuntimeMetadata) -> None:
|
|
184
|
+
ensure_runtime_directory(paths)
|
|
185
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{paths.metadata.name}.", dir=paths.runtime_directory)
|
|
186
|
+
try:
|
|
187
|
+
os.fchmod(fd, 0o600)
|
|
188
|
+
payload = (json.dumps(asdict(metadata), sort_keys=True) + "\n").encode()
|
|
189
|
+
with os.fdopen(fd, "wb", closefd=True) as stream:
|
|
190
|
+
fd = -1
|
|
191
|
+
stream.write(payload)
|
|
192
|
+
stream.flush()
|
|
193
|
+
os.fsync(stream.fileno())
|
|
194
|
+
os.replace(temporary, paths.metadata)
|
|
195
|
+
finally:
|
|
196
|
+
if fd >= 0:
|
|
197
|
+
os.close(fd)
|
|
198
|
+
with suppress(FileNotFoundError):
|
|
199
|
+
os.unlink(temporary)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def read_metadata(paths: LocalPaths) -> RuntimeMetadata | None:
|
|
203
|
+
try:
|
|
204
|
+
info = paths.metadata.lstat()
|
|
205
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid():
|
|
206
|
+
return None
|
|
207
|
+
raw = json.loads(paths.metadata.read_text(encoding="utf-8"))
|
|
208
|
+
metadata = RuntimeMetadata(**raw)
|
|
209
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
210
|
+
return None
|
|
211
|
+
if (
|
|
212
|
+
metadata.metadata_version != METADATA_VERSION
|
|
213
|
+
or metadata.role not in {"coordinator", "daemon"}
|
|
214
|
+
or metadata.pid <= 0
|
|
215
|
+
or not metadata.generation
|
|
216
|
+
or metadata.directory != str(paths.directory)
|
|
217
|
+
or metadata.database != str(paths.database)
|
|
218
|
+
or not isinstance(metadata.automatic_attempt_at, (int, float))
|
|
219
|
+
):
|
|
220
|
+
return None
|
|
221
|
+
return metadata
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def metadata_matches_database(paths: LocalPaths, metadata: RuntimeMetadata) -> bool:
|
|
225
|
+
try:
|
|
226
|
+
info = paths.database.stat()
|
|
227
|
+
except OSError:
|
|
228
|
+
return False
|
|
229
|
+
return (metadata.database_device, metadata.database_inode) == (info.st_dev, info.st_ino)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def metadata_owner_is_verified(paths: LocalPaths, metadata: RuntimeMetadata) -> bool:
|
|
233
|
+
return (
|
|
234
|
+
metadata_matches_database(paths, metadata)
|
|
235
|
+
and process_start_marker(metadata.pid) == metadata.process_start_marker
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def throttle_remaining(metadata: RuntimeMetadata | None, *, now: float | None = None) -> float:
|
|
240
|
+
if metadata is None:
|
|
241
|
+
return 0.0
|
|
242
|
+
current = time.time() if now is None else now
|
|
243
|
+
age = current - metadata.automatic_attempt_at
|
|
244
|
+
if age < 0 or age >= LAUNCH_THROTTLE_SECONDS:
|
|
245
|
+
return 0.0
|
|
246
|
+
return LAUNCH_THROTTLE_SECONDS - age
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def startup_age(metadata: RuntimeMetadata | None, *, now: float | None = None) -> float | None:
|
|
250
|
+
if metadata is None:
|
|
251
|
+
return None
|
|
252
|
+
current = time.time() if now is None else now
|
|
253
|
+
age = current - metadata.automatic_attempt_at
|
|
254
|
+
return age if 0 <= age < STARTUP_WAIT_SECONDS else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def socket_health(paths: LocalPaths, *, timeout: float = 0.2) -> bool:
|
|
258
|
+
try:
|
|
259
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
260
|
+
connection.settimeout(timeout)
|
|
261
|
+
connection.connect(str(paths.socket))
|
|
262
|
+
connection.sendall(
|
|
263
|
+
b"GET /health HTTP/1.1\r\nHost: labtasker\r\nConnection: close\r\n\r\n"
|
|
264
|
+
)
|
|
265
|
+
response = bytearray()
|
|
266
|
+
while len(response) <= 65536:
|
|
267
|
+
chunk = connection.recv(8192)
|
|
268
|
+
if not chunk:
|
|
269
|
+
break
|
|
270
|
+
response.extend(chunk)
|
|
271
|
+
except OSError:
|
|
272
|
+
return False
|
|
273
|
+
head, separator, body = bytes(response).partition(b"\r\n\r\n")
|
|
274
|
+
if not separator or not head.startswith(b"HTTP/1.1 200"):
|
|
275
|
+
return False
|
|
276
|
+
try:
|
|
277
|
+
payload = json.loads(body)
|
|
278
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
279
|
+
return False
|
|
280
|
+
if not isinstance(payload, dict):
|
|
281
|
+
return False
|
|
282
|
+
normalized = cast(dict[str, object], payload)
|
|
283
|
+
return normalized == {"status": "ok", "api_version": "2", "database": "ok"}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def wait_for_health(paths: LocalPaths, *, deadline: float) -> bool:
|
|
287
|
+
while time.monotonic() < deadline:
|
|
288
|
+
if socket_health(paths):
|
|
289
|
+
return True
|
|
290
|
+
time.sleep(HEALTH_POLL_SECONDS)
|
|
291
|
+
return socket_health(paths)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def ensure_local_daemon(
|
|
295
|
+
directory: Path,
|
|
296
|
+
*,
|
|
297
|
+
bypass_throttle: bool,
|
|
298
|
+
server_version: str,
|
|
299
|
+
emit: Callable[[str], None],
|
|
300
|
+
) -> tuple[bool, RuntimeMetadata | None]:
|
|
301
|
+
"""Ensure one healthy local daemon and return whether this call started it."""
|
|
302
|
+
require_local_capabilities()
|
|
303
|
+
paths = local_paths(directory)
|
|
304
|
+
ensure_runtime_directory(paths)
|
|
305
|
+
if socket_health(paths):
|
|
306
|
+
return False, read_metadata(paths)
|
|
307
|
+
|
|
308
|
+
database_fd = try_acquire_database(paths)
|
|
309
|
+
if database_fd is None:
|
|
310
|
+
publication_deadline = time.monotonic() + STARTUP_PUBLICATION_SECONDS
|
|
311
|
+
while True:
|
|
312
|
+
metadata = read_metadata(paths)
|
|
313
|
+
age = startup_age(metadata)
|
|
314
|
+
if (
|
|
315
|
+
metadata is not None
|
|
316
|
+
and metadata_owner_is_verified(paths, metadata)
|
|
317
|
+
and age is not None
|
|
318
|
+
):
|
|
319
|
+
emit(f"waiting for local daemon pid={metadata.pid} socket={paths.socket}")
|
|
320
|
+
deadline = time.monotonic() + max(0.0, STARTUP_WAIT_SECONDS - age)
|
|
321
|
+
if wait_for_health(paths, deadline=deadline):
|
|
322
|
+
return False, read_metadata(paths) or metadata
|
|
323
|
+
break
|
|
324
|
+
if socket_health(paths):
|
|
325
|
+
return False, read_metadata(paths)
|
|
326
|
+
if time.monotonic() >= publication_deadline:
|
|
327
|
+
break
|
|
328
|
+
time.sleep(HEALTH_POLL_SECONDS)
|
|
329
|
+
raise RuntimeError(f"Database is owned but local socket is unavailable: {paths.database}")
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
if socket_health(paths):
|
|
333
|
+
return False, read_metadata(paths)
|
|
334
|
+
previous = read_metadata(paths)
|
|
335
|
+
remaining = 0.0 if bypass_throttle else throttle_remaining(previous)
|
|
336
|
+
if remaining > 0:
|
|
337
|
+
raise RuntimeError(
|
|
338
|
+
f"Automatic launch is throttled for {remaining:.1f}s; log={paths.log}"
|
|
339
|
+
)
|
|
340
|
+
remove_stale_artifacts(paths)
|
|
341
|
+
process = _spawn_local_daemon(
|
|
342
|
+
paths,
|
|
343
|
+
database_fd=database_fd,
|
|
344
|
+
server_version=server_version,
|
|
345
|
+
)
|
|
346
|
+
finally:
|
|
347
|
+
os.close(database_fd)
|
|
348
|
+
|
|
349
|
+
emit(f"created local daemon pid={process.pid} database={paths.database} socket={paths.socket}")
|
|
350
|
+
if not wait_for_health(paths, deadline=time.monotonic() + STARTUP_WAIT_SECONDS):
|
|
351
|
+
raise RuntimeError(f"Daemon did not become healthy within 30 seconds; log={paths.log}")
|
|
352
|
+
return True, read_metadata(paths)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _spawn_local_daemon(
|
|
356
|
+
paths: LocalPaths,
|
|
357
|
+
*,
|
|
358
|
+
database_fd: int,
|
|
359
|
+
server_version: str,
|
|
360
|
+
) -> subprocess.Popen[bytes]:
|
|
361
|
+
generation = secrets.token_urlsafe(18)
|
|
362
|
+
attempt_at = time.time()
|
|
363
|
+
write_metadata(
|
|
364
|
+
paths,
|
|
365
|
+
make_metadata(
|
|
366
|
+
paths,
|
|
367
|
+
generation=generation,
|
|
368
|
+
role="coordinator",
|
|
369
|
+
pid=os.getpid(),
|
|
370
|
+
automatic_attempt_at=attempt_at,
|
|
371
|
+
database_fd=database_fd,
|
|
372
|
+
server_version=server_version,
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
paths.log.parent.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
with paths.log.open("ab", buffering=0) as log:
|
|
377
|
+
process = subprocess.Popen(
|
|
378
|
+
[
|
|
379
|
+
sys.executable,
|
|
380
|
+
"-m",
|
|
381
|
+
"labtasker_server",
|
|
382
|
+
"_daemon",
|
|
383
|
+
"--directory",
|
|
384
|
+
str(paths.directory),
|
|
385
|
+
"--database-fd",
|
|
386
|
+
str(database_fd),
|
|
387
|
+
"--generation",
|
|
388
|
+
generation,
|
|
389
|
+
"--automatic-attempt-at",
|
|
390
|
+
str(attempt_at),
|
|
391
|
+
],
|
|
392
|
+
cwd=paths.directory,
|
|
393
|
+
stdin=subprocess.DEVNULL,
|
|
394
|
+
stdout=log,
|
|
395
|
+
stderr=subprocess.STDOUT,
|
|
396
|
+
start_new_session=True,
|
|
397
|
+
pass_fds=(database_fd,),
|
|
398
|
+
)
|
|
399
|
+
write_metadata(
|
|
400
|
+
paths,
|
|
401
|
+
make_metadata(
|
|
402
|
+
paths,
|
|
403
|
+
generation=generation,
|
|
404
|
+
role="daemon",
|
|
405
|
+
pid=process.pid,
|
|
406
|
+
automatic_attempt_at=attempt_at,
|
|
407
|
+
database_fd=database_fd,
|
|
408
|
+
server_version=server_version,
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
return process
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def remove_stale_artifacts(paths: LocalPaths) -> None:
|
|
415
|
+
for path, expected in ((paths.socket, stat.S_ISSOCK), (paths.metadata, stat.S_ISREG)):
|
|
416
|
+
try:
|
|
417
|
+
info = path.lstat()
|
|
418
|
+
except FileNotFoundError:
|
|
419
|
+
continue
|
|
420
|
+
if info.st_uid != os.geteuid() or not expected(info.st_mode):
|
|
421
|
+
raise RuntimeError(f"Refusing to remove unverified runtime artifact: {path}")
|
|
422
|
+
path.unlink()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def remove_generation_artifacts(paths: LocalPaths, generation: str) -> None:
|
|
426
|
+
metadata = read_metadata(paths)
|
|
427
|
+
if metadata is None or metadata.generation != generation:
|
|
428
|
+
return
|
|
429
|
+
try:
|
|
430
|
+
info = paths.socket.lstat()
|
|
431
|
+
except FileNotFoundError:
|
|
432
|
+
pass
|
|
433
|
+
else:
|
|
434
|
+
if info.st_uid == os.geteuid() and stat.S_ISSOCK(info.st_mode):
|
|
435
|
+
paths.socket.unlink()
|
|
436
|
+
with suppress(FileNotFoundError):
|
|
437
|
+
paths.metadata.unlink()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def remove_generation_socket(paths: LocalPaths, generation: str) -> None:
|
|
441
|
+
metadata = read_metadata(paths)
|
|
442
|
+
if metadata is None or metadata.generation != generation:
|
|
443
|
+
return
|
|
444
|
+
try:
|
|
445
|
+
info = paths.socket.lstat()
|
|
446
|
+
except FileNotFoundError:
|
|
447
|
+
return
|
|
448
|
+
if info.st_uid == os.geteuid() and stat.S_ISSOCK(info.st_mode):
|
|
449
|
+
paths.socket.unlink()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def has_runtime_artifacts(paths: LocalPaths) -> bool:
|
|
453
|
+
return paths.socket.exists() or paths.metadata.exists()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from time import struct_time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UTCFormatter(logging.Formatter):
|
|
10
|
+
converter: Callable[[float | None], struct_time] = time.gmtime
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def uvicorn_log_config() -> dict[str, object]:
|
|
14
|
+
formatter = {
|
|
15
|
+
"()": UTCFormatter,
|
|
16
|
+
"format": ("%(asctime)s.%(msecs)03dZ %(levelname)s [labtasker-server] %(message)s"),
|
|
17
|
+
"datefmt": "%Y-%m-%dT%H:%M:%S",
|
|
18
|
+
}
|
|
19
|
+
handler = {
|
|
20
|
+
"class": "logging.StreamHandler",
|
|
21
|
+
"formatter": "labtasker-server",
|
|
22
|
+
"stream": "ext://sys.stderr",
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
"version": 1,
|
|
26
|
+
"disable_existing_loggers": False,
|
|
27
|
+
"formatters": {"labtasker-server": formatter},
|
|
28
|
+
"handlers": {"labtasker-server": handler},
|
|
29
|
+
"loggers": {
|
|
30
|
+
"labtasker_server": {
|
|
31
|
+
"handlers": ["labtasker-server"],
|
|
32
|
+
"level": "INFO",
|
|
33
|
+
"propagate": False,
|
|
34
|
+
},
|
|
35
|
+
"uvicorn": {
|
|
36
|
+
"handlers": ["labtasker-server"],
|
|
37
|
+
"level": "INFO",
|
|
38
|
+
"propagate": False,
|
|
39
|
+
},
|
|
40
|
+
"uvicorn.error": {"level": "INFO"},
|
|
41
|
+
"uvicorn.access": {
|
|
42
|
+
"handlers": ["labtasker-server"],
|
|
43
|
+
"level": "INFO",
|
|
44
|
+
"propagate": False,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from starlette.responses import JSONResponse
|
|
4
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class RequestBodyLimitMiddleware:
|
|
8
|
+
"""Buffer one bounded request body before entering the application."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, app: ASGIApp, *, max_bytes: int) -> None:
|
|
11
|
+
self.app = app
|
|
12
|
+
self.max_bytes = max_bytes
|
|
13
|
+
|
|
14
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
15
|
+
if scope["type"] != "http":
|
|
16
|
+
await self.app(scope, receive, send)
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
declared_length = _content_length(scope)
|
|
20
|
+
if declared_length is not None and declared_length > self.max_bytes:
|
|
21
|
+
await self._reject(scope, receive, send)
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
messages: list[Message] = []
|
|
25
|
+
received_bytes = 0
|
|
26
|
+
while True:
|
|
27
|
+
message = await receive()
|
|
28
|
+
messages.append(message)
|
|
29
|
+
if message["type"] != "http.request":
|
|
30
|
+
break
|
|
31
|
+
received_bytes += len(message.get("body", b""))
|
|
32
|
+
if received_bytes > self.max_bytes:
|
|
33
|
+
await self._reject(scope, receive, send)
|
|
34
|
+
return
|
|
35
|
+
if not message.get("more_body", False):
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
index = 0
|
|
39
|
+
|
|
40
|
+
async def replay() -> Message:
|
|
41
|
+
nonlocal index
|
|
42
|
+
if index < len(messages):
|
|
43
|
+
message = messages[index]
|
|
44
|
+
index += 1
|
|
45
|
+
return message
|
|
46
|
+
return await receive()
|
|
47
|
+
|
|
48
|
+
await self.app(scope, replay, send)
|
|
49
|
+
|
|
50
|
+
async def _reject(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
51
|
+
response = JSONResponse(
|
|
52
|
+
status_code=413,
|
|
53
|
+
content={
|
|
54
|
+
"error": {
|
|
55
|
+
"code": "request_too_large",
|
|
56
|
+
"message": "Request body exceeds the 1 MiB limit.",
|
|
57
|
+
"details": {"max_bytes": self.max_bytes},
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
await response(scope, receive, send)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _content_length(scope: Scope) -> int | None:
|
|
65
|
+
for name, raw_value in scope.get("headers", []):
|
|
66
|
+
if name.lower() != b"content-length":
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
value = int(raw_value)
|
|
70
|
+
except ValueError:
|
|
71
|
+
return None
|
|
72
|
+
return value if value >= 0 else None
|
|
73
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Alembic environment bundled with the Server distribution."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from alembic import context
|
|
4
|
+
|
|
5
|
+
from labtasker_server.models import Base
|
|
6
|
+
|
|
7
|
+
config = context.config
|
|
8
|
+
target_metadata = Base.metadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_migrations() -> None:
|
|
12
|
+
connection = config.attributes.get("connection")
|
|
13
|
+
if connection is None:
|
|
14
|
+
raise RuntimeError("Labtasker migrations require an existing Server connection.")
|
|
15
|
+
context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=False)
|
|
16
|
+
with context.begin_transaction():
|
|
17
|
+
context.run_migrations()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
run_migrations()
|