This guide explains how Marea works internally: how the binary is
shaped, how plugins compose into the CLI, how config is validated and
enriched, and how interactive commands take over the parent shell. It
is aimed at people writing plugins or debugging Marea itself; users who
just want to deploy can stick to the quick-start
and the marea.yaml reference.
The marea binary
mix marea.build produces a single file, priv/marea, that combines a
shell header with an escript (see lib/mix/tasks/marea.build.ex):
#!/bin/sh
_TMP="${TMPDIR:-/tmp}/.marea_$$"
tail -n +10 "$0" > "$_TMP"
escript "$_TMP" "$@"
_rc=$?
rm -f "$_TMP"
[ $_rc -ne 0 ] && exit $_rc
[ -x .marea/next_cmd ] && exec .marea/next_cmd
exit 0
The header:
- Splits itself off, leaving the escript bytes in a temp file.
- Runs the escript with the user's args.
- After the BEAM exits,
execs.marea/next_cmdif Marea wrote one. This is how interactive commands likemarea run localreplace the parent shell.
./marea in any project is a symlink to priv/marea, set up by
marea setup init-umbrella (or a manual ln -s /path/to/marea .).
Entry point and Service
Marea.main/1 (lib/marea.ex) is the escript entry point. It:
- Traps a few Unix signals.
- Clears any stale
.marea/next_cmdso a previous deferred command doesn't accidentally re-run. - Reads the project's
marea.yaml. - Resolves the
plugins:list to module names. - Starts
Marea.Service(aMalla.Service) with the five base plugins plus the user-listed plugins (and any plugins those pull in transitively viaplugin_deps:). - Calls
Service.execute/0, which fires theMarea.Plugins.Base.marea_cmd/2callback chain.
Marea.main_inline/1 is the equivalent for mix marea …. Instead of
writing .marea/next_cmd, it returns deferred commands as
{:exec, cmd} and Mix.Tasks.Marea runs them via a Port so stdio is
forwarded interactively.
Plugins
Marea is a Malla service, and every command lives in a plugin. The five base plugins, always loaded:
| Plugin | Responsibility |
|---|---|
Marea.Plugins.Base | Defines the shared callbacks (see Callback chains below) and ships marea schema show for inspecting the plugin-enriched schema. |
Marea.Plugins.Setup | marea setup init-umbrella and marea setup init-release. |
Marea.Plugins.Build | Owns the build parent subcommand and marea build clean / local / deps / release. Other plugins extend the same parent — Docker adds build docker / show-dockerfile / show-docker-versions, Helm adds build helm. |
Marea.Plugins.Run | marea run local / release / docker. |
Marea.Plugins.Mcp | marea mcp serve / tools / call. Exposes every other leaf subcommand as a typed MCP tool — see MCP server below and the MCP Plugin guide. |
Optional plugins shipped with Marea (opt-in via plugins: in
marea.yaml; transitive deps are pulled in via plugin_deps:):
| Plugin | Pulls in | Responsibility |
|---|---|---|
Marea.Plugins.Docker | Build | build docker / show-dockerfile / show-docker-versions. Contributes the docker: block at deploy and release level, the release type: enum (:elixir, :dockerfile_dir), and the validate_release refinement. |
Marea.Plugins.Helm | Docker, Build | marea helm chart / list / history / values / template / upgrade / delete / rollback plus build helm. Contributes the helm: block at deploy and release level, the deploy type: enum value :helm, and the validate_helm refinement. |
docker.python (Marea.Plugins.Docker.Python) | Docker | Contributes releases.<r>.docker.python: and a Dockerfile fragment via Marea.Plugins.Docker.marea_dockerfile_assigns/2 (a python-builder stage plus app-stage venv copy). No commands. |
Marea.Plugins.K8s | — | marea k8s describe / logs / shell / console / restart / remote. Talks to the cluster via kubectl using the deploy's helm.namespace and helm.kube_context. |
Marea.Plugins.Aws | — | marea aws ecr / route53 / s3 and the top-level aws: schema field. |
A plugin lives in one Elixir file, declares its dependencies on Base
(or other plugins) via plugin_deps:, and implements one or more
callbacks via defcb. See
06-plugin-development.md for the full
pattern.
Callback chains
Malla resolves callback chains at compile time. Marea defines the
following callbacks in Marea.Plugins.Base; each plugin can implement
any of them.
marea_config_schema(schema) → enrich the Zoi schema for marea.yaml
marea_config_args(args) → enrich the Optimus arg spec
marea_cmd(cmds, config) → handle a parsed command
marea_release_vsn() → compute a release version string
marea_init_release_file(file) → return content for `setup init-release` files
marea_deploy_types(types) → contribute values to the deploy `type:` enum
marea_release_types(types) → contribute values to the release `type:` enum
marea_dockerfile_assigns( → enrich EEx assigns when Docker
assigns, config) plugin renders the Dockerfile
(declared in Marea.Plugins.Docker)The control values are the standard Malla ones:
:cont— let the next plugin handle it.{:cont, args}— let the next plugin handle it, with modified args.- any other value — stop the chain and use this as the result.
For Marea.Plugins.Base.marea_cmd/2, the conventional return values are:
| Return | Meaning |
|---|---|
:ok | Command succeeded; save last values; no deferred command. |
{:ok, config} | Same, but with an updated config struct. |
:usage | Show --help for the matched command. |
{:cmd, cmd} | Write cmd to .marea/next_cmd for the wrapper to exec. |
{:cmd, cmd, config} | Same, but use the updated config when persisting state. |
{:error, msg} | Abort with the given message and a non-zero exit code. |
These are documented on the Marea.Plugins.Base.marea_cmd/2 callback in Marea.Plugins.Base
and consumed by Marea.Service.
Config flow
Marea.Config.make_config/1 (lib/marea/config.ex) orchestrates the
boot sequence:
- Collect deploy/release type enums. Run the
Marea.Plugins.Base.marea_deploy_types/1andMarea.Plugins.Base.marea_release_types/1chains so the schema knows whattype:values are available. - Build the schema. Call
Marea.Config.Schema.base_schema(deploy_types, release_types)and run it through theMarea.Plugins.Base.marea_config_schema/1chain. Plugins inject fields withMarea.Config.Schema.add_field_at_path/4and refinements withMarea.Config.Schema.add_refinement_at_path/3. - Read
marea.yaml.Marea.Config.Yaml.find_config/0finds the file (one of the three candidate paths), parses it, and recursively expands anyinclude!path.yamlvalues. If no config exists, the CLI still parses argv (steps 5–6) so it can tell asetupcommand (run it) from anything else (stop and suggestmarea setup init-marea); steps 7–9, which touchmarea.d//.marea, are skipped. - Validate.
Zoi.parse(schema, terms)either returns a structured map or aborts with line-by-line errors. - Build the CLI parser. Start from
Marea.Config.Args.base_args()and run it through theMarea.Plugins.Base.marea_config_args/1chain. Plugins useArgs.add_subcommands/2,add_options/2,add_flags/2. Global options/flags are then injected into every leaf subcommand. - Parse argv with
Optimus.parse/2. - Read configs/secrets from
<marea_dir>/configs/and<marea_dir>/secrets/. - Restore last values from
<state_dir>/last_values. - Resolve
:deployand:releaseoptions intovalues.
The result is a Marea.Config.t/0 struct passed to every Marea.Plugins.Base.marea_cmd/2
implementation:
%Marea.Config{
marea_config_file: "marea.d/marea.yaml",
marea_config: %{...}, # validated yaml
optimus: %Optimus{...},
cmds: [:build, :docker],
options: %{...},
flags: %{...},
values: %{deploy: "staging", release: "api", git_vsn: "..."},
configs: %{...}, # files in <marea_dir>/configs
secrets: %{...}, # files in <marea_dir>/secrets
marea_dir: "marea.d",
state_dir: ".marea",
cmd_prefix: nil
}Schema enrichment
Plugins call one of two helpers from Marea.Config.Schema:
Marea.Config.Schema.add_field_at_path(schema, path, key, type)
Marea.Config.Schema.add_refinement_at_path(schema, path, {Mod, fun, []})path is a list of map keys; an empty list targets the root.
For deeply nested map types — including ones whose keys are dynamic
(e.g. the deploys map keyed by deploy name) — Zoi.Schema.traverse/2
descends transparently, so plugins can add a field "to every release"
without writing custom matching:
add_field_at_path(schema, [:deploys, :releases], :my_field, Zoi.string())add_refinement_at_path/3 wraps the matched node in a Zoi.refine/2
and is what Marea.Plugins.Helm uses to attach the
releases / helm.chart_dir mutual-exclusion check to each deploy,
and what Marea.Plugins.Docker uses to attach the
release-type:-vs-docker-keys check to each release.
Args enrichment
Marea.Config.Args.base_args/0 returns the base subcommand tree (a
map with options, flags, subcommands). Plugins extend it via
add_subcommands/2. Any option or flag tagged global: true is
injected into every leaf subcommand by inject_globals/3. This is how
--marea-dir, --state-dir, --help, and --nopause are available
on every command without each plugin re-declaring them.
Deferred shell execution
Some commands have to run as the parent shell: iex --name … -S mix run, _build/prod/rel/<rel>/bin/<rel> start_iex, and so on. The
escript can't simply System.shell/2 those — they need stdio attached
to the user's terminal and they need to outlive the BEAM that started
them.
Marea handles this with a two-step trick:
- The plugin returns
{:cmd, cmd}fromMarea.Plugins.Base.marea_cmd/2. Lib.write_cmd!/2writescmd(preceded by a#!/bin/shheader) to.marea/next_cmdandchmod 755s it.- The escript exits with code 0.
- The shell wrapper around the escript notices
.marea/next_cmdis executable andexecs it, so the deferred command becomes the parent shell.
In mix marea … the same {:cmd, cmd} return value is short-circuited
to {:exec, cmd} and run via a Port (see Mix.Tasks.Marea), so
interactive commands work the same way during local dev.
MCP server
Marea.Plugins.Mcp exposes the same command tree as a stdio
Model Context Protocol server.
Three modules implement it; together they are self-contained and
add no runtime requirements on the rest of Marea.
| Module | Responsibility |
|---|---|
Marea.Mcp.Tools | Walks the result of Marea.Plugins.Base.marea_config_args/1 and emits one MCP tool descriptor per leaf subcommand: the joined path becomes the tool name, about: becomes the description, and the Optimus option/flag specs become a JSON-Schema inputSchema (option parser: mapped to integer/number/string, flags to booleans, required: true reflected in the required array). Globals (global: true options/flags) are threaded down branches the same way Args.build_optimus/1 injects them into the Optimus parser. |
Marea.Mcp.Runner | Executes a single tool call in isolation: stops the running Marea.Service, spawns a Task whose group leader is swapped for a StringIO (so every byte the command would print is captured), sets Marea.Lib.set_stop_mode(:raise) so Lib.stop/1 raises Marea.Stop instead of calling System.halt/1, and calls Marea.main_inline/1 with synthesized argv. If the command returns {:exec, cmd} (Run / Build), the shell command is executed inline, still captured. Always appends --nopause so pause_cmd!/3 does not block on IO.gets. |
Marea.Mcp.Server | Newline-delimited JSON-RPC 2.0 loop over stdin/stdout. Implements initialize, notifications/initialized, ping, tools/list, tools/call. The tool list is computed once at startup; per-call execution re-parses marea.yaml so config edits between calls are picked up. Aborts on startup if no marea.yaml is reachable (with a hint to run marea setup init-marea), since a server has no way to scaffold one interactively. |
Why the :raise stop mode matters: in the one-shot escript flow,
Marea.Lib.stop/1 prints in red and System.halt(126)s — fine when
the process is about to exit anyway. The MCP server is long-running
and must not halt the VM on a single bad tool call. The stop mode is
a per-process flag (Process.put(:marea_stop_mode, :raise)), set by
the Runner inside each task, so the escript path keeps the original
behaviour and only MCP-spawned tasks raise. The Marea.Stop
exception is caught by the Runner and turned into an
{"isError": true, "content": [text]} MCP result.
Pre-flight validation (unknown / missing required arguments) happens
in the Runner before invoking Marea.main_inline/1. Without it,
Optimus would call System.halt/1 internally on parse failure,
bypassing the stop-mode shim.
Embedded templates
Marea.Templates reads every regular file under
priv/templates/<prefix>/... at compile time via Path.wildcard/1
and @external_resource, so the binary is fully self-contained.
Names use the path relative to priv/templates/. Some prefixes have
a default extension that callers may omit (helm→.yaml.eex,
docker→.eex, rel→.eex); other prefixes (e.g. k8s) require
the full filename. As a convenience, callers passing the
rendered-output name (e.g. helm/configmap_env.yaml) also resolve to
the .eex source, so user-facing helm.template: values in
marea.yaml keep their .yaml extension.
priv/templates/helm/configmap_env.yaml.eex,priv/templates/helm/configmap_files.yaml.eex,priv/templates/helm/secret_env.yaml.eex,priv/templates/helm/secret_files.yaml.eexare Helm templates the user can reference byhelm.template:inmarea.yaml(as e.g.configmap_env.yaml).priv/templates/docker/dockerfile_v01.eexis the default Dockerfile template used bybuild docker(selectable per-release viadocker.template).priv/templates/rel/env.sh.eexis whatsetup init-releasewrites torel/env.sh.eex.priv/templates/k8s/remote.exsis the--dot-iexscript themarea k8s remotecommand drops next to the user'siexinvocation.
User overrides for helm and docker templates live under
<marea_dir>/templates/. If the user file exists, it is preferred
over the embedded one.
Streaming, prefixed shell calls
Marea.Lib exposes three flavours of shell execution:
cmd!/3— must succeed; aborts otherwise. Streams stdout.pause_cmd!/3— likecmd!, but prints the command and waits for Enter unless the--nopauseflag is set. Used for destructive or long-running operations (helm upgrade,route53changes).result!/3— captures stdout and returns it; aborts on non-zero.shell/3— same ascmd!but returns{output, exit_code}instead of aborting.
All of them honour cmd_prefix: from marea.yaml. When a caller
passes prefix: :build and the project has cmd_prefix.build set,
the command is wrapped with that prefix:
docker run … sh -c '<original cmd>'This is how Marea supports running the build inside a Linux container on macOS/Windows hosts.
Where to go next
- 06-plugin-development.md — turn this architecture into your own plugin.
- plugins/base.md — the callbacks you'll override.
- 04-marea-yaml.md — the schema your plugins enrich.