diff --git a/servers/legion/k8s/fornax/coordinator/deployment.yaml b/servers/legion/k8s/fornax/coordinator/deployment.yaml index 9e44478..eaaef48 100644 --- a/servers/legion/k8s/fornax/coordinator/deployment.yaml +++ b/servers/legion/k8s/fornax/coordinator/deployment.yaml @@ -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 diff --git a/servers/legion/k8s/media/fornax-coordinator.yaml b/servers/legion/k8s/media/fornax-coordinator.yaml index 734ea48..4404b10 100644 --- a/servers/legion/k8s/media/fornax-coordinator.yaml +++ b/servers/legion/k8s/media/fornax-coordinator.yaml @@ -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