ops/deploy.py ships each service's build context to the remote Docker daemon's /build endpoint and pushes docker-compose.prod.yml as a Portainer stack — there is no docker CLI in WSL and Unraid's SSH is closed. The prod compose file drops the dev bind mounts and uvicorn --reload, publishes only the frontend port (8000 is taken by Portainer's Edge tunnel), and pins bms_net to 172.31.42.0/24 because the host's default address pools are fully subnetted. Mosquitto's config is baked into an image since the repo is not checked out on the host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
265 lines
9.7 KiB
Python
265 lines
9.7 KiB
Python
#!/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()
|