**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
293 lines
10 KiB
Python
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,
|
|
)
|