From 7e80701f2b0cb9b6697147e63385f029508eb9b3 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:52:28 +0200 Subject: [PATCH] fix(docker): prune the gateway's dev packages from the all-in-one image The upstream saic-python-mqtt-gateway image runs a plain `poetry install`, so the venv the all-in-one image copies also carries the dev group: pytest, mypy, pylint, pre-commit and 28 more packages that never run. virtualenv, via pre-commit, raised code scanning alerts #592-#595. prune-venv.py keeps only what the gateway's six runtime dependencies resolve to and uninstalls the rest. Importing main.py afterwards loads every gateway module, so a runtime package pruned by mistake fails the build instead of the gateway at startup. Co-Authored-By: Claude Opus 5.5 --- docker/all-in-one/Dockerfile | 14 ++++++- docker/all-in-one/README.md | 2 +- docker/all-in-one/prune-venv.py | 74 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 docker/all-in-one/prune-venv.py diff --git a/docker/all-in-one/Dockerfile b/docker/all-in-one/Dockerfile index 3c0fd7e7..e1d6595c 100644 --- a/docker/all-in-one/Dockerfile +++ b/docker/all-in-one/Dockerfile @@ -127,11 +127,23 @@ COPY --from=saic-source /usr/src/app /opt/saic/app # httpx 0.28 accepts any anyio 4.x, so the upgrade stays inside the gateway's own bounds. # Drop this once the pinned gateway tag ships anyio >= 4.14.2. # +# The upstream image runs a plain `poetry install`, so the venv also carries the gateway's dev +# group (pytest, mypy, pylint, pre-commit, ...), 32 packages at 0.12.0 that never run. Image +# scans flag them all the same (virtualenv, via pre-commit: CVE-2026-102925/30/37/38), +# so prune-venv.py keeps only what the runtime dependencies below resolve to. They mirror +# [project].dependencies in the gateway's pyproject.toml at the pinned tag: compare them on every +# gateway bump. Importing main.py loads every gateway module, so a runtime package pruned by +# mistake fails the build here instead of the gateway at startup. +# # Nothing in this image installs Python packages at runtime, so remove pip afterwards from # both the gateway venv and the base interpreter. Even the newest pip vendors outdated copies # of msgpack and setuptools' pkg_resources that image scans keep flagging (GHSA-6v7p-g79w-8964, # CVE-2025-47273, CVE-2026-59890), and dropping it also retires the earlier pip CVE patching. -RUN /opt/saic/app/.venv/bin/python -m pip install --no-cache-dir --upgrade "anyio==4.15.1" \ +RUN --mount=type=bind,source=docker/all-in-one/prune-venv.py,target=/tmp/prune-venv.py \ + /opt/saic/app/.venv/bin/python -m pip install --no-cache-dir --upgrade "anyio==4.15.1" \ + && /opt/saic/app/.venv/bin/python /tmp/prune-venv.py \ + saic-ismart-client-ng httpx gmqtt inflection apscheduler python-dotenv \ + && cd /opt/saic/app && .venv/bin/python -B -c "import main" \ && /opt/saic/app/.venv/bin/python -m pip uninstall --yes pip \ && python -m pip uninstall --yes pip diff --git a/docker/all-in-one/README.md b/docker/all-in-one/README.md index 15b8cc4d..155559c3 100644 --- a/docker/all-in-one/README.md +++ b/docker/all-in-one/README.md @@ -11,7 +11,7 @@ Single container that bundles every GarageStack service. Designed for Unraid and | **GarageStack.Worker** | .NET background service -- MQTT consumer, push notifications | | **PostgreSQL 18** | Embedded database (localhost:5432, not directly exposed) | | **Mosquitto** | MQTT broker on port 1883 with username/password auth and ACL | -| **SAIC MQTT Gateway** | Python 3.14 service that polls the MG iSmart cloud and publishes telemetry to Mosquitto. Copied directly from the official `saicismartapi/saic-python-mqtt-gateway` image. | +| **SAIC MQTT Gateway** | Python 3.14 service that polls the MG iSmart cloud and publishes telemetry to Mosquitto. Copied from the official `saicismartapi/saic-python-mqtt-gateway` image, minus the dev-only packages that image ships in its venv. | All processes are managed by **supervisord**. Startup order is enforced via priority and startup delays so PostgreSQL is ready before the .NET services try to connect. diff --git a/docker/all-in-one/prune-venv.py b/docker/all-in-one/prune-venv.py new file mode 100644 index 00000000..ca0ba058 --- /dev/null +++ b/docker/all-in-one/prune-venv.py @@ -0,0 +1,74 @@ +"""Uninstall every package in this interpreter's environment that the given requirements do not need. + +The saic-python-mqtt-gateway image runs a plain `poetry install`, so its runtime venv also holds +the dev group (pytest, mypy, pylint, pre-commit and everything they pull in). None of it runs, +but image scans flag it all the same. Run with the venv's own interpreter, so environment +markers resolve for the Python and platform the gateway runs on. pip itself is left alone; the +Dockerfile removes it as the last step. + +Usage: /bin/python prune-venv.py REQUIREMENT [REQUIREMENT ...] +""" + +from __future__ import annotations + +import subprocess +import sys +from importlib.metadata import Distribution, distributions + +# Private to pip, but pip is the one installer in the image and this script runs before it goes. +from pip._vendor.packaging.requirements import Requirement +from pip._vendor.packaging.utils import canonicalize_name + +ALWAYS_KEEP = frozenset({"pip"}) + + +def installed_distributions() -> dict[str, Distribution]: + return { + canonicalize_name(dist.metadata["Name"]): dist + for dist in distributions() + if dist.metadata["Name"] + } + + +def needed_names(roots: list[str], installed: dict[str, Distribution]) -> set[str]: + """Walk Requires-Dist from the roots, following an extra's dependencies only where requested.""" + missing = [root for root in roots if canonicalize_name(Requirement(root).name) not in installed] + if missing: + # A root the venv lacks means the gateway's dependency list changed, so the roots need updating. + sys.exit(f"Not installed, check the gateway's dependencies: {', '.join(missing)}") + + visited: set[tuple[str, str]] = set() + pending = [Requirement(root) for root in roots] + while pending: + requirement = pending.pop() + name = canonicalize_name(requirement.name) + dist = installed.get(name) + if dist is None: + continue + for extra in {"", *requirement.extras}: + if (name, extra) in visited: + continue + visited.add((name, extra)) + for line in dist.requires or []: + dependency = Requirement(line) + if dependency.marker is None or dependency.marker.evaluate({"extra": extra}): + pending.append(dependency) + return {name for name, _ in visited} + + +def main(roots: list[str]) -> None: + if not roots: + sys.exit(__doc__) + installed = installed_distributions() + surplus = sorted(installed.keys() - needed_names(roots, installed) - ALWAYS_KEEP) + if not surplus: + print("Nothing to prune") + return + print(f"Pruning {len(surplus)} packages: {' '.join(surplus)}", flush=True) + subprocess.run( + [sys.executable, "-m", "pip", "uninstall", "--yes", "--quiet", *surplus], check=True + ) + + +if __name__ == "__main__": + main(sys.argv[1:])