#!/usr/bin/env python3 """Package project, upload to app server, install docker, start stack, install nginx.""" from __future__ import annotations import base64 import io import os import sys import tarfile import time 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", } EXCLUDE_FILES = {".DS_Store", "Thumbs.db"} 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]: # Normalize newlines for remote bash (Windows source files may have CRLF). cmd = cmd.replace("\r\n", "\n").replace("\r", "\n").strip() + "\n" if sudo: b64 = base64.b64encode(cmd.encode()).decode() full = f"echo {password!r} | sudo -S -p '' bash -c 'echo {b64} | base64 -d | bash'" else: full = f"bash -lc {cmd!r}" print(f"\n>>> {'[sudo] ' if sudo else ''}{cmd[:200].replace(chr(10), ' | ')}{'...' if len(cmd) > 200 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[-4000:] if len(text) > 4000 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_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) # prune excluded dirs in-place 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} && chmod 644 {remote!r}" code, _ = run(client, cmd, sudo=True, password=password, timeout=60) if code != 0: raise RuntimeError(f"failed writing {remote}") def main() -> int: tarball = make_tarball() # --- App server --- app = connect(APP_HOST, APP_USER, APP_PASS) try: run(app, f"mkdir -p {REMOTE_DIR}") upload_file(app, tarball, f"/tmp/torbatyar_bundle.tar.gz") run(app, f"mkdir -p {REMOTE_DIR} && tar -xzf /tmp/torbatyar_bundle.tar.gz -C {REMOTE_DIR} && rm -f /tmp/torbatyar_bundle.tar.gz && ls {REMOTE_DIR}") # Install docker if missing code, _ = run(app, "command -v docker >/dev/null && docker --version || echo NEED_DOCKER") if "NEED_DOCKER" in _: install = r""" set -e export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings if [ ! -f /etc/apt/keyrings/docker.asc ]; then curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc fi . /etc/os-release echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $VERSION_CODENAME stable" > /etc/apt/sources.list.d/docker.list apt-get update -y apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin usermod -aG docker morteza systemctl enable --now docker docker --version docker compose version """ code, out = run(app, install, sudo=True, password=APP_PASS, timeout=900) if code != 0: # fallback to ubuntu docker.io fallback = r""" set -e export DEBIAN_FRONTEND=noninteractive apt-get update -y apt-get install -y docker.io docker-compose-v2 usermod -aG docker morteza systemctl enable --now docker docker --version """ code, out = run(app, fallback, sudo=True, password=APP_PASS, timeout=900) if code != 0: raise RuntimeError("docker install failed") # Ensure morteza can use docker (new group may need sg) run(app, "groups") # Write production .env (HTTP-first until certs; CORS includes http) env_src = (ROOT / "infrastructure" / "deploy" / ".env.production.example").read_text(encoding="utf-8") # Start with HTTP until SSL certs exist; HTTPS URLs after certbot env_http = ( env_src.replace("https://auth.torbatyar.ir", "http://auth.torbatyar.ir") .replace("https://torbatyar.ir", "http://torbatyar.ir") .replace("https://www.torbatyar.ir", "http://www.torbatyar.ir") .replace("https://api.torbatyar.ir", "http://api.torbatyar.ir") .replace("https://identity.torbatyar.ir", "http://identity.torbatyar.ir") ) # upload env via sftp sftp = app.open_sftp() with sftp.file(f"{REMOTE_DIR}/.env", "w") as f: f.write(env_http) sftp.close() # Bring up stack (use sg docker for group) compose = f""" set -e cd {REMOTE_DIR} sg docker -c 'docker compose pull || true' sg docker -c 'docker compose up -d --build' sg docker -c 'docker compose ps' """ code, out = run(app, compose, timeout=1800) if code != 0: # retry once with sudo docker compose_sudo = f""" set -e cd {REMOTE_DIR} docker compose up -d --build docker compose ps """ code, out = run(app, compose_sudo, sudo=True, password=APP_PASS, timeout=1800) if code != 0: raise RuntimeError("compose up failed") finally: app.close() # --- Nginx server --- 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) code, out = run(ngx, "nginx -t && systemctl reload nginx && echo RELOADED", sudo=True, password=NGX_PASS) if code != 0: raise RuntimeError("nginx reload failed") # Try certbot for apex + service hosts (wildcard needs DNS later) 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 \ --non-interactive --agree-tos --email support@torbatyar.ir --expand || true ls -la /etc/letsencrypt/live/torbatyar.ir 2>/dev/null || ls /etc/letsencrypt/live/ | head """ run(ngx, certbot, sudo=True, password=NGX_PASS, timeout=300) finally: ngx.close() print("\n=== DEPLOY SCRIPT FINISHED ===") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(f"FATAL: {exc}", file=sys.stderr) raise