diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 00000000..ea7a1030 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Deploy a branch of diffusers-workflow to this box and restart dw.serve. +# +# scripts/deploy.sh [branch] [--force] +# +# Run ON the server box (lem), from anywhere: +# ssh lem ~/diffusers-workflow/scripts/deploy.sh develop +# +# What it does, in order, stopping at the first failure: +# 1. fetch; check out (default: the current branch); fast-forward +# pull. A dirty checkout or a non-fast-forward is an error, never a +# reset - a deploy must not lose anything. +# 2. `pip install -e .` only when pyproject.toml changed in the pull. +# 3. refuse to restart while a job is running (health says current_job), +# unless --force. Agent cycles are serialized, so in practice the only +# running job is the caller's own. +# 4. restart. With the systemd user unit installed (scripts/dw-serve.service) +# that is `systemctl --user restart dw-serve` and the log is the journal. +# Without it: SIGTERM the running dw.serve (never SIGKILL; a stuck one is +# a bug to report, so after 30 s this fails loudly instead of +# escalating), then start the new server as its own window of the +# `dw-serve` screen session (created if absent), stdout+stderr appended +# to ~/dw-serve.log. +# 5. poll /api/health until it answers ok (up to 120 s), then print the +# deployed commit and the server's reported version. +# +# Environment overrides, all optional: +# DW_DIR (checkout, default ~/diffusers-workflow), DW_TOKEN (default xyz), +# DW_PORT (8765), DW_WORKSPACE (~/diffusers-workspace), DW_HOST (0.0.0.0). +set -euo pipefail + +DW_DIR="${DW_DIR:-$HOME/diffusers-workflow}" +DW_TOKEN="${DW_TOKEN:-xyz}" +DW_PORT="${DW_PORT:-8765}" +DW_HOST="${DW_HOST:-0.0.0.0}" +DW_WORKSPACE="${DW_WORKSPACE:-$HOME/diffusers-workspace}" +LOG="$HOME/dw-serve.log" +SCREEN_SESSION="dw-serve" +HEALTH="http://localhost:$DW_PORT/api/health" + +branch="" +force=0 +for a in "$@"; do + case "$a" in + --force) force=1 ;; + -*) echo "deploy: unknown flag $a" >&2; exit 2 ;; + *) branch="$a" ;; + esac +done + +ts() { date '+%H:%M:%S'; } +say() { echo "[deploy $(ts)] $*"; } +health() { curl -s -m 5 -H "Authorization: Bearer $DW_TOKEN" "$HEALTH" 2>/dev/null; } +server_pids() { pgrep -f 'python -m dw\.serve' || true; } + +cd "$DW_DIR" +[ -z "$branch" ] && branch="$(git branch --show-current)" + +# 1. checkout + fast-forward +if [ -n "$(git status --porcelain)" ]; then + say "checkout is dirty; refusing to deploy over uncommitted changes:"; git status --short; exit 1 +fi +say "fetching origin" +git fetch -q origin +before="$(git rev-parse HEAD)" +git checkout -q "$branch" +git pull -q --ff-only origin "$branch" +after="$(git rev-parse HEAD)" +say "on $branch at $(git rev-parse --short "$after") (was $(git rev-parse --short "$before"))" + +# 2. deps, only when they changed +if [ "$before" != "$after" ] && git diff --name-only "$before" "$after" | grep -qx 'pyproject.toml'; then + say "pyproject.toml changed; reinstalling" + venv/bin/pip install -q -e . +fi + +# 3. don't yank a running job +h="$(health || true)" +if [ -n "$h" ] && echo "$h" | grep -q '"current_job":"' && [ "$force" -ne 1 ]; then + say "a job is running ($(echo "$h" | grep -o '"current_job":"[^"]*"')); waiting up to 5 min for it, or pass --force" + for _ in $(seq 1 60); do + sleep 5 + h="$(health || true)" + echo "$h" | grep -q '"current_job":"' || break + done + echo "$h" | grep -q '"current_job":"' && { say "still running; not restarting"; exit 1; } +fi + +# 4. restart - systemd unit when installed, screen otherwise +if systemctl --user cat dw-serve >/dev/null 2>&1; then + say "restarting via systemd user unit dw-serve" + systemctl --user restart dw-serve + LOG_HINT="journalctl --user -u dw-serve -n 200" + tail_log() { journalctl --user -u dw-serve -n 20 --no-pager; } +else +LOG_HINT="$LOG" +tail_log() { tail -n 20 "$LOG"; } +pids="$(server_pids)" +if [ -n "$pids" ]; then + say "stopping dw.serve (pid $pids)" + kill -TERM $pids + for _ in $(seq 1 30); do + [ -z "$(server_pids)" ] && break + sleep 1 + done + if [ -n "$(server_pids)" ]; then + say "dw.serve did not exit within 30 s of SIGTERM (pid $(server_pids)); not escalating - report this"; exit 1 + fi +else + say "no dw.serve running" +fi + +# start the new one in its own screen window +cmd="cd $DW_DIR && source venv/bin/activate && exec python -m dw.serve --host $DW_HOST --port $DW_PORT --mcp --token $DW_TOKEN --workspace $DW_WORKSPACE --examples-dir $DW_DIR/workflows >> $LOG 2>&1" +if screen -ls | grep -q "\.${SCREEN_SESSION}[[:space:]]"; then + screen -S "$SCREEN_SESSION" -X screen -t serve bash -c "$cmd" +else + screen -dmS "$SCREEN_SESSION" -t serve bash -c "$cmd" +fi +say "started; log: $LOG" +fi + +# 5. wait for health +for i in $(seq 1 120); do + h="$(health || true)" + if echo "$h" | grep -q '"status":"ok"'; then + say "healthy after ${i}s: $(echo "$h" | grep -o '"version":"[^"]*"') mcp=$(echo "$h" | grep -o '"mcp":[a-z]*' | cut -d: -f2)" + say "deployed $branch @ $(git rev-parse --short HEAD)" + exit 0 + fi + [ -z "$(server_pids)" ] && { say "dw.serve exited during startup; last log lines ($LOG_HINT):"; tail_log; exit 1; } + sleep 1 +done +say "no healthy answer within 120 s; last log lines ($LOG_HINT):"; tail_log; exit 1 diff --git a/scripts/dw-serve.service b/scripts/dw-serve.service new file mode 100644 index 00000000..4501ce61 --- /dev/null +++ b/scripts/dw-serve.service @@ -0,0 +1,27 @@ +# systemd user unit for dw.serve. Install on the server box (lem): +# +# mkdir -p ~/.config/systemd/user +# cp ~/diffusers-workflow/scripts/dw-serve.service ~/.config/systemd/user/ +# systemctl --user daemon-reload +# systemctl --user enable --now dw-serve +# loginctl enable-linger $USER # keep it running when no one is logged in +# +# Then scripts/deploy.sh restarts it with systemctl instead of screen, and +# `journalctl --user -u dw-serve -n 200` is where the log is. Adjust the +# paths and token below if the box differs from lem's layout. +[Unit] +Description=diffusers-workflow server (dw.serve, MCP on) +After=network.target + +[Service] +WorkingDirectory=%h/diffusers-workflow +Environment=VIRTUAL_ENV=%h/diffusers-workflow/venv +Environment=PATH=%h/diffusers-workflow/venv/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=%h/diffusers-workflow/venv/bin/python -m dw.serve --host 0.0.0.0 --port 8765 --mcp --token xyz --workspace %h/diffusers-workspace --examples-dir %h/diffusers-workflow/workflows +KillSignal=SIGTERM +TimeoutStopSec=30 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target