Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants. Co-authored-by: Cursor <cursoragent@cursor.com>
242 lines
8.7 KiB
Python
242 lines
8.7 KiB
Python
#!/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
|