How It Works
This describes Ratect’s internal pipeline, for anyone extending Ratect or trying to
understand its behavior in detail. For the code itself, see AGENTS.md
for a map of the source layout.
1. CLI parsing
Each binary has its own main.rs, and both parse arguments with
clap: ratect-compat/src/main.rs for Batect’s flat flag
interface (CLI reference), and ratect/src/main.rs for the
subcommand interface (ratect CLI reference). Everything below
this step is shared — both call into ratect-core, which is where the rest of
this document lives. Parsing has to happen before config resolution (step 2)
can finish, since --config-var/--config-vars-file feed into it.
2. Config loading and resolution (ratect-core/src/config.rs)
This is two separate steps, not one, because the second depends on CLI flags that aren’t known at the first:
-
Config::load_from_file(orload_from_file_native, forratect.toml): the root file is parsed intoConfig/Container/Task/TaskRun/ConfigVariablestructs — YAML vianoyalib, TOML viatoml, one struct set for both formats — and its top-levelincludelist (if any) is resolved, with every loaded file’scontainers/tasks/config_variablesmerged into oneConfig(see Includes). No expression interpolation yet.Includes are walked breadth-first, so every entry in the root file is reached before any included file’s own, and each file is loaded exactly once however many entries name it. A
type: gitentry clones its repository into~/.ratect/inclfirst (ratect-core/src/git_include.rs), and everything reached through one is confined to that clone and may do only what the entry granted it — see Git includes for the rules, andCONTEXT.mdfor what bundle, grant and boundary each denote.The result is a
LoadedConfig: the mergedConfig, plus two maps step 2 needs —container_base_paths, recording which directory each container came from, andcontainer_boundaries, recording the clone each container reached through a Git include must resolve its host paths within. -
LoadedConfig::resolve_expressions: called once, after--config-var/--config-vars-filehave been parsed and merged into an overrides map — fromload_project/load_project_nativeinconfig.rs, which run both steps in order so neither binary has to know the order. In one pass:-
Resolves expressions (
$VAR,${VAR:-default},<name,<{name}, plus the built-inbatect.project_directory) in every field that takes one:environmentvalues (container and taskrun),localvolume mount host paths,build_directory,build_args, abuild_secretsentry’spath, abuild_sshentry’spaths,run_as_current_user.home_directory, and —ratect.tomlonly —image. Acachemount’sname/containerare plain strings, matching Batect: nothing to interpolate (see Cache volumes). -
Volume path resolution: after interpolating a
localmount’s host path, if the result is relative, it’s resolved to an absolute path relative to that container’s own origin file’s directory (viacontainer_base_paths— the root config’s directory when there’s noincludeinvolved), not the current working directory — done in this order (interpolate, then resolve) because an expression can itself resolve to an absolute path, which mustn’t be treated as a relative fragment.batect.project_directoryitself always resolves to the root config’s directory regardless of which file a container came from. Acachemount’s Docker volume name/host directory is resolved later instead (ratect-core/src/cache.rs, viaengine.rs’sresolve_volumes), once--cache-typeand the project’s own cache key are known — neither available at this stage.The resolved path is then checked against
container_boundaries: a container that came from a Git-included file may only reach inside its own clone or your project directory, unless that include was grantedallow_host_paths. Checked twice: once lexically, with both the check and the path normalized first, sincePath::starts_withdoes not interpret..; then against the real locations, with symlinks resolved as far as the path exists, since a bundle can commit one inside its own clone.
See the configuration reference for the full expression syntax, precedence, and error rules.
-
3. Task engine (ratect-core/src/engine.rs)
TaskEngine::run_task(name) is a recursive async function. The order of its five
steps is the part worth knowing here; for what each one does in detail, read
engine.rs’s own module comment (cargo doc --open -p ratect-core), which is
where that lives and stays current.
- Already executed? If this task has already run successfully in this invocation, return immediately. This is what makes shared prerequisites run only once.
- Cycle detection: a task already in the middle of being run — an ancestor of itself in the current call stack — errors immediately rather than recursing forever.
- Run prerequisites, each through the same recursive function, before the
task’s own container step. They run with
top_level: false, unlike the task actually named on the command line, and that flag is the whole of interactive-TTY eligibility: a prerequisite chain isn’t the thing being run interactively, so only the originally-requested task’s own container is ever eligible, however deeply nested its prerequisites are. A task with norunof its own stops here and succeeds — it exists purely to chain prerequisites (see config reference), matching Batect’s ownTaskRunner. - Create the task’s network and start everything in the task’s container
graph on it, before the task’s own container, so it can reach them by name. The
graph is the container’s own
dependenciesunioned with the task’s, resolved recursively, with anycustomiseoverrides applied to whichever container they target at whatever depth. Every task execution gets its own network — a task’s container is never left on Docker’s shared default bridge — and it is torn down afterwards. With--use-networkan existing network is validated and reused instead, and never removed, since Ratect didn’t create it. See the task lifecycle for the step-by-step and diagrams. - Resolve and run the image.
resolve_imageturns a container’simageorbuild_directoryinto something runnable — pulling (perimage_pull_policy) or building, or erroring if neither is set — and is used identically for the task’s own container and for dependencies. The container then runs with the task’scommand, joined to the task’s network, its environment layered hostTERM→ proxy variables → the container’senvironment→ the task’srun.environment, each winning over the last. Everything else on the container — ports, hostnames, working directory, entrypoint, capabilities, devices, and the rest — is assembled here from the config and handed todocker.rsas plain values; the config reference is the list of what those fields mean, and which accept a task-levelrunoverride.
The “run once”, “pull once” and “build once” guarantees are in-memory and scoped to
a single invocation — nothing persists between runs, so a build_directory
container is rebuilt every time. Pulls and builds are memoized as shareable futures
rather than plain sets, so two containers resolving the same image share one
in-flight operation instead of racing. Dependency readiness, by contrast, is scoped
to a single task execution and discarded when it finishes.
Concurrency follows Batect exactly: prerequisites run sequentially, one to
completion after another, even when independent — while one task’s dependency
startup is concurrent, with independent branches of its graph pulling, building,
starting and health-waiting at the same time, gated only on each container’s own
dependencies being ready. Running independent prerequisites concurrently too is
a possible Rust-specific enhancement beyond Batect — see the
roadmap — and
task lifecycle has the detail.
Testability
The engine talks to Docker through a ContainerRuntime trait (defined in
ratect-core/src/docker.rs) rather than depending on the concrete Docker client
directly. This is what lets the engine’s prerequisite/cycle/dedup logic be
unit-tested with a fake implementation instead of a real Docker daemon.
4. Docker integration (ratect-core/src/docker.rs)
DockerClient wraps bollard and implements
ContainerRuntime. What each method is for:
pull_imageandbuild_image: fetch or build the image a container needs, streaming the daemon’s progress to the output layer as events — what, if anything, those render as is the selected output style’s decision, notdocker.rs’s (see Logging vs. output).build_imagefirst packs the build directory into an in-memory tar, honouring.dockerignorevia thedockerignorecrate.run_container: creates, starts and streams the task’s own container until it exits. Three start/attach paths sit behind it — fully non-interactive, stdin-forwarding, and a real TTY with raw mode and live resize — chosen by whether the task is interactive-eligible and whether Ratect’s own stdin and stdout are terminals. It does not remove the container: the engine’s cleanup stage removes everything a task created, its own container included, so--no-cleanup-*is interpreted in exactly one place.create_network/remove_network/network_exists: the per-task network, plus the up-front validation that makes--use-networkfail with a clear error rather than an unrelated API failure later.start_background_container/stop_and_remove_container: the same, for a dependency or sidecar — started and left running alongside the task rather than waited on, so no logs are streamed and no taskcommandapplies.wait_for_container_healthy/exec_in_container: the two halves of the dependency readiness gate. The first blocks on Docker’s own event stream, replayed from the beginning so a verdict that arrived before the stream opened still counts, and turns an unhealthy verdict into an error carrying the last health check’s exit code and output. The second runs onesetup_commandsentry in the running container and returns its exit code and output for the engine to judge.
Nothing here depends on config types: engine.rs converts config into plain
values first, which is why the same options struct serves both container methods.
The module’s own comment carries the gotchas — where each path calls Docker’s
start relative to attaching, why cleanup ownership is not to be split again, how
build_ssh paths are classified — and is the thing to read before changing any of
this.
Container creation/start/removal events are logged at debug level via tracing (see
below) — not shown by default, but useful with RUST_LOG=debug. This includes each
setup_commands exec’s raw output, which is whatever the command itself printed — so
if a setup command’s own output could include something sensitive (a failed connection
string, a verbose HTTP client dumping request headers), that ends up in the debug log
too. Treat RUST_LOG=debug (or narrower ratect_core=debug) output with the same care
you’d give the command’s own output before pasting it into a support ticket, chat
message, or CI log.
5. Logging vs. output
Ratect keeps two channels deliberately separate:
- stdout: the task’s user-facing output — container log output,
--list-taskslistings, and Ratect’s own progress lines (“Running build…”, “Pulling alpine:3.18…”, “build finished with exit code 0 in 2.3s.”), matching where Batect puts them. Internally these progress lines are typed events (ratect-core/src/ui/):engine.rsanddocker.rspost task-execution milestones to an event sink instead of printing, and the selected output style (--output/-o) decides what each event renders as —fancy’s live per-container status block on an interactive terminal,simple‘s plain append-only lines otherwise, nothing at all underquiet(whose stdout is then exactly the containers’ own output, safe to pipe), orall’s per-container prefixed lines (the one style where even container stdout routes through the event sink, line-buffered, instead of streaming to stdout directly). - stderr: Ratect’s own diagnostics, via
tracing/tracing-subscriber, filtered byRUST_LOG(defaults toinfo) — except a fatal error (the reason the process is about to exit non-zero), whichmain.rsprints directly (Error: <message>) rather than throughtracing::error!: it must stay visible even whenRUST_LOGsuppresses everything else, since there’d otherwise be no visible explanation at all for the failure underRUST_LOG=offcombined with-o quiet.
Colors (e.g. the exit code in the task summary line) are only emitted when stdout is actually a terminal — piped or redirected output gets plain text.
Filtering RUST_LOG
RUST_LOG isn’t just an on/off level switch — tracing-subscriber’s
EnvFilter
syntax lets you scope it to specific modules (target=level directives, comma-separated).
This matters in practice once you turn on debug for anything build-related (e.g. to see
a live image build transcript): bollard (the Docker
API client Ratect is built on) also logs at debug, and a bare RUST_LOG=debug includes
all of its raw API traffic — usually far more noise than signal.
A directive with no target (e.g. RUST_LOG=debug) applies everywhere, including
dependencies like bollard. Scoping to a specific target instead — ratect_core covers
everything Ratect itself logs — excludes anything not matched, including bollard,
without needing to name it:
# Only ratect_core's own logs, at debug — no bollard noise at all.
RUST_LOG=ratect_core=debug ratect-compat -f batect.yml build
# Keep the normal `info` default everywhere else, but add ratect_core's debug-level
# output on top (e.g. build transcripts) — usually the more useful combination.
RUST_LOG=info,ratect_core=debug ratect-compat -f batect.yml build
# Narrower still: just the Docker/build/container-runtime module, not task
# orchestration (`ratect_core::engine`) as well.
RUST_LOG=ratect_core::docker=debug ratect-compat -f batect.yml build
If you do want a blanket debug sweep across everything (including bollard) but need to
silence one specific dependency, add it as its own =off directive instead:
RUST_LOG=debug,bollard=off.