fetch() is Python that Datris executes on your server. By default it runs
in-process inside the server. For stronger separation, Datris can instead run each tap in a
dedicated, isolated runner container, cut off from the platform’s credentials and internal
services.
On docker compose / prod, the isolated runner is on by default (
USE_TAP_RUNNER=true).
sbt / IDE runs (no sidecar) stay in-process. Tap behavior is identical either way — the same
fetch() contract, the same environment variables, the same packages. The only difference is
where the code runs and what it can reach. scripts/install.sh and docker/vault-init.sh mint TAP_RUNNER_TOKEN;
the server refuses to start with isolation on and a missing or changeme token.Execution modes
In-process mode runs tap code inside the server. Tap code in this mode
shares the server’s filesystem and network, so it can reach internal services. This is
the
sbt / IDE path (no sidecar). Compose/prod should keep the isolated runner on.
In isolated runner mode (USE_TAP_RUNNER=true), tap code runs in a container that holds
no platform credentials and sits on a network with no route to the platform’s internal
services. Even a misbehaving tap has nothing to read and nowhere to pivot — it can fetch from
its source, install the packages it declares, call back into the Datris API, and return
records. That is all. Enable it when you want defense-in-depth beyond the
always-on protections.
The runner container
The isolated runner is a dedicateddatris-tap-runner sidecar — a small, standalone container
built from its own minimal Python image (not the server image). It is hardened to hold and
grant as little as possible:
- No platform secrets. The image carries no secrets-store credentials, no
.envfile, and no AI/database credentials — there is nothing sensitive inside it to read. - Network-isolated. It runs on a dedicated network with no route to the platform’s internal services (database, object store, secrets store). It can reach the public internet and the Datris API — nothing else internal.
- Non-root, read-only filesystem. Tap code runs as an unprivileged user on a read-only root filesystem, with all Linux capabilities dropped and privilege escalation disabled.
- Ephemeral scratch. The only writable area is a small in-memory scratch space (512 MB
by default, a
tmpfsmount, so it comes out of RAM rather than disk) that is wiped after every run, so nothing a tap writes — temp files, installed packages — survives into the next run. The per-run venv and any pip-installed packages live there too, so a script must stream the source, never download it: afetch()that saves a large file to disk (or lets a library spool one underTMPDIR) fails withNo space left on device. Read remote files directly over HTTP in chunks or row groups andyieldrecords as they arrive. The 512 MB figure is set on the runner’stmpfsentry in the compose file; raise it only on hosts with the RAM to spare.
runsc), or a microVM (Firecracker /
Kata).
Datris does not ship or configure these. But because the runner is a standard OCI container,
you can run it under a hardened runtime yourself if your host provides one — for example, with
gVisor installed, run the datris-tap-runner container under runsc (Docker’s
--runtime=runsc, or runtime: runsc on the service). Datris hasn’t validated this path, so
treat it as an advanced, self-managed hardening step.
Always-on protections
These apply in both modes, regardless of the runner setting:- Platform secrets are never placed in a tap’s environment. The credentials the platform
uses for itself (secrets-store token, AI provider keys, database passwords) are stripped
from the tap’s environment. A tap receives only Datris-injected variables (
DATRIS_*), any per-run parameters, and its own configured secret — nothing else. - Extra packages install in isolation. Packages a tap declares are installed into a throwaway virtual environment, never into the system Python. The pre-installed common packages remain available; the per-tap environment is discarded after the run.
- Private-network egress is blocked. HTTP taps and REST endpoint destinations refuse to
connect to loopback, link-local, private, or cloud-metadata addresses, so a tap cannot be
pointed at internal services. If your endpoints legitimately live on an internal network,
set
DATRIS_ALLOW_PRIVATE_EGRESS=truein.env(see the Configuration Reference).
Overload protection
When taps and generated scripts run in the runner containers (the compose default), a burst of scheduled taps or AI rules cannot take the Docker host down:- Bounded concurrency. Each runner runs at most
TAP_RUNNER_MAX_CONCURRENTscripts at once (default 4;CODEGEN_RUNNER_MAX_CONCURRENTfordatris-codegen-runner). A run that arrives when every slot is taken is answered “busy” at once, and nothing is started for it. - The server waits its turn. A busy answer is retried with a short backoff (never more than
30 seconds between attempts) for up to
TAP_RUNNER_QUEUE_TIMEOUT_SECONDS(default 600). A tap run that waited shows the time in its run history, for examplequeued 12.3snext to the duration (the duration includes the wait). A run that still has no slot after the timeout fails with one sentence namingTAP_RUNNER_MAX_CONCURRENT: raise it, or stagger tap schedules. - Container ceilings.
datris-tap-runneris limited to 1 CPU, 2 GB of memory (its 512 MB/tmpscratch counts toward it) and 256 processes;datris-codegen-runnerto 1 CPU, 1 GB and 128 processes (threads count toward the process ceiling). These are ceilings, not reservations: an idle runner uses no more than before. - Per-script limits. Each script gets a 1024 MB address-space limit
(
TAP_RUNNER_SCRIPT_MEMORY_MB,0= none). A script that allocates past the limit fails withMemoryErrorinstead of pushing the runner out of memory. The address-space limit is not enforced on macOS when you run the runner outside Docker. - Native thread pools sized to the runner. Libraries such as numpy (OpenBLAS), OpenMP and MKL
start one thread per CPU of the host, whatever the container’s CPU limit. Each script gets
OPENBLAS_NUM_THREADS,OMP_NUM_THREADSandMKL_NUM_THREADSset toTAP_RUNNER_SCRIPT_THREADS(default 1), so several pandas taps at once stay well inside the process ceiling. A tap can set its own value through its environment.
datris doctor reports the concurrency setting and slot use as runner.concurrency,
and warns when runs waited for a slot in the last 24 hours (error when a run was turned away).
Configuration
Set these on thedatris service (for example in your .env). Defaults work out of the box.
The runner containers take these (set them in
.env; compose passes them to the right service):
The runner is defined as the
datris-tap-runner service in docker-compose.yml; a normal
docker compose up starts it alongside the server and routes taps there unless you set
USE_TAP_RUNNER=false. To force in-process (for example a no-sidecar sbt run):
What changes for tap authors
Almost nothing. Taps fetch data and return records; the platform writes them to the destination pipeline. Reading a source over the internet, installing declared packages, using the tap’s own secret, and calling the Datris API viaDATRIS_PLATFORM_HOST / DATRIS_PLATFORM_PORT
all work the same in both modes.
The one thing to know: in isolated runner mode a tap cannot open a direct connection to
the platform’s internal database / object store by hostname — those live on a network the
runner can’t reach. This is intentional, and it’s not how taps are meant to work anyway: a tap
returns records and lets the pipeline handle the destination. If you have a legacy tap that
connected directly to an internal service, run it in-process or rework it to return records.
Reading platform data from a tap
A tap can read data already in Datris — for example astock_tickers table — to drive its
fetch. Do this through the query API, using the injected platform host and database name.
The same code works in both execution modes, because DATRIS_PLATFORM_HOST is set correctly
for each:
DATRIS_PLATFORM_TOKEN) and the tap wrapper attaches it to requests / urllib calls aimed at DATRIS_PLATFORM_HOST automatically — the script above works unchanged in both modes and with keys on or off. The token is read-only and scoped to the run; the audit log records the callback as the tap.
Use /api/v1/query/mongodb (or /api/v1/query/natural) the same way for Mongo-backed data.
In the isolated runner, a tap cannot instead open a direct database connection (e.g. to
postgres:5432) — internal services aren’t reachable from the runner’s network. Querying
through the API is the supported path and works regardless of mode.
When to use which
- Isolated runner (compose/prod default) — tap code stays separated from platform credentials and internal services. Isolated taps cannot open a direct connection to internal DB / MinIO / Vault; they return records and the pipeline writes destinations.
- In-process (
sbt/ IDE, orUSE_TAP_RUNNER=false) — tapfetch()runs inside the server JVM. Fine for local development without the sidecar, or a legacy tap that still opens a direct internal connection. The server logs a loud warning. Do not treat a laptop compose install as in-process — compose isolates by default.
Generated data-quality and transformation scripts
AI data-quality rules and AI transformations work by having a model write a short Python script and running it against the pipeline’s data. That script is shaped by the data it reads, so Datris treats it like tap code and runs it in an isolated runner too. On a compose install the scripts run indatris-codegen-runner, a sibling of the tap runner
built from the same datrisai/datris-tap-runner image, with the same token and the same hardening
(non-root, read-only root filesystem, all capabilities dropped, no privilege escalation). What
differs is its network: it sits only on codegen-net, a compose network declared
internal: true that only the datris server also joins.
How data moves. The server pushes the script and the pipeline’s staged input file to the
runner; the script gets no token and has nothing to call. The runner writes the input to a
per-run directory, runs
python3 script.py <input> [<output>], and sends back the result and,
for a transformation, the output file. Both directions are copied in 64 KB chunks: neither side
holds the file in memory. The server log shows the transfer and run time of each script
(CodeGen runner: sent ... in ... ms, script ran in ... ms, received ... in ... ms).
Scratch space. Per-run files live on the codegen-scratch named volume, mounted at /scratch
in the runner. It is on disk, not in memory, so there is no new size limit: the bound is free disk
on the Docker host, as it is for in-process runs. A transformation needs room for its input and
its output at once, in addition to the server’s own staged copy. Each run’s directory is removed
when the run ends (success, script failure, timeout or a dropped connection). Anything else a
script wrote, on the volume outside its own directory or in the runner’s small /tmp, is removed
at the same time, and everything left by a killed runner is removed when it starts, so nothing
carries from one run to the next. Runs that overlap in time share the volume, as concurrent tap
runs share theirs. Processes a script starts are stopped when the script ends (one that detaches into a new session is not). The runner image
treats its scratch directory as dedicated (CODEGEN_SCRATCH_DEDICATED=1) and empties all of it,
so if you run the image outside compose, point CODEGEN_SCRATCH_DIR only at an otherwise unused
directory (see the Configuration Reference). When the
disk fills, the rule or transformation fails with an error naming the codegen-scratch volume.
The script’s printed output (for an AI rule, its failure list) comes back in one result message
capped at 64 MB; a rule that prints a failure for nearly every row of a very large file fails with
“result line longer than …” rather than returning the list.
When the runner is absent.
USE_CODEGEN_RUNNER=true(the compose default): the runner is required. If it is stopped, unreachable, an older image without the/execute-fileendpoint, or rejects the token, the rule or transformation fails with a message naming the runner. Datris never falls back to running the script in-process.USE_CODEGEN_RUNNERunset orfalse(sbt/ IDE, or an install still on an older compose file): scripts run in-process, with only the scrubbed environment, as before. The server logs a warning at startup and on every run, anddatris doctorreportscodegen.isolationaswarn.
datris doctor shows which mode is in effect: ok when scripts run in the runner and it answers,
warn when they run in-process, error when the runner is required but cannot run a script.
The runner shares
TAP_RUNNER_TOKEN with the tap runner; nothing new to configure.
What changes for scripts. Generated scripts are already told to use only the standard library
and read the one file they are given. A script that opened a network connection or read a server
path used to work in-process; in the runner it fails. That is intended.
The Datris API port. The server listens on every network it joins, so datris:8080 is
reachable over codegen-net, but the runner never needs to call the server (the server pushes to
it). With USE_CODEGEN_RUNNER=true the server refuses, with 403 and before any authentication,
every request that arrives on its own codegen-net interface and every request whose source is
the runner’s address. This covers every path on the port, including /actuator. At startup the
server resolves the host in CODEGEN_RUNNER_URL, finds its own interface in the same subnet, and
logs the decision (CodeGen runner API block: ...). It re-resolves the runner every 30 seconds,
so a runner that starts after the server or restarts with a new address is covered.
The guard is built not to refuse anyone else. It does not refuse traffic arriving on the runner’s
network when that network is the server’s only one, or when another service (UI, MCP server, tap
runner, Vault, databases, MinIO) is on it. In those cases it refuses only the runner’s own address
and logs a warning. While the runner’s name does not resolve, nothing is refused and the server
retries every 2 seconds. A runner that resolves to the server itself or to loopback turns the guard
off, with a warning. The guard relies on codegen-net staying internal: true: without it the
server can send and receive host traffic through that network, and every request from the Docker
host would get 403. If you must remove it, set CODEGEN_RUNNER_API_BLOCK=false. Outside compose (for example a Kubernetes Service address) the runner’s source
address may not match, so use a network policy there.
To check the boundary on a running stack, run python3 scripts/check-codegen-isolation.py from a
checkout: it sends a probe script to the runner and exits non-zero if the script can reach the
internet, Vault, Postgres, MongoDB or MinIO, call the Datris API (anything but the server’s
403), read a server path, write outside its scratch directory, or see a secret-bearing variable.