Add the Healthcare platform backend (phases 13.0–13.7) with Alembic migration revision fix, production deploy script, validation reports, shared UI exports, and loyalty list page client directives so Next.js build succeeds. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
10 KiB
Python
337 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""Deploy Healthcare service + frontend (Phases 13.0–13.7) 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_HEALTHCARE_API_URL": "https://healthcare.torbatyar.ir",
|
||
"HEALTHCARE_SERVICE_URL": "http://healthcare-service:8010",
|
||
"HEALTHCARE_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/healthcare_db",
|
||
"HEALTHCARE_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/healthcare_db",
|
||
"HEALTHCARE_SERVICE_NAME": "healthcare-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_healthcare_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_healthcare_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_healthcare_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_healthcare_bundle.tar.gz
|
||
ls -la {REMOTE_DIR}/backend/services/healthcare | head
|
||
ls -la {REMOTE_DIR}/frontend/app/healthcare | 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='healthcare_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE healthcare_db;'"
|
||
""",
|
||
timeout=120,
|
||
)
|
||
|
||
compose_up(app, "healthcare-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 healthcare-service frontend' || docker compose logs --tail=80 healthcare-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:8010/health; then
|
||
echo
|
||
echo HEALTHCARE_HEALTH_OK
|
||
exit 0
|
||
fi
|
||
sleep 3
|
||
done
|
||
curl -sv http://127.0.0.1:8010/health || true
|
||
exit 1
|
||
""",
|
||
timeout=120,
|
||
)
|
||
if code != 0:
|
||
raise RuntimeError(f"Healthcare health failed: {health}")
|
||
|
||
code, fe = run(
|
||
app,
|
||
"""
|
||
curl -s -o /dev/null -w 'healthcare_hub=%{http_code}\n' http://127.0.0.1:3000/healthcare/hub
|
||
""",
|
||
timeout=60,
|
||
)
|
||
if code != 0:
|
||
raise RuntimeError(f"Frontend healthcare 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 \
|
||
--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=== HEALTHCARE DEPLOY FINISHED ===")
|
||
print("API: https://healthcare.torbatyar.ir/health")
|
||
print("Hub: https://torbatyar.ir/healthcare/hub")
|
||
print("Local API: http://192.168.10.162:8010/health")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
raise SystemExit(main())
|
||
except Exception as exc:
|
||
print(f"FATAL: {exc}", file=sys.stderr)
|
||
raise
|