Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docker/all-in-one/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docker/all-in-one/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
74 changes: 74 additions & 0 deletions docker/all-in-one/prune-venv.py
Original file line number Diff line number Diff line change
@@ -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: <venv>/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:])
Loading