139 lines
4.5 KiB
Python
139 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
One-shot deploy script for the Heicode Manager Azure VM. Designed to be
|
|
idempotent — running it twice in a row converges to the same state.
|
|
|
|
What it does:
|
|
1. SSH into the VM with credentials supplied via env (NEVER hard-coded
|
|
in the repo — the launcher passes them on stdin).
|
|
2. cd into the manager checkout, git pull origin main.
|
|
3. docker compose pull + up -d --build, then prune dangling images.
|
|
4. Wait for /api/status to return 200.
|
|
5. Print container status + tail of the manager logs.
|
|
|
|
Failure mode: if any step exits non-zero, the whole script aborts and
|
|
prints the offending command's stderr — caller (this Claude session)
|
|
escalates to the user instead of silently moving on.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import time
|
|
import paramiko
|
|
|
|
HOST = os.environ.get("MGR_HOST", "20.24.50.121")
|
|
USER = os.environ.get("MGR_USER", "heicode")
|
|
PASS = os.environ.get("MGR_PASS", "")
|
|
PORT = int(os.environ.get("MGR_PORT", "22"))
|
|
|
|
if not PASS:
|
|
print("MGR_PASS not set", file=sys.stderr)
|
|
sys.exit(2)
|
|
|
|
|
|
def run(client: paramiko.SSHClient, cmd: str, timeout: int = 600) -> int:
|
|
"""Stream a remote command's output to local stdout/stderr."""
|
|
print(f"\n$ {cmd}", flush=True)
|
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout, get_pty=True)
|
|
for line in iter(stdout.readline, ""):
|
|
if not line:
|
|
break
|
|
sys.stdout.write(line)
|
|
sys.stdout.flush()
|
|
rc = stdout.channel.recv_exit_status()
|
|
err = stderr.read().decode("utf-8", errors="replace")
|
|
if err.strip():
|
|
sys.stderr.write(err)
|
|
return rc
|
|
|
|
|
|
def must(rc: int, label: str) -> None:
|
|
if rc != 0:
|
|
print(f"\nFAIL: {label} exit={rc}", file=sys.stderr)
|
|
sys.exit(rc or 1)
|
|
|
|
|
|
def main() -> None:
|
|
client = paramiko.SSHClient()
|
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
print(f"Connecting {USER}@{HOST}:{PORT} ...", flush=True)
|
|
client.connect(
|
|
hostname=HOST,
|
|
port=PORT,
|
|
username=USER,
|
|
password=PASS,
|
|
look_for_keys=False,
|
|
allow_agent=False,
|
|
timeout=30,
|
|
)
|
|
|
|
# Locate the manager checkout. We try a few likely paths instead of
|
|
# hardcoding one — first match wins.
|
|
probe = (
|
|
"for p in "
|
|
"~/heicode-mananger ~/heicode ~/heicode-mananger/heicode "
|
|
"/opt/heicode-mananger /home/heicode/heicode-mananger; do "
|
|
" if [ -d $p/.git ]; then echo FOUND=$p; break; fi; "
|
|
"done"
|
|
)
|
|
stdin, stdout, _ = client.exec_command(probe, timeout=20)
|
|
out = stdout.read().decode().strip()
|
|
print(out)
|
|
repo = None
|
|
for line in out.splitlines():
|
|
if line.startswith("FOUND="):
|
|
repo = line.split("=", 1)[1]
|
|
break
|
|
if not repo:
|
|
print("Could not locate manager git checkout on VM", file=sys.stderr)
|
|
sys.exit(3)
|
|
print(f"Using repo: {repo}")
|
|
|
|
# Find docker-compose file we'll target.
|
|
compose = "docker-compose.azure-vm.yml"
|
|
must(run(client, f"test -f {repo}/heicode/{compose} || ls {repo}/heicode/docker-compose*.yml"),
|
|
"locate compose file")
|
|
|
|
# Pull latest code.
|
|
must(run(client, f"cd {repo} && git fetch origin && git checkout main && git pull --ff-only origin main"),
|
|
"git pull")
|
|
|
|
# Show what we have locally now.
|
|
run(client, f"cd {repo} && git log --oneline -3")
|
|
|
|
# Rebuild + restart.
|
|
must(run(client,
|
|
f"cd {repo}/heicode && sudo docker compose -f {compose} up -d --build 2>&1",
|
|
timeout=1800),
|
|
"compose up")
|
|
|
|
# Clean dangling images per CLAUDE.md.
|
|
run(client, "sudo docker image prune -f")
|
|
|
|
# Wait for /api/status to come back.
|
|
print("\nWaiting for /api/status to return 200 (max 90s) ...")
|
|
deadline = time.time() + 90
|
|
status_ok = False
|
|
while time.time() < deadline:
|
|
rc = run(client,
|
|
"curl -fsS -o /dev/null -w 'HTTP_%{http_code}\\n' https://code.xinghanlab.com/api/status")
|
|
if rc == 0:
|
|
status_ok = True
|
|
break
|
|
time.sleep(5)
|
|
if not status_ok:
|
|
print("FAIL: /api/status did not return 200 within 90s", file=sys.stderr)
|
|
run(client, f"cd {repo}/heicode && sudo docker compose -f {compose} logs --tail=80 heicode")
|
|
sys.exit(4)
|
|
|
|
print("\n--- Final state ---")
|
|
run(client, "sudo docker ps --format 'table {{.Names}}\\t{{.Status}}\\t{{.Image}}'")
|
|
run(client, f"cd {repo}/heicode && sudo docker compose -f {compose} logs --tail=20 heicode")
|
|
|
|
client.close()
|
|
print("\nDEPLOY OK")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|