Skip to main content
A tap’s 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 dedicated datris-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 .env file, 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 tmpfs mount, 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: a fetch() that saves a large file to disk (or lets a library spool one under TMPDIR) fails with No space left on device. Read remote files directly over HTTP in chunks or row groups and yield records as they arrive. The 512 MB figure is set on the runner’s tmpfs entry in the compose file; raise it only on hosts with the RAM to spare.
This is operating-system container isolation — separate process, filesystem, and network namespaces sharing the host kernel. It is the right boundary for a self-hosted, single-operator deployment. It is not a virtual-machine boundary: a kernel-level container escape would cross it, so running fully untrusted, multi-tenant tap code calls for stronger per-run isolation — a user-space kernel such as gVisor (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=true in .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_CONCURRENT scripts at once (default 4; CODEGEN_RUNNER_MAX_CONCURRENT for datris-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 example queued 12.3s next to the duration (the duration includes the wait). A run that still has no slot after the timeout fails with one sentence naming TAP_RUNNER_MAX_CONCURRENT: raise it, or stagger tap schedules.
  • Container ceilings. datris-tap-runner is limited to 1 CPU, 2 GB of memory (its 512 MB /tmp scratch counts toward it) and 256 processes; datris-codegen-runner to 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 with MemoryError instead 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_THREADS and MKL_NUM_THREADS set to TAP_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 the datris 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 via DATRIS_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 a stock_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:
Note there is no credential in that call. When the deployment requires API keys, the platform mints a per-run token (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, or USE_TAP_RUNNER=false) — tap fetch() 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 in datris-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-file endpoint, 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_RUNNER unset or false (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, and datris doctor reports codegen.isolation as warn.
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.
CODEGEN_RUNNER_API_BLOCK=false turns the guard off, and the runner can call the Datris API again. Use it only if the guard refuses traffic it should not, and keep USE_API_KEYS on so the port requires a key the script does not have.
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.