prefect-client 3.2.2__py3-none-any.whl → 3.2.4__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.
- prefect/__init__.py +15 -8
- prefect/_build_info.py +5 -0
- prefect/client/orchestration/__init__.py +16 -5
- prefect/main.py +0 -2
- prefect/server/api/__init__.py +34 -0
- prefect/server/api/admin.py +85 -0
- prefect/server/api/artifacts.py +224 -0
- prefect/server/api/automations.py +239 -0
- prefect/server/api/block_capabilities.py +25 -0
- prefect/server/api/block_documents.py +164 -0
- prefect/server/api/block_schemas.py +153 -0
- prefect/server/api/block_types.py +211 -0
- prefect/server/api/clients.py +246 -0
- prefect/server/api/collections.py +75 -0
- prefect/server/api/concurrency_limits.py +286 -0
- prefect/server/api/concurrency_limits_v2.py +269 -0
- prefect/server/api/csrf_token.py +38 -0
- prefect/server/api/dependencies.py +196 -0
- prefect/server/api/deployments.py +941 -0
- prefect/server/api/events.py +300 -0
- prefect/server/api/flow_run_notification_policies.py +120 -0
- prefect/server/api/flow_run_states.py +52 -0
- prefect/server/api/flow_runs.py +867 -0
- prefect/server/api/flows.py +210 -0
- prefect/server/api/logs.py +43 -0
- prefect/server/api/middleware.py +73 -0
- prefect/server/api/root.py +35 -0
- prefect/server/api/run_history.py +170 -0
- prefect/server/api/saved_searches.py +99 -0
- prefect/server/api/server.py +891 -0
- prefect/server/api/task_run_states.py +52 -0
- prefect/server/api/task_runs.py +342 -0
- prefect/server/api/task_workers.py +31 -0
- prefect/server/api/templates.py +35 -0
- prefect/server/api/ui/__init__.py +3 -0
- prefect/server/api/ui/flow_runs.py +128 -0
- prefect/server/api/ui/flows.py +173 -0
- prefect/server/api/ui/schemas.py +63 -0
- prefect/server/api/ui/task_runs.py +175 -0
- prefect/server/api/validation.py +382 -0
- prefect/server/api/variables.py +181 -0
- prefect/server/api/work_queues.py +230 -0
- prefect/server/api/workers.py +656 -0
- prefect/settings/sources.py +18 -5
- {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info}/METADATA +10 -15
- {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info}/RECORD +48 -10
- {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info}/WHEEL +1 -2
- prefect/_version.py +0 -21
- prefect_client-3.2.2.dist-info/top_level.txt +0 -1
- {prefect_client-3.2.2.dist-info → prefect_client-3.2.4.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,891 @@
|
|
1
|
+
"""
|
2
|
+
Defines the Prefect REST API FastAPI app.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
import asyncio
|
8
|
+
import atexit
|
9
|
+
import base64
|
10
|
+
import contextlib
|
11
|
+
import gc
|
12
|
+
import mimetypes
|
13
|
+
import os
|
14
|
+
import random
|
15
|
+
import shutil
|
16
|
+
import socket
|
17
|
+
import sqlite3
|
18
|
+
import subprocess
|
19
|
+
import sys
|
20
|
+
import time
|
21
|
+
from contextlib import asynccontextmanager
|
22
|
+
from functools import wraps
|
23
|
+
from hashlib import sha256
|
24
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, Optional
|
25
|
+
|
26
|
+
import anyio
|
27
|
+
import asyncpg
|
28
|
+
import httpx
|
29
|
+
import sqlalchemy as sa
|
30
|
+
import sqlalchemy.exc
|
31
|
+
import sqlalchemy.orm.exc
|
32
|
+
from fastapi import Depends, FastAPI, Request, Response, status
|
33
|
+
from fastapi.encoders import jsonable_encoder
|
34
|
+
from fastapi.exceptions import RequestValidationError
|
35
|
+
from fastapi.middleware.cors import CORSMiddleware
|
36
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
37
|
+
from fastapi.openapi.utils import get_openapi
|
38
|
+
from fastapi.responses import JSONResponse
|
39
|
+
from fastapi.staticfiles import StaticFiles
|
40
|
+
from starlette.exceptions import HTTPException
|
41
|
+
from typing_extensions import Self
|
42
|
+
|
43
|
+
import prefect
|
44
|
+
import prefect.server.api as api
|
45
|
+
import prefect.settings
|
46
|
+
from prefect.client.constants import SERVER_API_VERSION
|
47
|
+
from prefect.logging import get_logger
|
48
|
+
from prefect.server.api.dependencies import EnforceMinimumAPIVersion
|
49
|
+
from prefect.server.exceptions import ObjectNotFoundError
|
50
|
+
from prefect.server.services.base import RunInAllServers, Service
|
51
|
+
from prefect.server.utilities.database import get_dialect
|
52
|
+
from prefect.settings import (
|
53
|
+
PREFECT_API_DATABASE_CONNECTION_URL,
|
54
|
+
PREFECT_API_LOG_RETRYABLE_ERRORS,
|
55
|
+
PREFECT_DEBUG_MODE,
|
56
|
+
PREFECT_MEMO_STORE_PATH,
|
57
|
+
PREFECT_MEMOIZE_BLOCK_AUTO_REGISTRATION,
|
58
|
+
PREFECT_SERVER_EPHEMERAL_STARTUP_TIMEOUT_SECONDS,
|
59
|
+
PREFECT_UI_SERVE_BASE,
|
60
|
+
get_current_settings,
|
61
|
+
)
|
62
|
+
from prefect.utilities.hashing import hash_objects
|
63
|
+
|
64
|
+
if TYPE_CHECKING:
|
65
|
+
import logging
|
66
|
+
|
67
|
+
TITLE = "Prefect Server"
|
68
|
+
API_TITLE = "Prefect Prefect REST API"
|
69
|
+
UI_TITLE = "Prefect Prefect REST API UI"
|
70
|
+
API_VERSION: str = prefect.__version__
|
71
|
+
# migrations should run only once per app start; the ephemeral API can potentially
|
72
|
+
# create multiple apps in a single process
|
73
|
+
LIFESPAN_RAN_FOR_APP: set[Any] = set()
|
74
|
+
|
75
|
+
logger: "logging.Logger" = get_logger("server")
|
76
|
+
|
77
|
+
enforce_minimum_version: EnforceMinimumAPIVersion = EnforceMinimumAPIVersion(
|
78
|
+
# this should be <= SERVER_API_VERSION; clients that send
|
79
|
+
# a version header under this value will be rejected
|
80
|
+
minimum_api_version="0.8.0",
|
81
|
+
logger=logger,
|
82
|
+
)
|
83
|
+
|
84
|
+
|
85
|
+
API_ROUTERS = (
|
86
|
+
api.flows.router,
|
87
|
+
api.flow_runs.router,
|
88
|
+
api.task_runs.router,
|
89
|
+
api.flow_run_states.router,
|
90
|
+
api.task_run_states.router,
|
91
|
+
api.flow_run_notification_policies.router,
|
92
|
+
api.deployments.router,
|
93
|
+
api.saved_searches.router,
|
94
|
+
api.logs.router,
|
95
|
+
api.concurrency_limits.router,
|
96
|
+
api.concurrency_limits_v2.router,
|
97
|
+
api.block_types.router,
|
98
|
+
api.block_documents.router,
|
99
|
+
api.workers.router,
|
100
|
+
api.task_workers.router,
|
101
|
+
api.work_queues.router,
|
102
|
+
api.artifacts.router,
|
103
|
+
api.block_schemas.router,
|
104
|
+
api.block_capabilities.router,
|
105
|
+
api.collections.router,
|
106
|
+
api.variables.router,
|
107
|
+
api.csrf_token.router,
|
108
|
+
api.events.router,
|
109
|
+
api.automations.router,
|
110
|
+
api.templates.router,
|
111
|
+
api.ui.flows.router,
|
112
|
+
api.ui.flow_runs.router,
|
113
|
+
api.ui.schemas.router,
|
114
|
+
api.ui.task_runs.router,
|
115
|
+
api.admin.router,
|
116
|
+
api.root.router,
|
117
|
+
)
|
118
|
+
|
119
|
+
SQLITE_LOCKED_MSG = "database is locked"
|
120
|
+
|
121
|
+
|
122
|
+
class SPAStaticFiles(StaticFiles):
|
123
|
+
"""
|
124
|
+
Implementation of `StaticFiles` for serving single page applications.
|
125
|
+
|
126
|
+
Adds `get_response` handling to ensure that when a resource isn't found the
|
127
|
+
application still returns the index.
|
128
|
+
"""
|
129
|
+
|
130
|
+
async def get_response(self, path: str, scope: Any) -> Response:
|
131
|
+
try:
|
132
|
+
return await super().get_response(path, scope)
|
133
|
+
except HTTPException:
|
134
|
+
return await super().get_response("./index.html", scope)
|
135
|
+
|
136
|
+
|
137
|
+
class RequestLimitMiddleware:
|
138
|
+
"""
|
139
|
+
A middleware that limits the number of concurrent requests handled by the API.
|
140
|
+
|
141
|
+
This is a blunt tool for limiting SQLite concurrent writes which will cause failures
|
142
|
+
at high volume. Ideally, we would only apply the limit to routes that perform
|
143
|
+
writes.
|
144
|
+
"""
|
145
|
+
|
146
|
+
def __init__(self, app: Any, limit: float):
|
147
|
+
self.app = app
|
148
|
+
self._limiter = anyio.CapacityLimiter(limit)
|
149
|
+
|
150
|
+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
151
|
+
async with self._limiter:
|
152
|
+
await self.app(scope, receive, send)
|
153
|
+
|
154
|
+
|
155
|
+
async def validation_exception_handler(
|
156
|
+
request: Request, exc: RequestValidationError
|
157
|
+
) -> JSONResponse:
|
158
|
+
"""Provide a detailed message for request validation errors."""
|
159
|
+
return JSONResponse(
|
160
|
+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
161
|
+
content=jsonable_encoder(
|
162
|
+
{
|
163
|
+
"exception_message": "Invalid request received.",
|
164
|
+
"exception_detail": exc.errors(),
|
165
|
+
"request_body": exc.body,
|
166
|
+
}
|
167
|
+
),
|
168
|
+
)
|
169
|
+
|
170
|
+
|
171
|
+
async def integrity_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
172
|
+
"""Capture database integrity errors."""
|
173
|
+
logger.error("Encountered exception in request:", exc_info=True)
|
174
|
+
return JSONResponse(
|
175
|
+
content={
|
176
|
+
"detail": (
|
177
|
+
"Data integrity conflict. This usually means a "
|
178
|
+
"unique or foreign key constraint was violated. "
|
179
|
+
"See server logs for details."
|
180
|
+
)
|
181
|
+
},
|
182
|
+
status_code=status.HTTP_409_CONFLICT,
|
183
|
+
)
|
184
|
+
|
185
|
+
|
186
|
+
def is_client_retryable_exception(exc: Exception) -> bool:
|
187
|
+
if isinstance(exc, sqlalchemy.exc.OperationalError) and isinstance(
|
188
|
+
exc.orig, sqlite3.OperationalError
|
189
|
+
):
|
190
|
+
if getattr(exc.orig, "sqlite_errorname", None) in {
|
191
|
+
"SQLITE_BUSY",
|
192
|
+
"SQLITE_BUSY_SNAPSHOT",
|
193
|
+
} or SQLITE_LOCKED_MSG in getattr(exc.orig, "args", []):
|
194
|
+
return True
|
195
|
+
else:
|
196
|
+
# Avoid falling through to the generic `DBAPIError` case below
|
197
|
+
return False
|
198
|
+
|
199
|
+
if isinstance(
|
200
|
+
exc,
|
201
|
+
(
|
202
|
+
sqlalchemy.exc.DBAPIError,
|
203
|
+
asyncpg.exceptions.QueryCanceledError,
|
204
|
+
asyncpg.exceptions.ConnectionDoesNotExistError,
|
205
|
+
asyncpg.exceptions.CannotConnectNowError,
|
206
|
+
sqlalchemy.exc.InvalidRequestError,
|
207
|
+
sqlalchemy.orm.exc.DetachedInstanceError,
|
208
|
+
),
|
209
|
+
):
|
210
|
+
return True
|
211
|
+
|
212
|
+
return False
|
213
|
+
|
214
|
+
|
215
|
+
def replace_placeholder_string_in_files(
|
216
|
+
directory: str,
|
217
|
+
placeholder: str,
|
218
|
+
replacement: str,
|
219
|
+
allowed_extensions: list[str] | None = None,
|
220
|
+
) -> None:
|
221
|
+
"""
|
222
|
+
Recursively loops through all files in the given directory and replaces
|
223
|
+
a placeholder string.
|
224
|
+
"""
|
225
|
+
if allowed_extensions is None:
|
226
|
+
allowed_extensions = [".txt", ".html", ".css", ".js", ".json", ".txt"]
|
227
|
+
|
228
|
+
for root, _, files in os.walk(directory):
|
229
|
+
for file in files:
|
230
|
+
if any(file.endswith(ext) for ext in allowed_extensions):
|
231
|
+
file_path = os.path.join(root, file)
|
232
|
+
|
233
|
+
with open(file_path, "r", encoding="utf-8") as file:
|
234
|
+
file_data = file.read()
|
235
|
+
|
236
|
+
file_data = file_data.replace(placeholder, replacement)
|
237
|
+
|
238
|
+
with open(file_path, "w", encoding="utf-8") as file:
|
239
|
+
file.write(file_data)
|
240
|
+
|
241
|
+
|
242
|
+
def copy_directory(directory: str, path: str) -> None:
|
243
|
+
os.makedirs(path, exist_ok=True)
|
244
|
+
for item in os.listdir(directory):
|
245
|
+
source = os.path.join(directory, item)
|
246
|
+
destination = os.path.join(path, item)
|
247
|
+
|
248
|
+
if os.path.isdir(source):
|
249
|
+
if os.path.exists(destination):
|
250
|
+
shutil.rmtree(destination)
|
251
|
+
shutil.copytree(source, destination, symlinks=True)
|
252
|
+
else:
|
253
|
+
shutil.copy2(source, destination)
|
254
|
+
|
255
|
+
|
256
|
+
async def custom_internal_exception_handler(
|
257
|
+
request: Request, exc: Exception
|
258
|
+
) -> JSONResponse:
|
259
|
+
"""
|
260
|
+
Log a detailed exception for internal server errors before returning.
|
261
|
+
|
262
|
+
Send 503 for errors clients can retry on.
|
263
|
+
"""
|
264
|
+
if is_client_retryable_exception(exc):
|
265
|
+
if PREFECT_API_LOG_RETRYABLE_ERRORS.value():
|
266
|
+
logger.error("Encountered retryable exception in request:", exc_info=True)
|
267
|
+
|
268
|
+
return JSONResponse(
|
269
|
+
content={"exception_message": "Service Unavailable"},
|
270
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
271
|
+
)
|
272
|
+
|
273
|
+
logger.error("Encountered exception in request:", exc_info=True)
|
274
|
+
|
275
|
+
return JSONResponse(
|
276
|
+
content={"exception_message": "Internal Server Error"},
|
277
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
278
|
+
)
|
279
|
+
|
280
|
+
|
281
|
+
async def prefect_object_not_found_exception_handler(
|
282
|
+
request: Request, exc: ObjectNotFoundError
|
283
|
+
) -> JSONResponse:
|
284
|
+
"""Return 404 status code on object not found exceptions."""
|
285
|
+
return JSONResponse(
|
286
|
+
content={"exception_message": str(exc)}, status_code=status.HTTP_404_NOT_FOUND
|
287
|
+
)
|
288
|
+
|
289
|
+
|
290
|
+
def create_api_app(
|
291
|
+
dependencies: list[Any] | None = None,
|
292
|
+
health_check_path: str = "/health",
|
293
|
+
version_check_path: str = "/version",
|
294
|
+
fast_api_app_kwargs: dict[str, Any] | None = None,
|
295
|
+
final: bool = False,
|
296
|
+
) -> FastAPI:
|
297
|
+
"""
|
298
|
+
Create a FastAPI app that includes the Prefect REST API
|
299
|
+
|
300
|
+
Args:
|
301
|
+
dependencies: a list of global dependencies to add to each Prefect REST API router
|
302
|
+
health_check_path: the health check route path
|
303
|
+
fast_api_app_kwargs: kwargs to pass to the FastAPI constructor
|
304
|
+
final: whether this will be the last instance of the Prefect server to be
|
305
|
+
created in this process, so that additional optimizations may be applied
|
306
|
+
|
307
|
+
Returns:
|
308
|
+
a FastAPI app that serves the Prefect REST API
|
309
|
+
"""
|
310
|
+
fast_api_app_kwargs = fast_api_app_kwargs or {}
|
311
|
+
api_app = FastAPI(title=API_TITLE, **fast_api_app_kwargs)
|
312
|
+
api_app.add_middleware(GZipMiddleware)
|
313
|
+
|
314
|
+
@api_app.get(health_check_path, tags=["Root"])
|
315
|
+
async def health_check() -> bool: # type: ignore[reportUnusedFunction]
|
316
|
+
return True
|
317
|
+
|
318
|
+
@api_app.get(version_check_path, tags=["Root"])
|
319
|
+
async def server_version() -> str: # type: ignore[reportUnusedFunction]
|
320
|
+
return SERVER_API_VERSION
|
321
|
+
|
322
|
+
# always include version checking
|
323
|
+
if dependencies is None:
|
324
|
+
dependencies = [Depends(enforce_minimum_version)]
|
325
|
+
else:
|
326
|
+
dependencies.append(Depends(enforce_minimum_version))
|
327
|
+
|
328
|
+
for router in API_ROUTERS:
|
329
|
+
api_app.include_router(router, dependencies=dependencies)
|
330
|
+
if final:
|
331
|
+
# Important note about how FastAPI works:
|
332
|
+
#
|
333
|
+
# When including a router, FastAPI copies the routes and builds entirely new
|
334
|
+
# Pydantic models to represent the request bodies of the routes in the
|
335
|
+
# router. This is because the dependencies may change if the same router is
|
336
|
+
# included multiple times, but it also means that we are holding onto an
|
337
|
+
# entire set of Pydantic models on the original routers for the duration of
|
338
|
+
# the server process that will never be used.
|
339
|
+
#
|
340
|
+
# Because Prefect does not reuse routers, we are free to clean up the routes
|
341
|
+
# because we know they won't be used again. Thus, if we have the hint that
|
342
|
+
# this is the final instance we will create in this process, we can clean up
|
343
|
+
# the routes on the original source routers to conserve memory (~50-55MB as
|
344
|
+
# of introducing this change).
|
345
|
+
del router.routes
|
346
|
+
|
347
|
+
if final:
|
348
|
+
gc.collect()
|
349
|
+
|
350
|
+
auth_string = prefect.settings.PREFECT_SERVER_API_AUTH_STRING.value()
|
351
|
+
|
352
|
+
if auth_string is not None:
|
353
|
+
|
354
|
+
@api_app.middleware("http")
|
355
|
+
async def token_validation(request: Request, call_next: Any): # type: ignore[reportUnusedFunction]
|
356
|
+
header_token = request.headers.get("Authorization")
|
357
|
+
|
358
|
+
# used for probes in k8s and such
|
359
|
+
if request.url.path in ["/api/health", "/api/ready"]:
|
360
|
+
return await call_next(request)
|
361
|
+
try:
|
362
|
+
if header_token is None:
|
363
|
+
return JSONResponse(
|
364
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
365
|
+
content={"exception_message": "Unauthorized"},
|
366
|
+
)
|
367
|
+
scheme, creds = header_token.split()
|
368
|
+
assert scheme == "Basic"
|
369
|
+
decoded = base64.b64decode(creds).decode("utf-8")
|
370
|
+
except Exception:
|
371
|
+
return JSONResponse(
|
372
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
373
|
+
content={"exception_message": "Unauthorized"},
|
374
|
+
)
|
375
|
+
if decoded != auth_string:
|
376
|
+
return JSONResponse(
|
377
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
378
|
+
content={"exception_message": "Unauthorized"},
|
379
|
+
)
|
380
|
+
return await call_next(request)
|
381
|
+
|
382
|
+
return api_app
|
383
|
+
|
384
|
+
|
385
|
+
def create_ui_app(ephemeral: bool) -> FastAPI:
|
386
|
+
ui_app = FastAPI(title=UI_TITLE)
|
387
|
+
base_url = prefect.settings.PREFECT_UI_SERVE_BASE.value()
|
388
|
+
cache_key = f"{prefect.__version__}:{base_url}"
|
389
|
+
stripped_base_url = base_url.rstrip("/")
|
390
|
+
static_dir = (
|
391
|
+
prefect.settings.PREFECT_UI_STATIC_DIRECTORY.value()
|
392
|
+
or prefect.__ui_static_subpath__
|
393
|
+
)
|
394
|
+
reference_file_name = "UI_SERVE_BASE"
|
395
|
+
|
396
|
+
if os.name == "nt":
|
397
|
+
# Windows defaults to text/plain for .js files
|
398
|
+
mimetypes.init()
|
399
|
+
mimetypes.add_type("application/javascript", ".js")
|
400
|
+
|
401
|
+
@ui_app.get(f"{stripped_base_url}/ui-settings")
|
402
|
+
def ui_settings() -> dict[str, Any]: # type: ignore[reportUnusedFunction]
|
403
|
+
return {
|
404
|
+
"api_url": prefect.settings.PREFECT_UI_API_URL.value(),
|
405
|
+
"csrf_enabled": prefect.settings.PREFECT_SERVER_CSRF_PROTECTION_ENABLED.value(),
|
406
|
+
"auth": "BASIC"
|
407
|
+
if prefect.settings.PREFECT_SERVER_API_AUTH_STRING.value()
|
408
|
+
else None,
|
409
|
+
"flags": [],
|
410
|
+
}
|
411
|
+
|
412
|
+
def reference_file_matches_base_url() -> bool:
|
413
|
+
reference_file_path = os.path.join(static_dir, reference_file_name)
|
414
|
+
|
415
|
+
if os.path.exists(static_dir):
|
416
|
+
try:
|
417
|
+
with open(reference_file_path, "r") as f:
|
418
|
+
return f.read() == cache_key
|
419
|
+
except FileNotFoundError:
|
420
|
+
return False
|
421
|
+
else:
|
422
|
+
return False
|
423
|
+
|
424
|
+
def create_ui_static_subpath() -> None:
|
425
|
+
if not os.path.exists(static_dir):
|
426
|
+
os.makedirs(static_dir)
|
427
|
+
|
428
|
+
copy_directory(str(prefect.__ui_static_path__), str(static_dir))
|
429
|
+
replace_placeholder_string_in_files(
|
430
|
+
str(static_dir),
|
431
|
+
"/PREFECT_UI_SERVE_BASE_REPLACE_PLACEHOLDER",
|
432
|
+
stripped_base_url,
|
433
|
+
)
|
434
|
+
|
435
|
+
# Create a file to indicate that the static files have been copied
|
436
|
+
# This is used to determine if the static files need to be copied again
|
437
|
+
# when the server is restarted
|
438
|
+
with open(os.path.join(static_dir, reference_file_name), "w") as f:
|
439
|
+
f.write(cache_key)
|
440
|
+
|
441
|
+
ui_app.add_middleware(GZipMiddleware)
|
442
|
+
|
443
|
+
if (
|
444
|
+
os.path.exists(prefect.__ui_static_path__)
|
445
|
+
and prefect.settings.PREFECT_UI_ENABLED.value()
|
446
|
+
and not ephemeral
|
447
|
+
):
|
448
|
+
# If the static files have already been copied, check if the base_url has changed
|
449
|
+
# If it has, we delete the subpath directory and copy the files again
|
450
|
+
if not reference_file_matches_base_url():
|
451
|
+
create_ui_static_subpath()
|
452
|
+
|
453
|
+
ui_app.mount(
|
454
|
+
PREFECT_UI_SERVE_BASE.value(),
|
455
|
+
SPAStaticFiles(directory=static_dir),
|
456
|
+
name="ui_root",
|
457
|
+
)
|
458
|
+
|
459
|
+
return ui_app
|
460
|
+
|
461
|
+
|
462
|
+
APP_CACHE: dict[tuple[prefect.settings.Settings, bool], FastAPI] = {}
|
463
|
+
|
464
|
+
|
465
|
+
def _memoize_block_auto_registration(
|
466
|
+
fn: Callable[[], Awaitable[None]],
|
467
|
+
) -> Callable[[], Awaitable[None]]:
|
468
|
+
"""
|
469
|
+
Decorator to handle skipping the wrapped function if the block registry has
|
470
|
+
not changed since the last invocation
|
471
|
+
"""
|
472
|
+
import toml
|
473
|
+
|
474
|
+
import prefect.plugins
|
475
|
+
from prefect.blocks.core import Block
|
476
|
+
from prefect.server.models.block_registration import _load_collection_blocks_data
|
477
|
+
from prefect.utilities.dispatch import get_registry_for_type
|
478
|
+
|
479
|
+
@wraps(fn)
|
480
|
+
async def wrapper(*args: Any, **kwargs: Any) -> None:
|
481
|
+
if not PREFECT_MEMOIZE_BLOCK_AUTO_REGISTRATION.value():
|
482
|
+
await fn(*args, **kwargs)
|
483
|
+
return
|
484
|
+
|
485
|
+
# Ensure collections are imported and have the opportunity to register types
|
486
|
+
# before loading the registry
|
487
|
+
prefect.plugins.load_prefect_collections()
|
488
|
+
|
489
|
+
blocks_registry = get_registry_for_type(Block)
|
490
|
+
collection_blocks_data = await _load_collection_blocks_data()
|
491
|
+
current_blocks_loading_hash = hash_objects(
|
492
|
+
blocks_registry,
|
493
|
+
collection_blocks_data,
|
494
|
+
PREFECT_API_DATABASE_CONNECTION_URL.value(),
|
495
|
+
hash_algo=sha256,
|
496
|
+
)
|
497
|
+
|
498
|
+
memo_store_path = PREFECT_MEMO_STORE_PATH.value()
|
499
|
+
try:
|
500
|
+
if memo_store_path.exists():
|
501
|
+
saved_blocks_loading_hash = toml.load(memo_store_path).get(
|
502
|
+
"block_auto_registration"
|
503
|
+
)
|
504
|
+
if (
|
505
|
+
saved_blocks_loading_hash is not None
|
506
|
+
and current_blocks_loading_hash == saved_blocks_loading_hash
|
507
|
+
):
|
508
|
+
if PREFECT_DEBUG_MODE.value():
|
509
|
+
logger.debug(
|
510
|
+
"Skipping block loading due to matching hash for block "
|
511
|
+
"auto-registration found in memo store."
|
512
|
+
)
|
513
|
+
return
|
514
|
+
except Exception as exc:
|
515
|
+
logger.warning(
|
516
|
+
""
|
517
|
+
f"Unable to read memo_store.toml from {PREFECT_MEMO_STORE_PATH} during "
|
518
|
+
f"block auto-registration: {exc!r}.\n"
|
519
|
+
"All blocks will be registered."
|
520
|
+
)
|
521
|
+
|
522
|
+
await fn(*args, **kwargs)
|
523
|
+
|
524
|
+
if current_blocks_loading_hash is not None:
|
525
|
+
try:
|
526
|
+
if not memo_store_path.exists():
|
527
|
+
memo_store_path.touch(mode=0o0600)
|
528
|
+
|
529
|
+
memo_store_path.write_text(
|
530
|
+
toml.dumps({"block_auto_registration": current_blocks_loading_hash})
|
531
|
+
)
|
532
|
+
except Exception as exc:
|
533
|
+
logger.warning(
|
534
|
+
"Unable to write to memo_store.toml at"
|
535
|
+
f" {PREFECT_MEMO_STORE_PATH} after block auto-registration:"
|
536
|
+
f" {exc!r}.\n Subsequent server start ups will perform block"
|
537
|
+
" auto-registration, which may result in slower server startup."
|
538
|
+
)
|
539
|
+
|
540
|
+
return wrapper
|
541
|
+
|
542
|
+
|
543
|
+
def create_app(
|
544
|
+
settings: Optional[prefect.settings.Settings] = None,
|
545
|
+
ephemeral: bool = False,
|
546
|
+
webserver_only: bool = False,
|
547
|
+
final: bool = False,
|
548
|
+
ignore_cache: bool = False,
|
549
|
+
) -> FastAPI:
|
550
|
+
"""
|
551
|
+
Create a FastAPI app that includes the Prefect REST API and UI
|
552
|
+
|
553
|
+
Args:
|
554
|
+
settings: The settings to use to create the app. If not set, settings are pulled
|
555
|
+
from the context.
|
556
|
+
ephemeral: If set, the application will be treated as ephemeral. The UI
|
557
|
+
and services will be disabled.
|
558
|
+
webserver_only: If set, the webserver and UI will be available but all background
|
559
|
+
services will be disabled.
|
560
|
+
final: whether this will be the last instance of the Prefect server to be
|
561
|
+
created in this process, so that additional optimizations may be applied
|
562
|
+
ignore_cache: If set, a new application will be created even if the settings
|
563
|
+
match. Otherwise, an application is returned from the cache.
|
564
|
+
"""
|
565
|
+
settings = settings or prefect.settings.get_current_settings()
|
566
|
+
cache_key = (settings.hash_key(), ephemeral, webserver_only)
|
567
|
+
ephemeral = ephemeral or bool(os.getenv("PREFECT__SERVER_EPHEMERAL"))
|
568
|
+
webserver_only = webserver_only or bool(os.getenv("PREFECT__SERVER_WEBSERVER_ONLY"))
|
569
|
+
final = final or bool(os.getenv("PREFECT__SERVER_FINAL"))
|
570
|
+
|
571
|
+
from prefect.logging.configuration import setup_logging
|
572
|
+
|
573
|
+
setup_logging()
|
574
|
+
|
575
|
+
if cache_key in APP_CACHE and not ignore_cache:
|
576
|
+
return APP_CACHE[cache_key]
|
577
|
+
|
578
|
+
# TODO: Move these startup functions out of this closure into the top-level or
|
579
|
+
# another dedicated location
|
580
|
+
async def run_migrations():
|
581
|
+
"""Ensure the database is created and up to date with the current migrations"""
|
582
|
+
if prefect.settings.PREFECT_API_DATABASE_MIGRATE_ON_START:
|
583
|
+
from prefect.server.database import provide_database_interface
|
584
|
+
|
585
|
+
db = provide_database_interface()
|
586
|
+
await db.create_db()
|
587
|
+
|
588
|
+
@_memoize_block_auto_registration
|
589
|
+
async def add_block_types():
|
590
|
+
"""Add all registered blocks to the database"""
|
591
|
+
if not prefect.settings.PREFECT_API_BLOCKS_REGISTER_ON_START:
|
592
|
+
return
|
593
|
+
|
594
|
+
from prefect.server.database import provide_database_interface
|
595
|
+
from prefect.server.models.block_registration import run_block_auto_registration
|
596
|
+
|
597
|
+
db = provide_database_interface()
|
598
|
+
session = await db.session()
|
599
|
+
|
600
|
+
async with session:
|
601
|
+
await run_block_auto_registration(session=session)
|
602
|
+
|
603
|
+
@asynccontextmanager
|
604
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
605
|
+
if app not in LIFESPAN_RAN_FOR_APP:
|
606
|
+
await run_migrations()
|
607
|
+
await add_block_types()
|
608
|
+
|
609
|
+
Services: type[Service] = Service
|
610
|
+
if ephemeral or webserver_only:
|
611
|
+
Services = RunInAllServers
|
612
|
+
|
613
|
+
async with Services.running():
|
614
|
+
LIFESPAN_RAN_FOR_APP.add(app)
|
615
|
+
yield
|
616
|
+
else:
|
617
|
+
yield
|
618
|
+
|
619
|
+
def on_service_exit(service: Service, task: asyncio.Task[None]) -> None:
|
620
|
+
"""
|
621
|
+
Added as a callback for completion of services to log exit
|
622
|
+
"""
|
623
|
+
try:
|
624
|
+
# Retrieving the result will raise the exception
|
625
|
+
task.result()
|
626
|
+
except Exception:
|
627
|
+
logger.error(f"{service.name} service failed!", exc_info=True)
|
628
|
+
else:
|
629
|
+
logger.info(f"{service.name} service stopped!")
|
630
|
+
|
631
|
+
app = FastAPI(
|
632
|
+
title=TITLE,
|
633
|
+
version=API_VERSION,
|
634
|
+
lifespan=lifespan,
|
635
|
+
)
|
636
|
+
api_app = create_api_app(
|
637
|
+
fast_api_app_kwargs={
|
638
|
+
"exception_handlers": {
|
639
|
+
# NOTE: FastAPI special cases the generic `Exception` handler and
|
640
|
+
# registers it as a separate middleware from the others
|
641
|
+
Exception: custom_internal_exception_handler,
|
642
|
+
RequestValidationError: validation_exception_handler,
|
643
|
+
sa.exc.IntegrityError: integrity_exception_handler,
|
644
|
+
ObjectNotFoundError: prefect_object_not_found_exception_handler,
|
645
|
+
}
|
646
|
+
},
|
647
|
+
final=final,
|
648
|
+
)
|
649
|
+
ui_app = create_ui_app(ephemeral)
|
650
|
+
|
651
|
+
# middleware
|
652
|
+
app.add_middleware(
|
653
|
+
CORSMiddleware,
|
654
|
+
allow_origins=prefect.settings.PREFECT_SERVER_CORS_ALLOWED_ORIGINS.value().split(
|
655
|
+
","
|
656
|
+
),
|
657
|
+
allow_methods=prefect.settings.PREFECT_SERVER_CORS_ALLOWED_METHODS.value().split(
|
658
|
+
","
|
659
|
+
),
|
660
|
+
allow_headers=prefect.settings.PREFECT_SERVER_CORS_ALLOWED_HEADERS.value().split(
|
661
|
+
","
|
662
|
+
),
|
663
|
+
)
|
664
|
+
|
665
|
+
# Limit the number of concurrent requests when using a SQLite database to reduce
|
666
|
+
# chance of errors where the database cannot be opened due to a high number of
|
667
|
+
# concurrent writes
|
668
|
+
if (
|
669
|
+
get_dialect(prefect.settings.PREFECT_API_DATABASE_CONNECTION_URL.value()).name
|
670
|
+
== "sqlite"
|
671
|
+
):
|
672
|
+
app.add_middleware(RequestLimitMiddleware, limit=100)
|
673
|
+
|
674
|
+
if prefect.settings.PREFECT_SERVER_CSRF_PROTECTION_ENABLED.value():
|
675
|
+
app.add_middleware(api.middleware.CsrfMiddleware)
|
676
|
+
|
677
|
+
if prefect.settings.PREFECT_API_ENABLE_METRICS:
|
678
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
679
|
+
|
680
|
+
@api_app.get("/metrics")
|
681
|
+
async def metrics() -> Response: # type: ignore[reportUnusedFunction]
|
682
|
+
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
683
|
+
|
684
|
+
api_app.mount(
|
685
|
+
"/static",
|
686
|
+
StaticFiles(
|
687
|
+
directory=os.path.join(
|
688
|
+
os.path.dirname(os.path.realpath(__file__)), "static"
|
689
|
+
)
|
690
|
+
),
|
691
|
+
name="static",
|
692
|
+
)
|
693
|
+
app.api_app = api_app
|
694
|
+
app.mount("/api", app=api_app, name="api")
|
695
|
+
app.mount("/", app=ui_app, name="ui")
|
696
|
+
|
697
|
+
def openapi():
|
698
|
+
"""
|
699
|
+
Convenience method for extracting the user facing OpenAPI schema from the API app.
|
700
|
+
|
701
|
+
This method is attached to the global public app for easy access.
|
702
|
+
"""
|
703
|
+
partial_schema = get_openapi(
|
704
|
+
title=API_TITLE,
|
705
|
+
version=API_VERSION,
|
706
|
+
routes=api_app.routes,
|
707
|
+
)
|
708
|
+
new_schema = partial_schema.copy()
|
709
|
+
new_schema["paths"] = {}
|
710
|
+
for path, value in partial_schema["paths"].items():
|
711
|
+
new_schema["paths"][f"/api{path}"] = value
|
712
|
+
|
713
|
+
new_schema["info"]["x-logo"] = {"url": "static/prefect-logo-mark-gradient.png"}
|
714
|
+
return new_schema
|
715
|
+
|
716
|
+
app.openapi = openapi
|
717
|
+
|
718
|
+
APP_CACHE[cache_key] = app
|
719
|
+
return app
|
720
|
+
|
721
|
+
|
722
|
+
subprocess_server_logger: "logging.Logger" = get_logger()
|
723
|
+
|
724
|
+
|
725
|
+
class SubprocessASGIServer:
|
726
|
+
_instances: dict[int | None, "SubprocessASGIServer"] = {}
|
727
|
+
_port_range: range = range(8000, 9000)
|
728
|
+
|
729
|
+
def __new__(cls, port: int | None = None, *args: Any, **kwargs: Any) -> Self:
|
730
|
+
"""
|
731
|
+
Return an instance of the server associated with the provided port.
|
732
|
+
Prevents multiple instances from being created for the same port.
|
733
|
+
"""
|
734
|
+
if port not in cls._instances:
|
735
|
+
instance = super().__new__(cls)
|
736
|
+
cls._instances[port] = instance
|
737
|
+
return cls._instances[port]
|
738
|
+
|
739
|
+
def __init__(self, port: Optional[int] = None):
|
740
|
+
# This ensures initialization happens only once
|
741
|
+
if not hasattr(self, "_initialized"):
|
742
|
+
self.port: Optional[int] = port
|
743
|
+
self.server_process: subprocess.Popen[Any] | None = None
|
744
|
+
self.running: bool = False
|
745
|
+
self._initialized = True
|
746
|
+
|
747
|
+
def find_available_port(self) -> int:
|
748
|
+
max_attempts = 10
|
749
|
+
for _ in range(max_attempts):
|
750
|
+
port = random.choice(self._port_range)
|
751
|
+
if self.is_port_available(port):
|
752
|
+
return port
|
753
|
+
time.sleep(random.uniform(0.1, 0.5)) # Random backoff
|
754
|
+
raise RuntimeError("Unable to find an available port after multiple attempts")
|
755
|
+
|
756
|
+
@staticmethod
|
757
|
+
def is_port_available(port: int) -> bool:
|
758
|
+
with contextlib.closing(
|
759
|
+
socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
760
|
+
) as sock:
|
761
|
+
try:
|
762
|
+
sock.bind(("127.0.0.1", port))
|
763
|
+
return True
|
764
|
+
except socket.error:
|
765
|
+
return False
|
766
|
+
|
767
|
+
@property
|
768
|
+
def address(self) -> str:
|
769
|
+
return f"http://127.0.0.1:{self.port}"
|
770
|
+
|
771
|
+
@property
|
772
|
+
def api_url(self) -> str:
|
773
|
+
return f"{self.address}/api"
|
774
|
+
|
775
|
+
def start(self, timeout: Optional[int] = None) -> None:
|
776
|
+
"""
|
777
|
+
Start the server in a separate process. Safe to call multiple times; only starts
|
778
|
+
the server once.
|
779
|
+
|
780
|
+
Args:
|
781
|
+
timeout: The maximum time to wait for the server to start
|
782
|
+
"""
|
783
|
+
if not self.running:
|
784
|
+
if self.port is None:
|
785
|
+
self.port = self.find_available_port()
|
786
|
+
assert self.port is not None, "Port must be provided or available"
|
787
|
+
help_message = (
|
788
|
+
f"Starting temporary server on {self.address}\nSee "
|
789
|
+
"https://docs.prefect.io/3.0/manage/self-host#self-host-a-prefect-server "
|
790
|
+
"for more information on running a dedicated Prefect server."
|
791
|
+
)
|
792
|
+
subprocess_server_logger.info(help_message)
|
793
|
+
try:
|
794
|
+
self.running = True
|
795
|
+
self.server_process = self._run_uvicorn_command()
|
796
|
+
atexit.register(self.stop)
|
797
|
+
with httpx.Client() as client:
|
798
|
+
response = None
|
799
|
+
elapsed_time = 0
|
800
|
+
max_wait_time = (
|
801
|
+
timeout
|
802
|
+
or PREFECT_SERVER_EPHEMERAL_STARTUP_TIMEOUT_SECONDS.value()
|
803
|
+
)
|
804
|
+
while elapsed_time < max_wait_time:
|
805
|
+
if self.server_process.poll() == 3:
|
806
|
+
self.port = self.find_available_port()
|
807
|
+
self.server_process = self._run_uvicorn_command()
|
808
|
+
continue
|
809
|
+
try:
|
810
|
+
response = client.get(f"{self.api_url}/health")
|
811
|
+
except httpx.ConnectError:
|
812
|
+
pass
|
813
|
+
else:
|
814
|
+
if response.status_code == 200:
|
815
|
+
break
|
816
|
+
time.sleep(0.1)
|
817
|
+
elapsed_time += 0.1
|
818
|
+
if response:
|
819
|
+
response.raise_for_status()
|
820
|
+
if not response:
|
821
|
+
error_message = "Timed out while attempting to connect to ephemeral Prefect API server."
|
822
|
+
if self.server_process.poll() is not None:
|
823
|
+
error_message += f" Ephemeral server process exited with code {self.server_process.returncode}."
|
824
|
+
if self.server_process.stdout:
|
825
|
+
error_message += (
|
826
|
+
f" stdout: {self.server_process.stdout.read()}"
|
827
|
+
)
|
828
|
+
if self.server_process.stderr:
|
829
|
+
error_message += (
|
830
|
+
f" stderr: {self.server_process.stderr.read()}"
|
831
|
+
)
|
832
|
+
raise RuntimeError(error_message)
|
833
|
+
except Exception:
|
834
|
+
self.running = False
|
835
|
+
raise
|
836
|
+
|
837
|
+
def _run_uvicorn_command(self) -> subprocess.Popen[Any]:
|
838
|
+
# used to turn off serving the UI
|
839
|
+
server_env = {
|
840
|
+
"PREFECT_UI_ENABLED": "0",
|
841
|
+
"PREFECT__SERVER_EPHEMERAL": "1",
|
842
|
+
"PREFECT__SERVER_FINAL": "1",
|
843
|
+
}
|
844
|
+
return subprocess.Popen(
|
845
|
+
args=[
|
846
|
+
sys.executable,
|
847
|
+
"-m",
|
848
|
+
"uvicorn",
|
849
|
+
"--app-dir",
|
850
|
+
str(prefect.__module_path__.parent),
|
851
|
+
"--factory",
|
852
|
+
"prefect.server.api.server:create_app",
|
853
|
+
"--host",
|
854
|
+
"127.0.0.1",
|
855
|
+
"--port",
|
856
|
+
str(self.port),
|
857
|
+
"--log-level",
|
858
|
+
"error",
|
859
|
+
"--lifespan",
|
860
|
+
"on",
|
861
|
+
],
|
862
|
+
env={
|
863
|
+
**os.environ,
|
864
|
+
**server_env,
|
865
|
+
**get_current_settings().to_environment_variables(exclude_unset=True),
|
866
|
+
},
|
867
|
+
)
|
868
|
+
|
869
|
+
def stop(self) -> None:
|
870
|
+
if self.server_process:
|
871
|
+
subprocess_server_logger.info(
|
872
|
+
f"Stopping temporary server on {self.address}"
|
873
|
+
)
|
874
|
+
self.server_process.terminate()
|
875
|
+
try:
|
876
|
+
self.server_process.wait(timeout=5)
|
877
|
+
except subprocess.TimeoutExpired:
|
878
|
+
self.server_process.kill()
|
879
|
+
finally:
|
880
|
+
self.server_process = None
|
881
|
+
if self.port in self._instances:
|
882
|
+
del self._instances[self.port]
|
883
|
+
if self.running:
|
884
|
+
self.running = False
|
885
|
+
|
886
|
+
def __enter__(self) -> Self:
|
887
|
+
self.start()
|
888
|
+
return self
|
889
|
+
|
890
|
+
def __exit__(self, *args: Any) -> None:
|
891
|
+
self.stop()
|