|
| 1 | +#!/usr/bin/env bash |
| 2 | + |
| 3 | +set -euo pipefail |
| 4 | + |
| 5 | +REPO_URL="${CONVOY_REPO_URL:-https://github.com/frain-dev/convoy.git}" |
| 6 | +INSTALL_DIR="${CONVOY_INSTALL_DIR:-$HOME/convoy}" |
| 7 | +MAX_WAIT_SECONDS="${CONVOY_MAX_WAIT_SECONDS:-180}" |
| 8 | + |
| 9 | +log() { |
| 10 | + printf "\n==> %s\n" "$1" |
| 11 | +} |
| 12 | + |
| 13 | +warn() { |
| 14 | + printf "\n[WARN] %s\n" "$1" |
| 15 | +} |
| 16 | + |
| 17 | +die() { |
| 18 | + printf "\n[ERROR] %s\n" "$1" |
| 19 | + exit 1 |
| 20 | +} |
| 21 | + |
| 22 | +command_exists() { |
| 23 | + command -v "$1" >/dev/null 2>&1 |
| 24 | +} |
| 25 | + |
| 26 | +is_port_in_use() { |
| 27 | + local port="$1" |
| 28 | + if command_exists lsof; then |
| 29 | + lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 |
| 30 | + return $? |
| 31 | + fi |
| 32 | + |
| 33 | + # Fallback when lsof is unavailable: check host binding via Python. |
| 34 | + if command_exists python3; then |
| 35 | + python3 - "$port" <<'PY' |
| 36 | +import socket |
| 37 | +import sys |
| 38 | +
|
| 39 | +port = int(sys.argv[1]) |
| 40 | +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 41 | +try: |
| 42 | + s.bind(("0.0.0.0", port)) |
| 43 | +except OSError as e: |
| 44 | + # Permission denied on privileged ports (e.g. 80) is not proof that |
| 45 | + # another process is listening; treat as unknown. |
| 46 | + if getattr(e, "errno", None) == 13: |
| 47 | + sys.exit(2) # unknown |
| 48 | + sys.exit(0) # in use |
| 49 | +else: |
| 50 | + sys.exit(1) # free |
| 51 | +finally: |
| 52 | + s.close() |
| 53 | +PY |
| 54 | + return $? |
| 55 | + fi |
| 56 | + |
| 57 | + # Last resort when lsof/python3 are unavailable. |
| 58 | + warn "Could not reliably check port $port (missing lsof/python3); continuing." |
| 59 | + return 1 |
| 60 | +} |
| 61 | + |
| 62 | +check_prereqs() { |
| 63 | + log "Checking prerequisites" |
| 64 | + |
| 65 | + if ! command_exists git; then |
| 66 | + die "Git is not installed. Install Git first and run this script again." |
| 67 | + fi |
| 68 | + |
| 69 | + if ! command_exists curl; then |
| 70 | + die "curl is not installed. Install curl first and run this script again." |
| 71 | + fi |
| 72 | + |
| 73 | + if ! command_exists docker; then |
| 74 | + cat <<'EOF' |
| 75 | +[ERROR] Docker is not installed. |
| 76 | +
|
| 77 | +Install Docker Desktop (macOS/Windows): |
| 78 | + https://docs.docker.com/desktop/ |
| 79 | +
|
| 80 | +Install Docker Engine (Linux): |
| 81 | + https://docs.docker.com/engine/install/ |
| 82 | +EOF |
| 83 | + exit 1 |
| 84 | + fi |
| 85 | + |
| 86 | + if ! docker compose version >/dev/null 2>&1; then |
| 87 | + die "Docker Compose v2 is not available. Install/enable docker compose and try again." |
| 88 | + fi |
| 89 | + |
| 90 | + if ! docker info >/dev/null 2>&1; then |
| 91 | + cat <<'EOF' |
| 92 | +[ERROR] Docker daemon is not running. |
| 93 | +
|
| 94 | +Start Docker Desktop, wait until it is fully running, then run this script again. |
| 95 | +EOF |
| 96 | + exit 1 |
| 97 | + fi |
| 98 | +} |
| 99 | + |
| 100 | +check_ports() { |
| 101 | + # These are the host ports published by configs/local/docker-compose.yml. |
| 102 | + local required_ports=(80 5433 6432) |
| 103 | + local conflicts=() |
| 104 | + local unknown=() |
| 105 | + local p |
| 106 | + local rc |
| 107 | + |
| 108 | + log "Checking required ports" |
| 109 | + |
| 110 | + for p in "${required_ports[@]}"; do |
| 111 | + if is_port_in_use "$p"; then |
| 112 | + rc=0 |
| 113 | + else |
| 114 | + rc=$? |
| 115 | + fi |
| 116 | + if [ "$rc" -eq 0 ]; then |
| 117 | + conflicts+=("$p") |
| 118 | + elif [ "$rc" -eq 2 ]; then |
| 119 | + unknown+=("$p") |
| 120 | + fi |
| 121 | + done |
| 122 | + |
| 123 | + if [ "${#conflicts[@]}" -gt 0 ]; then |
| 124 | + cat <<EOF |
| 125 | +[ERROR] Required ports are already in use: ${conflicts[*]} |
| 126 | +
|
| 127 | +Stop conflicting services/containers, then retry. |
| 128 | +Helpful checks: |
| 129 | + docker ps --format 'table {{.Names}}\t{{.Ports}}' |
| 130 | + lsof -nP -iTCP -sTCP:LISTEN |
| 131 | +
|
| 132 | +If you previously started Convoy local stack: |
| 133 | + docker compose -f "$INSTALL_DIR/configs/local/docker-compose.yml" down |
| 134 | +EOF |
| 135 | + exit 1 |
| 136 | + fi |
| 137 | + |
| 138 | + if [ "${#unknown[@]}" -gt 0 ]; then |
| 139 | + warn "Could not reliably check privileged ports: ${unknown[*]} (permission denied without lsof)." |
| 140 | + fi |
| 141 | +} |
| 142 | + |
| 143 | +prepare_repo() { |
| 144 | + log "Preparing Convoy repository" |
| 145 | + |
| 146 | + if [ -d "$INSTALL_DIR/.git" ]; then |
| 147 | + printf "Found existing repo at %s. Pull latest changes? [Y/n]: " "$INSTALL_DIR" |
| 148 | + if ! read -r pull_choice; then |
| 149 | + pull_choice="" |
| 150 | + fi |
| 151 | + pull_choice="${pull_choice:-Y}" |
| 152 | + if [[ "$pull_choice" =~ ^[Yy]$ ]]; then |
| 153 | + git -C "$INSTALL_DIR" pull --ff-only |
| 154 | + fi |
| 155 | + else |
| 156 | + if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then |
| 157 | + die "Install directory '$INSTALL_DIR' exists and is not a git repo. Use an empty path or set CONVOY_INSTALL_DIR to another directory." |
| 158 | + fi |
| 159 | + git clone "$REPO_URL" "$INSTALL_DIR" |
| 160 | + fi |
| 161 | +} |
| 162 | + |
| 163 | +ensure_local_config() { |
| 164 | + local config_path="$INSTALL_DIR/configs/local/convoy.json" |
| 165 | + |
| 166 | + if [ -f "$config_path" ]; then |
| 167 | + return |
| 168 | + fi |
| 169 | + |
| 170 | + log "Ensuring local Convoy config exists" |
| 171 | + |
| 172 | + # Recover deleted tracked file from git if available. |
| 173 | + if [ -d "$INSTALL_DIR/.git" ] && git -C "$INSTALL_DIR" ls-files --error-unmatch "configs/local/convoy.json" >/dev/null 2>&1; then |
| 174 | + git -C "$INSTALL_DIR" checkout -- "configs/local/convoy.json" |
| 175 | + fi |
| 176 | + |
| 177 | + if [ ! -f "$config_path" ]; then |
| 178 | + die "Missing $config_path. Restore it from the repository or create it before running installer." |
| 179 | + fi |
| 180 | +} |
| 181 | + |
| 182 | +start_stack() { |
| 183 | + local compose_dir="$INSTALL_DIR/configs/local" |
| 184 | + |
| 185 | + [ -d "$compose_dir" ] || die "Missing compose directory: $compose_dir" |
| 186 | + |
| 187 | + if [ "${CONVOY_SKIP_PULL:-0}" != "1" ]; then |
| 188 | + log "Pulling latest images" |
| 189 | + docker compose -f "$compose_dir/docker-compose.yml" pull |
| 190 | + fi |
| 191 | + |
| 192 | + log "Starting Convoy stack" |
| 193 | + docker compose -f "$compose_dir/docker-compose.yml" up -d |
| 194 | +} |
| 195 | + |
| 196 | +wait_for_health() { |
| 197 | + local elapsed=0 |
| 198 | + local health_url="http://localhost/healthz" |
| 199 | + |
| 200 | + log "Waiting for Convoy health endpoint ($health_url)" |
| 201 | + |
| 202 | + until curl -fsS "$health_url" >/dev/null 2>&1; do |
| 203 | + if [ "$elapsed" -ge "$MAX_WAIT_SECONDS" ]; then |
| 204 | + die "Timed out waiting for health after ${MAX_WAIT_SECONDS}s. Check logs with: docker compose -f \"$INSTALL_DIR/configs/local/docker-compose.yml\" logs" |
| 205 | + fi |
| 206 | + |
| 207 | + sleep 3 |
| 208 | + elapsed=$((elapsed + 3)) |
| 209 | + done |
| 210 | + |
| 211 | + log "Convoy is healthy" |
| 212 | +} |
| 213 | + |
| 214 | +print_next_steps() { |
| 215 | + cat <<EOF |
| 216 | +
|
| 217 | +🎉 Convoy is set up. |
| 218 | +
|
| 219 | +Useful commands: |
| 220 | + docker compose -f "$INSTALL_DIR/configs/local/docker-compose.yml" ps |
| 221 | + docker compose -f "$INSTALL_DIR/configs/local/docker-compose.yml" logs -f web agent |
| 222 | + docker compose -f "$INSTALL_DIR/configs/local/docker-compose.yml" down |
| 223 | +
|
| 224 | +Open: |
| 225 | + http://localhost |
| 226 | +
|
| 227 | +EOF |
| 228 | +} |
| 229 | + |
| 230 | +main() { |
| 231 | + check_prereqs |
| 232 | + check_ports |
| 233 | + prepare_repo |
| 234 | + ensure_local_config |
| 235 | + start_stack |
| 236 | + wait_for_health |
| 237 | + print_next_steps |
| 238 | +} |
| 239 | + |
| 240 | +main "$@" |
0 commit comments