supython 0.5.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.
- supython/__init__.py +8 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +149 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/body_size.py +184 -0
- supython/cli.py +1653 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +118 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Admin service for the functions surface.
|
|
2
|
+
|
|
3
|
+
Three responsibilities:
|
|
4
|
+
|
|
5
|
+
1. Enumerate routes the running registry has discovered.
|
|
6
|
+
2. Read a function's source from disk (only for files registered as routes).
|
|
7
|
+
3. Invoke a function under ``service_role`` on behalf of the operator —
|
|
8
|
+
never minting an end-user JWT (Story 9.2 v1.1.3 contract).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from fastapi import Response, status
|
|
20
|
+
from fastapi.responses import JSONResponse
|
|
21
|
+
from pydantic import BaseModel
|
|
22
|
+
from starlette.requests import Request
|
|
23
|
+
|
|
24
|
+
from ... import db
|
|
25
|
+
from ...functions.loader import get_registry
|
|
26
|
+
from ...functions.schemas import FunctionMeta
|
|
27
|
+
from ...settings import get_settings
|
|
28
|
+
from ..errors import AdminError
|
|
29
|
+
from ..schemas import (
|
|
30
|
+
FunctionInvokeRequest,
|
|
31
|
+
FunctionInvokeResponse,
|
|
32
|
+
FunctionRoute,
|
|
33
|
+
FunctionSourceResponse,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_MAX_SOURCE_BYTES = 1_000_000
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_routes() -> list[FunctionRoute]:
|
|
43
|
+
return [
|
|
44
|
+
FunctionRoute(
|
|
45
|
+
name=meta.name,
|
|
46
|
+
path=str(meta.path),
|
|
47
|
+
methods=list(meta.methods),
|
|
48
|
+
auth=meta.auth,
|
|
49
|
+
)
|
|
50
|
+
for meta in get_registry().list()
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_meta(name: str) -> FunctionMeta:
|
|
55
|
+
meta = get_registry().get(name)
|
|
56
|
+
if meta is None:
|
|
57
|
+
raise AdminError("function_not_found", f"function {name!r} not found", 404)
|
|
58
|
+
return meta
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_source(name: str) -> FunctionSourceResponse:
|
|
62
|
+
meta = _resolve_meta(name)
|
|
63
|
+
try:
|
|
64
|
+
size = meta.path.stat().st_size
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise AdminError(
|
|
67
|
+
"function_source_unreadable",
|
|
68
|
+
f"could not stat function source: {exc}",
|
|
69
|
+
500,
|
|
70
|
+
) from exc
|
|
71
|
+
if size > _MAX_SOURCE_BYTES:
|
|
72
|
+
raise AdminError(
|
|
73
|
+
"function_source_too_large",
|
|
74
|
+
f"source exceeds {_MAX_SOURCE_BYTES} bytes",
|
|
75
|
+
413,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
text = meta.path.read_text(encoding="utf-8")
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
raise AdminError(
|
|
81
|
+
"function_source_unreadable",
|
|
82
|
+
f"could not read function source: {exc}",
|
|
83
|
+
500,
|
|
84
|
+
) from exc
|
|
85
|
+
return FunctionSourceResponse(
|
|
86
|
+
name=meta.name,
|
|
87
|
+
path=str(meta.path),
|
|
88
|
+
source=text,
|
|
89
|
+
size=size,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _build_request(
|
|
94
|
+
name: str,
|
|
95
|
+
method: str,
|
|
96
|
+
headers: dict[str, str],
|
|
97
|
+
body: bytes,
|
|
98
|
+
query: str | None,
|
|
99
|
+
) -> Request:
|
|
100
|
+
"""Build a Starlette Request that mirrors a real /functions/{name} call."""
|
|
101
|
+
raw_headers: list[tuple[bytes, bytes]] = [
|
|
102
|
+
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
|
103
|
+
for k, v in headers.items()
|
|
104
|
+
]
|
|
105
|
+
has_content_length = any(k == b"content-length" for k, _ in raw_headers)
|
|
106
|
+
if not has_content_length:
|
|
107
|
+
raw_headers.append((b"content-length", str(len(body)).encode("latin-1")))
|
|
108
|
+
|
|
109
|
+
path = f"/functions/{name}"
|
|
110
|
+
scope = {
|
|
111
|
+
"type": "http",
|
|
112
|
+
"asgi": {"version": "3.0", "spec_version": "2.3"},
|
|
113
|
+
"http_version": "1.1",
|
|
114
|
+
"method": method,
|
|
115
|
+
"scheme": "http",
|
|
116
|
+
"server": ("admin-invoke", 80),
|
|
117
|
+
"client": ("127.0.0.1", 0),
|
|
118
|
+
"root_path": "",
|
|
119
|
+
"path": path,
|
|
120
|
+
"raw_path": path.encode("ascii"),
|
|
121
|
+
"query_string": (query or "").encode("latin-1"),
|
|
122
|
+
"headers": raw_headers,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sent = False
|
|
126
|
+
|
|
127
|
+
async def receive() -> dict[str, Any]:
|
|
128
|
+
nonlocal sent
|
|
129
|
+
if not sent:
|
|
130
|
+
sent = True
|
|
131
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
132
|
+
return {"type": "http.disconnect"}
|
|
133
|
+
|
|
134
|
+
return Request(scope, receive=receive)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _translate(result: Any) -> Response:
|
|
138
|
+
"""Same translation contract as functions/router.py — kept local to avoid
|
|
139
|
+
importing a private symbol across modules."""
|
|
140
|
+
if isinstance(result, Response):
|
|
141
|
+
return result
|
|
142
|
+
if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], int):
|
|
143
|
+
status_code, payload = result
|
|
144
|
+
inner = _translate(payload)
|
|
145
|
+
inner.status_code = status_code
|
|
146
|
+
return inner
|
|
147
|
+
if isinstance(result, BaseModel):
|
|
148
|
+
return JSONResponse(content=result.model_dump(mode="json"))
|
|
149
|
+
if isinstance(result, (dict, list)) or result is None:
|
|
150
|
+
return JSONResponse(content=result)
|
|
151
|
+
if isinstance(result, (str, int, bool)):
|
|
152
|
+
return JSONResponse(content=result)
|
|
153
|
+
if isinstance(result, bytes):
|
|
154
|
+
return Response(content=result, media_type="application/octet-stream")
|
|
155
|
+
raise AdminError(
|
|
156
|
+
"function_invalid_return",
|
|
157
|
+
f"handler returned unsupported type {type(result).__name__!r}",
|
|
158
|
+
500,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _serialize_response(response: Response, elapsed_ms: float) -> FunctionInvokeResponse:
|
|
163
|
+
raw_body = getattr(response, "body", b"") or b""
|
|
164
|
+
if isinstance(raw_body, memoryview):
|
|
165
|
+
raw_body = bytes(raw_body)
|
|
166
|
+
try:
|
|
167
|
+
body_text = raw_body.decode("utf-8")
|
|
168
|
+
except UnicodeDecodeError:
|
|
169
|
+
body_text = ""
|
|
170
|
+
parsed: Any | None = None
|
|
171
|
+
if body_text:
|
|
172
|
+
try:
|
|
173
|
+
parsed = json.loads(body_text)
|
|
174
|
+
except (json.JSONDecodeError, ValueError):
|
|
175
|
+
parsed = None
|
|
176
|
+
headers: dict[str, str] = {}
|
|
177
|
+
for key, value in response.headers.items():
|
|
178
|
+
headers[key] = value
|
|
179
|
+
return FunctionInvokeResponse(
|
|
180
|
+
status=response.status_code,
|
|
181
|
+
headers=headers,
|
|
182
|
+
body=parsed,
|
|
183
|
+
body_text=body_text,
|
|
184
|
+
elapsed_ms=elapsed_ms,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def invoke_function(
|
|
189
|
+
name: str,
|
|
190
|
+
payload: FunctionInvokeRequest,
|
|
191
|
+
) -> FunctionInvokeResponse:
|
|
192
|
+
"""Invoke a discovered function under ``service_role``.
|
|
193
|
+
|
|
194
|
+
The admin session never becomes an end-user JWT — claims set on the
|
|
195
|
+
connection are informational (``role=service_role``) so audit/stamping
|
|
196
|
+
helpers behave, but RLS is bypassed by ``service_role`` regardless.
|
|
197
|
+
"""
|
|
198
|
+
meta = _resolve_meta(name)
|
|
199
|
+
method = payload.method.upper()
|
|
200
|
+
if method not in meta.methods:
|
|
201
|
+
raise AdminError(
|
|
202
|
+
"method_not_allowed",
|
|
203
|
+
f"function {name!r} does not allow {method}",
|
|
204
|
+
405,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if payload.body is None:
|
|
208
|
+
body_bytes = b""
|
|
209
|
+
elif isinstance(payload.body, (dict, list)):
|
|
210
|
+
body_bytes = json.dumps(payload.body).encode("utf-8")
|
|
211
|
+
elif isinstance(payload.body, str):
|
|
212
|
+
body_bytes = payload.body.encode("utf-8")
|
|
213
|
+
else:
|
|
214
|
+
body_bytes = json.dumps(payload.body).encode("utf-8")
|
|
215
|
+
|
|
216
|
+
headers = dict(payload.headers or {})
|
|
217
|
+
has_content_type = any(k.lower() == "content-type" for k in headers)
|
|
218
|
+
if body_bytes and not has_content_type and isinstance(payload.body, (dict, list)):
|
|
219
|
+
headers["content-type"] = "application/json"
|
|
220
|
+
|
|
221
|
+
request = _build_request(meta.name, method, headers, body_bytes, payload.query)
|
|
222
|
+
|
|
223
|
+
settings = get_settings()
|
|
224
|
+
handler = meta.handler
|
|
225
|
+
assert handler is not None # validated at load
|
|
226
|
+
|
|
227
|
+
from ...functions.context import build_ctx # local import: avoid cycles
|
|
228
|
+
|
|
229
|
+
started = time.monotonic()
|
|
230
|
+
try:
|
|
231
|
+
async with db.as_service_role(claims={"role": "service_role"}) as conn:
|
|
232
|
+
ctx = build_ctx(
|
|
233
|
+
conn=conn,
|
|
234
|
+
user=None,
|
|
235
|
+
request=request,
|
|
236
|
+
raw_jwt=None,
|
|
237
|
+
settings=settings,
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
result = await asyncio.wait_for(
|
|
241
|
+
handler(request, ctx),
|
|
242
|
+
timeout=settings.functions_max_handler_seconds,
|
|
243
|
+
)
|
|
244
|
+
finally:
|
|
245
|
+
try:
|
|
246
|
+
await ctx.postgrest.aclose()
|
|
247
|
+
except Exception:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"admin.functions: postgrest close failed for %s",
|
|
250
|
+
meta.name,
|
|
251
|
+
exc_info=True,
|
|
252
|
+
)
|
|
253
|
+
except TimeoutError:
|
|
254
|
+
elapsed = (time.monotonic() - started) * 1000.0
|
|
255
|
+
return FunctionInvokeResponse(
|
|
256
|
+
status=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
257
|
+
headers={"content-type": "application/json"},
|
|
258
|
+
body={
|
|
259
|
+
"code": "function_timeout",
|
|
260
|
+
"message": (
|
|
261
|
+
f"function exceeded {settings.functions_max_handler_seconds}s"
|
|
262
|
+
),
|
|
263
|
+
},
|
|
264
|
+
body_text="",
|
|
265
|
+
elapsed_ms=elapsed,
|
|
266
|
+
)
|
|
267
|
+
except AdminError:
|
|
268
|
+
raise
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
logger.warning(
|
|
271
|
+
"admin.functions: handler %s raised", meta.name, exc_info=True
|
|
272
|
+
)
|
|
273
|
+
elapsed = (time.monotonic() - started) * 1000.0
|
|
274
|
+
return FunctionInvokeResponse(
|
|
275
|
+
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
276
|
+
headers={"content-type": "application/json"},
|
|
277
|
+
body={
|
|
278
|
+
"code": "function_error",
|
|
279
|
+
"message": str(exc) or "function raised an unhandled exception",
|
|
280
|
+
},
|
|
281
|
+
body_text="",
|
|
282
|
+
elapsed_ms=elapsed,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
elapsed = (time.monotonic() - started) * 1000.0
|
|
286
|
+
response = _translate(result)
|
|
287
|
+
return _serialize_response(response, elapsed)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Admin service layer for the jobs queue surface.
|
|
2
|
+
|
|
3
|
+
Pure async functions over ``asyncpg.Connection``. No FastAPI imports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
import asyncpg
|
|
11
|
+
|
|
12
|
+
from ...jobs import service as jobs_service
|
|
13
|
+
from ...jobs.schemas import JobRecord
|
|
14
|
+
from ..errors import AdminError
|
|
15
|
+
from ..schemas import AdminCronRow, AdminJobRow, AdminJobsPage, PgCronHealth
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _row_to_admin_job(row: asyncpg.Record) -> AdminJobRow:
|
|
21
|
+
raw_payload = row["payload"]
|
|
22
|
+
if isinstance(raw_payload, str):
|
|
23
|
+
raw_payload = json.loads(raw_payload) if raw_payload else {}
|
|
24
|
+
return AdminJobRow(
|
|
25
|
+
id=row["id"],
|
|
26
|
+
name=row["name"],
|
|
27
|
+
version=row["version"],
|
|
28
|
+
status=row["status"],
|
|
29
|
+
payload=raw_payload,
|
|
30
|
+
queue=row["queue"],
|
|
31
|
+
user_id=row["user_id"],
|
|
32
|
+
attempts=row["attempts"],
|
|
33
|
+
max_attempts=row["max_attempts"],
|
|
34
|
+
run_at=row["run_at"],
|
|
35
|
+
locked_at=row["locked_at"],
|
|
36
|
+
locked_by=row["locked_by"],
|
|
37
|
+
role=row["role"],
|
|
38
|
+
finished_at=row["finished_at"],
|
|
39
|
+
created_at=row["created_at"],
|
|
40
|
+
last_error=row.get("last_error"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _job_record_to_admin_job(rec: JobRecord) -> AdminJobRow:
|
|
45
|
+
return AdminJobRow(
|
|
46
|
+
id=rec.id,
|
|
47
|
+
name=rec.name,
|
|
48
|
+
version=rec.version,
|
|
49
|
+
status=rec.status,
|
|
50
|
+
payload=rec.payload,
|
|
51
|
+
queue=rec.queue,
|
|
52
|
+
user_id=rec.user_id,
|
|
53
|
+
attempts=rec.attempts,
|
|
54
|
+
max_attempts=rec.max_attempts,
|
|
55
|
+
run_at=rec.run_at,
|
|
56
|
+
locked_at=rec.locked_at,
|
|
57
|
+
locked_by=rec.locked_by,
|
|
58
|
+
role=rec.role,
|
|
59
|
+
finished_at=rec.finished_at,
|
|
60
|
+
created_at=rec.created_at,
|
|
61
|
+
last_error=rec.last_error,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _row_to_admin_cron(row: asyncpg.Record) -> AdminCronRow:
|
|
66
|
+
raw_payload = row["payload"]
|
|
67
|
+
if isinstance(raw_payload, str):
|
|
68
|
+
raw_payload = json.loads(raw_payload) if raw_payload else {}
|
|
69
|
+
return AdminCronRow(
|
|
70
|
+
id=row["id"],
|
|
71
|
+
name=row["name"],
|
|
72
|
+
cron_expr=row["cron_expr"],
|
|
73
|
+
job_name=row["job_name"],
|
|
74
|
+
job_version=row["job_version"],
|
|
75
|
+
payload=raw_payload,
|
|
76
|
+
queue=row["queue"],
|
|
77
|
+
enabled=row["enabled"],
|
|
78
|
+
last_fire_at=row["last_fire_at"],
|
|
79
|
+
created_at=row["created_at"],
|
|
80
|
+
pg_cron_active=row.get("pg_cron_active"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _fetch_admin_job(conn: asyncpg.Connection, job_id: UUID) -> AdminJobRow:
|
|
85
|
+
row = await conn.fetchrow(
|
|
86
|
+
"""
|
|
87
|
+
select id, name, version, status, payload, queue, user_id,
|
|
88
|
+
attempts, max_attempts, run_at, locked_at, locked_by,
|
|
89
|
+
role, finished_at, created_at, last_error
|
|
90
|
+
from jobs.jobs
|
|
91
|
+
where id = $1
|
|
92
|
+
""",
|
|
93
|
+
job_id,
|
|
94
|
+
)
|
|
95
|
+
if row is None:
|
|
96
|
+
raise AdminError("job_not_found", f"job {job_id} not found", 404)
|
|
97
|
+
return _row_to_admin_job(row)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def list_queue(
|
|
101
|
+
conn: asyncpg.Connection,
|
|
102
|
+
*,
|
|
103
|
+
status: str | None = None,
|
|
104
|
+
queue: str | None = None,
|
|
105
|
+
limit: int = 50,
|
|
106
|
+
offset: int = 0,
|
|
107
|
+
) -> AdminJobsPage:
|
|
108
|
+
clauses: list[str] = []
|
|
109
|
+
args: list = []
|
|
110
|
+
idx = 1
|
|
111
|
+
|
|
112
|
+
if status is not None:
|
|
113
|
+
clauses.append(f"status = ${idx}")
|
|
114
|
+
args.append(status)
|
|
115
|
+
idx += 1
|
|
116
|
+
if queue is not None:
|
|
117
|
+
clauses.append(f"queue = ${idx}")
|
|
118
|
+
args.append(queue)
|
|
119
|
+
idx += 1
|
|
120
|
+
|
|
121
|
+
where = f"where {' and '.join(clauses)}" if clauses else ""
|
|
122
|
+
|
|
123
|
+
rows = await conn.fetch(
|
|
124
|
+
f"""
|
|
125
|
+
select id, name, version, status, payload, queue, user_id,
|
|
126
|
+
attempts, max_attempts, run_at, locked_at, locked_by,
|
|
127
|
+
role, finished_at, created_at, last_error
|
|
128
|
+
from jobs.jobs
|
|
129
|
+
{where}
|
|
130
|
+
order by created_at desc
|
|
131
|
+
limit ${idx} offset ${idx + 1}
|
|
132
|
+
""",
|
|
133
|
+
*args,
|
|
134
|
+
limit,
|
|
135
|
+
offset,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
total = await conn.fetchval(
|
|
139
|
+
f"select count(*) from jobs.jobs {where}",
|
|
140
|
+
*args,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
counts = await count_by_status(conn)
|
|
144
|
+
|
|
145
|
+
return AdminJobsPage(
|
|
146
|
+
rows=[_row_to_admin_job(r) for r in rows],
|
|
147
|
+
total=total or 0,
|
|
148
|
+
counts=counts,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def count_by_status(conn: asyncpg.Connection) -> dict[str, int]:
|
|
153
|
+
rows = await conn.fetch(
|
|
154
|
+
"""
|
|
155
|
+
select status, count(*) as cnt
|
|
156
|
+
from jobs.jobs
|
|
157
|
+
group by status
|
|
158
|
+
"""
|
|
159
|
+
)
|
|
160
|
+
return {r["status"]: r["cnt"] for r in rows}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def retry_job(conn: asyncpg.Connection, job_id: UUID) -> AdminJobRow:
|
|
164
|
+
try:
|
|
165
|
+
await jobs_service.retry(conn, job_id)
|
|
166
|
+
except jobs_service.JobError as exc:
|
|
167
|
+
raise AdminError(exc.code, exc.message, exc.status) from exc
|
|
168
|
+
return await _fetch_admin_job(conn, job_id)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def cancel_job(conn: asyncpg.Connection, job_id: UUID) -> AdminJobRow:
|
|
172
|
+
try:
|
|
173
|
+
await jobs_service.cancel(conn, job_id)
|
|
174
|
+
except jobs_service.JobError as exc:
|
|
175
|
+
raise AdminError(exc.code, exc.message, exc.status) from exc
|
|
176
|
+
return await _fetch_admin_job(conn, job_id)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def list_crons(conn: asyncpg.Connection) -> list[AdminCronRow]:
|
|
180
|
+
pg_cron_installed = await _pg_cron_installed(conn)
|
|
181
|
+
|
|
182
|
+
rows = await conn.fetch(
|
|
183
|
+
"""
|
|
184
|
+
select id, name, cron_expr, job_name, job_version, payload,
|
|
185
|
+
queue, enabled, last_fire_at, created_at
|
|
186
|
+
from jobs.cron_schedules
|
|
187
|
+
order by name
|
|
188
|
+
"""
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
pg_cron_map: dict[str, bool] = {}
|
|
192
|
+
if pg_cron_installed:
|
|
193
|
+
cron_rows = await conn.fetch(
|
|
194
|
+
"""
|
|
195
|
+
select jobname, active
|
|
196
|
+
from cron.job
|
|
197
|
+
"""
|
|
198
|
+
)
|
|
199
|
+
pg_cron_map = {r["jobname"]: r["active"] for r in cron_rows}
|
|
200
|
+
|
|
201
|
+
results: list[AdminCronRow] = []
|
|
202
|
+
for r in rows:
|
|
203
|
+
cron = _row_to_admin_cron(r)
|
|
204
|
+
if pg_cron_installed:
|
|
205
|
+
cron.pg_cron_active = pg_cron_map.get(r["name"])
|
|
206
|
+
results.append(cron)
|
|
207
|
+
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def pg_cron_health(conn: asyncpg.Connection) -> PgCronHealth:
|
|
212
|
+
installed = await _pg_cron_installed(conn)
|
|
213
|
+
if not installed:
|
|
214
|
+
return PgCronHealth(installed=False)
|
|
215
|
+
|
|
216
|
+
ext_version = await conn.fetchval(
|
|
217
|
+
"""
|
|
218
|
+
select extversion from pg_extension where extname = 'pg_cron'
|
|
219
|
+
"""
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
active_jobs = (
|
|
224
|
+
await conn.fetchval(
|
|
225
|
+
"""
|
|
226
|
+
select count(*) from cron.job where active
|
|
227
|
+
"""
|
|
228
|
+
)
|
|
229
|
+
or 0
|
|
230
|
+
)
|
|
231
|
+
except Exception:
|
|
232
|
+
logger.warning("pg_cron_health: cron.job not accessible", exc_info=True)
|
|
233
|
+
active_jobs = 0
|
|
234
|
+
|
|
235
|
+
return PgCronHealth(
|
|
236
|
+
installed=True,
|
|
237
|
+
active_jobs=active_jobs,
|
|
238
|
+
extension_version=ext_version,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def run_cron_now(conn: asyncpg.Connection, cron_name: str) -> AdminJobRow:
|
|
243
|
+
row = await conn.fetchrow(
|
|
244
|
+
"""
|
|
245
|
+
select id, name, job_name, job_version, payload, queue, enabled
|
|
246
|
+
from jobs.cron_schedules
|
|
247
|
+
where name = $1
|
|
248
|
+
""",
|
|
249
|
+
cron_name,
|
|
250
|
+
)
|
|
251
|
+
if row is None:
|
|
252
|
+
raise AdminError("cron_not_found", f"cron schedule {cron_name!r} not found", 404)
|
|
253
|
+
if not row["enabled"]:
|
|
254
|
+
raise AdminError("cron_disabled", f"cron schedule {cron_name!r} is disabled", 409)
|
|
255
|
+
|
|
256
|
+
raw_payload = row["payload"]
|
|
257
|
+
if isinstance(raw_payload, str):
|
|
258
|
+
raw_payload = json.loads(raw_payload) if raw_payload else {}
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
result = await jobs_service.enqueue(
|
|
262
|
+
conn,
|
|
263
|
+
name=row["job_name"],
|
|
264
|
+
payload=raw_payload,
|
|
265
|
+
queue=row["queue"],
|
|
266
|
+
version=row["job_version"],
|
|
267
|
+
)
|
|
268
|
+
except jobs_service.JobError as exc:
|
|
269
|
+
raise AdminError(exc.code, exc.message, exc.status) from exc
|
|
270
|
+
|
|
271
|
+
return _job_record_to_admin_job(result.job)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _pg_cron_installed(conn: asyncpg.Connection) -> bool:
|
|
275
|
+
val = await conn.fetchval(
|
|
276
|
+
"""
|
|
277
|
+
select exists(
|
|
278
|
+
select 1 from pg_extension where extname = 'pg_cron'
|
|
279
|
+
)
|
|
280
|
+
"""
|
|
281
|
+
)
|
|
282
|
+
return bool(val)
|