#!/usr/bin/env python3 """ Deploy (or destroy / status) the Odoo 19 stateless dev stack to Easypanel. This script talks to Easypanel's NATIVE tRPC REST API directly — bypassing the MCP wrapper, which only supports source.type=image. The full pipeline that worked end-to-end is: 1. services.app.createService (projectName, serviceName) 2. services.app.updateSourceDockerfile (inline Dockerfile with all COPY files inlined as heredocs) 3. services.app.updateEnv (HOST/USER/PASSWORD/ADMIN_PASSWD) 4. services.app.deployService (triggers docker buildx build + run) Usage: python3 deploy.py deploy python3 deploy.py destroy python3 deploy.py status python3 deploy.py redeploy (just re-trigger deployService) Reads: easypanel.schema.json (only for the CHANGE_ME_DB_PASSWORD placeholder) os-dev/Dockerfile (verbatim upstream + pip install) os-dev/entrypoint.sh, wait-for-psql.py, odoo.conf, requirements.txt Easypanel token: from /root/.trae-server/data/Machine/mcp.json (Bearer). """ import json, os, pathlib, re, secrets, string, sys, urllib.request, urllib.error ROOT = pathlib.Path(__file__).resolve().parent SCHEMA = ROOT / "easypanel.schema.json" OSDEV = ROOT.parent / "os-dev" if (ROOT.parent / "os-dev").exists() else pathlib.Path("/root/Documents/trae_projects/one_b_os/os-dev") MCP_JSON = pathlib.Path("/root/.trae-server/data/Machine/mcp.json") EASYPANEL_URL = "http://45.137.70.123:3000" PROJECT = "onebspace" SERVICE = "odoo-dev" # ---------- helpers ---------- def gen_password(n=24): return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(n)) def load_token(): cfg = json.loads(MCP_JSON.read_text()) return cfg["mcpServers"]["Easypanel"]["headers"]["Authorization"].split(" ", 1)[1] TOKEN = load_token() def call(proc, payload, timeout=600): body = json.dumps(payload).encode() req = urllib.request.Request( f"{EASYPANEL_URL}/api/trpc/{proc}", data=body, method="POST", headers={"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.status, r.read().decode() except urllib.error.HTTPError as e: return e.code, e.read().decode() # ---------- build the inline Dockerfile (no COPY, all heredocs) ---------- def build_inline_dockerfile(): df = (OSDEV / "Dockerfile").read_text() entrypoint = (OSDEV / "entrypoint.sh").read_text() wait_psql = (OSDEV / "wait-for-psql.py").read_text() odoo_conf = (OSDEV / "odoo.conf").read_text() require = (OSDEV / "requirements.txt").read_text() replacements = [ ("COPY ./entrypoint.sh /", "RUN cat > /entrypoint.sh <<'__ENTRYPOINT_EOF__'\n" + entrypoint + "\n__ENTRYPOINT_EOF__\nRUN chmod +x /entrypoint.sh"), ("COPY ./odoo.conf /etc/odoo/", "RUN cat > /etc/odoo/odoo.conf <<'__ODOOCONF_EOF__'\n" + odoo_conf + "\n__ODOOCONF_EOF__"), ("COPY wait-for-psql.py /usr/local/bin/wait-for-psql.py", "RUN cat > /usr/local/bin/wait-for-psql.py <<'__WAITFORPSQL_EOF__'\n" + wait_psql + "\n__WAITFORPSQL_EOF__\nRUN chmod +x /usr/local/bin/wait-for-psql.py"), ("COPY requirements.txt /tmp/requirements.txt\n" "RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt \\\n" " && rm /tmp/requirements.txt", "RUN cat > /tmp/requirements.txt <<'__REQUIREMENTS_EOF__'\n" + require + "\n__REQUIREMENTS_EOF__\n" "RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt \\\n" " && rm /tmp/requirements.txt"), ] out = df for src, dst in replacements: out = out.replace(src, dst) return out # ---------- commands ---------- def cmd_deploy(): schema = json.loads(SCHEMA.read_text()) pwd = gen_password() schema_str = json.dumps(schema).replace("CHANGE_ME_DB_PASSWORD", pwd) schema = json.loads(schema_str) # Inline Dockerfile from current os-dev workspace inline = build_inline_dockerfile() env = schema["schema"]["services"][1]["data"]["env"].replace( "CHANGE_ME_DB_PASSWORD", pwd) print(f"--- 1. services.app.createService ({SERVICE}) ---") s, b = call("services.app.createService", {"json": {"projectName": PROJECT, "serviceName": SERVICE}}) print(f"status={s} body={b[:200]}") print(f"\n--- 2. services.app.updateSourceDockerfile ({len(inline)} bytes) ---") s, b = call("services.app.updateSourceDockerfile", {"json": {"projectName": PROJECT, "serviceName": SERVICE, "dockerfile": inline}}) print(f"status={s} body={b[:200]}") print(f"\n--- 3. services.app.updateEnv ---") s, b = call("services.app.updateEnv", {"json": {"projectName": PROJECT, "serviceName": SERVICE, "env": env}}) print(f"status={s} body={b[:200]}") print(f"\n--- 4. services.app.deployService (build + run) ---") s, b = call("services.app.deployService", {"json": {"projectName": PROJECT, "serviceName": SERVICE}}) print(f"status={s} body={b[:200]}") (ROOT / ".db_password").write_text(pwd + "\n") print(f"\nDB password saved to {ROOT / '.db_password'}") print("Verify: curl -sS -o /dev/null -w 'HTTP %{http_code}\\n' " "http://127.0.0.1:18069/web/database/manager") def cmd_destroy(): for svc in (SERVICE, f"{SERVICE}-db"): for proc in ("destroyAppService", "destroyPostgresService"): s, b = call(proc, {"projectName": PROJECT, "serviceName": svc}, timeout=30) print(f"[{proc}({svc})] status={s} body={b[:120]}") def cmd_status(): s, b = call("services.app.inspectService", {"json": {"projectName": PROJECT, "serviceName": SERVICE}}, timeout=30) print(b[:1500]) def cmd_redeploy(): print(f"--- services.app.deployService ({SERVICE}) ---") s, b = call("services.app.deployService", {"json": {"projectName": PROJECT, "serviceName": SERVICE}}) print(f"status={s} body={b[:200]}") if __name__ == "__main__": if len(sys.argv) < 2 or sys.argv[1] not in ("deploy", "destroy", "status", "redeploy"): print(__doc__); sys.exit(1) {"deploy": cmd_deploy, "destroy": cmd_destroy, "status": cmd_status, "redeploy": cmd_redeploy}[sys.argv[1]]()