nomadweb/scripts/deploy_update.py
eric f6019e050f Harden deploy with flock and verify frontend-only incremental rebuilds.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 01:42:32 -05:00

180 lines
4.5 KiB
Python

"""
Fast incremental deploy for nomadro.
- Pulls latest origin/dev1
- Rebuilds only services touched since last deploy
- Uses BuildKit cache mounts (npm/pip)
- Single remote critical section under flock (no concurrent collide)
- Polls health instead of fixed long sleep
"""
from __future__ import annotations
import sys
import time
import paramiko
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
HOST = "107.173.30.245"
USER = "root"
PASS = "Xiao4669805"
GIT = "https://eric:Xiao4669805@gitea.dsx2020.com/eric/nomadweb.git"
DOMAIN = "nomadweb.nomadro.com"
REMOTE = "/opt/nomadweb"
MARKER = f"{REMOTE}/.last_deploy_sha"
COMPOSE = "docker-compose.prod.yml"
def run(client: paramiko.SSHClient, cmd: str, timeout: int = 900) -> tuple[int, str, str]:
print("\n>>", cmd[:200].replace("\n", " "))
_, stdout, stderr = client.exec_command(cmd, timeout=timeout)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
if out.strip():
print(out[-5000:])
if err.strip():
print("LOG:", err[-2000:])
print("exit:", code)
return code, out, err
REMOTE_SCRIPT = r"""
set -euo pipefail
exec 9>/var/lock/nomadro-deploy.lock
if ! flock -n 9; then
echo "LOCK_BUSY"
exit 75
fi
DOMAIN="__DOMAIN__"
GIT="__GIT__"
REMOTE="__REMOTE__"
MARKER="__MARKER__"
COMPOSE="__COMPOSE__"
export DOMAIN \
NEXT_PUBLIC_API_URL="https://${DOMAIN}/api/v1" \
NEXT_PUBLIC_SITE_URL="https://${DOMAIN}" \
CORS_ORIGINS="https://${DOMAIN},http://${DOMAIN}" \
DOCKER_BUILDKIT=1 \
COMPOSE_DOCKER_CLI_BUILD=1 \
BUILDKIT_PROGRESS=plain
cd "$REMOTE"
git remote set-url origin "$GIT"
git fetch --depth=80 origin
git checkout -B dev1 origin/dev1
git reset --hard origin/dev1
NEW=$(git rev-parse HEAD)
OLD=$(cat "$MARKER" 2>/dev/null || true)
MODE=full
if [ -n "$OLD" ] && git cat-file -e "${OLD}^{commit}" 2>/dev/null; then
CHANGED=$(git diff --name-only "$OLD" "$NEW" || true)
FE=0; BE=0
echo "$CHANGED" | grep -qE '^frontend/' && FE=1 || true
echo "$CHANGED" | grep -qE '^backend/' && BE=1 || true
echo "$CHANGED" | grep -qE '^docker-compose\.prod\.yml$' && FE=1 && BE=1 || true
if [ -z "$CHANGED" ]; then
MODE=noop
elif [ "$FE" = 1 ] && [ "$BE" = 0 ]; then
MODE=frontend
elif [ "$BE" = 1 ] && [ "$FE" = 0 ]; then
MODE=backend
elif [ "$FE" = 1 ] || [ "$BE" = 1 ]; then
MODE=full
else
MODE=noop
fi
fi
echo "MODE=$MODE"
echo "NEW=$NEW"
echo "OLD=${OLD:-none}"
case "$MODE" in
noop)
echo "No app changes — skip build"
;;
frontend)
docker compose -f "$COMPOSE" build frontend
docker compose -f "$COMPOSE" up -d --no-deps frontend
;;
backend)
docker compose -f "$COMPOSE" build backend
docker compose -f "$COMPOSE" up -d --no-deps backend
;;
full)
docker compose -f "$COMPOSE" build
docker compose -f "$COMPOSE" up -d
;;
esac
ok=0
for i in $(seq 1 25); do
web=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3055/ || true)
api=$(curl -s http://127.0.0.1:8055/api/v1/health || true)
echo "try=$i web=$web api=$api"
if echo "$api" | grep -q '"status":"ok"' && [ "$web" = "200" ]; then
ok=1
break
fi
sleep 1
done
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | grep nomadflow || true
curl -skI "https://${DOMAIN}" | head -6
curl -sk "https://${DOMAIN}/api/v1/health"
echo "$NEW" > "$MARKER"
[ "$ok" = 1 ]
"""
def main() -> int:
t0 = time.time()
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASS, timeout=30)
script = (
REMOTE_SCRIPT
.replace("__DOMAIN__", DOMAIN)
.replace("__GIT__", GIT)
.replace("__REMOTE__", REMOTE)
.replace("__MARKER__", MARKER)
.replace("__COMPOSE__", COMPOSE)
)
# Upload and run as one locked remote job
code, out, _ = run(
client,
f"cat > /tmp/nomadro-deploy.sh <<'EOF'\n{script}\nEOF\nbash /tmp/nomadro-deploy.sh",
timeout=900,
)
mode = "unknown"
for line in out.splitlines():
if line.startswith("MODE="):
mode = line.split("=", 1)[1].strip()
client.close()
elapsed = int(time.time() - t0)
if code == 75:
print("Deploy aborted: another deploy is in progress")
return 75
if code != 0:
print(f"\nDeploy FAILED in {elapsed}s (mode={mode})")
return code
print(f"\nDeploy OK in {elapsed}s (mode={mode})")
return 0
if __name__ == "__main__":
raise SystemExit(main())