TorbatYar/scripts/deploy_healthcare_prod.py
Mortezakoohjani 625275e55b feat(healthcare): add backend service and finalize frontend build
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>
2026-07-27 11:38:31 +03:30

337 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Deploy Healthcare service + frontend (Phases 13.013.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