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,92 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Body, Depends, Request
|
|
6
|
+
|
|
7
|
+
from ... import db
|
|
8
|
+
from .. import audit
|
|
9
|
+
from ..deps import require_admin
|
|
10
|
+
from ..errors import AdminError, to_http
|
|
11
|
+
from ..schemas import (
|
|
12
|
+
FunctionInvokeRequest,
|
|
13
|
+
FunctionInvokeResponse,
|
|
14
|
+
FunctionRoute,
|
|
15
|
+
FunctionSourceResponse,
|
|
16
|
+
)
|
|
17
|
+
from . import service_functions
|
|
18
|
+
|
|
19
|
+
router = APIRouter(prefix="/admin/api/v1/functions", tags=["admin.functions"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client_ip(request: Request) -> str | None:
|
|
23
|
+
if request.client is None:
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
ipaddress.ip_address(request.client.host)
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
return request.client.host
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/routes", response_model=list[FunctionRoute])
|
|
33
|
+
async def list_routes(
|
|
34
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
35
|
+
) -> list[FunctionRoute]:
|
|
36
|
+
return service_functions.list_routes()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get("/{name:path}/source", response_model=FunctionSourceResponse)
|
|
40
|
+
async def read_source(
|
|
41
|
+
name: str,
|
|
42
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
43
|
+
) -> FunctionSourceResponse:
|
|
44
|
+
try:
|
|
45
|
+
return service_functions.read_source(name)
|
|
46
|
+
except AdminError as exc:
|
|
47
|
+
raise to_http(exc) from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.post("/{name:path}/invoke", response_model=FunctionInvokeResponse)
|
|
51
|
+
async def invoke(
|
|
52
|
+
name: str,
|
|
53
|
+
request: Request,
|
|
54
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
55
|
+
payload: Annotated[FunctionInvokeRequest | None, Body()] = None,
|
|
56
|
+
) -> FunctionInvokeResponse:
|
|
57
|
+
body = payload or FunctionInvokeRequest()
|
|
58
|
+
ip = _client_ip(request)
|
|
59
|
+
ua = request.headers.get("user-agent")
|
|
60
|
+
try:
|
|
61
|
+
result = await service_functions.invoke_function(name, body)
|
|
62
|
+
except AdminError as exc:
|
|
63
|
+
async with db.as_service_role() as conn:
|
|
64
|
+
await audit.write(
|
|
65
|
+
conn,
|
|
66
|
+
admin_id=admin_id,
|
|
67
|
+
action="functions.invoke.failed",
|
|
68
|
+
target=name,
|
|
69
|
+
payload={
|
|
70
|
+
"method": body.method.upper(),
|
|
71
|
+
"error_code": exc.code,
|
|
72
|
+
},
|
|
73
|
+
ip=ip,
|
|
74
|
+
ua=ua,
|
|
75
|
+
)
|
|
76
|
+
raise to_http(exc) from exc
|
|
77
|
+
|
|
78
|
+
async with db.as_service_role() as conn:
|
|
79
|
+
await audit.write(
|
|
80
|
+
conn,
|
|
81
|
+
admin_id=admin_id,
|
|
82
|
+
action="functions.invoke",
|
|
83
|
+
target=name,
|
|
84
|
+
payload={
|
|
85
|
+
"method": body.method.upper(),
|
|
86
|
+
"status": result.status,
|
|
87
|
+
"elapsed_ms": result.elapsed_ms,
|
|
88
|
+
},
|
|
89
|
+
ip=ip,
|
|
90
|
+
ua=ua,
|
|
91
|
+
)
|
|
92
|
+
return result
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Admin jobs control plane.
|
|
2
|
+
|
|
3
|
+
GET /admin/api/v1/jobs/queue?status=&queue=&limit=&offset=
|
|
4
|
+
Paginated job queue listing with per-status counts.
|
|
5
|
+
|
|
6
|
+
POST /admin/api/v1/jobs/{id}/retry
|
|
7
|
+
Retry a failed or cancelled job. Returns 409 unless status is
|
|
8
|
+
retryable.
|
|
9
|
+
|
|
10
|
+
POST /admin/api/v1/jobs/{id}/cancel
|
|
11
|
+
Cancel a queued job. Returns 404/409 for non-cancellable states.
|
|
12
|
+
|
|
13
|
+
GET /admin/api/v1/jobs/crons
|
|
14
|
+
List cron schedules from ``jobs.cron_schedules`` with pg_cron
|
|
15
|
+
health data.
|
|
16
|
+
|
|
17
|
+
GET /admin/api/v1/jobs/crons/health
|
|
18
|
+
pg_cron extension health banner data.
|
|
19
|
+
|
|
20
|
+
POST /admin/api/v1/jobs/crons/{name}/run-now
|
|
21
|
+
Enqueue the underlying job for a cron schedule once.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import ipaddress
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Annotated
|
|
27
|
+
from uuid import UUID
|
|
28
|
+
|
|
29
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
30
|
+
|
|
31
|
+
from ... import db
|
|
32
|
+
from .. import audit
|
|
33
|
+
from ..deps import require_admin
|
|
34
|
+
from ..errors import AdminError, to_http
|
|
35
|
+
from ..schemas import AdminCronRow, AdminJobRow, AdminJobsPage, PgCronHealth
|
|
36
|
+
from . import service_jobs
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
router = APIRouter(prefix="/admin/api/v1/jobs", tags=["admin.jobs"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _client_ip(request: Request) -> str | None:
|
|
44
|
+
if request.client is None:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
ipaddress.ip_address(request.client.host)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
return request.client.host
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# GET /queue — paginated job queue
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@router.get("/queue", response_model=AdminJobsPage)
|
|
59
|
+
async def list_queue(
|
|
60
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
61
|
+
status: Annotated[str | None, Query(description="Filter by status")] = None,
|
|
62
|
+
queue: Annotated[str | None, Query(description="Filter by queue name")] = None,
|
|
63
|
+
limit: Annotated[int, Query(ge=1, le=500)] = 50,
|
|
64
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
65
|
+
) -> AdminJobsPage:
|
|
66
|
+
async with db.as_service_role() as conn:
|
|
67
|
+
return await service_jobs.list_queue(
|
|
68
|
+
conn,
|
|
69
|
+
status=status,
|
|
70
|
+
queue=queue,
|
|
71
|
+
limit=limit,
|
|
72
|
+
offset=offset,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# POST /{id}/retry — retry a terminal job
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.post("/{job_id}/retry", response_model=AdminJobRow)
|
|
82
|
+
async def retry_job(
|
|
83
|
+
request: Request,
|
|
84
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
85
|
+
job_id: UUID,
|
|
86
|
+
) -> AdminJobRow:
|
|
87
|
+
async with db.as_service_role() as conn:
|
|
88
|
+
try:
|
|
89
|
+
job = await service_jobs.retry_job(conn, job_id)
|
|
90
|
+
except AdminError as exc:
|
|
91
|
+
raise to_http(exc) from exc
|
|
92
|
+
|
|
93
|
+
ip = _client_ip(request)
|
|
94
|
+
ua = request.headers.get("user-agent")
|
|
95
|
+
await audit.write(
|
|
96
|
+
conn,
|
|
97
|
+
admin_id=admin_id,
|
|
98
|
+
action="jobs.retry",
|
|
99
|
+
target=str(job_id),
|
|
100
|
+
payload={"name": job.name, "status": job.status},
|
|
101
|
+
ip=ip,
|
|
102
|
+
ua=ua,
|
|
103
|
+
)
|
|
104
|
+
return job
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# POST /{id}/cancel — cancel a queued job
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@router.post("/{job_id}/cancel", response_model=AdminJobRow)
|
|
113
|
+
async def cancel_job(
|
|
114
|
+
request: Request,
|
|
115
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
116
|
+
job_id: UUID,
|
|
117
|
+
) -> AdminJobRow:
|
|
118
|
+
async with db.as_service_role() as conn:
|
|
119
|
+
try:
|
|
120
|
+
job = await service_jobs.cancel_job(conn, job_id)
|
|
121
|
+
except AdminError as exc:
|
|
122
|
+
raise to_http(exc) from exc
|
|
123
|
+
|
|
124
|
+
ip = _client_ip(request)
|
|
125
|
+
ua = request.headers.get("user-agent")
|
|
126
|
+
await audit.write(
|
|
127
|
+
conn,
|
|
128
|
+
admin_id=admin_id,
|
|
129
|
+
action="jobs.cancel",
|
|
130
|
+
target=str(job_id),
|
|
131
|
+
payload={"name": job.name},
|
|
132
|
+
ip=ip,
|
|
133
|
+
ua=ua,
|
|
134
|
+
)
|
|
135
|
+
return job
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# GET /crons — list cron schedules
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.get("/crons", response_model=list[AdminCronRow])
|
|
144
|
+
async def list_crons(
|
|
145
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
146
|
+
) -> list[AdminCronRow]:
|
|
147
|
+
async with db.as_service_role() as conn:
|
|
148
|
+
return await service_jobs.list_crons(conn)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# GET /crons/health — pg_cron health banner
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.get("/crons/health", response_model=PgCronHealth)
|
|
157
|
+
async def cron_health(
|
|
158
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
159
|
+
) -> PgCronHealth:
|
|
160
|
+
async with db.as_service_role() as conn:
|
|
161
|
+
return await service_jobs.pg_cron_health(conn)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# POST /crons/{name}/run-now — enqueue a cron's underlying job once
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.post("/crons/{cron_name}/run-now", response_model=AdminJobRow)
|
|
170
|
+
async def run_cron_now(
|
|
171
|
+
request: Request,
|
|
172
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
173
|
+
cron_name: str,
|
|
174
|
+
) -> AdminJobRow:
|
|
175
|
+
async with db.as_service_role() as conn:
|
|
176
|
+
try:
|
|
177
|
+
job = await service_jobs.run_cron_now(conn, cron_name)
|
|
178
|
+
except AdminError as exc:
|
|
179
|
+
raise to_http(exc) from exc
|
|
180
|
+
|
|
181
|
+
ip = _client_ip(request)
|
|
182
|
+
ua = request.headers.get("user-agent")
|
|
183
|
+
await audit.write(
|
|
184
|
+
conn,
|
|
185
|
+
admin_id=admin_id,
|
|
186
|
+
action="jobs.run_cron_now",
|
|
187
|
+
target=cron_name,
|
|
188
|
+
payload={"job_id": str(job.id), "job_name": job.name},
|
|
189
|
+
ip=ip,
|
|
190
|
+
ua=ua,
|
|
191
|
+
)
|
|
192
|
+
return job
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Admin ops control plane.
|
|
2
|
+
|
|
3
|
+
GET /admin/api/v1/ops/backups
|
|
4
|
+
List backups (id, kind, size, started_at, finished_at, status).
|
|
5
|
+
|
|
6
|
+
POST /admin/api/v1/ops/backups
|
|
7
|
+
Start a new backup (full or schema-only). Returns the record immediately;
|
|
8
|
+
the backup runs in the background.
|
|
9
|
+
|
|
10
|
+
GET /admin/api/v1/ops/backups/{id}/download
|
|
11
|
+
Returns a signed download URL with 10 min TTL.
|
|
12
|
+
|
|
13
|
+
GET /admin/api/v1/ops/downloads/{token}
|
|
14
|
+
Streams the backup file after verifying the signed token.
|
|
15
|
+
Does NOT require admin session — the token is the authority.
|
|
16
|
+
|
|
17
|
+
GET /admin/api/v1/ops/logs/tail
|
|
18
|
+
SSE endpoint. Streams live log entries from the in-memory ring buffer.
|
|
19
|
+
Supports optional query filters: level, substring, request_id.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import ipaddress
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Annotated
|
|
26
|
+
from uuid import UUID
|
|
27
|
+
|
|
28
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
29
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
30
|
+
|
|
31
|
+
from ... import backups, db
|
|
32
|
+
from ...settings import get_settings
|
|
33
|
+
from .. import audit
|
|
34
|
+
from ..deps import require_admin
|
|
35
|
+
from ..errors import AdminError, to_http
|
|
36
|
+
from ..schemas import (
|
|
37
|
+
AdminBackupRow,
|
|
38
|
+
AdminBackupsPage,
|
|
39
|
+
BackupDownloadResponse,
|
|
40
|
+
StartBackupRequest,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
router = APIRouter(prefix="/admin/api/v1/ops", tags=["admin.ops"])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _client_ip(request: Request) -> str | None:
|
|
49
|
+
if request.client is None:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
ipaddress.ip_address(request.client.host)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
return request.client.host
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# GET /backups — list backups
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.get("/backups", response_model=AdminBackupsPage)
|
|
64
|
+
async def list_backups(
|
|
65
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
66
|
+
limit: Annotated[int, Query(ge=1, le=500)] = 50,
|
|
67
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
68
|
+
) -> AdminBackupsPage:
|
|
69
|
+
async with db.as_service_role() as conn:
|
|
70
|
+
from . import service_ops
|
|
71
|
+
|
|
72
|
+
return await service_ops.list_backups(conn, limit=limit, offset=offset)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# POST /backups — start a new backup
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.post("/backups", response_model=AdminBackupRow, status_code=status.HTTP_202_ACCEPTED)
|
|
81
|
+
async def start_backup(
|
|
82
|
+
request: Request,
|
|
83
|
+
admin_id: Annotated[UUID, Depends(require_admin)],
|
|
84
|
+
body: StartBackupRequest,
|
|
85
|
+
) -> AdminBackupRow:
|
|
86
|
+
async with db.as_service_role() as conn:
|
|
87
|
+
from . import service_ops
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
backup = await service_ops.start_backup(conn, kind=body.kind)
|
|
91
|
+
except AdminError as exc:
|
|
92
|
+
raise to_http(exc) from exc
|
|
93
|
+
|
|
94
|
+
ip = _client_ip(request)
|
|
95
|
+
ua = request.headers.get("user-agent")
|
|
96
|
+
await audit.write(
|
|
97
|
+
conn,
|
|
98
|
+
admin_id=admin_id,
|
|
99
|
+
action="backup.start",
|
|
100
|
+
target=str(backup.id),
|
|
101
|
+
payload={"kind": body.kind, "status": backup.status},
|
|
102
|
+
ip=ip,
|
|
103
|
+
ua=ua,
|
|
104
|
+
)
|
|
105
|
+
return backup
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# GET /backups/{id}/download — signed download URL
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.get("/backups/{backup_id}/download", response_model=BackupDownloadResponse)
|
|
114
|
+
async def get_download_url(
|
|
115
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
116
|
+
backup_id: UUID,
|
|
117
|
+
) -> BackupDownloadResponse:
|
|
118
|
+
async with db.as_service_role() as conn:
|
|
119
|
+
from . import service_ops
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
return await service_ops.get_backup_download_response(conn, backup_id)
|
|
123
|
+
except AdminError as exc:
|
|
124
|
+
raise to_http(exc) from exc
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# GET /downloads/{token} — stream backup file (token-authenticated)
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.get("/downloads/{token}")
|
|
133
|
+
async def download_backup(request: Request, token: str) -> FileResponse:
|
|
134
|
+
backup_id = backups.verify_download_token(token)
|
|
135
|
+
if backup_id is None:
|
|
136
|
+
raise HTTPException(status_code=403, detail="Invalid or expired download token")
|
|
137
|
+
|
|
138
|
+
async with db.as_service_role() as conn:
|
|
139
|
+
record = await backups.get_backup(conn, backup_id)
|
|
140
|
+
|
|
141
|
+
if record is None:
|
|
142
|
+
raise HTTPException(status_code=404, detail="Backup not found")
|
|
143
|
+
if record.status != "completed" or not record.file_path:
|
|
144
|
+
raise HTTPException(status_code=409, detail="Backup not ready for download")
|
|
145
|
+
|
|
146
|
+
# Defense in depth: refuse to serve any file outside the configured
|
|
147
|
+
# backups directory, even if a stray file_path somehow ends up in the
|
|
148
|
+
# row. The token only authenticates the backup_id, not the path.
|
|
149
|
+
backups_dir = Path(get_settings().backups_dir).resolve()
|
|
150
|
+
target = Path(record.file_path).resolve()
|
|
151
|
+
try:
|
|
152
|
+
target.relative_to(backups_dir)
|
|
153
|
+
except ValueError:
|
|
154
|
+
logger.error(
|
|
155
|
+
"backup %s file_path %s is outside backups_dir %s",
|
|
156
|
+
backup_id,
|
|
157
|
+
target,
|
|
158
|
+
backups_dir,
|
|
159
|
+
)
|
|
160
|
+
raise HTTPException(
|
|
161
|
+
status_code=500, detail="Backup file outside backups directory"
|
|
162
|
+
) from None
|
|
163
|
+
|
|
164
|
+
if not target.is_file():
|
|
165
|
+
logger.error("backup file missing on disk: %s", target)
|
|
166
|
+
raise HTTPException(status_code=500, detail="Backup file missing on disk")
|
|
167
|
+
|
|
168
|
+
kind_label = "schema" if record.kind == "schema-only" else "full"
|
|
169
|
+
filename = f"backup_{backup_id}_{kind_label}.sql"
|
|
170
|
+
|
|
171
|
+
async with db.as_service_role() as conn:
|
|
172
|
+
await audit.write(
|
|
173
|
+
conn,
|
|
174
|
+
admin_id=None,
|
|
175
|
+
action="backup.download",
|
|
176
|
+
target=str(backup_id),
|
|
177
|
+
payload={"kind": record.kind, "size": record.size},
|
|
178
|
+
ip=_client_ip(request),
|
|
179
|
+
ua=request.headers.get("user-agent"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return FileResponse(
|
|
183
|
+
path=str(target),
|
|
184
|
+
media_type="application/sql",
|
|
185
|
+
filename=filename,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# GET /logs/tail — live log tail via SSE
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@router.get("/logs/tail")
|
|
195
|
+
async def tail_logs(
|
|
196
|
+
_: Annotated[UUID, Depends(require_admin)],
|
|
197
|
+
level: Annotated[
|
|
198
|
+
str | None,
|
|
199
|
+
Query(description="Minimum log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"),
|
|
200
|
+
] = None,
|
|
201
|
+
substring: Annotated[
|
|
202
|
+
str | None,
|
|
203
|
+
Query(description="Case-insensitive substring match on message"),
|
|
204
|
+
] = None,
|
|
205
|
+
request_id: Annotated[
|
|
206
|
+
str | None,
|
|
207
|
+
Query(description="Exact match on request_id field"),
|
|
208
|
+
] = None,
|
|
209
|
+
) -> StreamingResponse:
|
|
210
|
+
from . import service_ops
|
|
211
|
+
|
|
212
|
+
return StreamingResponse(
|
|
213
|
+
service_ops.tail_logs(
|
|
214
|
+
level=level,
|
|
215
|
+
substring=substring,
|
|
216
|
+
request_id=request_id,
|
|
217
|
+
),
|
|
218
|
+
media_type="text/event-stream",
|
|
219
|
+
headers={
|
|
220
|
+
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
221
|
+
"Connection": "keep-alive",
|
|
222
|
+
"X-Accel-Buffering": "no", # Disable nginx buffering for SSE
|
|
223
|
+
},
|
|
224
|
+
)
|