| 1 | #!/usr/bin/env bash |
| 2 | # Build whatever branch is currently checked out and deploy it to a single |
| 3 | # instance on the self-hosted router box (versioned, side-by-side deploys — |
| 4 | # see docs/instance-router.md and CLAUDE.local.md "Deploying"). Health-checked: |
| 5 | # `lchat deploy` itself verifies /api/version's build id after restarting and |
| 6 | # auto-rolls back on a mismatch, so a bad build never stays live. Also ensures |
| 7 | # the instance has its default bot sidecar (Claude/Eleven) running — |
| 8 | # deterministic-only (no ANTHROPIC_API_KEY) unless you pass one in — so Eleven |
| 9 | # always welcomes new members and answers slash commands out of the box. |
| 10 | # |
| 11 | # Usage: ./deploy/go-router.sh [host] |
| 12 | # ./deploy/go-router.sh # host defaults to team.elevenmessenger.com |
| 13 | # ./deploy/go-router.sh test.example.com |
| 14 | # SKIP_BOT=1 ./deploy/go-router.sh # skip the bot-ensure step |
| 15 | # ANTHROPIC_API_KEY=sk-ant-... ./deploy/go-router.sh # AI-enabled instead |
| 16 | # |
| 17 | # Never deploys the whole fleet (`--all`) — always one explicit host, so this |
| 18 | # script can never affect an instance other than the one you asked for. |
| 19 | set -euo pipefail |
| 20 | |
| 21 | HOST="${1:-team.elevenmessenger.com}" |
| 22 | # No user in the default: operator SSH rides the WireGuard tunnel and the |
| 23 | # username comes from ~/.ssh/config (root SSH is bootstrap-only — see |
| 24 | # docs/instance-router.md § Hardening). Privileged remote commands go through |
| 25 | # `sudo -n` (the committed allowlist, deploy/sudoers-operators); root can |
| 26 | # sudo too, so a bootstrap-era root session still works unchanged. |
| 27 | BOX="${LCHAT_BOX:-89.167.122.227}" |
| 28 | SKIP_BOT="${SKIP_BOT:-}" |
| 29 | ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" |
| 30 | |
| 31 | REPO="$(cd "$(dirname "$0")/.." && pwd)" |
| 32 | cd "$REPO" |
| 33 | |
| 34 | BRANCH="$(git rev-parse --abbrev-ref HEAD)" |
| 35 | VER="$(git rev-parse --short HEAD)" |
| 36 | if ! git diff --quiet || ! git diff --cached --quiet; then |
| 37 | VER="${VER}-dirty" |
| 38 | echo "Warning: working tree has uncommitted changes — building anyway (version tagged '$VER')." >&2 |
| 39 | fi |
| 40 | |
| 41 | OUT="$(mktemp -t lchat-linux-amd64.XXXXXX)" |
| 42 | trap 'rm -f "$OUT"' EXIT |
| 43 | |
| 44 | echo "Building branch '$BRANCH' ($VER) for linux/amd64..." |
| 45 | OUT="$OUT" ./deploy/build-linux.sh "$VER" |
| 46 | |
| 47 | # /opt/lchat/incoming is a DIRECTORY now (the auto-updater downloads into it |
| 48 | # too — see updater.go); upload as a file inside it, staged then removed. |
| 49 | echo "Uploading to $BOX:/opt/lchat/incoming/lchat-upload..." |
| 50 | # No sudo: the dir exists group-writable (lchat-operators) after operator |
| 51 | # setup, and mkdir -p on an existing dir succeeds without write access. |
| 52 | ssh "$BOX" 'mkdir -p /opt/lchat/incoming' |
| 53 | scp -q "$OUT" "$BOX:/opt/lchat/incoming/lchat-upload" |
| 54 | |
| 55 | echo "Staging on the box..." |
| 56 | STAGE_OUT="$(ssh "$BOX" 'sudo -n /opt/lchat/lchat stage -file /opt/lchat/incoming/lchat-upload && rm -f /opt/lchat/incoming/lchat-upload')" |
| 57 | echo " $STAGE_OUT" |
| 58 | STAGED_VER="$(echo "$STAGE_OUT" | awk '{print $2}')" |
| 59 | if [ "$STAGED_VER" != "$VER" ]; then |
| 60 | echo "go-router: staged version '$STAGED_VER' doesn't match built version '$VER' — aborting" >&2 |
| 61 | exit 1 |
| 62 | fi |
| 63 | |
| 64 | echo "Deploying $STAGED_VER to $HOST (health-checked; auto-rolls back on a build mismatch)..." |
| 65 | ssh "$BOX" "sudo -n /opt/lchat/lchat deploy '$HOST' --version '$STAGED_VER'" |
| 66 | |
| 67 | if [ -n "$SKIP_BOT" ]; then |
| 68 | echo "SKIP_BOT set — leaving the bot sidecar alone." |
| 69 | elif ssh "$BOX" "sudo -n test -f /data/${HOST}-bot.env"; then |
| 70 | echo "Bot sidecar already provisioned for $HOST — leaving it alone (add-bot mints fresh, one-time invites, so re-running it would orphan the existing Claude/Eleven identities)." |
| 71 | else |
| 72 | echo "No bot sidecar yet for $HOST — adding one (deterministic-only unless \$ANTHROPIC_API_KEY is set)..." |
| 73 | # Work around a known bug in the box's current control binary: add-bot's |
| 74 | # MkdirAll leaves the session dir root-owned, but the bot unit runs as the |
| 75 | # unprivileged `lchat` user (post-hardening) and can't write into it — every |
| 76 | # freshly-added bot crash-loops with EACCES, burning its one-time Claude |
| 77 | # invite before anyone can intervene (hit this firsthand adding Eleven here). |
| 78 | # Fixed upstream in 8e20692, not yet deployed to /opt/lchat/lchat's control |
| 79 | # binary on the box. Pre-creating the dir with the right owner is a no-op |
| 80 | # once that binary is updated, so this is safe to leave in permanently. |
| 81 | ssh "$BOX" "sudo -n mkdir -p /data/${HOST}-bot && sudo -n chown lchat:lchat /data/${HOST}-bot && sudo -n chmod 750 /data/${HOST}-bot" |
| 82 | if [ -n "$ANTHROPIC_API_KEY" ]; then |
| 83 | ssh "$BOX" "sudo -n ANTHROPIC_API_KEY='$ANTHROPIC_API_KEY' /opt/lchat/lchat add-bot '$HOST'" |
| 84 | else |
| 85 | ssh "$BOX" "sudo -n /opt/lchat/lchat add-bot '$HOST'" |
| 86 | fi |
| 87 | fi |
| 88 | |
| 89 | echo |
| 90 | echo "✅ $HOST is now running $STAGED_VER (branch '$BRANCH')" |
| 91 | echo " Recover the admin link anytime: ssh $BOX sudo -n /opt/lchat/lchat admin-link $HOST" |
| 92 | echo " Step one instance back: ssh $BOX sudo -n /opt/lchat/lchat rollback $HOST" |
| 93 | |