Files
stateless-container-for-one…/deploy.py
T
1b-agent fff19a9e17 feat: stateless hot-reload Odoo 19 dev workspace for onebspace
- Dockerfile: verbatim upstream odoo/docker 19.0 + pip install requirements.txt
- easypanel.schema.json: source.type=dockerfile with inline Dockerfile
- deploy.py: calls Easypanel tRPC API directly (bypass MCP that only supports image)
- .gitea/workflows/build.yml: optional Gitea Actions CI
- README.md: full research + reproduction steps
2026-08-30 18:55:00 +00:00

157 lines
6.4 KiB
Python

#!/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]]()