Bound what the API accepts, and close the holes the audit found

**SQLite is the database, and now says so.** `metric_minute` and every run
table are written with `sqlalchemy.dialects.sqlite.insert(...)
.on_conflict_do_update` and with `max(a, b)`, neither of which another
dialect has — so pointing `DATABASE_URL` at Postgres migrated cleanly,
served, logged in, and then lost every observability flush into the
collector's hold buffer and failed every run. It refuses at startup
instead. (The Postgres in the compose stack is Umami's; the engine's own
database has been a file beside the flows since 2026-08-21.)

**Every integer query parameter is bounded.** The caps were written as
`min(limit, 500)`, which a negative walks straight through — `?limit=-1`
compiles to `LIMIT -1` and SQLite returns the whole table. Ten signatures,
now `Query(ge=…, le=…)`. `hours=0` still means an hour, which
`_window_hours` was already deliberate about.

**Exports are capped at 10 000 runs** and say so with `X-Truncated`. The
filters bounded a sensible request and nothing bounded an unfiltered one,
which read every row into memory before a byte was streamed. `_series`
resolves cached curves in two queries rather than a `Run` lookup and a
`RunMetric` query per restored node — a comparison of twenty runs was
calling that twenty times over.

**`PUT /artifacts` has a size limit** (`MAX_ARTIFACT_BYTES`, 2 GiB, 0 to
disable), checked against `Content-Length` and again against the stream for
a chunked body, and its writes moved off the event loop.

**`/observability/timeseries` takes `since`/`until`**, the same window
`/runs` and `/events` take, capped at 2000 points — `hours=720&bucket_s=60`
was 43 200 of them in one array. It is also what a dragged chart needs to
re-fetch at its own resolution rather than magnifying buckets it has.

**Composite indexes** for the three list screens: `run(flow, created_at)`
and `(status, created_at)`, `flow_run(flow, started_at)`,
`engine_event(type, ts)`. Every index was single-column, so SQLite picked
one and sorted the rest by hand. Verified against a copy of a live database
(250k `flow_run` rows): the planner takes all four.

**Redis clients have socket timeouts.** A Redis that stops answering
without closing the connection hung the caller until the kernel gave up —
including `/utils/health/`, whose job is to notice.

**The panels file is written under one lock.** `save_panels` and
`unpair_panel` are both read-modify-write, and a save that read before an
unpair wrote put the old nonce back — silently un-revoking a screen that
had just been unpaired. The nonce carry-forward was written to make that
impossible; the gap between its read and its write is where it happened.

**Startup releases what it acquired.** Everything past `event_bus.bind`
registers how to close itself and the `finally` walks that list backwards;
a failure part-way through used to reach none of the shutdown steps and
leave the worker pool's subprocesses and every background task behind —
under `--reload`, once per bad edit. `modules.reconcile` moved into the
background: `uv` gets five minutes twice over, the healthcheck allows
eighty seconds, and the autoheal restarted the container before it could
finish installing.

`delete_run` takes SQLite's write lock up front (`core.db.writing`) rather
than upgrading a deferred transaction and losing to whichever flush
committed in between. `modules.sync` is serialised — two applies mutated
one venv at once. The proxied-call and stream dicts are bounded, and a
reused id cancels its predecessor instead of dropping the reference.

Security, found in passing and small enough to fix here:

- **`/secrets/` required only a signed-in user.** The names alone say what
  this installation talks to, and `PUT /{name}` takes any name, so any
  account could overwrite the credential a flow authenticates with.
  Superuser now — which `/search` already assumed and said so.
- **`POST /login/access-token` had no rate limit.** Argon2 is deliberately
  expensive and the route is unauthenticated and runs in the shared
  threadpool. Ten *failed* attempts per address per five minutes; a
  successful sign-in spends nothing.
- **a password reset link worked repeatedly for 48 hours.** The token now
  carries a digest of the password hash it was minted against, so it stops
  verifying once it has set one. No table of spent tokens needed.
- **enrolment accepted `http://`**, sending the claim code and then this
  installation's credential in clear. https, or a local address.
- the rate limiter read `request.client.host`, which behind Traefik is the
  proxy — so every per-address limit was one global bucket and one caller
  could lock out everyone. It reads the forwarded address, and its
  bucket table is capped rather than growing one key per address forever.
- SMTP has a timeout and sends after the response, so an unreachable mail
  host cannot pin a threadpool worker, and a reply's timing no longer says
  whether the address exists.

Test suite: engine-written rows are cleared between modules. A `FlowRun`
left `running` by one module turned up in another's query. Per-test
rollback is not available here — the module-scoped `client` runs the real
lifespan and its collector and run service write through sessions of their
own — so this bounds it where the writes come from. Three consecutive
green runs, orders randomised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
This commit is contained in:
2026-08-29 20:40:05 +02:00
co-authored by Claude Opus 5
parent 1069247085
commit 57eace2226
24 changed files with 821 additions and 260 deletions
+21 -2
View File
@@ -19,6 +19,7 @@ from starlette.concurrency import run_in_threadpool
from fluksio.api.deps import user_from_token
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.core.db import engine
from fluksio.flow.artifacts import ArtifactStore
@@ -84,14 +85,32 @@ async def put_artifact(
Spooled to disk as it arrives rather than buffered: a video segment is as
legitimate a body here as a checkpoint, and neither should have to fit in
memory twice.
memory twice. Capped, because nothing else here was: any account, and any
worker credential, could otherwise fill the data volume.
"""
store = _store(request)
cap = settings.MAX_ARTIFACT_BYTES
declared = request.headers.get("content-length")
if cap and declared and declared.isdigit() and int(declared) > cap:
raise HTTPException(
status_code=413, detail=f"An artifact may be at most {cap} bytes"
)
handle = tempfile.NamedTemporaryFile(dir=store.root, delete=False)
written = 0
try:
with handle:
async for chunk in request.stream():
handle.write(chunk)
written += len(chunk)
# A chunked body declares no length, so the stream is what
# actually holds the limit.
if cap and written > cap:
raise HTTPException(
status_code=413,
detail=f"An artifact may be at most {cap} bytes",
)
# Off the event loop: this is a write syscall per chunk, for
# as long as the upload lasts.
await run_in_threadpool(handle.write, chunk)
return await run_in_threadpool(
store.put_file, Path(handle.name), media_type, name
)
+46 -8
View File
@@ -1,18 +1,21 @@
from datetime import timedelta
from secrets import compare_digest
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.security import OAuth2PasswordRequestForm
from fluksio import crud
from fluksio.api.deps import CurrentUser, SessionDep, get_current_active_superuser
from fluksio.api.routes.oauth import too_many
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.models import Message, NewPassword, Token, UserPublic, UserUpdate
from fluksio.utils import (
generate_password_reset_token,
generate_reset_password_email,
password_fingerprint,
send_email,
verify_password_reset_token,
)
@@ -20,17 +23,35 @@ from fluksio.utils import (
router = APIRouter(tags=["login"])
#: Attempts per address per window, and the window. Argon2 is deliberately
#: expensive — tens of megabytes and several passes per verification — and
#: this route is unauthenticated and runs in the shared threadpool, so a
#: burst of guesses is a memory and a concurrency problem before it is a
#: credential one. A person who mistypes twice is nowhere near it.
LOGIN_ATTEMPTS = 10
LOGIN_WINDOW_S = 300.0
@router.post("/login/access-token")
def login_access_token(
session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
request: Request,
session: SessionDep,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
"""
OAuth2 compatible token login, get an access token for future requests
"""
if too_many(request, "login", LOGIN_ATTEMPTS, LOGIN_WINDOW_S, record=False):
raise HTTPException(
status_code=429, detail="Too many sign-in attempts. Try again shortly."
)
user = crud.authenticate(
session=session, email=form_data.username, password=form_data.password
)
if not user:
# Only the failures are counted, so somebody signing in all day is
# never near the limit and somebody guessing is.
too_many(request, "login", LOGIN_ATTEMPTS, LOGIN_WINDOW_S)
raise HTTPException(status_code=400, detail="Incorrect email or password")
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
@@ -51,7 +72,9 @@ def test_token(current_user: CurrentUser) -> Any:
@router.post("/password-recovery/{email}")
def recover_password(email: str, session: SessionDep) -> Message:
def recover_password(
email: str, session: SessionDep, background: BackgroundTasks
) -> Message:
"""
Password Recovery
"""
@@ -60,11 +83,18 @@ def recover_password(email: str, session: SessionDep) -> Message:
# Always return the same response to prevent email enumeration attacks
# Only send email if user actually exists
if user:
password_reset_token = generate_password_reset_token(email=email)
password_reset_token = generate_password_reset_token(
email=email, hashed_password=user.hashed_password
)
email_data = generate_reset_password_email(
email_to=user.email, email=email, token=password_reset_token
)
send_email(
# After the response, not before it: sending is a synchronous SMTP
# conversation, and doing it inline made the reply slow exactly when
# the address exists — which is the enumeration the identical wording
# below is there to prevent.
background.add_task(
send_email,
email_to=user.email,
subject=email_data.subject,
html_content=email_data.html_content,
@@ -79,15 +109,21 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message:
"""
Reset password
"""
email = verify_password_reset_token(token=body.token)
if not email:
claims = verify_password_reset_token(token=body.token)
if not claims:
raise HTTPException(status_code=400, detail="Invalid token")
email, fingerprint = claims
user = crud.get_user_by_email(session=session, email=email)
if not user:
# Don't reveal that the user doesn't exist - use same error as invalid token
raise HTTPException(status_code=400, detail="Invalid token")
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
if not compare_digest(fingerprint, password_fingerprint(user.hashed_password)):
# The link already set a password, or the password moved since it was
# sent. Either way this one is spent — a reset link used to work over
# and over for the whole of its 48 hours.
raise HTTPException(status_code=400, detail="Invalid token")
user_in_update = UserUpdate(password=body.new_password)
crud.update_user(
session=session,
@@ -113,7 +149,9 @@ def recover_password_html_content(email: str, session: SessionDep) -> Any:
status_code=404,
detail="The user with this username does not exist in the system.",
)
password_reset_token = generate_password_reset_token(email=email)
password_reset_token = generate_password_reset_token(
email=email, hashed_password=user.hashed_password
)
email_data = generate_reset_password_email(
email_to=user.email, email=email, token=password_reset_token
)
+37 -5
View File
@@ -26,7 +26,7 @@ import re
import secrets
import time
import uuid
from collections import defaultdict, deque
from collections import OrderedDict, defaultdict, deque
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import urlencode, urlparse
@@ -81,24 +81,56 @@ def _error(code: str, description: str, status: int = 400) -> JSONResponse:
# enough: the engine is one process, and this is a speed bump, not a boundary.
# -----------------------------------------------------------------------------
_hits: dict[str, deque[float]] = defaultdict(deque)
#: How many distinct buckets are remembered at once. Entries were pruned
#: *within* a deque and never removed, so one key per source address survived
#: for the life of the process.
_MAX_BUCKETS = 4096
_hits: OrderedDict[str, deque[float]] = OrderedDict()
def _too_many(bucket: str, limit: int, window: float) -> bool:
def _too_many(bucket: str, limit: int, window: float, record: bool = True) -> bool:
now = time.monotonic()
seen = _hits[bucket]
seen = _hits.get(bucket)
if seen is None:
seen = _hits[bucket] = deque()
while len(_hits) > _MAX_BUCKETS:
_hits.popitem(last=False)
else:
_hits.move_to_end(bucket)
while seen and now - seen[0] > window:
seen.popleft()
if len(seen) >= limit:
return True
seen.append(now)
if record:
seen.append(now)
return False
def _client_ip(request: Request) -> str:
"""The caller's address, or the proxy's if it did not forward one.
Behind Traefik `request.client.host` is the proxy for every request, which
made every per-address limit here one global bucket — so one caller could
lock out everyone. The leftmost forwarded entry is the client, and is what
the panel pairing screen already reads.
"""
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded:
return forwarded
return request.client.host if request.client else "unknown"
def too_many(
request: Request, action: str, limit: int, window: float, record: bool = True
) -> bool:
"""Per-address rate limit, for anything unauthenticated on any router.
``record=False`` asks the question without spending an attempt, for a
caller that only wants to count the ones that failed.
"""
return _too_many(f"{action}:{_client_ip(request)}", limit, window, record)
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
+34 -11
View File
@@ -8,9 +8,9 @@ cannot render while the engine is degraded is the wrong way round.
"""
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, model_validator
from sqlalchemy import ColumnElement, Integer, cast, func
@@ -141,6 +141,11 @@ def _closed_before(stride: int) -> datetime:
return datetime.fromtimestamp(now // stride * stride, UTC)
#: The most slices one timeseries answers with. `hours=720&bucket_s=60` is
#: 43 200 points into a single JSON array; a chart draws a few hundred.
MAX_POINTS = 2000
def _window_hours(hours: int) -> int:
"""A window the rollups can answer for: an hour at least, retention at most.
@@ -245,14 +250,28 @@ def read_timeseries(
session: SessionDep,
flow: str | None = None,
node: str | None = None,
hours: int = 24,
bucket_s: int = 60,
hours: Annotated[int, Query(ge=0, le=8760)] = 24,
bucket_s: Annotated[int, Query(ge=1, le=86400)] = 60,
since: datetime | None = None,
until: datetime | None = None,
) -> Any:
"""Executions, errors and timings over time, summed across nodes."""
"""Executions, errors and timings over time, summed across nodes.
``hours`` measures back from now, which is what a range picker asks for.
``since`` and ``until`` name a window instead — inclusive and exclusive,
the same pair ``/runs`` and ``/events`` take — so a chart dragged to a
span can be re-fetched at that span's own resolution rather than
magnifying the buckets it already has.
"""
hours = _window_hours(hours)
# The database does the fold: a week of minute rows per node used to cross
# the wire on every poll, and only the slices need to.
stride = max(60, bucket_s)
if since is not None and until is not None:
# Enough slices to draw with, and never so many that the response is
# the problem the fold was there to solve.
span = (_aware(until) - _aware(since)).total_seconds()
stride = max(stride, int(span // MAX_POINTS) + 1)
slot = (_epoch(col(MetricBucket.bucket)) // stride * stride).label("slot")
statement = sa_select(
slot,
@@ -264,8 +283,8 @@ def read_timeseries(
func.sum(col(MetricBucket.lag_sum_ms)).label("lag_sum_ms"),
func.sum(col(MetricBucket.items)).label("items"),
).where(
col(MetricBucket.bucket) >= _since(hours),
col(MetricBucket.bucket) < _closed_before(stride),
col(MetricBucket.bucket) >= (_aware(since) if since else _since(hours)),
col(MetricBucket.bucket) < (_aware(until) if until else _closed_before(stride)),
)
if flow:
statement = statement.where(col(MetricBucket.flow) == flow)
@@ -287,7 +306,9 @@ def read_timeseries(
@router.get("/flows", response_model=list[FlowRollup])
def read_flow_rollups(session: SessionDep, hours: int = 24) -> Any:
def read_flow_rollups(
session: SessionDep, hours: Annotated[int, Query(ge=0, le=8760)] = 24
) -> Any:
"""One row per flow, with a coarse trend of how much it ran."""
hours = _window_hours(hours)
window = hours * 3600
@@ -388,7 +409,7 @@ def read_runs(
status: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 50,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> Any:
"""Recent cascades, newest first, and how many there were in total.
@@ -432,7 +453,7 @@ def read_events(
run: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 100,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> Any:
"""What went wrong, or who changed what. Newest first.
@@ -459,7 +480,9 @@ def read_events(
@router.get("/dead-letter", response_model=list[DeadLetter])
async def read_dead_letters(controller: FlowControllerDep, limit: int = 50) -> Any:
async def read_dead_letters(
controller: FlowControllerDep, limit: Annotated[int, Query(ge=1, le=200)] = 50
) -> Any:
"""Work the engine gave up on, which nothing else surfaces."""
if controller.execution is None:
return []
+38 -12
View File
@@ -35,7 +35,15 @@ from fluksio.cloud import config as cloud_config
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.flow.events import event_bus
from fluksio.flow.panels import PanelDef, PanelsConfig, find, read_config, write_config
from fluksio.flow.panels import (
PanelDef,
PanelsConfig,
edit_lock,
find,
read_config,
write_config,
)
from fluksio.flow.state import CONNECT_TIMEOUT_S, SOCKET_TIMEOUT_S
from fluksio.models import Message
#: Gated per route rather than on the router: the two pairing endpoints are the
@@ -104,6 +112,10 @@ class _PendingStore:
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
decode_responses=True,
# A silent Redis must fail this request, not hold it open.
socket_timeout=SOCKET_TIMEOUT_S,
socket_connect_timeout=CONNECT_TIMEOUT_S,
health_check_interval=30,
)
if settings.REDIS_HOST
else None
@@ -288,7 +300,6 @@ async def save_panels(body: PanelsConfig) -> Any:
how a device is unpaired along with its assignment. Re-pairing one screen
and keeping the assignment is ``/{panel_id}/unpair`` below.
"""
stored = {p.id: p.nonce for p in (await run_in_threadpool(read_config)).panels}
seen = set()
for panel in body.panels:
if panel.id in seen:
@@ -296,12 +307,20 @@ async def save_panels(body: PanelsConfig) -> Any:
status_code=422, detail=f"Two panels named {panel.id!r}"
)
seen.add(panel.id)
# The nonce belongs to this installation, not to whoever is writing the
# panels back: a client holding an older copy must not be able to undo
# a revocation by saving an arrangement.
panel.nonce = stored.get(panel.id, 0)
await run_in_threadpool(write_config, body)
def _save() -> None:
# Read and write under one lock. The nonce belongs to this
# installation, not to whoever is writing the panels back: a client
# holding an older copy must not be able to undo a revocation by
# saving an arrangement — and reading it in a separate step from
# writing it is exactly how an unpair in between was undone.
with edit_lock:
stored = {p.id: p.nonce for p in read_config().panels}
for panel in body.panels:
panel.nonce = stored.get(panel.id, 0)
write_config(body)
await run_in_threadpool(_save)
# Which dashboards hang on which panel just changed. An empty name says
# that much and no more: every screen listening rescopes and refetches
# what it shows, rather than waiting for whenever it next reads.
@@ -436,12 +455,19 @@ async def unpair_panel(panel_id: str) -> Any:
A credential the portal minted for a remote screen carries no nonce, so
this does not reach it; that one is revoked at the hub.
"""
config = await run_in_threadpool(read_config)
panel = next((p for p in config.panels if p.id == panel_id), None)
if panel is None:
def _unpair() -> bool:
with edit_lock:
config = read_config()
panel = next((p for p in config.panels if p.id == panel_id), None)
if panel is None:
return False
panel.nonce += 1
write_config(config)
return True
if not await run_in_threadpool(_unpair):
raise HTTPException(status_code=404, detail=f"No panel named {panel_id!r}")
panel.nonce += 1
await run_in_threadpool(write_config, config)
# The screen is still holding a socket. One event and it refetches, which
# is where it meets the 401 that sends it back to the pairing code.
event_bus.publish({"type": "dashboard_changed", "dashboard": "", "ts": time.time()})
+104 -52
View File
@@ -12,9 +12,9 @@ import time
from collections.abc import Iterator
from datetime import UTC, datetime
from itertools import groupby
from typing import Any, Literal
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, model_validator
@@ -23,6 +23,7 @@ from sqlalchemy import select as sa_select
from sqlmodel import Session, col, select
from fluksio.api.deps import CurrentUser, SessionDep, get_current_user
from fluksio.core.db import writing
from fluksio.flow.events import event_bus
from fluksio.flow.messages import requalify
from fluksio.flow.runs import RunRejected, RunService, new_run_id
@@ -271,8 +272,8 @@ def read_runs(
digest: str | None = None,
since: datetime | None = None,
before: datetime | None = None,
limit: int = 50,
offset: int = 0,
limit: Annotated[int, Query(ge=1, le=500)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> Any:
"""Runs, newest first. The queryable table an experiment log needs.
@@ -306,6 +307,11 @@ def read_overview(session: SessionDep) -> Any:
The list caps at 500 newest runs, so counting flows on the client goes
wrong the moment a history outgrows one page. The database counts instead.
"""
# Deliberately unwindowed. It is a group-by over the whole `run` table,
# which is never pruned — but a run kept for years is the point of the
# table, and a window here would drop a finished experiment off the rail
# rather than making it cheaper to find. A rollup is the answer if the
# scan ever measures.
statement = sa_select(
col(Run.flow),
col(Run.status),
@@ -346,6 +352,12 @@ METRIC_COLUMNS = ("run", "name", "step", "ts", "value")
#: A run's own columns in the wide table. Its inputs and its final numbers
#: follow, prefixed, so an input named "status" cannot collide with the run's.
#: The most runs one export covers. High enough that a real sweep fits and low
#: enough that an unfiltered request cannot read the whole table into memory —
#: which it did, before a byte was streamed, while holding one of the fifteen
#: pooled database connections for the length of the download.
EXPORT_CAP = 10_000
RUN_COLUMNS = (
"id",
"flow",
@@ -370,7 +382,12 @@ def _selected(
since: datetime | None,
until: datetime | None,
) -> list[Run]:
"""The runs an export covers, newest first — the filters the list takes."""
"""The runs an export covers, newest first — the filters the list takes.
Capped: the filters bound a *sensible* request and nothing bounded an
unfiltered one, so asking for everything read every row of the table into
memory before a byte was sent. A truncated export says so in a header.
"""
statement = select(Run).order_by(col(Run.created_at).desc())
if flow:
statement = statement.where(col(Run.flow) == flow)
@@ -385,7 +402,7 @@ def _selected(
statement = statement.where(col(Run.created_at) >= _aware(since))
if until:
statement = statement.where(col(Run.created_at) < _aware(until))
return list(session.exec(statement))
return list(session.exec(statement.limit(EXPORT_CAP + 1)))
def _cell(value: Any) -> Any:
@@ -458,7 +475,11 @@ def _csv(columns: list[str], chunks: Iterator[list[dict[str, Any]]]) -> Iterator
def _stream(
fmt: str, name: str, columns: list[str], chunks: Iterator[list[dict[str, Any]]]
fmt: str,
name: str,
columns: list[str],
chunks: Iterator[list[dict[str, Any]]],
truncated: bool = False,
) -> StreamingResponse:
"""The rows out, a chunk at a time rather than a list.
@@ -473,11 +494,13 @@ def _stream(
else:
body = _csv(columns, chunks)
media = "text/csv; charset=utf-8"
return StreamingResponse(
body,
media_type=media,
headers={"Content-Disposition": f'attachment; filename="{name}.{fmt}"'},
)
headers = {"Content-Disposition": f'attachment; filename="{name}.{fmt}"'}
if truncated:
# The body is otherwise indistinguishable from a complete one, and an
# export quietly missing its oldest runs is the wrong thing to hand
# somebody who is about to plot it.
headers["X-Truncated"] = str(EXPORT_CAP)
return StreamingResponse(body, media_type=media, headers=headers)
@router.get("/export/metrics")
@@ -490,7 +513,7 @@ def export_metrics(
since: datetime | None = None,
until: datetime | None = None,
name: str = "",
stride: int = 1,
stride: Annotated[int, Query(ge=1, le=100_000)] = 1,
format: ExportFormat = "csv",
) -> Any:
"""Every selected run's series as one long table: run, name, step, ts, value.
@@ -500,6 +523,8 @@ def export_metrics(
for every tenth point of two metrics gives every tenth point of both.
"""
runs = _selected(session, flow, status, group, ids, since, until)
truncated = len(runs) > EXPORT_CAP
runs = runs[:EXPORT_CAP]
wanted = [part for part in name.split(",") if part]
step = max(1, stride)
@@ -523,7 +548,7 @@ def export_metrics(
)
yield rows
return _stream(format, "metrics", list(METRIC_COLUMNS), chunks())
return _stream(format, "metrics", list(METRIC_COLUMNS), chunks(), truncated)
@router.get("/export/runs")
@@ -552,6 +577,8 @@ def export_runs(
depth.
"""
runs = _selected(session, flow, status, group, ids, since, until)
truncated = len(runs) > EXPORT_CAP
runs = runs[:EXPORT_CAP]
inputs = [part for part in params.split(",") if part] or sorted(
{path for run in runs for path, _ in _leaves(run.params)}
)
@@ -569,7 +596,7 @@ def export_runs(
row.update((f"metric.{k}", _cell(_dig(run.result, k))) for k in scores)
yield [row]
return _stream(format, "runs", columns, chunks())
return _stream(format, "runs", columns, chunks(), truncated)
@router.get("/{run_id}", response_model=RunDetail)
@@ -623,23 +650,27 @@ def delete_run(run_id: str, session: SessionDep, user: CurrentUser) -> Response:
keeps whatever a ``run_artifact`` row or a live message still names, so
dropping the rows is enough and the hourly sweep reclaims the blobs.
"""
run = session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="No such run")
if run.status in ("running", "queued"):
raise HTTPException(
status_code=409,
detail=(
f"Run {run_id} is {run.status}. Cancel it, or wait for it to "
"finish, before deleting it."
),
)
flow = run.flow
session.execute(delete(RunNode).where(col(RunNode.run_id) == run_id))
session.execute(delete(RunMetric).where(col(RunMetric.run_id) == run_id))
session.execute(delete(RunArtifact).where(col(RunArtifact.run_id) == run_id))
session.execute(delete(Run).where(col(Run.id) == run_id))
session.commit()
# Reads the run, then decides whether to delete it — so it takes the write
# lock up front rather than upgrading and losing to whichever flush
# committed in between.
with writing(session):
run = session.get(Run, run_id)
if run is None:
raise HTTPException(status_code=404, detail="No such run")
if run.status in ("running", "queued"):
raise HTTPException(
status_code=409,
detail=(
f"Run {run_id} is {run.status}. Cancel it, or wait for it to "
"finish, before deleting it."
),
)
flow = run.flow
session.execute(delete(RunNode).where(col(RunNode.run_id) == run_id))
session.execute(delete(RunMetric).where(col(RunMetric.run_id) == run_id))
session.execute(delete(RunArtifact).where(col(RunArtifact.run_id) == run_id))
session.execute(delete(Run).where(col(Run.id) == run_id))
session.commit()
event_bus.publish(
{
"type": "audit",
@@ -674,32 +705,50 @@ def _series(session: Session, run_id: str, name: str = "") -> list[RunMetric]:
if restored:
run = session.get(Run, run_id)
flow = run.flow if run is not None else ""
# Two queries for the lot, rather than a `Run` lookup and a `RunMetric`
# query per restored node. A comparison of twenty runs calls this
# twenty times, so the per-node round trips multiplied.
sources = {
row.id: row.flow
for row in session.exec(
select(Run).where(col(Run.id).in_({n.cached_from for n in restored}))
)
}
wanted: dict[tuple[str, str], list[RunNode]] = {}
for node_row in restored:
source = session.get(Run, node_row.cached_from)
if source is None:
source_flow = sources.get(node_row.cached_from)
if source_flow is None:
# The run it came from is gone — deleted with its flow. The
# outputs are still on this run; the curve is not recoverable.
continue
source_node = requalify(node_row.node, flow, source.flow)
for row in session.exec(
key = (
node_row.cached_from,
requalify(node_row.node, flow, source_flow),
)
wanted.setdefault(key, []).append(node_row)
if wanted:
cached = session.exec(
select(RunMetric).where(
col(RunMetric.run_id) == node_row.cached_from,
col(RunMetric.node) == source_node,
col(RunMetric.run_id).in_({run_id for run_id, _ in wanted}),
col(RunMetric.node).in_({node for _, node in wanted}),
)
):
renamed = requalify(row.name, source.flow, flow)
if name and renamed != name:
continue
rows.append(
RunMetric(
run_id=run_id,
name=renamed,
step=row.step,
node=node_row.node,
ts=row.ts,
value=row.value,
)
for row in cached:
for node_row in wanted.get((row.run_id, row.node), ()):
source_flow = sources[node_row.cached_from]
renamed = requalify(row.name, source_flow, flow)
if name and renamed != name:
continue
rows.append(
RunMetric(
run_id=run_id,
name=renamed,
step=row.step,
node=node_row.node,
ts=row.ts,
value=row.value,
)
)
)
rows.sort(key=lambda row: (row.name, row.step))
return rows
@@ -707,7 +756,10 @@ def _series(session: Session, run_id: str, name: str = "") -> list[RunMetric]:
@router.get("/{run_id}/metrics", response_model=list[MetricPoint])
def read_metrics(
run_id: str, session: SessionDep, name: str = "", stride: int = 1
run_id: str,
session: SessionDep,
name: str = "",
stride: Annotated[int, Query(ge=1, le=100_000)] = 1,
) -> Any:
"""One metric's series, in step order — or every one of them, unnamed.
+8 -2
View File
@@ -9,12 +9,18 @@ from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import get_current_user
from fluksio.api.deps import get_current_active_superuser
from fluksio.flow.secrets import SecretNotFound, get_secrets
from fluksio.models import Message
# Superuser, not merely signed in. The names alone say what this installation
# talks to, and `PUT /{name}` takes any name — so an ordinary account could
# overwrite the credential a flow authenticates with. `/search` already gates
# secret names this way and said so; this router was the half that did not.
router = APIRouter(
prefix="/secrets", tags=["secrets"], dependencies=[Depends(get_current_user)]
prefix="/secrets",
tags=["secrets"],
dependencies=[Depends(get_current_active_superuser)],
)
+7 -3
View File
@@ -1,7 +1,7 @@
import uuid
from typing import Any
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import col, func, select
from fluksio import crud
@@ -34,7 +34,11 @@ router = APIRouter(prefix="/users", tags=["users"])
dependencies=[Depends(get_current_active_superuser)],
response_model=UsersPublic,
)
def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
def read_users(
session: SessionDep,
skip: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> Any:
"""
Retrieve users.
"""