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>
282 lines
10 KiB
Python
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
|