Files
app/backend/fluksio/core/config.py
T
stroblmeandClaude Opus 5 57eace2226 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
2026-08-29 20:40:05 +02:00

265 lines
11 KiB
Python

import os
import secrets
import warnings
from pathlib import Path
from typing import Annotated, Any, Literal, Self
from pydantic import (
AnyUrl,
BeforeValidator,
EmailStr,
HttpUrl,
PositiveInt,
computed_field,
model_validator,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
#: Everything the engine keeps on disk, relative to :attr:`Settings.DATA_DIR`.
#: One setting to move the lot; each still overridable on its own, which is
#: what the container images do.
DERIVED_PATHS = {
"FLOWS_DIR": "flows",
"SECRETS_FILE": "secrets.enc",
"ALERTS_FILE": "alerts.json",
"PANELS_FILE": "panels.json",
"OAUTH_PRIVATE_KEY_FILE": "oauth-key.pem",
"CLOUD_CONFIG_FILE": "cloud.json",
"PROVISIONERS_FILE": "provisioners.json",
}
def parse_cors(v: Any) -> list[str] | str:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",") if i.strip()]
elif isinstance(v, list | str):
return v
raise ValueError(v)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
# The stack's own file, one level above ./backend/. An installed
# `fluksio` has no such tree, so its CLI points this at the data
# directory instead — and at nothing it might find in the cwd.
env_file=os.environ.get("FLUKSIO_ENV_FILE", "../.env"),
env_ignore_empty=True,
extra="ignore",
)
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
FRONTEND_HOST: str = "http://localhost:5173"
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
#: Everything this installation keeps: the database, the flow repository,
#: secrets, artifacts and the user venv. The paths below derive from it
#: unless they are set explicitly.
DATA_DIR: Path = Path("flow-data")
#: Where the database file goes, as a SQLite URL. The default puts it in
#: the data directory, which is what makes `fluksio serve` need no
#: infrastructure at all. **SQLite only**: the upserts the metrics
#: collector and the run recorder are built on are written against that
#: dialect, so another backend would parse, migrate, serve — and then
#: silently drop every rollup and fail every run.
DATABASE_URL: str | None = None
# Flows live on disk as a git repository; secrets stay outside it.
FLOWS_DIR: Path = Path("flow-data/flows")
SECRETS_FILE: Path = Path("flow-data/secrets.enc")
# Which failures reach which channel. Beside the flows, not in them:
# alerting is the deployment's concern, not any one flow's.
ALERTS_FILE: Path = Path("flow-data/alerts.json")
# Where machines can be started from when a node needs one and nothing that
# could take it is attached. Operator-authored, like the alerts beside it,
# and absent on an installation that has nowhere to start one.
PROVISIONERS_FILE: Path = Path("flow-data/provisioners.json")
# Which dashboards each device shows. Beside the flows for the same reason
# alerting is: where a screen hangs is the deployment's concern rather than
# any one dashboard's.
PANELS_FILE: Path = Path("flow-data/panels.json")
# Which interpreter node code runs on. "auto" adopts the venv the engine
# was installed into, when it was installed into one and there is no venv
# of its own to lose — which is the `pip install fluksio` beside your own
# packages case. "managed" always builds a separate one, which is what a
# container wants. A path names an interpreter outright.
NODE_VENV: str = "auto"
# The MCP endpoint, and the OAuth server agents authenticate against. Off
# until someone asks for it: it opens client registration to the network.
MCP_ENABLED: bool = False
# Unauthenticated test-only endpoints (user seeding). Requires an explicit
# opt-in on top of ENVIRONMENT=local, so a deployment that merely kept the
# default environment never exposes them.
PRIVATE_API_ENABLED: bool = False
DOMAIN: str = "localhost"
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
# Written only when someone enrols this installation with a portal.
# Its absence is what keeps remote access off.
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
OAUTH_CODE_EXPIRE_SECONDS: int = 60
# Short, because an agent's token is a bearer secret held by a program
# rather than a person, and it can refresh unattended.
MCP_TOKEN_EXPIRE_MINUTES: int = 60
MCP_REFRESH_EXPIRE_DAYS: int = 30
# The three below are all pool sizes, so 0 says neither "none" nor
# "unlimited" — it is a pool that cannot be built and an engine that would
# accept no work. Rejected here rather than quietly read as the default,
# because a limit somebody set and did not get is the worse surprise.
FLOW_MAX_WORKERS: PositiveInt = 4
# How many cascades may be in flight at once. Sustained throughput is this
# over the mean cascade time, so an installation whose nodes wait on the
# network rather than on a CPU wants it higher than the core count.
FLOW_MAX_CASCADES: PositiveInt = 4
# How many batch runs are driven at once. A different limit from the one
# above: a run drives a whole graph, and its nodes are bounded by the worker
# pool rather than by cascade slots. A sweep is what this governs.
FLOW_MAX_RUNS: PositiveInt = 4
# How long a python node may be silent before its worker is killed, unless
# the node sets its own. 0, the default, disables it: a dead worker still
# fails fast, and a slow one is left to finish. Set it where silence means
# stuck rather than working.
FLOW_NODE_TIMEOUT: float = 0.0
# Cores nodes may be given, for the ones that declare `resources`. 0 works
# it out: every core but two, which are what keeps the engine's own event
# loop answering while the machine is busy. Nodes that declare nothing are
# not accounted against it — they only get its fair share as a thread cap.
FLOW_CPUS: int = 0
# GPUs on this machine. Not detected, because detecting it means depending
# on the vendor's tooling: say how many there are and each is held by one
# node at a time.
FLOW_GPUS: int = 0
# How long the engine's own metrics, events and run records are kept.
#: The largest body `PUT /artifacts` will take, in bytes. A checkpoint or a
#: video segment is a legitimate artifact, so this is generous rather than
#: small; 0 removes the limit. Nothing capped it at all before, so any
#: account or worker credential could fill the data volume.
MAX_ARTIFACT_BYTES: int = 2 * 1024 * 1024 * 1024
OBS_RETENTION_DAYS: int = 30
# How often artifact bytes nothing refers to any more are swept away; 0
# never sweeps. A flow streaming media writes one artifact per frame, so
# without this the store only grows.
ARTIFACT_GC_INTERVAL_S: int = 3600
# How long a freshly written artifact is spared, whatever refers to it.
# Storing bytes and recording the reference are two steps; this is the
# window between them.
ARTIFACT_GC_GRACE_S: int = 3600
# Without a Redis host the engine keeps its state in memory.
REDIS_HOST: str | None = None
REDIS_PORT: int = 6379
BACKEND_CORS_ORIGINS: Annotated[
list[AnyUrl] | str, BeforeValidator(parse_cors)
] = []
@model_validator(mode="before")
@classmethod
def _derive_data_paths(cls, data: Any) -> Any:
"""Put every stored thing under ``DATA_DIR`` unless it was named.
``setdefault``, so the container images keep their explicit ``/data``
paths and a checkout keeps ``flow-data/``.
"""
if not isinstance(data, dict):
return data
base = Path(str(data.get("DATA_DIR", "flow-data"))).expanduser()
data["DATA_DIR"] = base
for key, name in DERIVED_PATHS.items():
data.setdefault(key, base / name)
return data
@computed_field # type: ignore[prop-decorator]
@property
def oauth_issuer(self) -> str:
"""Who issues MCP tokens — this app, on its API host.
Kept separate from the app's own URL because a hosted deployment can
later point agents at a different issuer without the resource server
changing: it validates whatever issuer it is configured to trust.
"""
scheme = "http" if self.ENVIRONMENT == "local" else "https"
return f"{scheme}://api.{self.DOMAIN}"
@computed_field # type: ignore[prop-decorator]
@property
def mcp_resource(self) -> str:
"""The resource an MCP token is issued for (RFC 8707)."""
return f"{self.oauth_issuer}/mcp"
@computed_field # type: ignore[prop-decorator]
@property
def all_cors_origins(self) -> list[str]:
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
self.FRONTEND_HOST
]
PROJECT_NAME: str = "Fluksio"
SENTRY_DSN: HttpUrl | None = None
@computed_field # type: ignore[prop-decorator]
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
"""SQLite in the data directory, unless a URL names another file.
One engine process owns this database — the same reason the image runs
a single uvicorn worker — so a file beside the flows is the honest
shape for it, and needs nothing running to be one.
"""
if self.DATABASE_URL:
return self.DATABASE_URL
return f"sqlite:///{(self.DATA_DIR / 'fluksio.db').expanduser().resolve()}"
SMTP_TLS: bool = True
SMTP_SSL: bool = False
SMTP_PORT: int = 587
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
EMAILS_FROM_EMAIL: EmailStr | None = None
EMAILS_FROM_NAME: str | None = None
@model_validator(mode="after")
def _set_default_emails_from(self) -> Self:
if not self.EMAILS_FROM_NAME:
self.EMAILS_FROM_NAME = self.PROJECT_NAME
return self
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
@computed_field # type: ignore[prop-decorator]
@property
def emails_enabled(self) -> bool:
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
EMAIL_TEST_USER: EmailStr = "test@example.com"
# Absent means "the CLI will make one on first run" — a pip install is not
# asked for two environment variables before it can start.
FIRST_SUPERUSER: EmailStr | None = None
FIRST_SUPERUSER_PASSWORD: str | None = None
def _check_default_secret(self, var_name: str, value: str | None) -> None:
if value == "changethis":
message = (
f'The value of {var_name} is "changethis", '
"for security, please change it, at least for deployments."
)
if self.ENVIRONMENT == "local":
warnings.warn(message, stacklevel=1)
else:
raise ValueError(message)
@model_validator(mode="after")
def _enforce_non_default_secrets(self) -> Self:
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
self._check_default_secret(
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
)
return self
# No arguments and no required environment: a fresh install boots on the
# defaults above, into a data directory of its own.
settings = Settings()