revtrend 0.1.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.
- revtrend/__init__.py +0 -0
- revtrend/api.py +522 -0
- revtrend/auth.py +169 -0
- revtrend/cli.py +352 -0
- revtrend/config.py +183 -0
- revtrend/db.py +82 -0
- revtrend/migrations/001_initial.sql +21 -0
- revtrend/migrations/002_submitted_name.sql +2 -0
- revtrend/migrations/__init__.py +0 -0
- revtrend/mirror.py +310 -0
- revtrend/observability.py +96 -0
- revtrend/relay.py +65 -0
- revtrend/server.py +145 -0
- revtrend/store.py +158 -0
- revtrend-0.1.0.dist-info/METADATA +136 -0
- revtrend-0.1.0.dist-info/RECORD +19 -0
- revtrend-0.1.0.dist-info/WHEEL +4 -0
- revtrend-0.1.0.dist-info/entry_points.txt +4 -0
- revtrend-0.1.0.dist-info/licenses/LICENSE +21 -0
revtrend/__init__.py
ADDED
|
File without changes
|
revtrend/api.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import dataclasses
|
|
3
|
+
import fnmatch
|
|
4
|
+
import html
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
import fastapi
|
|
12
|
+
import fastapi.responses
|
|
13
|
+
import pydantic
|
|
14
|
+
|
|
15
|
+
import revtrend.auth as auth
|
|
16
|
+
import revtrend.config as config
|
|
17
|
+
import revtrend.db as db
|
|
18
|
+
import revtrend.mirror as mirror
|
|
19
|
+
import revtrend.observability as observability
|
|
20
|
+
import revtrend.relay as relay
|
|
21
|
+
import revtrend.store as store
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
router = fastapi.APIRouter()
|
|
26
|
+
|
|
27
|
+
FETCH_TIMEOUT = 60
|
|
28
|
+
MAX_POLL_TIMEOUT = 60
|
|
29
|
+
ON_DEMAND_FETCH_INTERVAL = 30
|
|
30
|
+
METRIC_RE = re.compile(r"^[a-z0-9_.]+$")
|
|
31
|
+
LABEL_KEY_RE = re.compile(r"^[a-z0-9_]+$")
|
|
32
|
+
|
|
33
|
+
Sha = typing.Annotated[str, pydantic.StringConstraints(pattern=r"^[0-9a-f]{40}$")]
|
|
34
|
+
MetricName = typing.Annotated[
|
|
35
|
+
str, pydantic.StringConstraints(pattern=METRIC_RE.pattern, max_length=128)
|
|
36
|
+
]
|
|
37
|
+
LabelKey = typing.Annotated[
|
|
38
|
+
str, pydantic.StringConstraints(pattern=LABEL_KEY_RE.pattern, max_length=64)
|
|
39
|
+
]
|
|
40
|
+
LabelValue = typing.Annotated[
|
|
41
|
+
str, pydantic.StringConstraints(min_length=1, max_length=256)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclasses.dataclass
|
|
46
|
+
class Project:
|
|
47
|
+
config: config.Project
|
|
48
|
+
id: int
|
|
49
|
+
mirror: mirror.Mirror
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclasses.dataclass
|
|
53
|
+
class State:
|
|
54
|
+
config: config.Config
|
|
55
|
+
db: db.Database
|
|
56
|
+
auth: auth.Authenticator
|
|
57
|
+
projects: dict[str, Project]
|
|
58
|
+
relay: relay.Relay
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_state(request: fastapi.Request):
|
|
62
|
+
return request.app.state.revtrend
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def get_principal(
|
|
66
|
+
state: typing.Annotated[State, fastapi.Depends(get_state)],
|
|
67
|
+
authorization: typing.Annotated[str | None, fastapi.Header()] = None,
|
|
68
|
+
):
|
|
69
|
+
scheme, _, token = (authorization or "").partition(" ")
|
|
70
|
+
if scheme.lower() != "bearer" or not token:
|
|
71
|
+
raise auth.AuthError("missing bearer token")
|
|
72
|
+
return await state.auth.authenticate(token.strip())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def project_access(role):
|
|
76
|
+
async def dependency(
|
|
77
|
+
project: str,
|
|
78
|
+
state: typing.Annotated[State, fastapi.Depends(get_state)],
|
|
79
|
+
principal: typing.Annotated[auth.Principal, fastapi.Depends(get_principal)],
|
|
80
|
+
):
|
|
81
|
+
if project not in state.projects:
|
|
82
|
+
raise fastapi.HTTPException(404, f"unknown project {project!r}")
|
|
83
|
+
if not principal.has_role(project, role):
|
|
84
|
+
raise fastapi.HTTPException(
|
|
85
|
+
403, f"role {role!r} required for project {project!r}"
|
|
86
|
+
)
|
|
87
|
+
return Access(state=state, project=state.projects[project], principal=principal)
|
|
88
|
+
|
|
89
|
+
return dependency
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclasses.dataclass
|
|
93
|
+
class Access:
|
|
94
|
+
state: State
|
|
95
|
+
project: Project
|
|
96
|
+
principal: auth.Principal
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
StateDep = typing.Annotated[State, fastapi.Depends(get_state)]
|
|
100
|
+
PrincipalDep = typing.Annotated[auth.Principal, fastapi.Depends(get_principal)]
|
|
101
|
+
ReaderDep = typing.Annotated[Access, fastapi.Depends(project_access("reader"))]
|
|
102
|
+
WriterDep = typing.Annotated[Access, fastapi.Depends(project_access("writer"))]
|
|
103
|
+
AdminDep = typing.Annotated[Access, fastapi.Depends(project_access("admin"))]
|
|
104
|
+
Aggregation = typing.Literal[tuple(store.AGGREGATIONS)]
|
|
105
|
+
MetricsQuery = typing.Annotated[list[str], fastapi.Query(alias="metric")]
|
|
106
|
+
LabelsQuery = typing.Annotated[list[str], fastapi.Query()]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@router.get("/metrics", include_in_schema=False)
|
|
110
|
+
async def prometheus_metrics():
|
|
111
|
+
content, media_type = observability.metrics_response()
|
|
112
|
+
return fastapi.Response(content, media_type=media_type)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.get("/healthz")
|
|
116
|
+
async def healthz(state: StateDep):
|
|
117
|
+
return {
|
|
118
|
+
"status": "ok",
|
|
119
|
+
"projects": {
|
|
120
|
+
name: {"ready": p.mirror.ready} for name, p in state.projects.items()
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Metric(pydantic.BaseModel):
|
|
126
|
+
model_config = pydantic.ConfigDict(extra="forbid")
|
|
127
|
+
name: MetricName
|
|
128
|
+
value: pydantic.FiniteFloat
|
|
129
|
+
labels: typing.Annotated[
|
|
130
|
+
dict[LabelKey, LabelValue], pydantic.Field(max_length=16)
|
|
131
|
+
] = {}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class Measurements(pydantic.BaseModel):
|
|
135
|
+
model_config = pydantic.ConfigDict(extra="forbid")
|
|
136
|
+
sha: Sha
|
|
137
|
+
run_id: typing.Annotated[
|
|
138
|
+
str, pydantic.StringConstraints(min_length=1, max_length=512)
|
|
139
|
+
]
|
|
140
|
+
metrics: typing.Annotated[
|
|
141
|
+
list[Metric], pydantic.Field(min_length=1, max_length=10000)
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
@pydantic.model_validator(mode="after")
|
|
145
|
+
def unique_series(self):
|
|
146
|
+
keys = [(m.name, tuple(sorted(m.labels.items()))) for m in self.metrics]
|
|
147
|
+
if len(keys) != len(set(keys)):
|
|
148
|
+
raise ValueError("duplicate metric name/labels combination")
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.post("/api/v1/projects/{project}/measurements", status_code=201)
|
|
153
|
+
async def ingest(body: Measurements, access: WriterDep, response: fastapi.Response):
|
|
154
|
+
await require_commit(access.project.mirror, body.sha)
|
|
155
|
+
principal = access.principal
|
|
156
|
+
run = store.Run(
|
|
157
|
+
project_id=access.project.id,
|
|
158
|
+
sha=body.sha,
|
|
159
|
+
run_id=body.run_id,
|
|
160
|
+
source=principal.source,
|
|
161
|
+
submitted_by=principal.submitted_by,
|
|
162
|
+
submitted_name=principal.display_name,
|
|
163
|
+
measured_at=int(time.time()),
|
|
164
|
+
)
|
|
165
|
+
metrics = [(m.name, m.labels, m.value) for m in body.metrics]
|
|
166
|
+
try:
|
|
167
|
+
created = await access.state.db.write(store.insert_measurements, run, metrics)
|
|
168
|
+
except store.ConflictError as e:
|
|
169
|
+
raise fastapi.HTTPException(409, str(e)) from e
|
|
170
|
+
log.info(
|
|
171
|
+
"ingested %d of %d measurements",
|
|
172
|
+
created,
|
|
173
|
+
len(body.metrics),
|
|
174
|
+
extra={
|
|
175
|
+
"project": access.project.config.name,
|
|
176
|
+
"sha": body.sha,
|
|
177
|
+
"run_id": body.run_id,
|
|
178
|
+
"source": principal.source,
|
|
179
|
+
"submitted_by": principal.submitted_by,
|
|
180
|
+
"submitted_name": principal.display_name,
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
observability.INGESTED.labels(access.project.config.name, principal.source).inc(
|
|
184
|
+
created
|
|
185
|
+
)
|
|
186
|
+
if not created:
|
|
187
|
+
response.status_code = 200
|
|
188
|
+
return {"created": created, "existing": len(body.metrics) - created}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def require_commit(m, sha):
|
|
192
|
+
"""Fetches if `sha` is unknown. Only commits that exist in the remote are accepted."""
|
|
193
|
+
if await m.has_commit(sha):
|
|
194
|
+
return
|
|
195
|
+
requested = time.monotonic()
|
|
196
|
+
try:
|
|
197
|
+
await asyncio.wait_for(
|
|
198
|
+
asyncio.shield(m.fetch(not_before=requested)), FETCH_TIMEOUT
|
|
199
|
+
)
|
|
200
|
+
except (TimeoutError, mirror.GitError) as e:
|
|
201
|
+
log.warning("fetch for unknown commit %s failed: %s", sha, e)
|
|
202
|
+
raise fastapi.HTTPException(503, "fetching from remote failed") from e
|
|
203
|
+
if not await m.has_commit(sha):
|
|
204
|
+
raise fastapi.HTTPException(409, f"commit {sha} not found in remote")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@router.get("/api/v1/projects/{project}/series")
|
|
208
|
+
async def series( # noqa: PLR0913
|
|
209
|
+
access: ReaderDep,
|
|
210
|
+
metrics: MetricsQuery,
|
|
211
|
+
ref: str = "default",
|
|
212
|
+
base: str = "default",
|
|
213
|
+
from_ms: typing.Annotated[int | None, fastapi.Query(alias="from")] = None,
|
|
214
|
+
to_ms: typing.Annotated[int | None, fastapi.Query(alias="to")] = None,
|
|
215
|
+
labels: LabelsQuery = (),
|
|
216
|
+
agg: Aggregation = "median",
|
|
217
|
+
):
|
|
218
|
+
metric_names = parse_metrics(metrics)
|
|
219
|
+
label_filters = parse_label_filters(labels)
|
|
220
|
+
m = access.project.mirror
|
|
221
|
+
ref_sha = await resolve(m, ref)
|
|
222
|
+
base_sha = await resolve(m, base)
|
|
223
|
+
history = await m.history(ref_sha)
|
|
224
|
+
on_branch = await m.branch_only(base_sha, ref_sha)
|
|
225
|
+
commits = [
|
|
226
|
+
(idx, c)
|
|
227
|
+
for idx, c in enumerate(history)
|
|
228
|
+
if (from_ms is None or c.time * 1000 >= from_ms)
|
|
229
|
+
and (to_ms is None or c.time * 1000 <= to_ms)
|
|
230
|
+
]
|
|
231
|
+
rows = await access.state.db.read(
|
|
232
|
+
store.measurements,
|
|
233
|
+
access.project.id,
|
|
234
|
+
[c.sha for _, c in commits],
|
|
235
|
+
metric_names,
|
|
236
|
+
label_filters,
|
|
237
|
+
)
|
|
238
|
+
points = store.aggregate(rows, agg)
|
|
239
|
+
return fastapi.responses.JSONResponse(series_rows(commits, points, on_branch))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def series_rows(commits, points, on_branch):
|
|
243
|
+
"""Flat rows for Grafana, oldest commit first."""
|
|
244
|
+
by_sha = series_points(points)
|
|
245
|
+
result = []
|
|
246
|
+
for idx, commit in reversed(commits):
|
|
247
|
+
for (metric, labels_json), point in sorted(by_sha.get(commit.sha, {}).items()):
|
|
248
|
+
series_labels = json.loads(labels_json)
|
|
249
|
+
result.append(
|
|
250
|
+
{
|
|
251
|
+
"time": commit.time * 1000,
|
|
252
|
+
"idx": idx,
|
|
253
|
+
"sha": commit.sha,
|
|
254
|
+
"short": commit.sha[:8],
|
|
255
|
+
"subject": commit.subject,
|
|
256
|
+
"metric": metric,
|
|
257
|
+
"labels": series_labels,
|
|
258
|
+
"series": series_name(metric, series_labels),
|
|
259
|
+
"value": point.value,
|
|
260
|
+
"runs": point.runs,
|
|
261
|
+
"source": point.source,
|
|
262
|
+
"on_branch": commit.sha in on_branch,
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
return result
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def series_points(points):
|
|
269
|
+
"""{(sha, metric, labels): point} -> {sha: {(metric, labels): point}}"""
|
|
270
|
+
result = {}
|
|
271
|
+
for (sha, metric, labels_json), point in points.items():
|
|
272
|
+
result.setdefault(sha, {})[(metric, labels_json)] = point
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def parse_metrics(values):
|
|
277
|
+
names = [n.strip() for v in values for n in v.split(",") if n.strip()]
|
|
278
|
+
invalid = [n for n in names if not METRIC_RE.match(n)]
|
|
279
|
+
if invalid or not names:
|
|
280
|
+
raise fastapi.HTTPException(400, f"invalid metric names: {invalid}")
|
|
281
|
+
return names
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def parse_label_filters(values):
|
|
285
|
+
filters = {}
|
|
286
|
+
for value in values:
|
|
287
|
+
key, sep, label_value = value.partition(":")
|
|
288
|
+
if not sep or not LABEL_KEY_RE.match(key):
|
|
289
|
+
raise fastapi.HTTPException(
|
|
290
|
+
400, f"invalid label filter {value!r}, expected key:value"
|
|
291
|
+
)
|
|
292
|
+
filters.setdefault(key, []).append(label_value)
|
|
293
|
+
return filters
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def resolve(m, ref):
|
|
297
|
+
"""Resolves a ref name or full SHA; 'default' means the remote's default branch.
|
|
298
|
+
|
|
299
|
+
Unknown refs trigger a (rate-limited) fetch before giving up.
|
|
300
|
+
"""
|
|
301
|
+
if ref == "default":
|
|
302
|
+
ref = m.default_branch
|
|
303
|
+
try:
|
|
304
|
+
return await m.resolve(ref)
|
|
305
|
+
except mirror.UnknownRefError:
|
|
306
|
+
pass
|
|
307
|
+
try:
|
|
308
|
+
not_before = time.monotonic() - ON_DEMAND_FETCH_INTERVAL
|
|
309
|
+
await asyncio.wait_for(
|
|
310
|
+
asyncio.shield(m.fetch(not_before=not_before)), FETCH_TIMEOUT
|
|
311
|
+
)
|
|
312
|
+
except (TimeoutError, mirror.GitError) as e:
|
|
313
|
+
log.warning("fetch for unknown ref %s failed: %s", ref, e)
|
|
314
|
+
return await m.resolve(ref)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def series_name(metric, labels):
|
|
318
|
+
if not labels:
|
|
319
|
+
return metric
|
|
320
|
+
return f"{metric}{{{','.join(f'{k}={v}' for k, v in sorted(labels.items()))}}}"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@router.get("/api/v1/projects")
|
|
324
|
+
async def projects(state: StateDep, principal: PrincipalDep):
|
|
325
|
+
return [
|
|
326
|
+
{"name": name, "default_branch": p.mirror.default_branch}
|
|
327
|
+
for name, p in state.projects.items()
|
|
328
|
+
if principal.has_role(name, "reader")
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@router.get("/api/v1/projects/{project}/refs")
|
|
333
|
+
async def refs(
|
|
334
|
+
access: ReaderDep, active_since: str | None = None, match: str | None = None
|
|
335
|
+
):
|
|
336
|
+
m = access.project.mirror
|
|
337
|
+
min_time = 0
|
|
338
|
+
if active_since is not None:
|
|
339
|
+
try:
|
|
340
|
+
min_time = time.time() - config.parse_duration(active_since)
|
|
341
|
+
except config.ConfigError as e:
|
|
342
|
+
raise fastapi.HTTPException(400, str(e)) from e
|
|
343
|
+
result = [
|
|
344
|
+
{
|
|
345
|
+
"name": r.name,
|
|
346
|
+
"default": r.name == m.default_branch,
|
|
347
|
+
"sha": r.sha,
|
|
348
|
+
"time": r.time * 1000,
|
|
349
|
+
}
|
|
350
|
+
for r in await m.refs()
|
|
351
|
+
if (r.time >= min_time or r.name == m.default_branch)
|
|
352
|
+
and (match is None or fnmatch.fnmatchcase(r.name, match))
|
|
353
|
+
]
|
|
354
|
+
result.sort(key=lambda r: (not r["default"], -r["time"], r["name"]))
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@router.get("/api/v1/projects/{project}/metrics")
|
|
359
|
+
async def metrics(access: ReaderDep):
|
|
360
|
+
return await access.state.db.read(store.metrics, access.project.id)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@router.get("/api/v1/projects/{project}/compare")
|
|
364
|
+
async def compare( # noqa: PLR0913
|
|
365
|
+
access: ReaderDep,
|
|
366
|
+
head: str,
|
|
367
|
+
base: str = "default",
|
|
368
|
+
metrics: typing.Annotated[list[str], fastapi.Query(alias="metric")] = (),
|
|
369
|
+
labels: LabelsQuery = (),
|
|
370
|
+
agg: Aggregation = "median",
|
|
371
|
+
):
|
|
372
|
+
if not mirror.SHA_RE.match(head):
|
|
373
|
+
raise fastapi.HTTPException(400, "head must be a full commit SHA")
|
|
374
|
+
metric_names = parse_metrics(metrics) if metrics else None
|
|
375
|
+
label_filters = parse_label_filters(labels)
|
|
376
|
+
m = access.project.mirror
|
|
377
|
+
await resolve(m, head)
|
|
378
|
+
tip = await resolve(m, base)
|
|
379
|
+
try:
|
|
380
|
+
fork = await m.merge_base(tip, head)
|
|
381
|
+
except mirror.NoMergeBaseError as e:
|
|
382
|
+
raise fastapi.HTTPException(409, str(e)) from e
|
|
383
|
+
walk = await m.first_parents(fork, access.project.config.compare_max_walk + 1)
|
|
384
|
+
rows = await access.state.db.read(
|
|
385
|
+
store.measurements,
|
|
386
|
+
access.project.id,
|
|
387
|
+
{head, tip, *walk},
|
|
388
|
+
metric_names,
|
|
389
|
+
label_filters,
|
|
390
|
+
)
|
|
391
|
+
points = series_points(store.aggregate(rows, agg))
|
|
392
|
+
head_points = points.get(head, {})
|
|
393
|
+
tip_points = points.get(tip, {})
|
|
394
|
+
baselines = {}
|
|
395
|
+
for distance, sha in enumerate(walk):
|
|
396
|
+
for key, point in points.get(sha, {}).items():
|
|
397
|
+
baselines.setdefault(key, (sha, distance, point))
|
|
398
|
+
results = [
|
|
399
|
+
comparison(key, head_points.get(key), baselines.get(key), tip_points.get(key))
|
|
400
|
+
for key in sorted(head_points.keys() | baselines.keys())
|
|
401
|
+
]
|
|
402
|
+
return {
|
|
403
|
+
"head": {"sha": head, "short": head[:8]},
|
|
404
|
+
"base": {
|
|
405
|
+
"ref": m.default_branch if base == "default" else base,
|
|
406
|
+
"tip_sha": tip,
|
|
407
|
+
"fork_sha": fork,
|
|
408
|
+
},
|
|
409
|
+
"results": results,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def comparison(key, head, baseline, tip):
|
|
414
|
+
metric, labels_json = key
|
|
415
|
+
result = {
|
|
416
|
+
"metric": metric,
|
|
417
|
+
"labels": json.loads(labels_json),
|
|
418
|
+
"status": "both" if head and baseline else "head_only" if head else "base_only",
|
|
419
|
+
"head": head.value if head else None,
|
|
420
|
+
"head_runs": head.runs if head else None,
|
|
421
|
+
"baseline": None,
|
|
422
|
+
"baseline_sha": None,
|
|
423
|
+
"baseline_distance": None,
|
|
424
|
+
"base_tip": tip.value if tip else None,
|
|
425
|
+
"delta": None,
|
|
426
|
+
"delta_pct": None,
|
|
427
|
+
}
|
|
428
|
+
if baseline:
|
|
429
|
+
sha, distance, point = baseline
|
|
430
|
+
result.update(
|
|
431
|
+
baseline=point.value, baseline_sha=sha, baseline_distance=distance
|
|
432
|
+
)
|
|
433
|
+
if head and baseline:
|
|
434
|
+
delta = head.value - result["baseline"]
|
|
435
|
+
result["delta"] = delta
|
|
436
|
+
if result["baseline"]:
|
|
437
|
+
result["delta_pct"] = round(delta / abs(result["baseline"]) * 100, 4)
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@router.delete("/api/v1/projects/{project}/measurements")
|
|
442
|
+
async def delete_measurements(
|
|
443
|
+
access: AdminDep, run_id: str | None = None, metric: str | None = None
|
|
444
|
+
):
|
|
445
|
+
if run_id is None and metric is None:
|
|
446
|
+
raise fastapi.HTTPException(400, "run_id or metric required")
|
|
447
|
+
deleted = await access.state.db.write(
|
|
448
|
+
store.delete_measurements, access.project.id, run_id, metric
|
|
449
|
+
)
|
|
450
|
+
log.info(
|
|
451
|
+
"deleted %d measurements",
|
|
452
|
+
deleted,
|
|
453
|
+
extra={
|
|
454
|
+
"project": access.project.config.name,
|
|
455
|
+
"run_id": run_id,
|
|
456
|
+
"metric": metric,
|
|
457
|
+
"submitted_by": access.principal.submitted_by,
|
|
458
|
+
"submitted_name": access.principal.display_name,
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
return {"deleted": deleted}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@router.get("/auth/cli/config")
|
|
465
|
+
async def cli_config(state: StateDep):
|
|
466
|
+
cli_auth = state.config.cli_auth
|
|
467
|
+
if cli_auth is None:
|
|
468
|
+
raise fastapi.HTTPException(404, "CLI login not configured")
|
|
469
|
+
return {
|
|
470
|
+
"authority": cli_auth.authority,
|
|
471
|
+
"client_id": cli_auth.client_id,
|
|
472
|
+
"scopes": list(cli_auth.scopes),
|
|
473
|
+
"redirect_uri": f"{state.config.server.public_url}/auth/cli/callback",
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@router.get("/auth/cli/callback", response_class=fastapi.responses.HTMLResponse)
|
|
478
|
+
async def cli_callback(
|
|
479
|
+
state: StateDep,
|
|
480
|
+
oauth_state: typing.Annotated[str, fastapi.Query(alias="state")],
|
|
481
|
+
code: str | None = None,
|
|
482
|
+
error: str | None = None,
|
|
483
|
+
error_description: str | None = None,
|
|
484
|
+
):
|
|
485
|
+
check_relay_state(oauth_state)
|
|
486
|
+
if code:
|
|
487
|
+
result = {"code": code}
|
|
488
|
+
message = "Login complete. Return to the terminal."
|
|
489
|
+
else:
|
|
490
|
+
result = {
|
|
491
|
+
"error": error or "unknown_error",
|
|
492
|
+
"error_description": error_description,
|
|
493
|
+
}
|
|
494
|
+
message = f"Login failed: {error} {error_description or ''}"
|
|
495
|
+
try:
|
|
496
|
+
state.relay.deliver(oauth_state, result)
|
|
497
|
+
except relay.RelayFullError as e:
|
|
498
|
+
raise fastapi.HTTPException(503, str(e)) from e
|
|
499
|
+
return f"<!doctype html><title>revtrend</title><p>{html.escape(message)}</p>"
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@router.get("/auth/cli/poll")
|
|
503
|
+
async def cli_poll(
|
|
504
|
+
state: StateDep,
|
|
505
|
+
oauth_state: typing.Annotated[str, fastapi.Query(alias="state")],
|
|
506
|
+
max_wait: typing.Annotated[
|
|
507
|
+
float, fastapi.Query(alias="timeout", gt=0, le=MAX_POLL_TIMEOUT)
|
|
508
|
+
] = MAX_POLL_TIMEOUT,
|
|
509
|
+
):
|
|
510
|
+
check_relay_state(oauth_state)
|
|
511
|
+
try:
|
|
512
|
+
result = await state.relay.wait(oauth_state, max_wait)
|
|
513
|
+
except relay.RelayFullError as e:
|
|
514
|
+
raise fastapi.HTTPException(503, str(e)) from e
|
|
515
|
+
if result is None:
|
|
516
|
+
return fastapi.Response(status_code=204)
|
|
517
|
+
return result
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def check_relay_state(value):
|
|
521
|
+
if not relay.STATE_RE.match(value):
|
|
522
|
+
raise fastapi.HTTPException(400, "invalid state")
|
revtrend/auth.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import dataclasses
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import jwt
|
|
7
|
+
|
|
8
|
+
import revtrend.config as config
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
ROLE_LEVELS = {role: level for level, role in enumerate(config.ROLES, start=1)}
|
|
13
|
+
ALGORITHMS = [
|
|
14
|
+
"RS256",
|
|
15
|
+
"RS384",
|
|
16
|
+
"RS512",
|
|
17
|
+
"PS256",
|
|
18
|
+
"PS384",
|
|
19
|
+
"PS512",
|
|
20
|
+
"ES256",
|
|
21
|
+
"ES384",
|
|
22
|
+
"ES512",
|
|
23
|
+
"EdDSA",
|
|
24
|
+
]
|
|
25
|
+
LEEWAY = 60
|
|
26
|
+
MAX_DISPLAY_NAME_LENGTH = 256
|
|
27
|
+
JWKS_MIN_REFRESH_INTERVAL = 60
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True)
|
|
35
|
+
class Principal:
|
|
36
|
+
issuer: str
|
|
37
|
+
subject: str
|
|
38
|
+
source: str
|
|
39
|
+
roles: dict[str, str]
|
|
40
|
+
display_name: str | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def submitted_by(self):
|
|
44
|
+
return f"{self.issuer}:{self.subject}"
|
|
45
|
+
|
|
46
|
+
def has_role(self, project, role):
|
|
47
|
+
granted = self.roles.get(project)
|
|
48
|
+
return granted is not None and ROLE_LEVELS[granted] >= ROLE_LEVELS[role]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Authenticator:
|
|
52
|
+
"""Validates bearer JWTs from the configured issuers and maps claims to roles."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, issuers, http_client):
|
|
55
|
+
self._issuers = {i.issuer: i for i in issuers}
|
|
56
|
+
self._http = http_client
|
|
57
|
+
self._keys = {url: _IssuerKeys() for url in self._issuers}
|
|
58
|
+
|
|
59
|
+
async def authenticate(self, token):
|
|
60
|
+
try:
|
|
61
|
+
header = jwt.get_unverified_header(token)
|
|
62
|
+
unverified = jwt.decode(token, options={"verify_signature": False})
|
|
63
|
+
except jwt.PyJWTError as e:
|
|
64
|
+
raise AuthError(f"malformed token: {e}") from e
|
|
65
|
+
issuer = self._issuers.get(unverified.get("iss"))
|
|
66
|
+
if issuer is None:
|
|
67
|
+
raise AuthError("unknown issuer")
|
|
68
|
+
key = await self._key(issuer, header.get("kid"))
|
|
69
|
+
try:
|
|
70
|
+
claims = jwt.decode(
|
|
71
|
+
token,
|
|
72
|
+
key,
|
|
73
|
+
algorithms=ALGORITHMS,
|
|
74
|
+
audience=issuer.audience,
|
|
75
|
+
issuer=issuer.issuer,
|
|
76
|
+
leeway=LEEWAY,
|
|
77
|
+
options={"require": ["exp", "iss", "aud", "sub"]},
|
|
78
|
+
)
|
|
79
|
+
except jwt.PyJWTError as e:
|
|
80
|
+
raise AuthError(f"invalid token: {e}") from e
|
|
81
|
+
return principal(issuer, claims)
|
|
82
|
+
|
|
83
|
+
async def _key(self, issuer, kid):
|
|
84
|
+
keys = self._keys[issuer.issuer]
|
|
85
|
+
key = keys.find(kid)
|
|
86
|
+
if key is None:
|
|
87
|
+
await keys.refresh(self._http, issuer.issuer)
|
|
88
|
+
key = keys.find(kid)
|
|
89
|
+
if key is None:
|
|
90
|
+
raise AuthError(f"no signing key {kid!r} for issuer {issuer.name}")
|
|
91
|
+
return key
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _IssuerKeys:
|
|
95
|
+
def __init__(self):
|
|
96
|
+
self.jwks = None
|
|
97
|
+
self.fetched_at = None
|
|
98
|
+
self.lock = asyncio.Lock()
|
|
99
|
+
|
|
100
|
+
def find(self, kid):
|
|
101
|
+
if self.jwks is None:
|
|
102
|
+
return None
|
|
103
|
+
if kid is None:
|
|
104
|
+
return self.jwks.keys[0] if len(self.jwks.keys) == 1 else None
|
|
105
|
+
for key in self.jwks.keys:
|
|
106
|
+
if key.key_id == kid:
|
|
107
|
+
return key
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
async def refresh(self, http, issuer_url):
|
|
111
|
+
async with self.lock:
|
|
112
|
+
now = time.monotonic()
|
|
113
|
+
if (
|
|
114
|
+
self.fetched_at is not None
|
|
115
|
+
and now - self.fetched_at < JWKS_MIN_REFRESH_INTERVAL
|
|
116
|
+
):
|
|
117
|
+
return
|
|
118
|
+
self.fetched_at = now
|
|
119
|
+
discovery = await _get_json(
|
|
120
|
+
http, f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
|
|
121
|
+
)
|
|
122
|
+
self.jwks = jwt.PyJWKSet.from_dict(
|
|
123
|
+
await _get_json(http, discovery["jwks_uri"])
|
|
124
|
+
)
|
|
125
|
+
log.info("loaded %d signing keys for %s", len(self.jwks.keys), issuer_url)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def _get_json(http, url):
|
|
129
|
+
response = await http.get(url)
|
|
130
|
+
response.raise_for_status()
|
|
131
|
+
return response.json()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def principal(issuer, claims):
|
|
135
|
+
roles = {}
|
|
136
|
+
source = None
|
|
137
|
+
for rule in issuer.rules:
|
|
138
|
+
if not matches(rule, claims.get(rule.claim)):
|
|
139
|
+
continue
|
|
140
|
+
for grant in rule.grants:
|
|
141
|
+
current = roles.get(grant.project)
|
|
142
|
+
if current is None or ROLE_LEVELS[grant.role] > ROLE_LEVELS[current]:
|
|
143
|
+
roles[grant.project] = grant.role
|
|
144
|
+
if source is None:
|
|
145
|
+
source = rule.source
|
|
146
|
+
return Principal(
|
|
147
|
+
issuer=issuer.name,
|
|
148
|
+
subject=claims["sub"],
|
|
149
|
+
source=source or issuer.source,
|
|
150
|
+
roles=roles,
|
|
151
|
+
display_name=display_name(issuer, claims),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def display_name(issuer, claims):
|
|
156
|
+
"""First configured claim with a string value, for humans reading the audit trail."""
|
|
157
|
+
for claim in issuer.display_claims:
|
|
158
|
+
value = claims.get(claim)
|
|
159
|
+
if isinstance(value, str) and value:
|
|
160
|
+
return value[:MAX_DISPLAY_NAME_LENGTH]
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def matches(rule, value):
|
|
165
|
+
values = value if isinstance(value, list) else [value]
|
|
166
|
+
values = [v for v in values if isinstance(v, str)]
|
|
167
|
+
if rule.regex is not None:
|
|
168
|
+
return any(rule.regex.search(v) for v in values)
|
|
169
|
+
return rule.contains in values
|