mando-cli v0.4.0 compose-runtime bug triage (2026-05-26)

Triage of mando-cli v0.4.0 compose-runtime bug report from Gabi (2026-05-25). The report claimed two critical bugs against the bollard → docker-compose rewrite (commit 6ba0d61, shipped 2026-05-13). Initial user instinct was skepticism. After verification against live source, both bugs are real, and Bug 2’s root cause is one layer deeper than the reporter diagnosed.

For Agents

This note pins (a) two confirmed compose-runtime bugs in v0.4.0 with code refs, (b) a previously-undocumented load-bearing invariant in render_override — the profiles: ["{run_tag}"] line is what keeps malformed mocked-service stubs inert under normal use, and (c) the process lesson that smoke-testing the rewrite did not exercise the mando-mocked-algos default profile against a fresh checkout.

Status

Bugs confirmed, fixes not yet committed. v0.4.0 binary distributed via install.ps1 is broken for both mando-full and mando-mocked-algos profiles.

Reporter

  • Gabi, date 2026-05-25
  • Report path: ~/Downloads/bug-report-v0.4.0-compose-issues.md
  • Severity claimed: Critical — mando up broken for both default profiles

Bug 1 — build context resolves to runconfig/ not project root

CONFIRMED

Templates src/runtime/templates/runconfig/build.yml:11 and build_dev.yml:18 use context: .. Docker Compose resolves relative build context paths relative to the compose file’s own directory. Since these render to <project>/runconfig/<kind>.builtin.yaml (per RunconfigKind::filename, templates.rs:74-78, written by ensure_runconfigs at templates.rs:244), . resolves to <project>/runconfig/ — where no Dockerfile exists.

Affected profiles

All profiles using build or build-dev runconfig: mando-full, mando-mocked-algos, mando-fast-dev.

Error surfaced

target bess-optimization: failed to solve: failed to read dockerfile: open Dockerfile: no such file or directory

Proof

$ docker compose -f bess-optimization/runconfig/build.builtin.yaml -f .infra/postgres.builtin.yaml config | grep context
      context: /home/gabi/.../bess-optimization/runconfig   # WRONG (should be .../bess-optimization)

Fix

 services:
   __MANDO_SERVICE__:
     build:
-      context: .
+      context: ..
       dockerfile: __MANDO_DOCKERFILE__

Apply in both src/runtime/templates/runconfig/build.yml and build_dev.yml.

Test gap

build_renders_with_service_and_dockerfile (templates.rs:366) asserts dockerfile and image fields but never asserts context. Add:

assert_eq!(svc["build"]["context"].as_str().unwrap(), "..");

Bug 2 — mocked runconfig passes bare service name to compose

CONFIRMED — reporter's diagnosis incomplete

mocked.yml only defines __MANDO_SERVICE__-mocks (the one-shot curl loader). up.rs:192 (docker_targets.iter().map(|(s, _)| s.clone())) collects bare slugs regardless of runconfig and passes them positionally to docker compose up. But the exact error message “Must specify either image or build” doesn’t come from the missing-service path — it comes from render_override in templates.rs:298-325.

Hidden mechanism — the load-bearing profiles: line

render_override writes a <service>: block to .mando/override.builtin.yaml for every entry in docker_targets, mocked or not. The block has env_file, restart, profiles, logging but no image and no build:

services:
  bess-optimization:                          # <-- mocked, but stub still emitted
    env_file:
      - '/.../bess-optimization/.mando/resolved.env'
    restart: unless-stopped
    profiles: ["mando-mocked-algos"]          # <-- this line keeps it inert
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

Compose normally ignores profile-gated services unless --profile is passed. Since up.rs never passes --profile, the malformed stub stays dormant. But passing the bare slug as a positional arg activates that exact stub from the override, and compose fails validation with the reported error.

Error surfaced

invalid service "bess-optimization". Must specify either image or build

Affected profiles

All profiles with mocked services: mando-mocked-algos (default), mando-fast-dev.

Why Gabi’s proposed fix works

Renaming the positional arg from <service> to <service>-mocks (the helper container, which has a real image: curlimages/curl:8.16.0) sidesteps activation of the malformed override stub. The stub remains dormant because the profile gate is never triggered.

-let svc_names: Vec<String> = docker_targets.iter().map(|(s, _)| s.clone()).collect();
+let svc_names: Vec<String> = docker_targets
+    .iter()
+    .map(|(s, run)| {
+        if run.runconfig == "mocked" {
+            format!("{s}-mocks")
+        } else {
+            s.clone()
+        }
+    })
+    .collect();

Cleaner fix — kill the hidden invariant

Gabi’s fix is correct but leaves a load-bearing reliance on profiles: ["{run_tag}"] at templates.rs:317. Anyone who later removes that line — or starts passing --profile flags from a future feature — will resurface the bug. Better:

  1. Apply Gabi’s <service>-mocks rename in up.rs:192.
  2. Also skip mocked entries when building entries: Vec<OverrideEntry> at up.rs:164-180. Mocked sidecars don’t need env_file/restart/logging — they’re one-shot curl loaders.

With (2), no malformed stub is ever generated for mocked services, and the profiles: line becomes incidental rather than load-bearing.

Naming-convention coupling

The <service>-mocks suffix is duplicated in two places: mocked.yml:9 (the template) and the proposed up.rs:192 fix. If the template ever renames, the up command silently breaks. Reporter suggests extracting a helper:

pub fn mocked_compose_service_name(slug: &str) -> String {
    format!("{slug}-mocks")
}

Worth adopting — call it from both render-time and runtime sites.

Why the smoke-test round (8de8f64) missed both

mando-cli received two cleanup rounds after the rewrite:

  • 5dd5112 triple-review pre-merge fixes (6 real bugs + DRY consolidation)
  • 8de8f64 smoke-test fixes (curlimages/curl:8 tag unpublished, --wait on one-shot sidecars, infra-only profile skipped compose::run, flyway U-prefix rejected)

The smoke-test commit fixed mocked-related issues, so the mocked path was exercised. But:

  1. Bug 1 — masked by build caching. Smoke test likely ran against a workspace where prior docker build had populated the image cache, so docker compose up skipped rebuild and never re-resolved the broken context path.
  2. Bug 2 — not exercised against default profile. mando-mocked-algos set as the workspace default and run cleanly from scratch was apparently not part of the smoke matrix.
#ActionFile(s)
1Apply Bug 1 fix (context: ..)src/runtime/templates/runconfig/build.yml, build_dev.yml
2Add context assertion to existing testsrc/runtime/templates.rs (build_renders_with_service_and_dockerfile)
3Apply Bug 2 positional-arg renamesrc/commands/up.rs:192
4Skip mocked entries in override generationsrc/commands/up.rs:164-180
5Extract mocked_compose_service_name(slug) helpersrc/runtime/templates.rs
6Add integration test: mando up --profile mando-mocked-algos against tmp workspace, assert docker compose config succeedstests/
7Concede to Gabi, push fix branch, cut v0.4.1git

Process lesson

Per Agent Context (user pushback pattern): “resist abstract critiques; concede when reporter has runtime trace or reproducer.” Gabi supplied:

  • Specific file:line refs
  • Verbatim error messages
  • docker compose config output showing the wrong resolved context path
  • Confirmation that a rebuild with proposed fixes works

That’s the concede signal. Initial user skepticism was wrong here. The lesson is not “trust all bug reports” — it’s that the triple-review pattern (5dd5112) and smoke-test pass (8de8f64) gave false confidence in coverage because neither exercised the default-mocked-profile-against-fresh-checkout path that v0.4.0 ships as the user-facing default.