From 22979572f9507d2de618b30270192b1a20a30d9d Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 4 Mar 2026 21:35:54 -0800 Subject: [PATCH 1/2] Replace unmaintained transmission-exporter with homegrown Python exporter Swap out docker.io/metalmatze/transmission-exporter:master for a minimal Python exporter using prometheus_client and transmission-rpc with the collect-on-scrape pattern. Same metric names so Grafana dashboards work unchanged. Uses uv for dependency management in the container build. Co-Authored-By: Claude Opus 4.6 --- argocd/manifests/torrent/deployment.yaml | 2 +- argocd/manifests/torrent/kustomization.yaml | 2 + containers/transmission-exporter/Dockerfile | 31 ++++ containers/transmission-exporter/exporter.py | 154 ++++++++++++++++++ ...ture-transmission-exporter-python.infra.md | 1 + service-versions.yaml | 7 + 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 containers/transmission-exporter/Dockerfile create mode 100644 containers/transmission-exporter/exporter.py create mode 100644 docs/changelog.d/feature-transmission-exporter-python.infra.md diff --git a/argocd/manifests/torrent/deployment.yaml b/argocd/manifests/torrent/deployment.yaml index dfad219..df715a8 100644 --- a/argocd/manifests/torrent/deployment.yaml +++ b/argocd/manifests/torrent/deployment.yaml @@ -56,7 +56,7 @@ spec: initialDelaySeconds: 10 periodSeconds: 10 - name: transmission-exporter - image: docker.io/metalmatze/transmission-exporter:master + image: registry.ops.eblu.me/blumeops/transmission-exporter env: - name: TRANSMISSION_ADDR value: "http://localhost:9091" diff --git a/argocd/manifests/torrent/kustomization.yaml b/argocd/manifests/torrent/kustomization.yaml index 42232a3..8e66fc3 100644 --- a/argocd/manifests/torrent/kustomization.yaml +++ b/argocd/manifests/torrent/kustomization.yaml @@ -11,3 +11,5 @@ resources: images: - name: registry.ops.eblu.me/blumeops/transmission newTag: v4.1.1-r1-ab34cbd + - name: registry.ops.eblu.me/blumeops/transmission-exporter + newTag: v1.0.0 diff --git a/containers/transmission-exporter/Dockerfile b/containers/transmission-exporter/Dockerfile new file mode 100644 index 0000000..10c6a76 --- /dev/null +++ b/containers/transmission-exporter/Dockerfile @@ -0,0 +1,31 @@ +# Transmission Prometheus exporter - collect-on-scrape, no polling loop +# Two-stage build: uv installs deps into venv, runtime is minimal Alpine + +ARG CONTAINER_APP_VERSION=1.0.0 + +FROM python:3.12-alpine3.22 AS base + +FROM base AS builder +WORKDIR /app +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +COPY exporter.py . +RUN uv venv .venv && \ + uv pip install --python .venv/bin/python \ + "prometheus-client>=0.24,<1.0" \ + "transmission-rpc>=7.0,<8.0" && \ + find /app/.venv \( -type d -a -name test -o -name tests \) \ + -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ + +FROM base + +LABEL org.opencontainers.image.title="Transmission Exporter" +LABEL org.opencontainers.image.description="Prometheus exporter for Transmission BitTorrent client" + +ENV PYTHONUNBUFFERED=1 +WORKDIR /app +COPY --from=builder /app /app +ENV PATH="/app/.venv/bin:$PATH" + +EXPOSE 19091 +USER 65534:65534 +CMD ["python", "-u", "/app/exporter.py"] diff --git a/containers/transmission-exporter/exporter.py b/containers/transmission-exporter/exporter.py new file mode 100644 index 0000000..60ce34d --- /dev/null +++ b/containers/transmission-exporter/exporter.py @@ -0,0 +1,154 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "prometheus-client>=0.24,<1.0", +# "transmission-rpc>=7.0,<8.0", +# ] +# /// +"""Minimal Prometheus exporter for Transmission, using collect-on-scrape.""" + +import os +import sys +import urllib.parse + +from prometheus_client import start_http_server +from prometheus_client.core import REGISTRY, GaugeMetricFamily +from transmission_rpc import Client + + +def parse_addr(addr: str) -> dict: + """Parse TRANSMISSION_ADDR into kwargs for transmission_rpc.Client.""" + parsed = urllib.parse.urlparse(addr) + kwargs: dict = {} + if parsed.hostname: + kwargs["host"] = parsed.hostname + if parsed.port: + kwargs["port"] = parsed.port + if parsed.scheme == "https": + kwargs["protocol"] = "https" + if parsed.path and parsed.path != "/": + kwargs["path"] = parsed.path.strip("/") + if parsed.username: + kwargs["username"] = parsed.username + if parsed.password: + kwargs["password"] = parsed.password + return kwargs + + +class TransmissionCollector: + def __init__(self, client_kwargs: dict): + self._client_kwargs = client_kwargs + + def collect(self): + try: + client = Client(**self._client_kwargs) + session = client.session_stats() + torrents = client.get_torrents() + except Exception as e: + print(f"Error collecting metrics: {e}", file=sys.stderr) + return + + # Session stats + yield _gauge( + "transmission_session_stats_download_speed_bytes", + "Current download speed in bytes/s", + session.download_speed, + ) + yield _gauge( + "transmission_session_stats_upload_speed_bytes", + "Current upload speed in bytes/s", + session.upload_speed, + ) + yield _gauge( + "transmission_session_stats_torrents_active", + "Number of active torrents", + session.active_torrent_count, + ) + yield _gauge( + "transmission_session_stats_torrents_total", + "Total number of torrents", + session.torrent_count, + ) + + downloaded = GaugeMetricFamily( + "transmission_session_stats_downloaded_bytes", + "Total bytes downloaded", + labels=["type"], + ) + downloaded.add_metric(["cumulative"], session.cumulative_stats.downloaded_bytes) + yield downloaded + + uploaded = GaugeMetricFamily( + "transmission_session_stats_uploaded_bytes", + "Total bytes uploaded", + labels=["type"], + ) + uploaded.add_metric(["cumulative"], session.cumulative_stats.uploaded_bytes) + yield uploaded + + # Per-torrent metrics + t_download = GaugeMetricFamily( + "transmission_torrent_download_bytes", + "Torrent total downloaded bytes", + labels=["name"], + ) + t_upload = GaugeMetricFamily( + "transmission_torrent_upload_bytes", + "Torrent total uploaded bytes", + labels=["name"], + ) + t_ratio = GaugeMetricFamily( + "transmission_torrent_ratio", + "Torrent upload ratio", + labels=["name"], + ) + t_uploaded_ever = GaugeMetricFamily( + "transmission_torrent_uploaded_ever", + "Torrent total uploaded ever in bytes", + labels=["name"], + ) + t_done = GaugeMetricFamily( + "transmission_torrent_done", + "Torrent percent done (0.0-1.0)", + labels=["name"], + ) + + for t in torrents: + name = t.name or "unknown" + t_download.add_metric([name], t.total_size * t.percent_done) + t_upload.add_metric([name], t.uploaded_ever) + t_ratio.add_metric([name], t.ratio) + t_uploaded_ever.add_metric([name], t.uploaded_ever) + t_done.add_metric([name], t.percent_done) + + yield t_download + yield t_upload + yield t_ratio + yield t_uploaded_ever + yield t_done + + +def _gauge(name: str, doc: str, value: float) -> GaugeMetricFamily: + g = GaugeMetricFamily(name, doc) + g.add_metric([], value) + return g + + +def main(): + addr = os.environ.get("TRANSMISSION_ADDR", "http://localhost:9091") + port = int(os.environ.get("EXPORTER_PORT", "19091")) + + client_kwargs = parse_addr(addr) + REGISTRY.register(TransmissionCollector(client_kwargs)) + + print(f"Listening on :{port}, scraping {addr}") + start_http_server(port) + + # Block forever + import threading + + threading.Event().wait() + + +if __name__ == "__main__": + main() diff --git a/docs/changelog.d/feature-transmission-exporter-python.infra.md b/docs/changelog.d/feature-transmission-exporter-python.infra.md new file mode 100644 index 0000000..04ce4a6 --- /dev/null +++ b/docs/changelog.d/feature-transmission-exporter-python.infra.md @@ -0,0 +1 @@ +Replace unmaintained `metalmatze/transmission-exporter` sidecar with homegrown Python exporter using `prometheus_client` and `transmission-rpc`. Same metric names, so Grafana dashboards work unchanged. diff --git a/service-versions.yaml b/service-versions.yaml index 88e10b5..bd36b83 100644 --- a/service-versions.yaml +++ b/service-versions.yaml @@ -173,6 +173,13 @@ services: current-version: "4.1.1-r1" upstream-source: https://github.com/transmission/transmission/releases + - name: transmission-exporter + type: argocd + last-reviewed: 2026-03-04 + current-version: "1.0.0" + upstream-source: null + notes: Homegrown Python exporter, no upstream + - name: kiwix type: argocd last-reviewed: null -- 2.50.1 (Apple Git-155) From 9c1b4a9a238353a1955a8d744179e80e3c924009 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Wed, 4 Mar 2026 21:47:21 -0800 Subject: [PATCH 2/2] Address PR feedback: uv run --script, latest Python/Alpine, serve_forever - Use #!/usr/bin/env -S uv run --script shebang with PEP 723 metadata - Drop venv/pip multi-stage build; uv handles deps at runtime - Upgrade to python:3.13-alpine3.23 - Unpin dependency versions (no upper bounds) - Replace threading.Event().wait() with wsgiref serve_forever() Co-Authored-By: Claude Opus 4.6 --- containers/transmission-exporter/Dockerfile | 26 ++++++-------------- containers/transmission-exporter/exporter.py | 20 ++++++--------- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/containers/transmission-exporter/Dockerfile b/containers/transmission-exporter/Dockerfile index 10c6a76..8a20a86 100644 --- a/containers/transmission-exporter/Dockerfile +++ b/containers/transmission-exporter/Dockerfile @@ -1,31 +1,21 @@ # Transmission Prometheus exporter - collect-on-scrape, no polling loop -# Two-stage build: uv installs deps into venv, runtime is minimal Alpine +# uv run --script handles dependency resolution at runtime ARG CONTAINER_APP_VERSION=1.0.0 -FROM python:3.12-alpine3.22 AS base - -FROM base AS builder -WORKDIR /app -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -COPY exporter.py . -RUN uv venv .venv && \ - uv pip install --python .venv/bin/python \ - "prometheus-client>=0.24,<1.0" \ - "transmission-rpc>=7.0,<8.0" && \ - find /app/.venv \( -type d -a -name test -o -name tests \) \ - -o \( -type f -a -name '*.pyc' -o -name '*.pyo' \) -exec rm -rf '{}' \+ - -FROM base +FROM python:3.13-alpine3.23 LABEL org.opencontainers.image.title="Transmission Exporter" LABEL org.opencontainers.image.description="Prometheus exporter for Transmission BitTorrent client" +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + ENV PYTHONUNBUFFERED=1 +ENV UV_CACHE_DIR=/tmp/uv-cache + WORKDIR /app -COPY --from=builder /app /app -ENV PATH="/app/.venv/bin:$PATH" +COPY exporter.py . EXPOSE 19091 USER 65534:65534 -CMD ["python", "-u", "/app/exporter.py"] +CMD ["uv", "run", "--script", "/app/exporter.py"] diff --git a/containers/transmission-exporter/exporter.py b/containers/transmission-exporter/exporter.py index 60ce34d..bde1034 100644 --- a/containers/transmission-exporter/exporter.py +++ b/containers/transmission-exporter/exporter.py @@ -1,8 +1,9 @@ +#!/usr/bin/env -S uv run --script # /// script -# requires-python = ">=3.12" +# requires-python = ">=3.13" # dependencies = [ -# "prometheus-client>=0.24,<1.0", -# "transmission-rpc>=7.0,<8.0", +# "prometheus-client", +# "transmission-rpc", # ] # /// """Minimal Prometheus exporter for Transmission, using collect-on-scrape.""" @@ -10,8 +11,9 @@ import os import sys import urllib.parse +from wsgiref.simple_server import make_server -from prometheus_client import start_http_server +from prometheus_client import make_wsgi_app from prometheus_client.core import REGISTRY, GaugeMetricFamily from transmission_rpc import Client @@ -48,7 +50,6 @@ class TransmissionCollector: print(f"Error collecting metrics: {e}", file=sys.stderr) return - # Session stats yield _gauge( "transmission_session_stats_download_speed_bytes", "Current download speed in bytes/s", @@ -86,7 +87,6 @@ class TransmissionCollector: uploaded.add_metric(["cumulative"], session.cumulative_stats.uploaded_bytes) yield uploaded - # Per-torrent metrics t_download = GaugeMetricFamily( "transmission_torrent_download_bytes", "Torrent total downloaded bytes", @@ -142,12 +142,8 @@ def main(): REGISTRY.register(TransmissionCollector(client_kwargs)) print(f"Listening on :{port}, scraping {addr}") - start_http_server(port) - - # Block forever - import threading - - threading.Event().wait() + httpd = make_server("", port, make_wsgi_app()) + httpd.serve_forever() if __name__ == "__main__": -- 2.50.1 (Apple Git-155)