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,371 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import signal
6
+ import socket
7
+ import time
8
+ from contextlib import suppress
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+ import uvicorn
14
+
15
+ from labtasker_server import __version__
16
+ from labtasker_server.app import create_app
17
+ from labtasker_server.config import ServerSettings
18
+ from labtasker_server.database import DatabaseOwnershipError
19
+ from labtasker_server.local import (
20
+ database_identity,
21
+ database_is_free,
22
+ ensure_local_daemon,
23
+ ensure_runtime_directory,
24
+ has_runtime_artifacts,
25
+ local_paths,
26
+ make_metadata,
27
+ metadata_matches_database,
28
+ metadata_owner_is_verified,
29
+ read_metadata,
30
+ remove_generation_artifacts,
31
+ remove_generation_socket,
32
+ remove_stale_artifacts,
33
+ require_local_capabilities,
34
+ socket_health,
35
+ startup_age,
36
+ throttle_remaining,
37
+ write_metadata,
38
+ )
39
+ from labtasker_server.logging import uvicorn_log_config
40
+
41
+ app = typer.Typer(
42
+ help="Run the Labtasker v2 HTTP Server.",
43
+ add_completion=False,
44
+ no_args_is_help=True,
45
+ pretty_exceptions_enable=False,
46
+ rich_markup_mode=None,
47
+ )
48
+
49
+
50
+ @app.callback()
51
+ def main() -> None:
52
+ """Run and manage the Labtasker v2 Server."""
53
+
54
+
55
+ @app.command()
56
+ def serve(
57
+ host: Annotated[
58
+ str,
59
+ typer.Option(help="Address to bind; a non-loopback address requires a token."),
60
+ ] = "127.0.0.1",
61
+ port: Annotated[
62
+ int,
63
+ typer.Option(min=1, max=65535, help="TCP port to listen on."),
64
+ ] = 8000,
65
+ database: Annotated[
66
+ Path,
67
+ typer.Option(help="SQLite database file owned by this Server process."),
68
+ ] = Path(".labtasker/server.db"),
69
+ ) -> None:
70
+ """Initialize the database and run one Labtasker v2 Server process.
71
+
72
+ Authentication is optional on loopback. For any non-loopback bind, set the
73
+ shared token with LABTASKER_SERVER_TOKEN; tokens are never accepted as a
74
+ command-line option. Run only one Server process for each SQLite file.
75
+
76
+ Examples:
77
+
78
+ 
79
+ labtasker-server serve
80
+ LABTASKER_SERVER_TOKEN=secret labtasker-server serve \\
81
+ --host 0.0.0.0 --database /data/labtasker.db
82
+ """
83
+ try:
84
+ settings = ServerSettings.from_values(host=host, port=port, database=database)
85
+ except ValueError as error:
86
+ typer.echo(f"[labtasker-server] Server configuration error: {error}", err=True)
87
+ raise typer.Exit(1) from error
88
+ try:
89
+ application = create_app(settings)
90
+ except (DatabaseOwnershipError, OSError, RuntimeError) as error:
91
+ typer.echo(f"[labtasker-server] Server startup error: {error}", err=True)
92
+ raise typer.Exit(1) from error
93
+ uvicorn.run(
94
+ application,
95
+ host=settings.host,
96
+ port=settings.port,
97
+ log_level="info",
98
+ log_config=uvicorn_log_config(),
99
+ )
100
+
101
+
102
+ @app.command()
103
+ def start() -> None:
104
+ """Start the current directory's local daemon, or report the existing one."""
105
+ try:
106
+ paths = local_paths()
107
+ started, metadata = ensure_local_daemon(
108
+ paths.directory,
109
+ bypass_throttle=True,
110
+ server_version=__version__,
111
+ emit=lambda message: typer.echo(f"[labtasker-server] {message}", err=True),
112
+ )
113
+ except (OSError, RuntimeError) as error:
114
+ typer.echo(f"[labtasker-server] Local Server startup error: {error}", err=True)
115
+ raise typer.Exit(1) from error
116
+ action = "started" if started else "already running"
117
+ pid = metadata.pid if metadata is not None else "unknown"
118
+ typer.echo(
119
+ f"[labtasker-server] {action} local daemon pid={pid} socket={paths.socket}",
120
+ err=True,
121
+ )
122
+
123
+
124
+ @app.command("_ensure-daemon", hidden=True)
125
+ def ensure_daemon(
126
+ directory: Annotated[Path, typer.Option(hidden=True)],
127
+ ) -> None:
128
+ """Ensure one healthy local daemon for an automatic Client request."""
129
+ try:
130
+ paths = local_paths(directory)
131
+ started, metadata = ensure_local_daemon(
132
+ paths.directory,
133
+ bypass_throttle=False,
134
+ server_version=__version__,
135
+ emit=lambda message: typer.echo(f"[labtasker-server] {message}", err=True),
136
+ )
137
+ except (OSError, RuntimeError) as error:
138
+ typer.echo(f"[labtasker-server] automatic local startup error: {error}", err=True)
139
+ try:
140
+ result = _local_status(directory)
141
+ except (OSError, RuntimeError):
142
+ result = {
143
+ "state": "unhealthy",
144
+ "directory": str(directory.resolve()),
145
+ "database": None,
146
+ "socket": None,
147
+ "log": None,
148
+ "pid": None,
149
+ "version": None,
150
+ "retry_after_seconds": None,
151
+ }
152
+ result.update({"ok": False, "message": str(error)})
153
+ typer.echo(json.dumps(result, ensure_ascii=False))
154
+ raise typer.Exit(1) from error
155
+ result = _local_status(paths.directory)
156
+ result.update(
157
+ {
158
+ "ok": True,
159
+ "started": started,
160
+ "pid": metadata.pid if metadata is not None else result["pid"],
161
+ "version": (metadata.server_version if metadata is not None else result["version"]),
162
+ }
163
+ )
164
+ typer.echo(json.dumps(result, ensure_ascii=False))
165
+
166
+
167
+ @app.command()
168
+ def status() -> None:
169
+ """Print the current directory's local daemon status as JSON."""
170
+ try:
171
+ paths = local_paths()
172
+ result = _local_status(paths.directory)
173
+ except (OSError, RuntimeError) as error:
174
+ typer.echo(f"[labtasker-server] Local Server status error: {error}", err=True)
175
+ raise typer.Exit(1) from error
176
+ typer.echo(json.dumps(result, indent=2, ensure_ascii=False) + "\n", nl=False)
177
+
178
+
179
+ @app.command()
180
+ def stop(
181
+ force: Annotated[
182
+ bool,
183
+ typer.Option(help="Send SIGKILL after the 30-second graceful deadline."),
184
+ ] = False,
185
+ ) -> None:
186
+ """Stop the current directory's verified local daemon."""
187
+ try:
188
+ paths = local_paths()
189
+ except RuntimeError as error:
190
+ typer.echo(f"[labtasker-server] Local Server stop error: {error}", err=True)
191
+ raise typer.Exit(1) from error
192
+ metadata = read_metadata(paths)
193
+ if database_is_free(paths):
194
+ if has_runtime_artifacts(paths):
195
+ try:
196
+ remove_stale_artifacts(paths)
197
+ except RuntimeError as error:
198
+ typer.echo(f"[labtasker-server] Local Server stop error: {error}", err=True)
199
+ raise typer.Exit(1) from error
200
+ typer.echo("[labtasker-server] local daemon is already stopped", err=True)
201
+ return
202
+ if (
203
+ metadata is None
204
+ or metadata.role != "daemon"
205
+ or not metadata_owner_is_verified(paths, metadata)
206
+ ):
207
+ typer.echo(
208
+ "[labtasker-server] Local Server stop error: "
209
+ "database owner is not a verified local daemon.",
210
+ err=True,
211
+ )
212
+ raise typer.Exit(1)
213
+
214
+ with suppress(ProcessLookupError):
215
+ os.kill(metadata.pid, signal.SIGTERM)
216
+ typer.echo(f"[labtasker-server] stopping local daemon pid={metadata.pid}", err=True)
217
+ if _wait_for_exit(paths.directory, timeout=30.0):
218
+ remove_generation_artifacts(paths, metadata.generation)
219
+ typer.echo(f"[labtasker-server] stopped local daemon pid={metadata.pid}", err=True)
220
+ return
221
+ if not force:
222
+ typer.echo(
223
+ "[labtasker-server] Local Server stop error: daemon did not stop within "
224
+ "30 seconds; retry with --force.",
225
+ err=True,
226
+ )
227
+ raise typer.Exit(1)
228
+
229
+ current = read_metadata(paths)
230
+ if (
231
+ current is None
232
+ or current.generation != metadata.generation
233
+ or not metadata_owner_is_verified(paths, current)
234
+ ):
235
+ typer.echo(
236
+ "[labtasker-server] Local Server stop error: "
237
+ "daemon identity changed; refusing SIGKILL.",
238
+ err=True,
239
+ )
240
+ raise typer.Exit(1)
241
+ os.kill(current.pid, signal.SIGKILL)
242
+ typer.echo(f"[labtasker-server] force-stopping local daemon pid={current.pid}", err=True)
243
+ if not _wait_for_exit(paths.directory, timeout=5.0):
244
+ typer.echo(
245
+ "[labtasker-server] Local Server stop error: database ownership was not released.",
246
+ err=True,
247
+ )
248
+ raise typer.Exit(1)
249
+ remove_generation_artifacts(paths, current.generation)
250
+ typer.echo(f"[labtasker-server] stopped local daemon pid={current.pid}", err=True)
251
+
252
+
253
+ @app.command()
254
+ def logs() -> None:
255
+ """Print the current directory's complete local Server log."""
256
+ try:
257
+ path = local_paths().log
258
+ typer.echo(path.read_text(encoding="utf-8"), nl=False)
259
+ except FileNotFoundError:
260
+ return
261
+ except (OSError, UnicodeError) as error:
262
+ typer.echo(f"[labtasker-server] Local Server log error: {error}", err=True)
263
+ raise typer.Exit(1) from error
264
+
265
+
266
+ @app.command("_daemon", hidden=True)
267
+ def daemon(
268
+ directory: Annotated[Path, typer.Option(hidden=True)],
269
+ database_fd: Annotated[int, typer.Option(hidden=True)],
270
+ generation: Annotated[str, typer.Option(hidden=True)],
271
+ automatic_attempt_at: Annotated[float, typer.Option(hidden=True)],
272
+ ) -> None:
273
+ """Run one private local daemon process."""
274
+ paths = local_paths(directory)
275
+ listener: socket.socket | None = None
276
+ try:
277
+ require_local_capabilities()
278
+ ensure_runtime_directory(paths)
279
+ metadata = make_metadata(
280
+ paths,
281
+ generation=generation,
282
+ role="daemon",
283
+ pid=os.getpid(),
284
+ automatic_attempt_at=automatic_attempt_at,
285
+ database_fd=database_fd,
286
+ server_version=__version__,
287
+ )
288
+ write_metadata(paths, metadata)
289
+ if database_identity(database_fd) != (
290
+ metadata.database_device,
291
+ metadata.database_inode,
292
+ ):
293
+ raise RuntimeError("Inherited database identity changed.")
294
+ settings = ServerSettings(database=paths.database, token=None, database_fd=database_fd)
295
+ application = create_app(settings)
296
+ os.close(database_fd)
297
+ database_fd = -1
298
+
299
+ listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
300
+ listener.bind(str(paths.socket))
301
+ os.chmod(paths.socket, 0o600)
302
+ uvicorn.run(
303
+ application,
304
+ fd=listener.fileno(),
305
+ log_level="info",
306
+ log_config=uvicorn_log_config(),
307
+ )
308
+ except BaseException as error:
309
+ typer.echo(f"[labtasker-server] Local daemon failed: {error}", err=True)
310
+ raise
311
+ finally:
312
+ if database_fd >= 0:
313
+ os.close(database_fd)
314
+ if listener is not None:
315
+ listener.close()
316
+ # Preserve the attempt metadata so an unexpected exit remains throttled.
317
+ # An explicit successful stop removes the full generation itself.
318
+ remove_generation_socket(paths, generation)
319
+
320
+
321
+ def _local_status(directory: Path) -> dict[str, object]:
322
+ paths = local_paths(directory)
323
+ metadata = read_metadata(paths)
324
+ if socket_health(paths):
325
+ state = "running"
326
+ retry_after: float | None = None
327
+ elif not database_is_free(paths):
328
+ state = (
329
+ "starting"
330
+ if metadata is not None
331
+ and metadata_owner_is_verified(paths, metadata)
332
+ and startup_age(metadata) is not None
333
+ else "unhealthy"
334
+ )
335
+ retry_after = None
336
+ else:
337
+ remaining = (
338
+ throttle_remaining(metadata)
339
+ if metadata is not None and metadata_matches_database(paths, metadata)
340
+ else 0.0
341
+ )
342
+ if remaining > 0:
343
+ state = "backoff"
344
+ retry_after = round(remaining, 3)
345
+ elif has_runtime_artifacts(paths):
346
+ state = "stale"
347
+ retry_after = None
348
+ else:
349
+ state = "stopped"
350
+ retry_after = None
351
+ verified = metadata is not None and metadata_owner_is_verified(paths, metadata)
352
+ return {
353
+ "state": state,
354
+ "directory": str(paths.directory),
355
+ "database": str(paths.database),
356
+ "socket": str(paths.socket),
357
+ "log": str(paths.log),
358
+ "pid": metadata.pid if verified and metadata is not None else None,
359
+ "version": metadata.server_version if verified and metadata is not None else None,
360
+ "retry_after_seconds": retry_after,
361
+ }
362
+
363
+
364
+ def _wait_for_exit(directory: Path, *, timeout: float) -> bool:
365
+ paths = local_paths(directory)
366
+ deadline = time.monotonic() + timeout
367
+ while time.monotonic() < deadline:
368
+ if database_is_free(paths):
369
+ return True
370
+ time.sleep(0.05)
371
+ return database_is_free(paths)
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ServerSettings:
11
+ host: str = "127.0.0.1"
12
+ port: int = 8000
13
+ database: Path = Path(".labtasker/server.db")
14
+ token: str | None = None
15
+ database_fd: int | None = None
16
+
17
+ @classmethod
18
+ def from_values(
19
+ cls,
20
+ *,
21
+ host: str = "127.0.0.1",
22
+ port: int = 8000,
23
+ database: str | Path = ".labtasker/server.db",
24
+ token: str | None = None,
25
+ ) -> ServerSettings:
26
+ effective_token = os.environ.get("LABTASKER_SERVER_TOKEN") if token is None else token
27
+ if effective_token == "":
28
+ raise ValueError("LABTASKER_SERVER_TOKEN must not be empty.")
29
+ if not 1 <= port <= 65535:
30
+ raise ValueError("port must be between 1 and 65535.")
31
+ if effective_token is None and not _is_tokenless_host_allowed(host):
32
+ raise ValueError("A token is required when binding to a non-loopback host.")
33
+ return cls(host=host, port=port, database=Path(database), token=effective_token)
34
+
35
+
36
+ def _is_tokenless_host_allowed(host: str) -> bool:
37
+ if host.lower() == "localhost":
38
+ return True
39
+ try:
40
+ return ipaddress.ip_address(host).is_loopback
41
+ except ValueError:
42
+ return False
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+
8
+ try:
9
+ import fcntl
10
+ except ImportError: # pragma: no cover - explicit HTTP remains best effort off POSIX
11
+ fcntl = None # type: ignore[assignment]
12
+
13
+ from alembic import command
14
+ from alembic.config import Config
15
+ from sqlalchemy import Engine, create_engine, event, inspect, text
16
+ from sqlalchemy.exc import OperationalError
17
+ from sqlalchemy.orm import Session, sessionmaker
18
+
19
+ from labtasker_server.errors import DomainError
20
+ from labtasker_server.models import QueueRow
21
+
22
+ LOCAL_GITIGNORE = "*\n!.gitignore\n"
23
+
24
+
25
+ class DatabaseOwnershipError(RuntimeError):
26
+ pass
27
+
28
+
29
+ class Database:
30
+ def __init__(self, path: Path, *, ownership_fd: int | None = None) -> None:
31
+ self.path = path.resolve()
32
+ self.path.parent.mkdir(parents=True, exist_ok=True)
33
+ labtasker_dir = next(
34
+ (parent for parent in self.path.parents if parent.name == ".labtasker"),
35
+ None,
36
+ )
37
+ if labtasker_dir is not None:
38
+ _ensure_local_gitignore(labtasker_dir)
39
+ self._ownership_fd: int | None = _acquire_database_ownership(self.path, ownership_fd)
40
+ self.engine = _create_sqlite_engine(self.path)
41
+ self._session_factory = sessionmaker(self.engine, expire_on_commit=False)
42
+
43
+ def initialize(self) -> None:
44
+ existing_tables = set(inspect(self.engine).get_table_names())
45
+ is_fresh = not existing_tables
46
+ if existing_tables and "alembic_version" not in existing_tables:
47
+ raise RuntimeError("Database has tables but is not a recognized Labtasker v2 schema.")
48
+
49
+ alembic_config = Config()
50
+ alembic_config.set_main_option(
51
+ "script_location",
52
+ str(Path(__file__).resolve().parent / "migrations"),
53
+ )
54
+ with self.engine.begin() as connection:
55
+ alembic_config.attributes["connection"] = connection
56
+ command.upgrade(alembic_config, "head")
57
+ _verify_sqlite_settings(self.engine)
58
+
59
+ if is_fresh:
60
+ with self.write_session() as session:
61
+ session.add(QueueRow(name="default"))
62
+
63
+ @contextmanager
64
+ def read_session(self) -> Iterator[Session]:
65
+ with self._session_factory() as session:
66
+ yield session
67
+
68
+ @contextmanager
69
+ def write_session(self) -> Iterator[Session]:
70
+ with self._session_factory() as session:
71
+ try:
72
+ session.execute(text("BEGIN IMMEDIATE"))
73
+ yield session
74
+ session.commit()
75
+ except OperationalError as error:
76
+ session.rollback()
77
+ if _is_sqlite_busy(error):
78
+ raise DomainError(
79
+ 503,
80
+ "database_busy",
81
+ "The database is busy; retry the operation.",
82
+ {},
83
+ ) from error
84
+ raise
85
+ except BaseException:
86
+ session.rollback()
87
+ raise
88
+
89
+ def dispose(self) -> None:
90
+ self.engine.dispose()
91
+ if self._ownership_fd is not None:
92
+ os.close(self._ownership_fd)
93
+ self._ownership_fd = None
94
+
95
+
96
+ def _acquire_database_ownership(path: Path, inherited_fd: int | None) -> int:
97
+ if inherited_fd is None:
98
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
99
+ if fcntl is not None:
100
+ try:
101
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
102
+ except BlockingIOError as error:
103
+ os.close(fd)
104
+ raise DatabaseOwnershipError(
105
+ f"Another Server process already owns database {path}."
106
+ ) from error
107
+ else:
108
+ fd = os.dup(inherited_fd)
109
+ os.set_inheritable(fd, False)
110
+
111
+ descriptor_stat = os.fstat(fd)
112
+ try:
113
+ path_stat = path.stat()
114
+ except OSError:
115
+ os.close(fd)
116
+ raise
117
+ if (descriptor_stat.st_dev, descriptor_stat.st_ino) != (
118
+ path_stat.st_dev,
119
+ path_stat.st_ino,
120
+ ):
121
+ os.close(fd)
122
+ raise DatabaseOwnershipError(
123
+ f"Database descriptor does not identify configured path {path}."
124
+ )
125
+ return fd
126
+
127
+
128
+ def _create_sqlite_engine(path: Path) -> Engine:
129
+ engine = create_engine(
130
+ f"sqlite+pysqlite:///{path}",
131
+ connect_args={"check_same_thread": False, "timeout": 5.0},
132
+ )
133
+
134
+ @event.listens_for(engine, "connect")
135
+ def configure_connection(dbapi_connection: object, _: object) -> None:
136
+ cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
137
+ try:
138
+ cursor.execute("PRAGMA foreign_keys=ON")
139
+ cursor.execute("PRAGMA busy_timeout=5000")
140
+ cursor.execute("PRAGMA synchronous=FULL")
141
+ cursor.execute("PRAGMA journal_mode=WAL")
142
+ finally:
143
+ cursor.close()
144
+
145
+ return engine
146
+
147
+
148
+ def _ensure_local_gitignore(labtasker_dir: Path) -> None:
149
+ try:
150
+ with (labtasker_dir / ".gitignore").open("x", encoding="utf-8", newline="\n") as stream:
151
+ stream.write(LOCAL_GITIGNORE)
152
+ except FileExistsError:
153
+ pass
154
+
155
+
156
+ def _is_sqlite_busy(error: OperationalError) -> bool:
157
+ code = getattr(error.orig, "sqlite_errorcode", None)
158
+ return code in {5, 6} or "database is locked" in str(error.orig).lower()
159
+
160
+
161
+ def _verify_sqlite_settings(engine: Engine) -> None:
162
+ with engine.connect() as connection:
163
+ actual = {
164
+ "journal_mode": connection.scalar(text("PRAGMA journal_mode")),
165
+ "foreign_keys": connection.scalar(text("PRAGMA foreign_keys")),
166
+ "busy_timeout": connection.scalar(text("PRAGMA busy_timeout")),
167
+ "synchronous": connection.scalar(text("PRAGMA synchronous")),
168
+ }
169
+ expected = {
170
+ "journal_mode": "wal",
171
+ "foreign_keys": 1,
172
+ "busy_timeout": 5000,
173
+ "synchronous": 2,
174
+ }
175
+ if actual != expected:
176
+ raise RuntimeError(f"Required SQLite settings were not applied: {actual!r}")
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class DomainError(Exception):
9
+ status_code: int
10
+ code: str
11
+ message: str
12
+ details: dict[str, Any] = field(default_factory=dict)
13
+
14
+ def __str__(self) -> str:
15
+ return self.message
16
+
17
+
18
+ def not_found(code: str, message: str, **details: Any) -> DomainError:
19
+ return DomainError(404, code, message, details)
20
+
21
+
22
+ def conflict(code: str, message: str, **details: Any) -> DomainError:
23
+ return DomainError(409, code, message, details)
24
+
25
+
26
+ def invalid(code: str, message: str, **details: Any) -> DomainError:
27
+ return DomainError(422, code, message, details)