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>
339 lines
11 KiB
Python
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
|