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