TorbatYar/scripts/deploy_beauty_prod.py
Mortezakoohjani d579d0b142 feat(loyalty): add Loyalty Platform Frontend module
Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:50:55 +03:30

339 lines
11 KiB
Python

#!/usr/bin/env python3
"""Deploy Beauty Business service + frontend to production (192.168.10.162)."""
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 = os.environ.get("TORBATYAR_APP_PASS", "")
NGX_HOST = "192.168.10.156"
NGX_USER = "torbatyaruser"
NGX_PASS = os.environ.get("TORBATYAR_NGX_PASS", "")
REMOTE_DIR = "/home/morteza/torbatyar"
EXCLUDE_DIRS = {
".git",
"node_modules",
".next",
".venv",
"venv",
"__pycache__",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".idea",
".vscode",
"agent-transcripts",
}
EXCLUDE_FILES = {".DS_Store", "Thumbs.db", ".env"}
ENV_UPSERT = {
"NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL": "https://beauty.torbatyar.ir",
"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",
}
def connect(host: str, user: str, password: str) -> paramiko.SSHClient:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(
host,
username=user,
password=password,
timeout=30,
allow_agent=False,
look_for_keys=False,
)
return c
def run(
client: paramiko.SSHClient,
cmd: str,
*,
sudo: bool = False,
password: str = "",
timeout: int = 600,
) -> 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 {password!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'"
else:
full = f"bash -lc 'echo {b64} | base64 -d | bash'"
print(
f"\n>>> {'[sudo] ' if sudo else ''}{cmd[:220].replace(chr(10), ' | ')}"
f"{'...' if len(cmd) > 220 else ''}"
)
_stdin, stdout, stderr = client.exec_command(full, timeout=timeout, get_pty=True)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
text = out + (("\n" + err) if err.strip() else "")
printable = text[-5000:] if len(text) > 5000 else text
try:
print(printable)
except UnicodeEncodeError:
print(printable.encode("ascii", "replace").decode("ascii"))
return code, text
def should_exclude(path: Path) -> bool:
parts = set(path.parts)
if parts & EXCLUDE_DIRS:
return True
if path.name in EXCLUDE_FILES:
return True
if path.suffix in {".pyc", ".log"}:
return True
return False
def make_tarball() -> Path:
out = ROOT / "scripts" / "_deploy_beauty_bundle.tar.gz"
print(f"Creating tarball {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 and not d.startswith(".cursor")
]
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
if rel.as_posix().startswith("scripts/_deploy"):
continue
tar.add(fp, arcname=str(rel).replace("\\", "/"))
print(f"Tarball size: {out.stat().st_size / 1024 / 1024:.1f} MB")
return out
def upload_file(client: paramiko.SSHClient, local: Path, remote: str) -> None:
sftp = client.open_sftp()
print(f"Uploading {local} -> {remote}")
sftp.put(str(local), remote)
sftp.close()
def put_text_sudo(
client: paramiko.SSHClient, password: str, remote: str, content: str
) -> None:
b64 = base64.b64encode(content.encode()).decode()
cmd = (
f"mkdir -p $(dirname {remote!r}) && echo {b64} | base64 -d > {remote!r} "
f"&& chmod 644 {remote!r}"
)
code, _ = run(client, cmd, sudo=True, password=password, timeout=60)
if code != 0:
raise RuntimeError(f"failed writing {remote}")
def merge_env_remote(client: 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(
client,
f"python3 -c \"import base64; exec(base64.b64decode('{b64}').decode())\"",
)
if code != 0:
raise RuntimeError("env merge failed")
def compose_up(client: paramiko.SSHClient, services: str) -> None:
code, _ = run(
client,
f"""
set -e
cd {REMOTE_DIR}
sg docker -c 'docker compose up -d --build {services}'
sg docker -c 'docker compose ps'
""",
timeout=1800,
)
if code != 0:
code, _ = run(
client,
f"""
set -e
cd {REMOTE_DIR}
docker compose up -d --build {services}
docker compose ps
""",
sudo=True,
password=APP_PASS,
timeout=1800,
)
if code != 0:
raise RuntimeError(f"compose up failed for {services}")
def main() -> int:
tarball = make_tarball()
app = connect(APP_HOST, APP_USER, APP_PASS)
try:
run(app, f"mkdir -p {REMOTE_DIR}")
upload_file(app, tarball, "/tmp/torbatyar_beauty_bundle.tar.gz")
code, _ = run(
app,
f"""
set -e
cd {REMOTE_DIR}
if [ -f .env ]; then cp .env /tmp/torbatyar.env.bak; fi
chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/frontend {REMOTE_DIR}/docs {REMOTE_DIR}/docker-compose.yml {REMOTE_DIR}/infrastructure 2>/dev/null || true
chmod -R u+rwX {REMOTE_DIR}/backend {REMOTE_DIR}/frontend {REMOTE_DIR}/docs 2>/dev/null || true
tar --overwrite -xzf /tmp/torbatyar_beauty_bundle.tar.gz -C {REMOTE_DIR}
if [ -f /tmp/torbatyar.env.bak ]; then cp /tmp/torbatyar.env.bak .env; fi
chown -R {APP_USER}:{APP_USER} {REMOTE_DIR}/backend {REMOTE_DIR}/frontend {REMOTE_DIR}/docs 2>/dev/null || true
rm -f /tmp/torbatyar_beauty_bundle.tar.gz
ls -la {REMOTE_DIR}/backend/services/beauty_business | head
ls -la {REMOTE_DIR}/frontend/app/beauty | head
""",
sudo=True,
password=APP_PASS,
timeout=300,
)
if code != 0:
raise RuntimeError("extract failed")
merge_env_remote(app)
run(
app,
f"""
set -e
cd {REMOTE_DIR}
sg docker -c "docker compose exec -T postgres psql -U superapp -d postgres -tc \\"SELECT 1 FROM pg_database WHERE datname='beauty_business_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE beauty_business_db;'"
""",
timeout=120,
)
compose_up(app, "beauty-business-service frontend")
run(
app,
f"""
set -e
cd {REMOTE_DIR}
sg docker -c 'docker compose exec -T frontend npm install' || docker compose exec -T frontend npm install
sg docker -c 'docker compose restart frontend' || docker compose restart frontend
sleep 20
sg docker -c 'docker compose logs --tail=80 beauty-business-service frontend' || docker compose logs --tail=80 beauty-business-service frontend
""",
sudo=True,
password=APP_PASS,
timeout=1800,
)
code, health = run(
app,
"""
set -e
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -sf http://127.0.0.1:8011/health; then
echo
echo BEAUTY_HEALTH_OK
exit 0
fi
sleep 3
done
curl -sv http://127.0.0.1:8011/health || true
exit 1
""",
timeout=120,
)
if code != 0:
raise RuntimeError(f"Beauty health failed: {health}")
code, fe = run(
app,
"""
curl -s -o /dev/null -w 'beauty_hub=%{http_code}\n' http://127.0.0.1:3000/beauty/hub
curl -s -o /dev/null -w 'beauty_site=%{http_code}\n' http://127.0.0.1:3000/beauty/site
""",
timeout=60,
)
if code != 0:
raise RuntimeError(f"Frontend beauty routes failed: {fe}")
finally:
app.close()
ngx = connect(NGX_HOST, NGX_USER, NGX_PASS)
try:
conf = (ROOT / "infrastructure" / "nginx" / "torbatyar.ir.conf").read_text(
encoding="utf-8"
)
put_text_sudo(ngx, NGX_PASS, "/etc/nginx/conf.d/torbatyar.ir.conf", conf)
certbot = r"""
set -e
mkdir -p /var/www/html
certbot certonly --webroot -w /var/www/html \
-d torbatyar.ir -d www.torbatyar.ir \
-d api.torbatyar.ir -d identity.torbatyar.ir -d auth.torbatyar.ir \
-d accounting.torbatyar.ir -d crm.torbatyar.ir -d loyalty.torbatyar.ir \
-d healthcare.torbatyar.ir -d beauty.torbatyar.ir \
--non-interactive --agree-tos --email support@torbatyar.ir --expand || true
nginx -t && systemctl reload nginx && echo NGINX_RELOADED
"""
code, _ = run(ngx, certbot, sudo=True, password=NGX_PASS, timeout=300)
if code != 0:
raise RuntimeError("nginx reload / certbot failed")
finally:
ngx.close()
print("\n=== BEAUTY BUSINESS DEPLOY FINISHED ===")
print("API: https://beauty.torbatyar.ir/health")
print("Hub: https://torbatyar.ir/beauty/hub")
print("Site: https://torbatyar.ir/beauty/site")
print("Local API: http://192.168.10.162:8011/health")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"FATAL: {exc}", file=sys.stderr)
raise