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:

  1. Splits itself off, leaving the escript bytes in a temp file.
  2. Runs the escript with the user's args.
  3. After the BEAM exits, execs .marea/next_cmd if Marea wrote one. This is how interactive commands like marea run local replace 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:

  1. Traps a few Unix signals.
  2. Clears any stale .marea/next_cmd so a previous deferred command doesn't accidentally re-run.
  3. Reads the project's marea.yaml.
  4. Resolves the plugins: list to module names.
  5. Starts Marea.Service (a Malla.Service) with the five base plugins plus the user-listed plugins (and any plugins those pull in transitively via plugin_deps:).
  6. Calls Service.execute/0, which fires the Marea.Plugins.Base.marea_cmd/2 callback 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:

PluginResponsibility
Marea.Plugins.BaseDefines the shared callbacks (see Callback chains below) and ships marea schema show for inspecting the plugin-enriched schema.
Marea.Plugins.Setupmarea setup init-umbrella and marea setup init-release.
Marea.Plugins.BuildOwns 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.Runmarea run local / release / docker.
Marea.Plugins.Mcpmarea 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:):

PluginPulls inResponsibility
Marea.Plugins.DockerBuildbuild 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.HelmDocker, Buildmarea 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)DockerContributes 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.K8smarea 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.Awsmarea 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:

ReturnMeaning
:okCommand succeeded; save last values; no deferred command.
{:ok, config}Same, but with an updated config struct.
:usageShow --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:

  1. Collect deploy/release type enums. Run the Marea.Plugins.Base.marea_deploy_types/1 and Marea.Plugins.Base.marea_release_types/1 chains so the schema knows what type: values are available.
  2. Build the schema. Call Marea.Config.Schema.base_schema(deploy_types, release_types) and run it through the Marea.Plugins.Base.marea_config_schema/1 chain. Plugins inject fields with Marea.Config.Schema.add_field_at_path/4 and refinements with Marea.Config.Schema.add_refinement_at_path/3.
  3. Read marea.yaml. Marea.Config.Yaml.find_config/0 finds the file (one of the three candidate paths), parses it, and recursively expands any include!path.yaml values. If no config exists, the CLI still parses argv (steps 5–6) so it can tell a setup command (run it) from anything else (stop and suggest marea setup init-marea); steps 7–9, which touch marea.d//.marea, are skipped.
  4. Validate. Zoi.parse(schema, terms) either returns a structured map or aborts with line-by-line errors.
  5. Build the CLI parser. Start from Marea.Config.Args.base_args() and run it through the Marea.Plugins.Base.marea_config_args/1 chain. Plugins use Args.add_subcommands/2, add_options/2, add_flags/2. Global options/flags are then injected into every leaf subcommand.
  6. Parse argv with Optimus.parse/2.
  7. Read configs/secrets from <marea_dir>/configs/ and <marea_dir>/secrets/.
  8. Restore last values from <state_dir>/last_values.
  9. Resolve :deploy and :release options into values.

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:

  1. The plugin returns {:cmd, cmd} from Marea.Plugins.Base.marea_cmd/2.
  2. Lib.write_cmd!/2 writes cmd (preceded by a #!/bin/sh header) to .marea/next_cmd and chmod 755s it.
  3. The escript exits with code 0.
  4. The shell wrapper around the escript notices .marea/next_cmd is executable and execs 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.

ModuleResponsibility
Marea.Mcp.ToolsWalks 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.RunnerExecutes 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.ServerNewline-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.eex are Helm templates the user can reference by helm.template: in marea.yaml (as e.g. configmap_env.yaml).
  • priv/templates/docker/dockerfile_v01.eex is the default Dockerfile template used by build docker (selectable per-release via docker.template).
  • priv/templates/rel/env.sh.eex is what setup init-release writes to rel/env.sh.eex.
  • priv/templates/k8s/remote.exs is the --dot-iex script the marea k8s remote command drops next to the user's iex invocation.

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 — like cmd!, but prints the command and waits for Enter unless the --nopause flag is set. Used for destructive or long-running operations (helm upgrade, route53 changes).
  • result!/3 — captures stdout and returns it; aborts on non-zero.
  • shell/3 — same as cmd! 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