Files
app/backend/fluksio/flow/modules.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

293 lines
10 KiB
Python

"""The python packages node code may import, in a venv of the user's own.
A pip manifest lives beside the flows in the same git repository, so what a
deployment installed is versioned with what uses it. The packages themselves go
into a venv on the data volume rather than into the engine's environment: a
pin here can never shadow — or be shadowed by — what the app itself runs on,
and the worker processes that import them need nothing from the app.
``uv pip sync`` rather than install, so a line taken out of the manifest is
uninstalled. The manifest is written only after a sync succeeds, which is all
the rollback a failed resolve needs.
None of that applies to an *adopted* venv. A data scientist makes a venv,
installs what they work with, and then installs Fluksio into it too — at which
point building a second environment beside it is exactly wrong: the packages
the nodes need are already here. So when the engine is running from a venv of
somebody else's, node code runs on it. That venv is theirs: `uv pip sync` is
never pointed at it, because sync means "hold exactly this" and would uninstall
their work along with the engine. It is read-only here, and `pip` is how they
change it.
"""
from __future__ import annotations
import hashlib
import importlib.metadata
import logging
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path
from typing import TYPE_CHECKING
from fluksio.core.config import settings
from fluksio.flow.schemas import ModulePackage, ModulesInfo
if TYPE_CHECKING:
from fluksio.flow.store import FlowStore
logger = logging.getLogger(__name__)
#: Beside the flow store rather than in it: this is installed state, not source.
VENV_DIR = settings.FLOWS_DIR.parent / "user-venv"
#: A resolve that takes longer than this is not going to finish.
SYNC_TIMEOUT = 300
def uv_bin() -> str:
"""Where ``uv`` is, without depending on what PATH happens to hold.
It is a dependency, so it is installed beside the interpreter running this
— which is what a systemd unit naming an absolute ExecStart, or a container
entrypoint, would otherwise miss.
"""
beside = Path(sys.executable).with_name("uv")
if beside.exists():
return str(beside)
return shutil.which("uv") or "uv"
def adopted() -> Path | None:
"""The venv this engine was installed into, when node code should use it.
``None`` means the managed venv — one the engine builds and owns. The three
ways that is the answer, in order:
* ``NODE_VENV=managed`` says so. A container sets this: its venv holds the
app and nothing of anybody's, so there is nothing to adopt.
* There is already a managed venv. It may have packages in it that
somebody installed on purpose, and an upgrade must not take them away.
* The engine is not running from a venv at all.
Anything else — ``pip install fluksio`` into the environment you already
work in — is the case this exists for.
"""
setting = settings.NODE_VENV.strip()
if setting == "managed":
return None
if setting and setting != "auto":
return Path(setting)
if (VENV_DIR / "bin" / "python").exists():
return None
prefix = Path(sys.prefix)
if prefix == Path(sys.base_prefix) or prefix == VENV_DIR:
return None
return prefix
def venv_dir() -> Path:
"""Whichever venv node code runs from, adopted or managed."""
return adopted() or VENV_DIR
def venv_python() -> str:
"""The interpreter node code runs on.
Falls back to the engine's own when there is no venv — an installation that
could not build one still runs python nodes, it just cannot add packages
to them.
"""
root = adopted()
if root is not None:
# NODE_VENV may name a venv or the interpreter inside one; both are
# useful things to be handed, and they are told apart by shape.
inner = root / "bin" / "python"
return str(inner if inner.exists() or root.is_dir() else root)
path = VENV_DIR / "bin" / "python"
return str(path) if path.exists() else sys.executable
def _marker() -> Path:
return VENV_DIR / ".applied"
def _digest(requirements: str) -> str:
version = f"{sys.version_info.major}.{sys.version_info.minor}"
return hashlib.sha256(f"{version}\n{requirements}".encode()).hexdigest()
def ensure_venv() -> None:
"""Create the venv if it is missing or its interpreter has gone."""
if adopted() is not None:
return
if (VENV_DIR / "bin" / "python").exists():
return
VENV_DIR.parent.mkdir(parents=True, exist_ok=True)
# From the base interpreter, not from the engine's venv: nesting one venv
# inside another is how a user pin ends up resolving against app packages.
subprocess.run(
[
uv_bin(),
"venv",
"--python",
str(Path(sys.base_prefix, "bin", "python3")),
str(VENV_DIR),
],
capture_output=True,
text=True,
check=True,
timeout=SYNC_TIMEOUT,
)
#: Held for the length of a `uv pip sync`. Two applies at once — two browser
#: tabs, or an apply landing while the boot-time reconcile is still running —
#: mutate the same venv simultaneously.
_sync_lock = threading.Lock()
def sync(requirements: str) -> tuple[bool, str]:
"""Make the venv hold exactly these packages. Returns success and uv's output.
``uv`` missing, or refusing to make the venv, is a failed apply like any
other — the caller answers 400 with what came back, which is the only thing
a person can act on.
"""
if adopted() is not None:
# The one thing this must never do. `uv pip sync` makes a venv hold
# exactly the manifest, so pointed at somebody's own environment it
# uninstalls their packages — and the engine with them.
return False, (
"These packages are not Fluksio's to install: node code runs on "
f"{venv_python()}, the environment Fluksio itself was installed "
"into. Install into it with pip or uv, or set NODE_VENV=managed "
"for a venv the engine owns."
)
if not _sync_lock.acquire(timeout=0.0):
return False, (
"Another apply is already installing packages. Wait for it to "
"finish, then try again."
)
try:
return _sync(requirements)
finally:
_sync_lock.release()
def _sync(requirements: str) -> tuple[bool, str]:
"""The install itself, under `_sync_lock`."""
manifest = ""
try:
ensure_venv()
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle:
handle.write(requirements)
manifest = handle.name
result = subprocess.run(
# Empty means empty: without the flag uv refuses to clear a venv,
# so deleting the last line would leave the package installed.
[
uv_bin(),
"pip",
"sync",
"--allow-empty-requirements",
"--python",
venv_python(),
manifest,
],
capture_output=True,
text=True,
timeout=SYNC_TIMEOUT,
)
except (OSError, subprocess.SubprocessError) as exc:
detail = str(getattr(exc, "stderr", None) or exc).strip()
return False, f"uv could not run: {detail}"
finally:
if manifest:
Path(manifest).unlink(missing_ok=True)
output = (result.stdout + result.stderr).strip()
if result.returncode == 0:
_marker().write_text(_digest(requirements))
return result.returncode == 0, output
def reconcile(store: FlowStore) -> None:
"""Bring the venv in line with the stored manifest. Blocking.
Called at startup, where nothing may be fatal: a deployment whose packages
cannot be installed still runs, with the nodes needing them reporting an
import error each time they execute.
"""
try:
root = adopted()
if root is not None:
# Nothing to reconcile: the environment is somebody else's, and
# bringing it "in line with the manifest" would empty it.
logger.info("Node code runs on the adopted venv at %s", root)
return
# First, and unconditionally: the workers are started against this
# interpreter, and it has to be the venv's one before anything is
# installed into it, not after.
ensure_venv()
requirements = store.read_requirements()
if not requirements.strip() and not _marker().exists():
return
if _marker().exists() and _marker().read_text() == _digest(requirements):
return
ok, output = sync(requirements)
if not ok:
logger.warning("Could not install the stored modules: %s", output)
except Exception:
logger.exception("Could not reconcile the module venv")
def info(store: FlowStore) -> ModulesInfo:
"""What is installed, what was asked for, and whether the two agree."""
requirements = store.read_requirements()
root = venv_dir()
is_adopted = adopted() is not None
version = ""
config = root / "pyvenv.cfg"
if config.exists():
for line in config.read_text().splitlines():
if line.startswith("version"):
version = line.split("=", 1)[1].strip()
if not version:
version = ".".join(str(part) for part in sys.version_info[:3])
packages: list[ModulePackage] = []
site = sorted(root.glob("lib/python*/site-packages"))
if site:
packages = sorted(
(
ModulePackage(name=dist.metadata["Name"] or "", version=dist.version)
for dist in importlib.metadata.distributions(path=[str(site[0])])
),
key=lambda package: package.name.lower(),
)
return ModulesInfo(
python_version=version,
venv_path=str(root),
requirements=requirements,
packages=packages,
# An adopted venv is never "out of step": the manifest does not
# describe it, so there is nothing for it to disagree with.
applied=(
True
if is_adopted
else (
_marker().read_text() == _digest(requirements)
if _marker().exists()
# Nothing asked for and nothing installed is already in step.
else not requirements.strip()
)
),
adopted=is_adopted,
)