interloper-api 0.57.0__tar.gz → 0.58.0__tar.gz
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.
- {interloper_api-0.57.0 → interloper_api-0.58.0}/PKG-INFO +1 -1
- {interloper_api-0.57.0 → interloper_api-0.58.0}/pyproject.toml +1 -1
- {interloper_api-0.57.0 → interloper_api-0.58.0}/pyproject.toml.orig +1 -1
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/app.py +11 -1
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/dependencies.py +20 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/admin.py +105 -1
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/backfills.py +33 -9
- {interloper_api-0.57.0 → interloper_api-0.58.0}/README.md +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/components.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/components.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/tokens.py +0 -0
- {interloper_api-0.57.0 → interloper_api-0.58.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI, Request
|
|
|
9
9
|
from fastapi.middleware.cors import CORSMiddleware
|
|
10
10
|
from fastapi.responses import JSONResponse
|
|
11
11
|
from interloper.catalog.base import Catalog
|
|
12
|
-
from interloper.errors import ComponentDriftError, NotFoundError
|
|
12
|
+
from interloper.errors import ComponentDriftError, NotFoundError, QuotaExceededError
|
|
13
13
|
from interloper_db import Store
|
|
14
14
|
|
|
15
15
|
from interloper_api.dependencies import (
|
|
@@ -17,6 +17,7 @@ from interloper_api.dependencies import (
|
|
|
17
17
|
set_auth_config,
|
|
18
18
|
set_catalog,
|
|
19
19
|
set_features,
|
|
20
|
+
set_quota_defaults,
|
|
20
21
|
set_smtp_config,
|
|
21
22
|
set_store,
|
|
22
23
|
)
|
|
@@ -76,6 +77,14 @@ def create_app(
|
|
|
76
77
|
"""
|
|
77
78
|
return JSONResponse(status_code=409, content={"detail": str(exc)})
|
|
78
79
|
|
|
80
|
+
@app.exception_handler(QuotaExceededError)
|
|
81
|
+
async def _quota_exceeded_handler(_request: Request, exc: QuotaExceededError) -> JSONResponse:
|
|
82
|
+
"""Store-level quota enforcement surfaces as 429 with structured context."""
|
|
83
|
+
return JSONResponse(
|
|
84
|
+
status_code=429,
|
|
85
|
+
content={"detail": {"message": str(exc), "quota": exc.quota, "limit": exc.limit, "used": exc.used}},
|
|
86
|
+
)
|
|
87
|
+
|
|
79
88
|
if cors_origins:
|
|
80
89
|
app.add_middleware(
|
|
81
90
|
CORSMiddleware,
|
|
@@ -92,6 +101,7 @@ def create_app(
|
|
|
92
101
|
if settings:
|
|
93
102
|
set_auth_config(settings.auth)
|
|
94
103
|
set_smtp_config(settings.smtp)
|
|
104
|
+
set_quota_defaults(settings.quota)
|
|
95
105
|
|
|
96
106
|
api = APIRouter(prefix="/api")
|
|
97
107
|
api.include_router(auth.router)
|
|
@@ -18,6 +18,7 @@ _auth_config: Any | None = None
|
|
|
18
18
|
_smtp_config: Any | None = None
|
|
19
19
|
_features: dict[str, bool] = {}
|
|
20
20
|
_admin_config: Any | None = None
|
|
21
|
+
_quota_defaults: Any | None = None
|
|
21
22
|
|
|
22
23
|
# Role hierarchy: admin > editor > viewer
|
|
23
24
|
_ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
|
|
@@ -154,6 +155,25 @@ def get_admin_config() -> Any:
|
|
|
154
155
|
return _admin_config
|
|
155
156
|
|
|
156
157
|
|
|
158
|
+
def set_quota_defaults(defaults: Any) -> None:
|
|
159
|
+
"""Set the global default quota limits (from settings, at app creation).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
defaults: The QuotaSettings instance.
|
|
163
|
+
"""
|
|
164
|
+
global _quota_defaults # noqa: PLW0603
|
|
165
|
+
_quota_defaults = defaults
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_quota_defaults() -> Any:
|
|
169
|
+
"""Return the global default quota limits.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The QuotaSettings instance, or None if not configured.
|
|
173
|
+
"""
|
|
174
|
+
return _quota_defaults
|
|
175
|
+
|
|
176
|
+
|
|
157
177
|
# -- Auth dependencies -------------------------------------------------------
|
|
158
178
|
|
|
159
179
|
|
|
@@ -8,6 +8,7 @@ no access to org-scoped *data* (sources, jobs, runs, …).
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import datetime as dt
|
|
11
12
|
import inspect
|
|
12
13
|
import logging
|
|
13
14
|
from datetime import datetime
|
|
@@ -17,9 +18,10 @@ from uuid import UUID
|
|
|
17
18
|
|
|
18
19
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
19
20
|
from interloper_db import Organisation, Profile, Store
|
|
21
|
+
from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS
|
|
20
22
|
from pydantic import BaseModel
|
|
21
23
|
|
|
22
|
-
from interloper_api.dependencies import get_admin_config, get_store, require_super_admin
|
|
24
|
+
from interloper_api.dependencies import get_admin_config, get_quota_defaults, get_store, require_super_admin
|
|
23
25
|
from interloper_api.email import send_invite_email
|
|
24
26
|
|
|
25
27
|
logger = logging.getLogger(__name__)
|
|
@@ -223,6 +225,38 @@ class AdminDataConfig(BaseModel):
|
|
|
223
225
|
catalog: dict[str, list[str]]
|
|
224
226
|
|
|
225
227
|
|
|
228
|
+
class AdminQuotaLimits(BaseModel):
|
|
229
|
+
"""One set of quota limits; null means unlimited (or unset, for overrides)."""
|
|
230
|
+
|
|
231
|
+
max_sources: int | None = None
|
|
232
|
+
max_assets_per_source: int | None = None
|
|
233
|
+
max_successful_runs_per_month: int | None = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class AdminOrgQuotaStatus(BaseModel):
|
|
237
|
+
"""One organisation's quota limits and current usage."""
|
|
238
|
+
|
|
239
|
+
id: UUID
|
|
240
|
+
name: str
|
|
241
|
+
limits: AdminQuotaLimits
|
|
242
|
+
effective: AdminQuotaLimits
|
|
243
|
+
sources: int
|
|
244
|
+
max_assets_per_source: int
|
|
245
|
+
successful_runs: int
|
|
246
|
+
reserved_runs: int
|
|
247
|
+
#: Recomputed from the runs table; a mismatch with ``successful_runs``
|
|
248
|
+
#: (the ledger) signals counter drift.
|
|
249
|
+
recomputed_successful_runs: int
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class AdminQuotasResponse(BaseModel):
|
|
253
|
+
"""Quota overview: global defaults plus per-organisation status."""
|
|
254
|
+
|
|
255
|
+
period_start: dt.date
|
|
256
|
+
defaults: AdminQuotaLimits
|
|
257
|
+
organisations: list[AdminOrgQuotaStatus]
|
|
258
|
+
|
|
259
|
+
|
|
226
260
|
class AdminConfigResponse(BaseModel):
|
|
227
261
|
"""Read-only, secrets-redacted snapshot of the instance configuration."""
|
|
228
262
|
|
|
@@ -230,6 +264,7 @@ class AdminConfigResponse(BaseModel):
|
|
|
230
264
|
auth: AdminAuthConfig
|
|
231
265
|
services: AdminServicesConfig
|
|
232
266
|
data: AdminDataConfig
|
|
267
|
+
quotas: AdminQuotaLimits
|
|
233
268
|
|
|
234
269
|
|
|
235
270
|
# Non-secret launcher/runner config keys exposed on /admin/config. Anything not
|
|
@@ -400,12 +435,39 @@ def build_config_snapshot(settings: Any, features: dict[str, bool], catalog: Any
|
|
|
400
435
|
encryption_configured=bool(settings.secrets.encryption_key),
|
|
401
436
|
catalog=_catalog_by_kind(catalog) if catalog is not None else {},
|
|
402
437
|
),
|
|
438
|
+
quotas=_quota_limits(settings.quota),
|
|
403
439
|
)
|
|
404
440
|
|
|
405
441
|
|
|
406
442
|
# -- Helpers ------------------------------------------------------------------
|
|
407
443
|
|
|
408
444
|
|
|
445
|
+
def _quota_limits(limits: Any) -> AdminQuotaLimits:
|
|
446
|
+
"""Map any limits-shaped object (settings, Quota row, None) to the model."""
|
|
447
|
+
return AdminQuotaLimits(
|
|
448
|
+
max_sources=getattr(limits, "max_sources", None),
|
|
449
|
+
max_assets_per_source=getattr(limits, "max_assets_per_source", None),
|
|
450
|
+
max_successful_runs_per_month=getattr(limits, "max_successful_runs_per_month", None),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _effective_limits(overrides: AdminQuotaLimits, defaults: AdminQuotaLimits) -> AdminQuotaLimits:
|
|
455
|
+
"""Per-org overrides win field-by-field over the global defaults."""
|
|
456
|
+
return AdminQuotaLimits(
|
|
457
|
+
max_sources=overrides.max_sources if overrides.max_sources is not None else defaults.max_sources,
|
|
458
|
+
max_assets_per_source=(
|
|
459
|
+
overrides.max_assets_per_source
|
|
460
|
+
if overrides.max_assets_per_source is not None
|
|
461
|
+
else defaults.max_assets_per_source
|
|
462
|
+
),
|
|
463
|
+
max_successful_runs_per_month=(
|
|
464
|
+
overrides.max_successful_runs_per_month
|
|
465
|
+
if overrides.max_successful_runs_per_month is not None
|
|
466
|
+
else defaults.max_successful_runs_per_month
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
409
471
|
def _require_org(store: Store, org_id: UUID) -> Organisation:
|
|
410
472
|
"""Fetch an organisation or raise 404."""
|
|
411
473
|
org = store.get_organisation(org_id)
|
|
@@ -466,6 +528,48 @@ def get_instance_config(
|
|
|
466
528
|
return config
|
|
467
529
|
|
|
468
530
|
|
|
531
|
+
# -- Quotas ---------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@router.get("/quotas")
|
|
535
|
+
def get_quotas(
|
|
536
|
+
user: Profile = Depends(require_super_admin),
|
|
537
|
+
store: Store = Depends(get_store),
|
|
538
|
+
quota_defaults: Any = Depends(get_quota_defaults),
|
|
539
|
+
) -> AdminQuotasResponse:
|
|
540
|
+
"""Quota limits and current-period usage for every organisation."""
|
|
541
|
+
defaults = _quota_limits(quota_defaults)
|
|
542
|
+
period_start = store.current_period_start()
|
|
543
|
+
overrides = {quota.org_id: quota for quota in store.list_quotas()}
|
|
544
|
+
usage = {
|
|
545
|
+
row.org_id: row
|
|
546
|
+
for row in store.list_usage(period_start=period_start)
|
|
547
|
+
if row.metric == METRIC_SUCCESSFUL_RUNS
|
|
548
|
+
}
|
|
549
|
+
sources = store.count_sources_by_org()
|
|
550
|
+
max_assets = store.max_assets_per_source_by_org()
|
|
551
|
+
recomputed = store.count_successful_runs_by_org(period_start)
|
|
552
|
+
|
|
553
|
+
organisations = []
|
|
554
|
+
for org, _member_count in store.list_all_organisations():
|
|
555
|
+
limits = _quota_limits(overrides.get(org.id))
|
|
556
|
+
org_usage = usage.get(org.id)
|
|
557
|
+
organisations.append(
|
|
558
|
+
AdminOrgQuotaStatus(
|
|
559
|
+
id=org.id,
|
|
560
|
+
name=org.name,
|
|
561
|
+
limits=limits,
|
|
562
|
+
effective=_effective_limits(limits, defaults),
|
|
563
|
+
sources=sources.get(org.id, 0),
|
|
564
|
+
max_assets_per_source=max_assets.get(org.id, 0),
|
|
565
|
+
successful_runs=org_usage.used if org_usage else 0,
|
|
566
|
+
reserved_runs=org_usage.reserved if org_usage else 0,
|
|
567
|
+
recomputed_successful_runs=recomputed.get(org.id, 0),
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
return AdminQuotasResponse(period_start=period_start, defaults=defaults, organisations=organisations)
|
|
571
|
+
|
|
572
|
+
|
|
469
573
|
# -- Users --------------------------------------------------------------------
|
|
470
574
|
|
|
471
575
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Backfills API:
|
|
1
|
+
"""Backfills API: queue, inspect, and cancel backfills."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -100,14 +100,17 @@ def create_backfill(
|
|
|
100
100
|
job = load_authorized(
|
|
101
101
|
lambda i: store.get_component(i, kind="job"), body.component_id, user, store, label="Job", minimum="editor"
|
|
102
102
|
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
103
|
+
try:
|
|
104
|
+
backfill = store.create_backfill(
|
|
105
|
+
job.org_id,
|
|
106
|
+
component_id=body.component_id,
|
|
107
|
+
start_date=body.start_date,
|
|
108
|
+
end_date=body.end_date,
|
|
109
|
+
concurrency=body.concurrency,
|
|
110
|
+
fail_fast=body.fail_fast,
|
|
111
|
+
)
|
|
112
|
+
except ValueError as e: # span cap
|
|
113
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
111
114
|
return _backfill_to_response(backfill)
|
|
112
115
|
|
|
113
116
|
|
|
@@ -124,3 +127,24 @@ def get_backfill(
|
|
|
124
127
|
raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
|
|
125
128
|
authorize_org_member(user, backfill.org_id, store, detail=f"Backfill {backfill_id} not found")
|
|
126
129
|
return _backfill_to_response(backfill)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@router.post("/{backfill_id}/cancel")
|
|
133
|
+
def cancel_backfill(
|
|
134
|
+
backfill_id: UUID,
|
|
135
|
+
user: Profile = Depends(get_current_user),
|
|
136
|
+
store: Store = Depends(get_store),
|
|
137
|
+
) -> BackfillResponse:
|
|
138
|
+
"""Cancel a backfill: pending and queued runs are canceled, in-flight runs drain."""
|
|
139
|
+
try:
|
|
140
|
+
backfill = store.get_backfill(backfill_id)
|
|
141
|
+
except NotFoundError:
|
|
142
|
+
raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
|
|
143
|
+
authorize_org_member(
|
|
144
|
+
user, backfill.org_id, store, minimum="editor", detail=f"Backfill {backfill_id} not found"
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
backfill = store.cancel_backfill(backfill_id)
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
raise HTTPException(status_code=409, detail=str(e))
|
|
150
|
+
return _backfill_to_response(backfill)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|