#!/usr/bin/env python3 """Deploy the BMS stack to the Unraid Docker host via the Portainer API. Why this shape: there is no usable `docker` CLI in WSL, no SSH to the Unraid box (port 22 is closed) and no shared registry the Unraid daemon can pull from. So each service's build *context* is packed into a tar and POSTed to the remote daemon's `/build` endpoint — Unraid runs the Dockerfiles itself. Once the four images exist locally on that daemon, docker-compose.prod.yml is pushed as a Portainer stack, which references the images by tag and never needs the repo on the host. Mirrors the pattern in ~/claude/projects/toknmtr/ops/deploy.py. Idempotent: re-running rebuilds every image and updates the existing stack in place. The Postgres bind mount at /mnt/user/appdata/bms/db survives redeploys. Usage: python3 ops/deploy.py # build everything + deploy the stack python3 ops/deploy.py --no-build # redeploy the stack using existing images """ import io import json import os import sys import tarfile import time import urllib.error import urllib.request PORTAINER = "http://192.168.1.249:9000" EP = 3 # 'local' docker endpoint (unix:///var/run/docker.sock) DOCKER = f"{PORTAINER}/api/endpoints/{EP}/docker" HOST = "192.168.1.249" FRONTEND_PORT = "5646" STACK_NAME = "bms" APPDATA = "/mnt/user/appdata/bms" PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) COMPOSE_FILE = os.path.join(PROJECT_DIR, "docker-compose.prod.yml") # (image tag, context subdirectory) — built in this order. # mqtt/backend first so the slow frontend build is last and failures surface early. IMAGES = [ ("bms-mqtt:latest", "infra/mosquitto"), ("bms-backend:latest", "backend"), ("bms-simulators:latest", "simulators"), ("bms-frontend:latest", "frontend"), ] # Never shipped in a build context. EXCLUDE_DIRS = {".git", "node_modules", ".next", "__pycache__", ".venv", "venv", ".vscode", ".idea", ".claude"} EXCLUDE_SUFFIXES = (".pyc", ".pyo", ".log", ".tsbuildinfo") EXCLUDE_FILES = {".env", ".env.local", ".DS_Store", "Thumbs.db"} TOKEN = open(os.path.expanduser("~/.portainer-token")).read().strip() HDR = {"X-API-Key": TOKEN} def req(url, method, data=None, headers=None, raw=False, timeout=900): h = dict(HDR) if headers: h.update(headers) body = data if data is not None and not raw: body = json.dumps(data).encode() h["Content-Type"] = "application/json" r = urllib.request.Request(url, data=body, headers=h, method=method) try: with urllib.request.urlopen(r, timeout=timeout) as resp: return resp.status, resp.read() except urllib.error.HTTPError as e: return e.code, e.read() def docker(method, path, **kw): return req(DOCKER + path, method, **kw) def api(method, path, **kw): return req(PORTAINER + "/api" + path, method, **kw) # ── image builds ───────────────────────────────────────────────────── def context_tar(subdir): """Pack one service directory (minus excludes) for the Docker /build API.""" root_dir = os.path.join(PROJECT_DIR, subdir) if not os.path.isdir(root_dir): sys.exit(f"ERROR: build context {subdir} does not exist.") if not os.path.exists(os.path.join(root_dir, "Dockerfile")): sys.exit(f"ERROR: no Dockerfile in {subdir}.") buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as tar: for root, dirs, files in os.walk(root_dir): dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] for fn in files: if fn in EXCLUDE_FILES or fn.endswith(EXCLUDE_SUFFIXES): continue full = os.path.join(root, fn) tar.add(full, arcname=os.path.relpath(full, root_dir)) return buf.getvalue() def build_image(tag, subdir): ctx = context_tar(subdir) print(f"\n── building {tag} from {subdir}/ ({len(ctx)/1024:.0f} KiB context)") status, out = docker( "POST", f"/build?t={tag}&dockerfile=Dockerfile&forcerm=true", data=ctx, headers={"Content-Type": "application/x-tar"}, raw=True, timeout=1800, # the Next.js build is the slow one ) text = out.decode(errors="replace") errored = None last = "" for line in text.splitlines(): try: msg = json.loads(line) except ValueError: continue if "stream" in msg: s = msg["stream"].rstrip() if s: last = s if s.startswith("Step ") or "Successfully" in s: print(" " + s) if "error" in msg: errored = msg["error"] if status != 200 or errored: print(f"BUILD FAILED ({tag}) — status {status}") print(text[-3000:]) sys.exit(f"ERROR: build of {tag} failed.") print(f" OK: {last[:120]}") # ── host prep ──────────────────────────────────────────────────────── def ensure_appdata(): """Create /mnt/user/appdata/bms/db before Postgres bind-mounts it. dockerd would auto-create the path, but doing it explicitly turns a silent permission problem into a visible one. """ print(f"\n── ensuring {APPDATA}/db exists on the host") docker("POST", "/images/create?fromImage=busybox&tag=latest", data=b"", raw=True, headers={"Content-Type": "application/json"}, timeout=300) body = { "Image": "busybox:latest", "Cmd": ["mkdir", "-p", "/hostdata/bms/db"], "HostConfig": {"Binds": ["/mnt/user/appdata:/hostdata"], "AutoRemove": True}, } status, out = docker("POST", "/containers/create?name=bms_mkdir_oneshot", data=body) if status not in (200, 201): print(" warn: could not create helper container:", out.decode(errors="replace")[:200]) print(" continuing — dockerd will create the bind path itself") return cid = json.loads(out)["Id"] docker("POST", f"/containers/{cid}/start") docker("POST", f"/containers/{cid}/wait", data={}, timeout=120) print(" OK") # ── stack deploy ───────────────────────────────────────────────────── def find_stack(): status, out = api("GET", "/stacks") if status != 200: sys.exit(f"ERROR: could not list stacks ({status}): " f"{out.decode(errors='replace')[:300]}") for s in json.loads(out): if s.get("Name") == STACK_NAME: return s return None def deploy_stack(): compose = open(COMPOSE_FILE).read() existing = find_stack() if existing: sid = existing["Id"] print(f"\n── updating existing stack '{STACK_NAME}' (id {sid})") status, out = api( "PUT", f"/stacks/{sid}?endpointId={EP}", data={"stackFileContent": compose, "env": existing.get("Env", []), "prune": True, "pullImage": False}, timeout=900, ) else: print(f"\n── creating stack '{STACK_NAME}'") status, out = api( "POST", f"/stacks/create/standalone/string?endpointId={EP}", data={"name": STACK_NAME, "stackFileContent": compose, "env": []}, timeout=900, ) if status == 404: # Portainer < 2.19 route status, out = api( "POST", f"/stacks?type=2&method=string&endpointId={EP}", data={"Name": STACK_NAME, "StackFileContent": compose, "Env": []}, timeout=900, ) if status not in (200, 201): print("STACK DEPLOY FAILED — status", status) print(out.decode(errors="replace")[:3000]) sys.exit("ERROR: stack deploy failed.") print(" stack deployed") # ── verify ─────────────────────────────────────────────────────────── def container_states(): status, out = docker("GET", "/containers/json?all=1") if status != 200: return {} return {n.lstrip("/"): (c.get("State"), c.get("Status")) for c in json.loads(out) for n in c.get("Names", []) if n.lstrip("/").startswith("bms_")} def verify(): print("\n── verifying") home = f"http://{HOST}:{FRONTEND_PORT}/dashboard" health = f"http://{HOST}:{FRONTEND_PORT}/api/backend/api/health" ok = False for i in range(40): time.sleep(5) try: with urllib.request.urlopen(health, timeout=10) as r: j = json.load(r) with urllib.request.urlopen(home, timeout=15) as r2: code = r2.status print(f" OK after {5*(i+1)}s — backend health={j}, /dashboard HTTP {code}") ok = True break except Exception as e: print(f" ...not ready ({type(e).__name__}: {str(e)[:70]})") print("\n container states:") for name, (state, st) in sorted(container_states().items()): print(f" {name:20s} {state:10s} {st}") if not ok: sys.exit("ERROR: stack did not come up healthy in time.") def main(): if "--no-build" not in sys.argv: for tag, subdir in IMAGES: build_image(tag, subdir) else: print("Skipping image builds (--no-build).") ensure_appdata() deploy_stack() verify() print(f"\nDeployed. Dashboard: http://{HOST}:{FRONTEND_PORT}/dashboard") print(f"API docs: http://{HOST}:{FRONTEND_PORT}/api/backend/docs") if __name__ == "__main__": main()