ecu-tests/docker/README.md
Hosam-Eldin Mostafa e1ea1fb7db build(docker): switch Melexis bundle to named build context
Replaces BuildKit's `--mount=type=secret` with `--mount=type=bind,from=…`
backed by a named build context. Secrets are capped at 500 KiB and are
meant for keys, not blobs — the Melexis tarball routinely exceeds that.
A named context overriding a `FROM scratch AS melexis-bundle` stub stage
gives "optional, file-of-any-size, never-in-image" semantics without
polluting the default build context.

- docker/Dockerfile: add the scratch stub stage, change the install step
  to `--mount=type=bind,from=melexis-bundle,target=/melexis-bundle`,
  update the usage header to show the new `--build-context` invocation,
  fail loudly with a clear message when INCLUDE_MELEXIS=1 but no bundle
  is bound.
- docker/README.md: document the new build flow, the rationale for the
  bind-mount vs secret tradeoff, and bench instructions.
- .dockerignore: ignore the new `melexis-bundle/` directory at the repo
  root (named build contexts respect a .dockerignore at THEIR own root,
  not the default one — so this entry only prevents accidental inclusion
  via the default context).
- requirements.txt: pin the Melexis stack's transitive PyPI deps
  (pyparsing, natsort, intelhex, pygdbmi, crcmod, packaging, zeroconf)
  unconditionally so mock and hw images share a single venv layout. The
  size delta in the mock image is a few MB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:46:19 +02:00

19 KiB

Docker — quick reference

Full reference: docs/20_docker_image.md. This file is just the copy-paste commands.

File What it is
Dockerfile Multi-stage image. Mock-only by default; hardware variant via --build-arg INCLUDE_MELEXIS=1 + a named BuildKit context carrying the Melexis Python packages.
compose.hw.yml docker-compose service for the hardware variant — host networking, USB device passthrough, reports volume, bench-config bind mount.
../.dockerignore Excludes .venv/, reports/*, the deprecated BabyLIN SDK, generated caches, etc.

All commands below assume you're running them from the repo root inside a WSL2 distro (Ubuntu / Debian / …) with docker already on the $PATH. If docker --version doesn't work yet, install it first — see § Prerequisites.


Prerequisites — install Docker on WSL

If docker --version already prints a version, skip this section.

Two install paths. Option A (Docker Desktop) is the easy one that most teams use. Option B (Docker Engine directly inside WSL2) is for environments where Docker Desktop's licensing or policies are blocked.

The Windows host runs Docker Desktop; your WSL2 distro talks to it. You install the daemon in exactly one place (Windows) and it appears seamlessly inside every enabled WSL distro.

  1. Make sure WSL2 is current (Windows PowerShell, admin):

    wsl --install                # no-op if WSL is already there
    wsl --set-default-version 2
    wsl --update
    

    Reboot if Windows prompts.

  2. Verify your distro is on WSL 2 (not WSL 1):

    wsl -l -v
    

    The VERSION column should read 2 for your distro. If it shows 1, convert: wsl --set-version <DistroName> 2.

  3. Install Docker Desktop:

  4. Enable WSL integration (one-time):

    • Docker Desktop → SettingsResourcesWSL Integration.
    • Toggle on integration for every WSL distro you'll run docker from (Ubuntu, Debian, …).
    • Click Apply & Restart.
  5. Verify from inside WSL:

    docker --version
    docker run --rm hello-world
    

    hello-world should print "Hello from Docker!" and exit 0.

Option B — Docker Engine inside WSL2 (no Docker Desktop)

Use this when Docker Desktop isn't allowed (corporate / license policy) or when you want a single isolated Linux install.

  1. Enable systemd in WSL2 (Docker's daemon expects it). In your WSL distro edit /etc/wsl.conf:

    [boot]
    systemd=true
    

    Then from Windows PowerShell:

    wsl --shutdown
    

    Reopen the WSL terminal; check systemctl --version runs.

  2. Install Docker Engine (Ubuntu / Debian example — Docker's official apt repo):

    # Remove anything old that might shadow the new install
    sudo apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
    
    # Add Docker's apt key + repo
    sudo apt-get update
    sudo apt-get install -y ca-certificates curl gnupg lsb-release
    sudo install -m 0755 -d /etc/apt/keyrings
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
         https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
      | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
    
    # Install
    sudo apt-get update
    sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
                            docker-buildx-plugin docker-compose-plugin
    
  3. Run without sudo:

    sudo usermod -aG docker $USER
    

    Log out and back into WSL (or exec su -l $USER) so the new group membership takes effect.

  4. Start the daemon:

    sudo systemctl enable --now docker
    
  5. Verify:

    docker --version
    docker run --rm hello-world
    

Hardware-only — pass the Owon PSU USB device into WSL with usbipd-win

The mock image needs nothing beyond Docker itself. The hardware image needs the Owon PSU's USB-serial adapter exposed inside WSL2. Windows doesn't share USB devices with WSL2 out of the box; the de-facto bridge is usbipd-win.

  1. Install usbipd-win on Windows (PowerShell, admin):

    winget install --interactive --exact dorssel.usbipd-win
    

    Reboot.

  2. List USB devices to find the BUSID of the serial adapter:

    usbipd list
    

    Look for a row that describes your adapter — "USB Serial", "CH340", "FT232", "Owon" — and note its BUSID (e.g. 2-3).

  3. Bind the device (one-time per device, admin):

    usbipd bind --busid 2-3
    
  4. Attach the device to WSL (every time you plug it in, normal user):

    usbipd attach --wsl --busid 2-3
    
  5. Confirm it appeared inside WSL:

    ls /dev/ttyUSB*
    

    You should see /dev/ttyUSB0 (or similar). That's the path you pass to docker run --device /dev/ttyUSB0:/dev/ttyUSB0.

If you want the device to re-attach automatically every time you plug it in, use usbipd attach --auto-attach --wsl --busid 2-3 (consult usbipd --help for the full set of options).

MUM network access (192.168.7.2)

The MUM presents itself as a USB-RNDIS Ethernet adapter on Windows. With Docker Desktop's WSL2 backend, --network host in the container reaches the MUM automatically — no extra setup beyond plugging the MUM in and seeing it appear in ipconfig (it should add an interface with a 192.168.7.x address on the Windows side).

If you went with Option B (Engine in WSL2), the MUM still works because the WSL2 distro shares the Windows network stack for host-mode containers.

Sanity check before the hardware run

# Docker reachable from WSL?
docker version

# USB-serial visible in WSL?
ls -la /dev/ttyUSB*

# MUM reachable?
ping -c 2 192.168.7.2

If all three succeed you're ready for the hardware run below.


Mock-only image (CI-ready, no hardware needed)

Build

docker build -f docker/Dockerfile -t ecu-tests:mock .

Run the mock suite

mkdir -p reports

docker run --rm \
    -v "$PWD/reports:/reports" \
    ecu-tests:mock \
    pytest -m "not hardware" -v \
           --junitxml=/reports/junit.xml \
           --html=/reports/report.html --self-contained-html

When the container exits, reports/report.html and reports/junit.xml are on the host. Open the HTML report:

xdg-open reports/report.html      # Linux
open reports/report.html          # macOS
start reports\report.html         # Windows

Interactive shell

docker run --rm -it -v "$PWD:/workspace" ecu-tests:mock bash

Edit files on the host, run pytest inside the container — code changes show up immediately.


Hardware image (real bench)

One-time setup — Melexis packages

pylin / pymumclient / pylinframe ship inside the Melexis IDE, not on PyPI. They also pull in a handful of transitive Melexis packages that pylin and friends import at module load — if any are missing the build fails partway through with ModuleNotFoundError. Bundle the full set into a dedicated melexis-bundle/ subdir at the repo root:

# Adjust the path to where Melexis IDE is installed
MELEXIS_SITE="/mnt/c/Program Files/Melexis/Melexis IDE/plugins/com.melexis.mlxide.python_1.2.0.202408130945/python/Lib/site-packages"

mkdir -p melexis-bundle
tar -czf melexis-bundle/melexis-pkgs.tar.gz \
    -C "$MELEXIS_SITE" \
    mlx \
    pylin pylinframe pymumclient \
    pymlxabc pymlxchip pymlxexceptions \
    pymlxgdb pymlxhex pymlxloader \
    pyldfparser pymbdfparser pymelibu pymelibuframe

Reason for the long list: pylin/__init__.py transitively imports pymlxabc and pyldfparser; pylinframe pulls in pymbdfparser and the pymelibu* pair; the pymlx* family pulls in the rest. Shipping a partial set fails during the docker build, not at runtime — the builder runs a smoke import as the final extraction step (see Dockerfile §7).

If you already have a working install in a local venv (e.g. ~/ecu-tests/.venv/lib/python3.10/site-packages/), you can tar from there instead — mlx* / py* are pure-Python, so a 3.10-sourced tarball extracts cleanly into the image's Python 3.11 site-packages. Include the matching *.dist-info/ directories if you want pip list and metadata-aware tools to work inside the container.

melexis-bundle/ is excluded by the root .dockerignore, so the tarball never enters the default build context (no leak into /workspace). The hw build reaches it via a named build context (--build-context melexis-bundle=./melexis-bundle) that overrides a stub scratch stage in the Dockerfile; named contexts apply only the .dockerignore at their own root (none here), so the file is visible there. The single RUN that extracts it uses --mount=type=bind from that named context — no size limit (--mount=type=secret is capped at 500 KiB, which the full tarball exceeds), and like secrets the mount exists only for the duration of one RUN.

License: the resulting image contains proprietary Melexis code. Treat it like the Melexis IDE itself — keep it on a private registry, not Docker Hub.

Build

DOCKER_BUILDKIT=1 docker build \
    -f docker/Dockerfile -t ecu-tests:hw \
    --build-arg INCLUDE_MELEXIS=1 \
    --build-context melexis-bundle=./melexis-bundle \
    .

--build-context melexis-bundle=./melexis-bundle points the named context at the melexis-bundle/ subdir (where the tarball lives). If you keep the tarball elsewhere, pass that directory instead — any directory containing a file called melexis-pkgs.tar.gz works, e.g. --build-context melexis-bundle=/path/to/melexis/bundle/dir. Don't point it at the repo root: the root .dockerignore filters melexis-pkgs.tar.gz out of every context anchored there, named or otherwise.

Verify the Melexis packages landed inside the image:

docker run --rm ecu-tests:hw \
    python -c "import pylin, pymumclient, pylinframe, pymlxabc; print('OK')"

The Dockerfile already runs a smoke import during the secret-mount step, but it only checks the top-level packages — import pylin transitively imports pymlxabc, so if pymlxabc is missing the build fails at that step rather than at runtime.

Run the hardware suite — direct docker run

docker run --rm \
    --network host \
    --device /dev/ttyUSB0:/dev/ttyUSB0 \
    --group-add dialout \
    -v "$PWD/reports:/reports" \
    -v "$PWD/config/test_config.yaml:/workspace/config/test_config.yaml:ro" \
    -e ECU_TESTS_CONFIG=/workspace/config/test_config.yaml \
    ecu-tests:hw \
    pytest -m "hardware and mum and not slow" -v \
           --junitxml=/reports/junit.xml \
           --html=/reports/report.html --self-contained-html

The flags, in plain English:

Flag Reason
--network host MUM is at 192.168.7.2 via USB-RNDIS on the host; bridge networking would hide it.
--device /dev/ttyUSB0:/dev/ttyUSB0 Pass the Owon PSU's USB-serial device into the container. Adjust to whatever ls /dev/ttyUSB* shows on the host.
--group-add dialout Without it, the tester user can't open the serial device.
-v config/test_config.yaml:…:ro Tweak bench config without rebuilding the image.

Run via docker-compose

docker compose -f docker/compose.hw.yml build
docker compose -f docker/compose.hw.yml up --abort-on-container-exit

Same effect as the docker run above, but the parameters are checked into compose.hw.yml so all you remember is the file path.

Iteration — edit-on-host, run-in-container

docker run --rm -it \
    --network host \
    --device /dev/ttyUSB0:/dev/ttyUSB0 \
    --group-add dialout \
    -v "$PWD:/workspace" \
    -v "$PWD/reports:/reports" \
    ecu-tests:hw \
    bash

Inside the container:

# Run a specific file
pytest tests/hardware/test_overvolt.py -v -s

# Or one parametrized case
pytest "tests/hardware/test_overvolt.py::test_template_voltage_status_parametrized[overvoltage]" -v -s

# Or the settle characterization
pytest -m psu_settling -v -s

Where everything lives

After docker build and docker run, three different stores hold three different things. Knowing the difference saves time when you want to find a report, free disk space, or confirm "did my build actually succeed?"

Thing Lives where How you access it
The image Docker daemon's content-addressed layer store. Not a single file. docker images, docker inspect, docker history
A running / stopped container Daemon's runtime state. Ephemeral when --rm is used. docker ps, docker ps -a, docker logs, docker exec
The test reports Host filesystem at ./reports/, via the -v bind-mount in every run command. Survives container deletion. ls reports/, open reports/report.html

The image

You don't navigate to it as files — query it through docker:

docker images                  # all images on this daemon
docker images ecu-tests        # just the ones tagged ecu-tests
docker inspect ecu-tests:mock  # full metadata (JSON)
docker history ecu-tests:mock  # layer-by-layer breakdown

The on-disk location is daemon-internal:

Host setup Backing store
Native Docker Engine on Linux (Option B in the install section) /var/lib/docker/overlay2/…
Docker Desktop + WSL2 (Option A) Inside a hidden WSL2 distro docker-desktop-data. Windows side: %LOCALAPPDATA%\Docker\wsl\disk\docker_data.vhdx. Don't poke directly — always use the docker CLI.

Images persist across reboots until you delete them:

docker rmi ecu-tests:mock        # one image
docker system prune -a           # everything unused (careful)
docker system df                 # what's eating disk

A running container

docker run … creates a container from the image. The container has its own writable filesystem layer on top of the image's read-only layers. The image is unchanged when the container exits.

docker ps                # running right now
docker ps -a             # all, including exited
docker logs <id>         # captured stdout / stderr
docker exec -it <id> bash    # shell into a still-running container

Every run command in this README uses --rm, so the container is deleted the moment it exits. The image stays. The reports (see below) stay too because they're on the host filesystem, not inside the container.

Inside the container — what the Dockerfile lays out

/                          (container root)
├── opt/
│   └── venv/              ← Python venv with all pip-installed deps
├── workspace/             ← the repo, copied in at build time
│   ├── ecu_framework/
│   ├── tests/
│   ├── config/
│   └── …
├── reports/               ← mount point for the host's ./reports/
└── home/tester/           ← unprivileged user home (uid 1000)

Peek at the layout from a throwaway container:

docker run --rm -it ecu-tests:mock bash
# inside:
ls /workspace
ls /opt/venv/bin
which pytest

/workspace is a frozen snapshot of the repo from the moment you ran docker build. Edits to files on the host afterwards do NOT show up inside the image — unless you bind-mount the repo at run time:

docker run --rm -it -v "$PWD:/workspace" ecu-tests:mock bash

(That's exactly what the "Iteration" example does.)

Reports on the host — what you actually look at

Every docker run command in this README includes a bind-mount:

-v "$PWD/reports:/reports"

The container writes its outputs to /reports/; the daemon's bind-mount makes those writes show up on the host at ./reports/ in your repo. After the container exits, the files are still there:

<repo root>/
└── reports/
    ├── report.html                  ← open this in a browser
    ├── junit.xml                    ← machine-readable for CI
    ├── summary.md
    └── requirements_coverage.json

--rm deletes the container; it does not touch the bind-mounted host directory.

Three commands cover 95% of "where is it?"

docker images ecu-tests                      # is the image there?
docker run --rm -v "$PWD/reports:/reports" \
    ecu-tests:mock pytest -m "not hardware" -q
ls reports/                                  # outputs landed where?

Platform notes

  • Linux: works as shown above.
  • WSL2 (Windows): USB devices need usbipd-win to bind them into the WSL2 distro; from there they appear as /dev/ttyUSB0 exactly like on native Linux. Docker Desktop bridges WSL2 to the host network, so --network host reaches the MUM normally.
  • macOS Docker Desktop: USB passthrough is not supported. Workaround is to run a TCP-to-serial bridge on the host (socat) and have the container connect to that — fiddly, documented in docs/20_docker_image.md §4.3 as a non-default path.

Troubleshooting

Symptom Likely cause Fix
'docker buildx build' requires 1 argument Missing build-context path on the command line Append . (or the repo root) as the final argument to docker build …
failed to build: resolve : lstat docker: no such file or directory Running docker build -f docker/Dockerfile … from somewhere other than the repo root cd into the repo root first (the directory that contains docker/Dockerfile)
ModuleNotFoundError: No module named 'pylin' Image built without INCLUDE_MELEXIS=1 Rebuild with the build-arg + secret
ModuleNotFoundError: No module named 'pymlxabc' (or pymlxchip, pymlxhex, …) during the build Melexis tarball is missing a transitive package Rebuild the tarball with the full package list above
secret melexis_tarball too big. max size 500KiB Old --secret id=melexis_tarball,src=… flag with the full Melexis bundle Switch to --build-context melexis-bundle=<dir> (see Build command above) — BuildKit caps secrets at 500 KiB
INCLUDE_MELEXIS=1 but melexis-pkgs.tar.gz missing --build-context melexis-bundle=<dir> not passed, points at the wrong dir, or points at a dir whose .dockerignore filters the tarball (typical foot-gun: passing =. from the repo root — the root .dockerignore excludes melexis-pkgs.tar.gz) Place the tarball at melexis-bundle/melexis-pkgs.tar.gz and pass --build-context melexis-bundle=./melexis-bundle
Permission denied: '/dev/ttyUSB0' Missing --group-add dialout Add it (or the group that owns the device on the host)
MUM unreachable at 192.168.7.2 Bridge network instead of host network Add --network host (Linux); on macOS see §4.3
Empty reports/ after run /reports not bind-mounted Add -v "$PWD/reports:/reports"
HTML report missing styling Forgot --self-contained-html Pytest renders the report without inlined CSS otherwise

See docs/20_docker_image.md §8 for the full table.