- 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
157 lines
6.4 KiB
Python
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]]()
|