#!/usr/bin/env python3 """Deploy CRM service (Phases 6.0–6.3) to production without wiping .env secrets.""" 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_CRM_API_URL": "https://crm.torbatyar.ir", "CRM_SERVICE_URL": "http://crm-service:8003", "CRM_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/crm_db", "CRM_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db", "CRM_SERVICE_NAME": "crm-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_crm_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 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_crm_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}/docs {REMOTE_DIR}/docker-compose.yml {REMOTE_DIR}/infrastructure 2>/dev/null || true chmod -R u+rwX {REMOTE_DIR}/backend {REMOTE_DIR}/docs 2>/dev/null || true tar --overwrite -xzf /tmp/torbatyar_crm_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}/docs 2>/dev/null || true rm -f /tmp/torbatyar_crm_bundle.tar.gz ls -la {REMOTE_DIR}/backend/services/crm | 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='crm_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE crm_db;'" """, timeout=120, ) code, _ = run( app, f""" set -e cd {REMOTE_DIR} sg docker -c 'docker compose up -d --build crm-service' sg docker -c 'docker compose ps' sg docker -c 'docker compose logs --tail=60 crm-service' """, timeout=1800, ) if code != 0: code, _ = run( app, f""" set -e cd {REMOTE_DIR} docker compose up -d --build crm-service docker compose ps docker compose logs --tail=100 crm-service """, sudo=True, password=APP_PASS, timeout=1800, ) if code != 0: raise RuntimeError("compose up failed") code, health = run( app, "curl -sf http://127.0.0.1:8003/health || curl -sf http://192.168.10.162:8003/health", ) if code != 0 or "ok" not in health.lower() and '"status"' not in health: # retry once after short wait run(app, "sleep 8") code, health = run( app, "curl -sf http://127.0.0.1:8003/health || curl -sf http://192.168.10.162:8003/health", ) if code != 0: raise RuntimeError(f"CRM health failed: {health}") 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 \ --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=== CRM DEPLOY FINISHED ===") print("API: https://crm.torbatyar.ir/health") print("Local: http://192.168.10.162:8003/health") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(f"FATAL: {exc}", file=sys.stderr) raise