TorbatYar/scripts/deploy_platform_full.py
Mortezakoohjani 5c6a2e78cf feat(platform): seed service registry, deploy all modules, and fix homepage catalog.
Register all active platform services and base features in core DB so admin can manage them, add delivery/hospitality/sports-center backend phases, update apps catalog and production deploy tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:39:51 +03:30

282 lines
10 KiB
Python

#!/usr/bin/env python3
"""Full platform deploy: all backend services, frontend, env, DBs, catalog seed."""
from __future__ import annotations
import base64
import os
import sys
import tarfile
from pathlib import Path
import paramiko
ROOT = Path(__file__).resolve().parents[1]
APP_HOST = "192.168.10.162"
APP_USER = "morteza"
APP_PASS = "Moli@5404"
REMOTE_DIR = "/home/morteza/torbatyar"
EXCLUDE_DIRS = {
".git",
"node_modules",
".next",
".venv",
"venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".idea",
".vscode",
".cursor",
"agent-transcripts",
"scripts/.gitea-migration-staging",
}
EXCLUDE_FILES = {".DS_Store", "Thumbs.db", ".env"}
ENV_UPSERT = {
"NEXT_PUBLIC_BACKEND_URL": "https://api.torbatyar.ir",
"NEXT_PUBLIC_API_BASE_URL": "https://api.torbatyar.ir",
"NEXT_PUBLIC_IDENTITY_API_URL": "https://identity.torbatyar.ir",
"NEXT_PUBLIC_KEYCLOAK_URL": "https://auth.torbatyar.ir",
"NEXT_PUBLIC_ACCOUNTING_API_URL": "https://accounting.torbatyar.ir",
"NEXT_PUBLIC_CRM_API_URL": "https://crm.torbatyar.ir",
"NEXT_PUBLIC_HEALTHCARE_API_URL": "https://healthcare.torbatyar.ir",
"NEXT_PUBLIC_HOSPITALITY_API_URL": "https://hospitality.torbatyar.ir",
"COMMUNICATION_SERVICE_URL": "http://communication-service:8005",
"COMMUNICATION_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db",
"COMMUNICATION_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db",
"COMMUNICATION_SERVICE_NAME": "communication-service",
"SPORTS_CENTER_SERVICE_URL": "http://sports-center-service:8006",
"SPORTS_CENTER_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db",
"SPORTS_CENTER_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db",
"SPORTS_CENTER_SERVICE_NAME": "sports-center-service",
"DELIVERY_SERVICE_URL": "http://delivery-service:8007",
"DELIVERY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/delivery_db",
"DELIVERY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/delivery_db",
"DELIVERY_SERVICE_NAME": "delivery-service",
"EXPERIENCE_SERVICE_URL": "http://experience-service:8008",
"EXPERIENCE_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/experience_db",
"EXPERIENCE_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/experience_db",
"EXPERIENCE_SERVICE_NAME": "experience-service",
"HOSPITALITY_SERVICE_URL": "http://hospitality-service:8009",
"HOSPITALITY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/hospitality_db",
"HOSPITALITY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/hospitality_db",
"HOSPITALITY_SERVICE_NAME": "hospitality-service",
"BEAUTY_BUSINESS_SERVICE_URL": "http://beauty-business-service:8011",
"BEAUTY_BUSINESS_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/beauty_business_db",
"BEAUTY_BUSINESS_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/beauty_business_db",
"BEAUTY_BUSINESS_SERVICE_NAME": "beauty-business-service",
"KEYCLOAK_SERVER_URL": "http://keycloak:8080",
"KEYCLOAK_REALM": "superapp",
}
ALL_SERVICES = (
"core-service identity-access-service frontend "
"accounting-service crm-service loyalty-service communication-service "
"sports-center-service delivery-service experience-service "
"healthcare-service beauty-business-service hospitality-service "
"celery-worker celery-beat"
)
EXTRA_DBS = (
"communication_db",
"sports_center_db",
"delivery_db",
"experience_db",
"beauty_business_db",
)
def connect() -> paramiko.SSHClient:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(
APP_HOST,
username=APP_USER,
password=APP_PASS,
timeout=30,
allow_agent=False,
look_for_keys=False,
)
return c
def run(c: paramiko.SSHClient, cmd: str, *, sudo: bool = False, timeout: int = 900) -> tuple[int, str]:
cmd = cmd.replace("\r\n", "\n").replace("\r", "\n").strip() + "\n"
b64 = base64.b64encode(cmd.encode()).decode()
if sudo:
full = f"echo {APP_PASS!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'"
else:
full = f"bash -lc 'echo {b64} | base64 -d | bash'"
print("\n>>>", cmd[:220].replace("\n", " | "))
_, stdout, stderr = c.exec_command(full, timeout=timeout, get_pty=True)
out = stdout.read().decode("utf-8", "replace")
err = stderr.read().decode("utf-8", "replace")
code = stdout.channel.recv_exit_status()
text = out + (("\n" + err) if err.strip() else "")
print(text[-6000:].encode("ascii", "replace").decode("ascii"))
return code, text
def should_exclude(path: Path) -> bool:
if path.name in EXCLUDE_FILES or path.suffix in {".pyc", ".log", ".tar.gz", ".zip"}:
return True
if any(part in EXCLUDE_DIRS for part in path.parts):
return True
if path.as_posix().startswith("scripts/_"):
return True
return False
def make_tarball() -> Path:
out = ROOT / "scripts" / "_deploy_platform_full.tar.gz"
print(f"Creating {out} ...")
with tarfile.open(out, "w:gz") as tar:
for dirpath, dirnames, filenames in os.walk(ROOT):
p = Path(dirpath)
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
rel_dir = p.relative_to(ROOT)
if should_exclude(rel_dir) and rel_dir != Path("."):
continue
for name in filenames:
fp = p / name
rel = fp.relative_to(ROOT)
if should_exclude(rel):
continue
tar.add(fp, arcname=str(rel).replace("\\", "/"))
print(f"Size: {out.stat().st_size / 1024 / 1024:.1f} MB")
return out
def merge_env_remote(c: paramiko.SSHClient) -> None:
py = f"""
from pathlib import Path
p = Path({REMOTE_DIR!r}) / '.env'
text = p.read_text(encoding='utf-8') if p.exists() else ''
lines = text.splitlines()
keys = {ENV_UPSERT!r}
seen = set()
out = []
for line in lines:
if not line.strip() or line.lstrip().startswith('#') or '=' not in line:
out.append(line)
continue
k = line.split('=', 1)[0].strip()
if k in keys:
out.append(f"{{k}}={{keys[k]}}")
seen.add(k)
else:
out.append(line)
for k, v in keys.items():
if k not in seen:
out.append(f"{{k}}={{v}}")
p.write_text('\\n'.join(out).rstrip() + '\\n', encoding='utf-8')
print('ENV_MERGED', len(keys))
"""
b64 = base64.b64encode(py.encode()).decode()
code, _ = run(c, f"python3 -c \"import base64; exec(base64.b64decode('{b64}').decode())\"")
if code != 0:
raise RuntimeError("env merge failed")
def main() -> int:
tarball = make_tarball()
c = connect()
try:
sftp = c.open_sftp()
print("Uploading bundle...")
sftp.put(str(tarball), "/tmp/torbatyar_platform_full.tar.gz")
sftp.close()
code, _ = run(
c,
f"""
set -e
cd {REMOTE_DIR}
cp -a .env /tmp/torbatyar.env.bak
tar -xzf /tmp/torbatyar_platform_full.tar.gz -C {REMOTE_DIR}
cp /tmp/torbatyar.env.bak .env
rm -f /tmp/torbatyar_platform_full.tar.gz
""",
sudo=True,
timeout=300,
)
if code != 0:
raise RuntimeError("extract failed")
merge_env_remote(c)
db_cmds = " ".join(
f"docker compose exec -T postgres psql -U superapp -d postgres -tc "
f"\"SELECT 1 FROM pg_database WHERE datname='{db}'\" | grep -q 1 || "
f"docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE {db}';"
for db in EXTRA_DBS
)
code, _ = run(
c,
f"""
set -e
cd {REMOTE_DIR}
{db_cmds}
echo DBS_READY
""",
sudo=True,
timeout=300,
)
if code != 0:
raise RuntimeError("database create failed")
code, _ = run(
c,
f"""
set -e
cd {REMOTE_DIR}
docker ps -a --format '{{{{.Names}}}}' | grep -E '_superapp_' | xargs -r docker rm -f || true
docker compose down --remove-orphans || true
docker compose up -d --build {ALL_SERVICES}
sleep 20
sg docker -c 'docker compose exec -T frontend npm install' || docker compose exec -T frontend npm install
sg docker -c 'docker compose restart frontend core-service' || docker compose restart frontend core-service
sleep 25
sg docker -c 'docker compose exec -T core-service python scripts/seed_platform_catalog.py' || docker compose exec -T core-service python scripts/seed_platform_catalog.py
""",
sudo=True,
timeout=3600,
)
if code != 0:
raise RuntimeError("compose/seed failed")
verify = """
set -e
cd /home/morteza/torbatyar
docker compose ps --format '{{.Service}} {{.State}}' | sort
echo '--- registry ---'
docker compose exec -T postgres psql -U superapp -d core_platform_db -t -A -c 'SELECT count(*) FROM service_registry;'
echo '--- features ---'
docker compose exec -T postgres psql -U superapp -d core_platform_db -t -A -c 'SELECT count(*) FROM features;'
for path in / /delivery/hub /communication /loyalty/hub /hospitality/hub /admin/services; do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000$path)
echo "fe$path=$code"
done
for port in 8005 8006 8007 8008; do
curl -sf http://127.0.0.1:$port/health && echo " health:$port=ok" || echo " health:$port=fail"
done
"""
run(c, verify, sudo=True, timeout=300)
finally:
c.close()
print("\n=== PLATFORM DEPLOY DONE ===")
print("https://torbatyar.ir")
print("https://torbatyar.ir/admin/services")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"FATAL: {exc}", file=sys.stderr)
raise