TorbatYar/scripts/deploy_loyalty_prod.py
Mortezakoohjani 071c484530 Ship Loyalty phases 7.2-7.6 (points through wallet) with production deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:27:22 +03:30

307 lines
9.4 KiB
Python
Raw Permalink 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 Loyalty service (Phases 7.07.6) 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 = {
"LOYALTY_SERVICE_URL": "http://loyalty-service:8004",
"LOYALTY_DATABASE_URL": "postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db",
"LOYALTY_DATABASE_URL_SYNC": "postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db",
"LOYALTY_SERVICE_NAME": "loyalty-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_loyalty_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_loyalty_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_loyalty_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_loyalty_bundle.tar.gz
ls -la {REMOTE_DIR}/backend/services/loyalty | 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='loyalty_db'\\" | grep -q 1 || docker compose exec -T postgres psql -U superapp -d postgres -c 'CREATE DATABASE loyalty_db;'"
""",
timeout=120,
)
code, _ = run(
app,
f"""
set -e
cd {REMOTE_DIR}
sg docker -c 'docker compose up -d --build loyalty-service'
sg docker -c 'docker compose ps'
sg docker -c 'docker compose logs --tail=80 loyalty-service'
""",
timeout=1800,
)
if code != 0:
code, _ = run(
app,
f"""
set -e
cd {REMOTE_DIR}
docker compose up -d --build loyalty-service
docker compose ps
docker compose logs --tail=120 loyalty-service
""",
sudo=True,
password=APP_PASS,
timeout=1800,
)
if code != 0:
raise RuntimeError("compose up failed")
code, health = run(
app,
"""
set -e
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if curl -sf http://127.0.0.1:8004/health; then
echo
echo LOYALTY_HEALTH_OK
exit 0
fi
sleep 4
done
curl -sv http://127.0.0.1:8004/health || true
docker compose -f /home/morteza/torbatyar/docker-compose.yml logs --tail=80 loyalty-service || true
exit 1
""",
timeout=180,
)
if code != 0:
raise RuntimeError(f"Loyalty 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 -d loyalty.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=== LOYALTY DEPLOY FINISHED ===")
print("API: https://loyalty.torbatyar.ir/health")
print("Local: http://192.168.10.162:8004/health")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"FATAL: {exc}", file=sys.stderr)
raise