Fix fornax service port and add qBittorrent worker env vars

- fornax service: targetPort 8080→3000 (Express listens on 3000, not 8080)
  This was silently dropping all Radarr/Sonarr requests
- coordinator deployment: add QBT_WORKER_ADDRS, QBT_USER, QBT_PASS
  so /api/ui/state can query real worker qBittorrent instances
- remove Python coordinator ConfigMap and Deployment (superseded)
This commit is contained in:
Will Anderson
2026-04-14 19:17:00 -05:00
parent 788f103c0e
commit 2a21c9d57e
2 changed files with 10 additions and 164 deletions
@@ -29,6 +29,12 @@ spec:
value: "3000"
- name: NODE_ENV
value: production
- name: QBT_WORKER_ADDRS
value: "tx253:TX-253:fornax-worker-tx253.media.svc:8080,tx34:TX-34:fornax-worker-tx34.media.svc:8080"
- name: QBT_USER
value: "admin"
- name: QBT_PASS
value: "adminadmin"
envFrom:
- secretRef:
name: fornax-coordinator-secrets
@@ -1,165 +1,5 @@
# Fornax coordinator — simple round-robin proxy in front of all qBittorrent workers
# Distributes torrent-add requests across workers; proxies monitoring to primary.
# Sonarr/Radarr point here instead of directly at qbittorrent.
# Replace with the full Fornax coordinator once that's built.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: fornax-coordinator-script
namespace: media
data:
coordinator.py: |
#!/usr/bin/env python3
"""
Fornax coordinator — least-loaded proxy for qBittorrent API.
- POST /api/v2/torrents/add → routed to worker with fewest active torrents
- Everything else → proxied to primary backend (qbittorrent)
Manages its own SID sessions to each backend so auth is transparent.
"""
import json, os, urllib.request, urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
BACKENDS = [b.strip() for b in os.environ.get("BACKENDS", "").split(",") if b.strip()]
USER = os.environ.get("QBT_USER", "admin")
PASS = os.environ.get("QBT_PASS", "adminadmin")
PRIMARY = BACKENDS[0] if BACKENDS else ""
sids = {}
def fetch_sid(backend):
data = urllib.parse.urlencode({"username": USER, "password": PASS}).encode()
req = urllib.request.Request(f"{backend}/api/v2/auth/login", data=data)
try:
with urllib.request.urlopen(req, timeout=5) as r:
for k, v in r.getheaders():
if k.lower() == "set-cookie" and "SID=" in v:
return v.split("SID=")[1].split(";")[0]
except Exception as e:
print(f"auth failed for {backend}: {e}")
return None
def sid(backend):
if not sids.get(backend):
sids[backend] = fetch_sid(backend)
return sids.get(backend, "")
def active_count(backend):
"""Return number of downloading torrents on this backend, or large int on error."""
try:
req = urllib.request.Request(
f"{backend}/api/v2/torrents/info?filter=downloading",
headers={"Cookie": f"SID={sid(backend)}"},
)
with urllib.request.urlopen(req, timeout=3) as r:
return len(json.loads(r.read()))
except Exception:
return 9999
def least_loaded():
counts = {b: active_count(b) for b in BACKENDS}
chosen = min(counts, key=counts.get)
print(f" load: {counts} → routing to {chosen}")
return chosen
def forward(backend, method, path, body, content_type):
headers = {"Cookie": f"SID={sid(backend)}"}
if content_type:
headers["Content-Type"] = content_type
req = urllib.request.Request(
f"{backend}{path}", data=body if method == "POST" else None,
headers=headers, method=method,
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read(), list(r.getheaders())
except urllib.error.HTTPError as e:
if e.code == 403:
sids[backend] = fetch_sid(backend)
headers["Cookie"] = f"SID={sids[backend]}"
req2 = urllib.request.Request(
f"{backend}{path}", data=body if method == "POST" else None,
headers=headers, method=method,
)
try:
with urllib.request.urlopen(req2, timeout=30) as r:
return r.status, r.read(), list(r.getheaders())
except Exception as e2:
return 500, str(e2).encode(), []
return e.code, e.read(), []
except Exception as e:
return 500, str(e).encode(), []
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"{self.command} {self.path}")
def _proxy(self, method):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) if length else None
ct = self.headers.get("Content-Type", "")
backend = least_loaded() if (method == "POST" and "/api/v2/torrents/add" in self.path) else PRIMARY
status, resp_body, resp_headers = forward(backend, method, self.path, body, ct)
self.send_response(status)
for k, v in resp_headers:
if k.lower() not in {"transfer-encoding", "connection"}:
self.send_header(k, v)
self.end_headers()
self.wfile.write(resp_body)
def do_GET(self): self._proxy("GET")
def do_POST(self): self._proxy("POST")
if __name__ == "__main__":
print(f"Fornax coordinator ready — backends: {BACKENDS}")
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fornax-coordinator
namespace: media
labels:
app: fornax-coordinator
spec:
replicas: 1
selector:
matchLabels:
app: fornax-coordinator
template:
metadata:
labels:
app: fornax-coordinator
spec:
containers:
- name: coordinator
image: python:3-alpine
command: ["python", "/app/coordinator.py"]
env:
- name: BACKENDS
value: "http://qbittorrent.media.svc:8080,http://fornax-worker-tx253.media.svc:8080,http://fornax-worker-tx34.media.svc:8080"
- name: QBT_USER
value: "admin"
- name: QBT_PASS
value: "adminadmin"
ports:
- containerPort: 8080
volumeMounts:
- name: script
mountPath: /app
resources:
requests:
memory: 32Mi
cpu: 10m
limits:
memory: 128Mi
cpu: 100m
volumes:
- name: script
configMap:
name: fornax-coordinator-script
---
# fornax service — used by Radarr/Sonarr as their download client endpoint.
# Routes to the Express coordinator (port 3000).
apiVersion: v1
kind: Service
metadata:
@@ -171,7 +11,7 @@ spec:
selector:
app: fornax-coordinator
ports:
- name: webui
- name: http
port: 8080
targetPort: 8080
targetPort: 3000
type: ClusterIP