# Rolling Upgrade Script Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship a `rolling-upgrade.sh` script that orchestrates zero-downtime upgrades of a running fafb-q server.

**Architecture:** A single POSIX sh script that sequences: preflight checks, process management detection, optional binary download, admin drain API trigger, shutdown poll, binary install, restart, and health verification. Supports both systemd and direct PID process management. Documentation updated in deploy.md, README.md, and CLAUDE.md. Pipeline updated to publish the script.

**Tech Stack:** POSIX sh, curl, fafb-q admin API (`/admin/drain`, `/health/ready`), Bitbucket Pipelines

**Spec:** `docs/superpowers/specs/2026-03-29-rolling-upgrade-script-design.md`

---

## File Structure

| File | Action | Responsibility |
|------|--------|----------------|
| `src/main.rs:36` | Modify | Add `#[command(version)]` to Cli struct (prerequisite). No change to `install.sh` needed — it already uses `--version`; this fixes the latent bug. |
| `scripts/rolling-upgrade.sh` | Create | The rolling upgrade script |
| `tests/test_rolling_upgrade.sh` | Create | Tests for flag parsing, detection logic, error paths |
| `bitbucket-pipelines.yml:85` | Modify | Upload rolling-upgrade.sh to Bitbucket Downloads |
| `docs/deploy.md:211` | Modify | Add "Automated Upgrade Script" section |
| `README.md:34` | Modify | Add "Live upgrade" section |
| `CLAUDE.md:69` | Modify | Add to module map (after `scripts/install.sh` entry) |

---

### Task 1: Add `--version` flag to the fafb-q binary

This is a prerequisite for both `install.sh` and `rolling-upgrade.sh` smoke tests.

**Files:**
- Modify: `src/main.rs:36`

Note: `scripts/install.sh:78` already calls `--version` as a smoke test. Adding `version` to the Cli struct fixes this latent bug — no change to install.sh needed. The spec calls for `--help` with a note to switch to `--version` once available; since this task adds `--version` first, the script goes directly to `--version`.

- [ ] **Step 1: Add `version` attribute to the Cli struct**

In `src/main.rs`, the Cli struct has:
```rust
#[command(name = "fafb-q", about = "Centralized queue server and CLI tool")]
```

Change to:
```rust
#[command(name = "fafb-q", version, about = "Centralized queue server and CLI tool")]
```

This makes clap auto-generate `--version` from `Cargo.toml`'s version field.

- [ ] **Step 2: Verify it works**

Run: `cargo run -- --version`
Expected: `fafb-q 0.1.7` (or current version from Cargo.toml)

- [ ] **Step 3: Run tests to ensure nothing breaks**

Run: `cargo test`
Expected: all tests pass

- [ ] **Step 4: Commit**

```bash
git add src/main.rs
git commit -m "feat: add --version flag to CLI"
```

---

### Task 2: Write the rolling-upgrade.sh script

**Files:**
- Create: `scripts/rolling-upgrade.sh`

- [ ] **Step 1: Create `scripts/rolling-upgrade.sh`**

```sh
#!/bin/sh
# rolling-upgrade.sh — Zero-downtime upgrade for a running fafb-q server.
#
# Orchestrates: preflight → drain → shutdown wait → binary swap → restart → health check.
#
# Usage:
#   FAFB_ADMIN_API_KEY=mykey ./scripts/rolling-upgrade.sh
#   FAFB_ADMIN_API_KEY=mykey ./scripts/rolling-upgrade.sh --binary /path/to/fafb-q
#   ./scripts/rolling-upgrade.sh --admin-url http://host:8080 --admin-key mykey
#   ./scripts/rolling-upgrade.sh --force
#   ./scripts/rolling-upgrade.sh --config /etc/fafb-q/config.toml
#
# Prerequisites:
#   - A running fafb-q server with admin API enabled
#   - FAFB_ADMIN_API_KEY set (env var or --admin-key flag)
#   - curl installed
#
# The script uses the admin drain API (POST /admin/drain) to trigger a graceful
# shutdown. The server drains connections, sends GoingAway to capable clients,
# and exits on its own. The script then installs the new binary and restarts.
#
# For the full deploy lifecycle, see: docs/deploy.md
set -eu

# ── Constants ──────────────────────────────────────────────────────────────────

BINARY_NAME="fafb-q"
DEFAULT_INSTALL_DIR="/usr/local/bin"
BASE_URL="https://bitbucket.org/thermsio/fafb-q/downloads"

# ── Helpers ────────────────────────────────────────────────────────────────────

# Run a command with sudo if not root. Same pattern as install.sh.
needs_sudo=false
run() {
    if [ "$needs_sudo" = true ]; then
        sudo "$@"
    else
        "$@"
    fi
}

die() {
    echo "Error: $1" >&2
    exit 1
}

usage() {
    cat <<'USAGE'
Usage: rolling-upgrade.sh [OPTIONS]

Orchestrate a zero-downtime upgrade of a running fafb-q server.

Options:
  --admin-url URL       Admin endpoint base URL (default: http://localhost:8080)
  --admin-key KEY       Admin API key (default: $FAFB_ADMIN_API_KEY env var)
  --binary PATH         Path to pre-staged binary (default: download latest)
  --force               SIGKILL if drain/shutdown times out
  --drain-timeout SECS  Seconds to wait for shutdown (default: 30)
  --config PATH         Config file for server restart (required in non-systemd mode)
  --help                Show this help message

Environment:
  FAFB_ADMIN_API_KEY    Admin API key (alternative to --admin-key)

Examples:
  FAFB_ADMIN_API_KEY=mykey ./scripts/rolling-upgrade.sh
  ./scripts/rolling-upgrade.sh --binary /tmp/fafb-q --admin-key mykey
  ./scripts/rolling-upgrade.sh --force --drain-timeout 60

Full deploy documentation: docs/deploy.md
USAGE
    exit 0
}

# ── Flag Parsing ───────────────────────────────────────────────────────────────
# Each flag has a default. --admin-key falls back to $FAFB_ADMIN_API_KEY env var.
# --config is only required in non-systemd (direct PID) mode for restart.

ADMIN_URL="http://localhost:8080"
ADMIN_KEY="${FAFB_ADMIN_API_KEY:-}"
BINARY_PATH=""
FORCE=false
DRAIN_TIMEOUT=30
CONFIG_PATH=""

while [ $# -gt 0 ]; do
    case "$1" in
        --admin-url)  ADMIN_URL="$2";  shift 2 ;;
        --admin-key)  ADMIN_KEY="$2";  shift 2 ;;
        --binary)     BINARY_PATH="$2"; shift 2 ;;
        --force)      FORCE=true;       shift ;;
        --drain-timeout) DRAIN_TIMEOUT="$2"; shift 2 ;;
        --config)     CONFIG_PATH="$2"; shift 2 ;;
        --help)       usage ;;
        *)            die "unknown flag: $1. Use --help for usage." ;;
    esac
done

# ── Main ───────────────────────────────────────────────────────────────────────

main() {
    # ── Step 1: Preflight Checks ──────────────────────────────────────────
    # Validate everything before touching the running server.
    # If anything fails here, we exit without side effects.

    if ! command -v curl >/dev/null 2>&1; then
        die "curl is required but not found"
    fi

    if [ -z "$ADMIN_KEY" ]; then
        die "admin key required. Set FAFB_ADMIN_API_KEY or use --admin-key."
    fi

    # Validate the new binary first (pure input validation — no network needed).
    # This catches obvious errors (bad --binary path) before we talk to the server.
    tmpdir="$(mktemp -d)"
    trap 'rm -rf "$tmpdir"' EXIT INT TERM

    if [ -n "$BINARY_PATH" ]; then
        # Pre-staged binary provided
        if [ ! -x "$BINARY_PATH" ]; then
            die "binary not found or not executable: $BINARY_PATH"
        fi
        new_binary="$BINARY_PATH"
    else
        # Download latest — same logic as install.sh
        os="$(uname -s)"
        if [ "$os" != "Linux" ]; then
            die "unsupported OS: $os. This script only supports Linux."
        fi

        arch="$(uname -m)"
        case "$arch" in
            x86_64)        target="x86_64-unknown-linux-musl" ;;
            aarch64|arm64) target="aarch64-unknown-linux-musl" ;;
            *)             die "unsupported architecture: $arch" ;;
        esac

        tarball="fafb-q-latest-${target}.tar.gz"
        echo "Preflight: downloading fafb-q for ${target}..."
        curl -fsSL "${BASE_URL}/${tarball}" -o "${tmpdir}/${tarball}"
        tar xzf "${tmpdir}/${tarball}" --strip-components=1 -C "$tmpdir"

        if [ ! -x "${tmpdir}/${BINARY_NAME}" ]; then
            die "binary not found or not executable after extraction"
        fi
        new_binary="${tmpdir}/${BINARY_NAME}"
    fi

    # Smoke test the new binary
    new_version=$("$new_binary" --version 2>&1) || die "new binary failed to execute"
    echo "Preflight: new binary version: $new_version"

    # Verify server is reachable and healthy (after binary validation —
    # this is the last preflight check before we start affecting the server).
    echo "Preflight: checking server at $ADMIN_URL..."
    http_code=$(curl -s -o /dev/null -w '%{http_code}' "$ADMIN_URL/health/ready") || true
    if [ "$http_code" != "200" ]; then
        die "server not reachable or not ready at $ADMIN_URL/health/ready (got HTTP $http_code)"
    fi
    echo "Preflight: server reachable at $ADMIN_URL"

    # ── Step 2: Resolve Process Management ────────────────────────────────
    # Detect whether the server is managed by systemd or running as a bare
    # process. This determines how we wait for shutdown and restart.
    # - systemd: the drain API triggers exit; we poll systemctl is-active.
    # - direct PID: we find the PID and poll kill -0 until it's gone.

    use_systemd=false
    server_pid=""
    install_path="$DEFAULT_INSTALL_DIR/$BINARY_NAME"

    if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files fafb-q.service >/dev/null 2>&1; then
        use_systemd=true
        echo "Detected: systemd service (fafb-q.service)"
    else
        # Direct PID mode — find the running process
        if command -v pidof >/dev/null 2>&1; then
            server_pid=$(pidof "$BINARY_NAME" 2>/dev/null) || true
        fi
        if [ -z "$server_pid" ] && command -v pgrep >/dev/null 2>&1; then
            server_pid=$(pgrep -x "$BINARY_NAME" 2>/dev/null) || true
        fi
        if [ -z "$server_pid" ]; then
            die "no running fafb-q process found"
        fi
        echo "Detected: direct PID mode (PID $server_pid)"

        # Resolve the binary path from /proc for install target.
        # Requires same user or root. Falls back to default if unreadable.
        if [ -L "/proc/$server_pid/exe" ]; then
            resolved=$(readlink "/proc/$server_pid/exe" 2>/dev/null) || true
            if [ -n "$resolved" ]; then
                install_path="$resolved"
            fi
        fi
    fi

    # ── Step 3: Trigger Drain ─────────────────────────────────────────────
    # POST /admin/drain triggers the server's graceful shutdown sequence:
    #   force-flush → Draining → GoingAway to clients → wait → ShuttingDown → exit
    # The server exits on its own after drain completes. We do NOT need to
    # send SIGTERM or call systemctl stop in the normal path.

    echo "Draining server..."
    drain_code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$ADMIN_URL/admin/drain" \
        -H "Authorization: Bearer $ADMIN_KEY" \
        -H "Content-Type: application/json" \
        -d '{"reason": "upgrade"}') || true

    case "$drain_code" in
        200)
            echo "Drain accepted. Waiting for server to shut down..."
            ;;
        409)
            # Server is already draining (e.g., another operator triggered it).
            # This is not an error — proceed to wait for shutdown.
            echo "Warning: server is already draining. Proceeding to wait for shutdown."
            ;;
        *)
            die "drain request failed (HTTP $drain_code). Check admin key and URL."
            ;;
    esac

    # Poll /health/ready until it returns 503 (confirms drain mode entered).
    # Timeout after 10s — the drain was accepted; ready may already be 503.
    drain_confirm_waited=0
    while [ "$drain_confirm_waited" -lt 10 ]; do
        ready_code=$(curl -s -o /dev/null -w '%{http_code}' "$ADMIN_URL/health/ready") || true
        if [ "$ready_code" = "503" ] || [ "$ready_code" = "000" ]; then
            break
        fi
        sleep 2
        drain_confirm_waited=$((drain_confirm_waited + 2))
    done

    # ── Step 4: Wait for Shutdown ─────────────────────────────────────────
    # The server should exit on its own after drain completes. We poll until
    # the process is gone. If it doesn't exit within --drain-timeout, we
    # either abort (safe default) or SIGKILL (--force).

    waited=0
    while [ "$waited" -lt "$DRAIN_TIMEOUT" ]; do
        if [ "$use_systemd" = true ]; then
            status=$(systemctl is-active fafb-q 2>/dev/null) || true
            if [ "$status" = "inactive" ] || [ "$status" = "failed" ]; then
                break
            fi
        else
            if ! kill -0 "$server_pid" 2>/dev/null; then
                break
            fi
        fi
        sleep 2
        waited=$((waited + 2))
    done

    # Check if server is still running
    still_running=false
    if [ "$use_systemd" = true ]; then
        status=$(systemctl is-active fafb-q 2>/dev/null) || true
        if [ "$status" = "active" ] || [ "$status" = "activating" ]; then
            still_running=true
        fi
    else
        if kill -0 "$server_pid" 2>/dev/null; then
            still_running=true
        fi
    fi

    if [ "$still_running" = true ]; then
        if [ "$FORCE" = true ]; then
            echo "Drain timed out. Force-killing server..."
            if [ "$use_systemd" = true ]; then
                systemctl kill -s KILL fafb-q 2>/dev/null || true
            else
                kill -9 "$server_pid" 2>/dev/null || true
            fi
            # Wait up to 5 seconds for the process to die
            force_waited=0
            while [ "$force_waited" -lt 5 ]; do
                if [ "$use_systemd" = true ]; then
                    status=$(systemctl is-active fafb-q 2>/dev/null) || true
                    if [ "$status" = "inactive" ] || [ "$status" = "failed" ]; then
                        break
                    fi
                else
                    if ! kill -0 "$server_pid" 2>/dev/null; then
                        break
                    fi
                fi
                sleep 1
                force_waited=$((force_waited + 1))
            done
            # Final check
            if [ "$use_systemd" = true ]; then
                status=$(systemctl is-active fafb-q 2>/dev/null) || true
                if [ "$status" = "active" ]; then
                    die "failed to kill server even with --force"
                fi
            else
                if kill -0 "$server_pid" 2>/dev/null; then
                    die "failed to kill PID $server_pid even with --force"
                fi
            fi
        else
            die "drain timed out after ${DRAIN_TIMEOUT}s. Server is still running. Use --force to kill, or increase --drain-timeout."
        fi
    fi

    if [ -n "$server_pid" ]; then
        echo "Server stopped (was PID $server_pid)."
    else
        echo "Server stopped."
    fi

    # ── Step 5: Install Binary ────────────────────────────────────────────
    # Install the new binary to the same path as the old one.
    # Uses sudo if not root (same pattern as install.sh).

    if [ "$(id -u)" -ne 0 ]; then
        if command -v sudo >/dev/null 2>&1; then
            needs_sudo=true
        else
            die "permission denied. Run as root or install sudo."
        fi
    fi

    echo "Installing $new_version to $install_path..."
    run mkdir -p "$(dirname "$install_path")"
    run install -m 755 "$new_binary" "$install_path"

    # ── Step 6: Start and Verify ──────────────────────────────────────────
    # Restart the server and verify it becomes healthy.
    # - systemd: systemctl start, check is-active, poll /health/ready.
    # - direct PID: nohup with --config, poll /health/ready.
    # - no --config in PID mode: skip start, tell user to start manually.

    started=false
    if [ "$use_systemd" = true ]; then
        echo "Starting server via systemctl..."
        systemctl start fafb-q

        # Verify systemd reports the service as active
        sleep 1
        status=$(systemctl is-active fafb-q 2>/dev/null) || true
        if [ "$status" = "failed" ]; then
            echo "Error: server failed to start. Check logs:" >&2
            systemctl status fafb-q --no-pager 2>&1 || true
            die "server failed to start. Check logs with: journalctl -u fafb-q"
        fi
        started=true
    elif [ -n "$CONFIG_PATH" ]; then
        echo "Starting server..."
        nohup "$install_path" serve --config "$CONFIG_PATH" > /dev/null 2>&1 &
        new_pid=$!
        echo "Server started (PID $new_pid)."
        started=true
    else
        echo "Binary installed. Start the server manually."
    fi

    # Poll /health/ready until it returns 200 (server initialized).
    # Timeout: 60s — accounts for large dataset replay during engine.init().
    if [ "$started" = true ]; then
        echo "Waiting for server to become ready..."
        health_waited=0
        while [ "$health_waited" -lt 60 ]; do
            ready_code=$(curl -s -o /dev/null -w '%{http_code}' "$ADMIN_URL/health/ready") || true
            if [ "$ready_code" = "200" ]; then
                break
            fi
            sleep 2
            health_waited=$((health_waited + 2))
        done

        if [ "$ready_code" = "200" ]; then
            echo "Health check: /health/ready returned 200"
            "$install_path" --version 2>/dev/null || true
            echo "Upgrade complete."
        else
            echo "Warning: server started but /health/ready not yet 200. It may still be initializing."
        fi
    fi
}

main
```

- [ ] **Step 2: Make the script executable**

Run: `chmod +x scripts/rolling-upgrade.sh`

- [ ] **Step 3: Verify with shellcheck (if available)**

Run: `shellcheck scripts/rolling-upgrade.sh || echo "shellcheck not installed, skipping"`

- [ ] **Step 4: Commit**

```bash
git add scripts/rolling-upgrade.sh
git commit -m "feat: add rolling-upgrade.sh for zero-downtime server upgrades"
```

---

### Task 3: Write tests for rolling-upgrade.sh

**Files:**
- Create: `tests/test_rolling_upgrade.sh`

- [ ] **Step 1: Create `tests/test_rolling_upgrade.sh`**

```sh
#!/bin/sh
# Tests for scripts/rolling-upgrade.sh
#
# Tests flag parsing, error paths, and detection logic via shims.
# Does not require a running server, network access, or root.
#
# Usage: ./tests/test_rolling_upgrade.sh
set -eu

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
UPGRADE_SCRIPT="$PROJECT_DIR/scripts/rolling-upgrade.sh"

passed=0
failed=0

fail() {
    echo "FAIL: $1"
    failed=$((failed + 1))
}

pass() {
    echo "PASS: $1"
    passed=$((passed + 1))
}

# --- Test: script is POSIX sh ---
test_posix_shebang() {
    first_line=$(head -1 "$UPGRADE_SCRIPT")
    if [ "$first_line" = "#!/bin/sh" ]; then
        pass "shebang is #!/bin/sh"
    else
        fail "shebang should be #!/bin/sh — got: $first_line"
    fi
}

# --- Test: script is executable ---
test_executable() {
    if [ -x "$UPGRADE_SCRIPT" ]; then
        pass "rolling-upgrade.sh is executable"
    else
        fail "rolling-upgrade.sh is not executable"
    fi
}

# --- Test: --help prints usage and exits 0 ---
test_help_flag() {
    output=$(sh "$UPGRADE_SCRIPT" --help 2>&1) || true
    if echo "$output" | grep -q "Usage:"; then
        pass "--help prints usage"
    else
        fail "--help should print usage — got: $output"
    fi
    if echo "$output" | grep -q "admin-url"; then
        pass "--help mentions --admin-url"
    else
        fail "--help should mention --admin-url"
    fi
    if echo "$output" | grep -q "deploy.md"; then
        pass "--help references deploy.md"
    else
        fail "--help should reference deploy.md"
    fi
}

# --- Test: exits with error when no admin key ---
test_no_admin_key() {
    # Unset the env var and don't pass --admin-key
    output=$(FAFB_ADMIN_API_KEY="" sh "$UPGRADE_SCRIPT" 2>&1) || true
    if echo "$output" | grep -q "admin key required"; then
        pass "exits with error when no admin key"
    else
        fail "should require admin key — got: $output"
    fi
}

# --- Test: exits with error on unknown flag ---
test_unknown_flag() {
    output=$(FAFB_ADMIN_API_KEY="test" sh "$UPGRADE_SCRIPT" --bogus 2>&1) || true
    if echo "$output" | grep -q "unknown flag"; then
        pass "exits with error on unknown flag"
    else
        fail "should reject unknown flag — got: $output"
    fi
}

# --- Test: exits when server not reachable ---
test_server_not_reachable() {
    # Point at a URL that doesn't exist
    output=$(FAFB_ADMIN_API_KEY="test" sh "$UPGRADE_SCRIPT" --admin-url http://localhost:19999 2>&1) || true
    if echo "$output" | grep -qi "not reachable\|not ready"; then
        pass "exits when server not reachable"
    else
        fail "should fail when server not reachable — got: $output"
    fi
}

# --- Test: exits when --binary path doesn't exist ---
test_binary_not_found() {
    output=$(FAFB_ADMIN_API_KEY="test" sh "$UPGRADE_SCRIPT" --binary /nonexistent/fafb-q --admin-url http://localhost:19999 2>&1) || true
    if echo "$output" | grep -q "not found or not executable"; then
        pass "exits when --binary path doesn't exist"
    else
        fail "should fail when binary missing — got: $output"
    fi
}

# --- Run all tests ---
echo "Running rolling-upgrade.sh tests..."
echo "---"

test_posix_shebang
test_executable
test_help_flag
test_no_admin_key
test_unknown_flag
test_server_not_reachable
test_binary_not_found

echo "---"
echo "Results: $passed passed, $failed failed"

if [ "$failed" -gt 0 ]; then
    exit 1
fi
```

- [ ] **Step 2: Make the test executable**

Run: `chmod +x tests/test_rolling_upgrade.sh`

- [ ] **Step 3: Run the tests**

Run: `./tests/test_rolling_upgrade.sh`
Expected: all tests pass

- [ ] **Step 4: Commit**

```bash
git add tests/test_rolling_upgrade.sh
git commit -m "test: add tests for rolling-upgrade.sh"
```

---

### Task 4: Add pipeline upload for rolling-upgrade.sh

**Files:**
- Modify: `bitbucket-pipelines.yml:85` (after the install.sh upload block)

- [ ] **Step 1: Add upload block after the install.sh upload**

After the install.sh upload block (which ends at line 85), add:

```yaml
          # Upload the rolling-upgrade script (stable URL, always overwritten)
          - |
            echo "Uploading rolling-upgrade.sh..."
            curl -X POST "$UPLOAD_URL" \
              --user "$ATLASSIAN_ACCOUNT_EMAIL:$ATLASSIAN_API_TOKEN" \
              --form files=@"scripts/rolling-upgrade.sh" \
              --fail --show-error
```

- [ ] **Step 2: Commit**

```bash
git add bitbucket-pipelines.yml
git commit -m "ci: upload rolling-upgrade.sh to Bitbucket Downloads"
```

---

### Task 5: Update documentation

**Files:**
- Modify: `docs/deploy.md:211` (after "HTTP Drain API" section)
- Modify: `README.md:34` (after "Versioned downloads" line)
- Modify: `CLAUDE.md:68` (module map, after `scripts/install.sh` entry)

- [ ] **Step 1: Add "Automated Upgrade Script" section to deploy.md**

After line 211 (after `kill $SERVER_PID` and its closing code block), add:

```markdown
## Automated Upgrade Script

For single-instance deployments, `scripts/rolling-upgrade.sh` automates the full upgrade sequence:

```bash
# Download latest and upgrade
FAFB_ADMIN_API_KEY=mykey ./scripts/rolling-upgrade.sh

# Use a specific binary
FAFB_ADMIN_API_KEY=mykey ./scripts/rolling-upgrade.sh --binary /path/to/fafb-q

# Force completion even if drain times out
./scripts/rolling-upgrade.sh --force
```

The script handles: preflight validation, drain trigger via admin API, shutdown wait, binary swap, restart, and health verification. See `scripts/rolling-upgrade.sh --help` for all options.

For multi-instance rolling deploys, use your orchestrator (Kubernetes, ECS) with the health endpoints documented above.
```

- [ ] **Step 2: Add "Live upgrade" section to README.md**

After the "Versioned downloads" line (line 34), before "### From crates.io", add:

```markdown

### Live upgrade

Upgrade a running server with zero downtime:

```bash
FAFB_ADMIN_API_KEY=mykey curl -fsSL https://bitbucket.org/thermsio/fafb-q/downloads/rolling-upgrade.sh | sh
```

Or with a local copy:

```bash
FAFB_ADMIN_API_KEY=mykey ./scripts/rolling-upgrade.sh
```

See `scripts/rolling-upgrade.sh --help` for options (custom admin URL, pre-staged binary, force mode). Full deploy documentation: [`docs/deploy.md`](docs/deploy.md).
```

- [ ] **Step 3: Add rolling-upgrade.sh to CLAUDE.md module map**

After the `scripts/install.sh` entry in the module map, add:

```
| `scripts/rolling-upgrade.sh` | Zero-downtime upgrade script — drains running server via admin API, swaps binary, restarts, verifies health |
```

- [ ] **Step 4: Commit**

```bash
git add docs/deploy.md README.md CLAUDE.md
git commit -m "docs: add rolling-upgrade.sh to deploy guide, README, and module map"
```
