mando-cli build context filtering (2026-05-06)

mando build mando was hanging on the upload to dockerd because it was tarring the entire workspace — including target/, .git/, and .worktrees/ — into the build context. Same flaw existed in runner.rs::build_context_tar reachable from mando up with a docker target. This note pins the fix: a yaml-driven build.context_includes field plus a single-source-of-truth helper, build_filtered_tar, used by both call sites.

For Agents

Commits: 6b1f7c7 (yaml-driven build context) + 302be50 (defaults shipped for all 5 app services). Files: src/runtime/build_profile.rs, new src/runtime/build_context.rs, src/commands/build.rs, src/runtime/runner.rs, src/config/defaults/*.yaml. Limitation: context_includes are literal paths — no glob support. See “Known limitation” below.

Symptoms

mando build mando hung indefinitely (or many minutes, depending on workspace size) on the “uploading build context” step. dockerd was eating gigabytes of target/ artefacts that the Dockerfile never references. Same hang reproduced via mando up <service-with-docker-target> because that path went through DockerServiceRunner::startbuild_context_tar with the same naive tar logic.

Root cause

Both commands/build.rs::create_build_context and runtime/runner.rs::build_context_tar were doing essentially append_dir_all(".", workspace_root). There was no respect for .dockerignore, no inspection of the Dockerfile’s COPY directives, and no opt-in include list. So everything in the workspace got tarred up and shipped to dockerd, every build, blocking on I/O proportional to du -sh ..

Fix shape

1. New yaml field — build.context_includes: Vec<String>

In src/runtime/build_profile.rs:

pub struct ServiceBuildDef {
    // …
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub context_includes: Vec<String>,
}

#[serde(default)] means existing yamls without this field still parse cleanly. skip_serializing_if = "Vec::is_empty" keeps re-serialised configs minimal.

2. New module — src/runtime/build_context.rs

Single source of truth for build-context tar construction:

pub fn build_filtered_tar(
    context_dir: &Path,
    dockerfile: &str,
    includes: &[String],
    service_name: &str,
) -> Result<Vec<u8>, …>

Algorithm:

  1. Always include the Dockerfile (whatever dockerfile resolves to — see below).
  2. If includes.is_empty() → emit a warn! log explaining we’re falling back to the whole context, then append_dir_all(".", context_dir) for backward compatibility. Transitional fallback only — once every service ships a context_includes, this branch becomes a hard error.
  3. Otherwise, iterate includes:
    • Dedupe against the dockerfile via a HashSet (so context_includes: [Dockerfile, mando-bess] doesn’t tar the dockerfile twice).
    • Warn-skip missing paths. Don’t error on the build — surface the warning, ship the tar without that path, and let the docker build fail naturally if the path was actually required.
    • Recurse for directories, single-add for files.

3. Used by both call sites

Replaces the bespoke tar code in:

  • src/commands/build.rs::create_build_context
  • src/runtime/runner.rs::DockerServiceRunner::start

Now neither side can drift from the other.

4. Dockerfile-name regression caught and fixed

While unifying the call sites, runner.rs was found to be hard-coding "Dockerfile" as the dockerfile name. With context_includes active, that would have broken any service using a custom dockerfile: field once it tried to tar+build. Fix: read the yaml’s dockerfile field on the service, thread it through to both build_filtered_tar(... dockerfile, ...) AND docker.build_image(... dockerfile, ...). Both consumers now agree on the same name.

5. Loud config-load failures

ServiceConfiguration::load previously swallowed parse errors into an empty Vec. Now: warn!("failed to parse {path}: {err}") then return empty. A user with a typo in their service yaml gets told instead of silently getting “no includes”.

6. Defaults for all 5 app services (commit 302be50)

Shipped context_includes defaults in src/config/defaults/*.yaml for every app service the workspace builds. Each list was derived directly from that project’s actual Dockerfile COPY directives — so a fresh checkout works out of the box without the user authoring service yamls by hand.

Known limitation — no glob support

Literal paths only

context_includes entries are passed straight to the filesystem. They are NOT globs.

A Dockerfile that does COPY ./.env* only gets .env listed in our defaults. A user who needs .env.local, .env.development, etc. has to extend the list themselves with each filename.

If glob support is added later, mind that:

  • Globs change the dedup logic (the HashSet<&str> becomes a HashSet<PathBuf> after expansion).
  • Globs need to be evaluated relative to context_dir, not the CWD.
  • Watch out for ** performance on large workspaces — the whole reason for context_includes is to NOT walk the whole tree.