Scaffold frontend/modules/loyalty with types, API client, design system, feature pages, and thin App Router routes. Wire all screens to backend Loyalty service via BFF proxy. Add loyalty docs and update progress. Co-authored-by: Cursor <cursoragent@cursor.com>
338 lines
11 KiB
Python
338 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Deploy Hospitality service + frontend (Torbat Food) to production."""
|
|
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"
|
|
NGX_HOST = "192.168.10.156"
|
|
NGX_USER = "torbatyaruser"
|
|
NGX_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",
|
|
"agent-transcripts",
|
|
}
|
|
EXCLUDE_FILES = {".DS_Store", "Thumbs.db", ".env"}
|
|
|
|
ENV_UPSERT = {
|
|
"NEXT_PUBLIC_HOSPITALITY_API_URL": "https://hospitality.torbatyar.ir",
|
|
"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",
|
|
"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_hospitality_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_hospitality_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_hospitality_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_hospitality_bundle.tar.gz
|
|
ls -la {REMOTE_DIR}/backend/services/hospitality | head
|
|
ls -la {REMOTE_DIR}/frontend/app/hospitality | 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='hospitality_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE hospitality_db;'"
|
|
""",
|
|
timeout=120,
|
|
)
|
|
|
|
compose_up(app, "hospitality-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 15
|
|
sg docker -c 'docker compose logs --tail=80 hospitality-service frontend' || docker compose logs --tail=80 hospitality-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:8009/health; then
|
|
echo
|
|
echo HOSPITALITY_HEALTH_OK
|
|
exit 0
|
|
fi
|
|
sleep 3
|
|
done
|
|
curl -sv http://127.0.0.1:8009/health || true
|
|
exit 1
|
|
""",
|
|
timeout=120,
|
|
)
|
|
if code != 0:
|
|
raise RuntimeError(f"Hospitality health failed: {health}")
|
|
|
|
code, fe = run(
|
|
app,
|
|
"""
|
|
curl -s -o /dev/null -w 'hospitality_hub=%{http_code}\n' http://127.0.0.1:3000/hospitality/hub
|
|
curl -s -o /dev/null -w 'hospitality_root=%{http_code}\n' http://127.0.0.1:3000/hospitality
|
|
""",
|
|
timeout=60,
|
|
)
|
|
if code != 0:
|
|
raise RuntimeError(f"Frontend hospitality route 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 -d hospitality.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=== HOSPITALITY DEPLOY FINISHED ===")
|
|
print("API: https://hospitality.torbatyar.ir/health")
|
|
print("Hub: https://torbatyar.ir/hospitality/hub")
|
|
print("Local API: http://192.168.10.162:8009/health")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Exception as exc:
|
|
print(f"FATAL: {exc}", file=sys.stderr)
|
|
raise
|