# Docker — quick reference

Full reference: [`docs/20_docker_image.md`](../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).

---

## 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.

### Option A — Docker Desktop on Windows (WSL2 backend, recommended)

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):
   ```powershell
   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):
   ```powershell
   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**:
   - Download <https://www.docker.com/products/docker-desktop/>.
   - During install, leave **"Use WSL 2 instead of Hyper-V"** ticked.
   - Launch Docker Desktop after install completes.

4. **Enable WSL integration** (one-time):
   - Docker Desktop → **Settings** → **Resources** → **WSL Integration**.
   - Toggle on integration for every WSL distro you'll run `docker`
     from (Ubuntu, Debian, …).
   - Click **Apply & Restart**.

5. **Verify from inside WSL**:
   ```bash
   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`:
   ```ini
   [boot]
   systemd=true
   ```
   Then from Windows PowerShell:
   ```powershell
   wsl --shutdown
   ```
   Reopen the WSL terminal; check `systemctl --version` runs.

2. **Install Docker Engine** (Ubuntu / Debian example — Docker's
   official apt repo):
   ```bash
   # 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`**:
   ```bash
   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**:
   ```bash
   sudo systemctl enable --now docker
   ```

5. **Verify**:
   ```bash
   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`](https://github.com/dorssel/usbipd-win).

1. **Install `usbipd-win` on Windows** (PowerShell, admin):
   ```powershell
   winget install --interactive --exact dorssel.usbipd-win
   ```
   Reboot.

2. **List USB devices** to find the BUSID of the serial adapter:
   ```powershell
   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):
   ```powershell
   usbipd bind --busid 2-3
   ```

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

5. **Confirm it appeared inside WSL**:
   ```bash
   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

```bash
# 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

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

### Run the mock suite

```bash
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:

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

### Interactive shell

```bash
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:

```bash
# 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

```bash
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:

```bash
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`

```bash
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

```bash
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

```bash
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:

```bash
# 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`:

```bash
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:

```bash
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.

```bash
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:

```bash
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:

```bash
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?"

```bash
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`](../docs/20_docker_image.md) §8 for
the full table.
