#!/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