#!/usr/bin/env bash # deploy.sh — manual host deploy for the read-only Fleet Analytics MCP server. # ───────────────────────────────────────────────────────────────────────────── # Use this if you are NOT letting Coolify build the Dockerfile (see README §Deploy # for the Coolify-managed path, which is the recommended default). This script # builds the image from this repo ON THE HOST and runs it as a standalone # Traefik-labelled bridge — the same proven pattern as the dashboard_api bridges: # it joins the network that can reach timescale_db, derives a READ-ONLY DATABASE_URL # for the analytics_ro role, and exposes the MCP over HTTPS with Bearer auth. # # Prereqs ON THE HOST: # * the analytics_ro role exists -> scripts/bootstrap_analytics_ro.sh (writes ~/.analytics_ro.pw) # * this repo is checked out (e.g. ~/fleetanalytics_mcp) — run this script from inside it # # Run: # cd ~/fleetanalytics_mcp && git pull # MCP_AUTH_TOKENS="alice:$(openssl rand -hex 16)" bash deploy.sh # # A token/env change needs a container RECREATE — this script does that. Record # each analyst's token securely; it is only shown once (when you generate it). # ───────────────────────────────────────────────────────────────────────────── set -euo pipefail NAME=analytics_mcp PORT=8892 HOST_DOMAIN="${HOST_DOMAIN:-fleetmcp.fivetitude.com}" # prod: fleetmcp.rahamafresh.com # Comma-separated list of every domain this service answers on (defaults to # HOST_DOMAIN). All are folded into ONE Traefik router rule so a single cert # covers them and connectors on either domain keep working. HOST_DOMAINS="${HOST_DOMAINS:-$HOST_DOMAIN}" BT='`' RULE="" IFS=',' read -ra _DOMS <<< "$HOST_DOMAINS" for _d in "${_DOMS[@]}"; do _d="${_d// /}" if [ -n "$_d" ]; then seg="Host(${BT}${_d}${BT})" if [ -z "$RULE" ]; then RULE="$seg"; else RULE="$RULE || $seg"; fi fi done IMAGE="fleetanalytics-mcp:latest" ENV_FILE="$(pwd)/.deploy.env" # Per-analyst Bearer tokens. For a CODE-ONLY redeploy you can omit MCP_AUTH_TOKENS: # we reuse the tokens from the currently running container so existing analysts keep # working and no secret has to be re-typed or printed. Only set MCP_AUTH_TOKENS when # you are adding/rotating/revoking a token. if [ -z "${MCP_AUTH_TOKENS:-}" ]; then MCP_AUTH_TOKENS="$(docker inspect "$NAME" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | sed -n 's/^MCP_AUTH_TOKENS=//p' | head -1)" [ -n "$MCP_AUTH_TOKENS" ] && echo "Reusing existing analyst tokens from running $NAME container." fi : "${MCP_AUTH_TOKENS:?set MCP_AUTH_TOKENS=name:token[,name:token...] before running (per-analyst Bearer tokens)}" # Resolve the network + DB DSN from the running webhook_receiver (it sits on the # same internal network as timescale_db and holds a DATABASE_URL we can reuse the # host:port/dbname from). This avoids hardcoding the internal DB hostname. WH=$(docker ps --filter name=webhook_receiver --format "{{.Names}}" | head -1) [ -n "$WH" ] || { echo "ERROR: webhook_receiver container not found (need the tracksolid stack running)"; exit 1; } APPNET=$(docker inspect "$WH" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}') SRC_DB_URL=$(docker inspect "$WH" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^DATABASE_URL=//p' | head -1) [ -n "$SRC_DB_URL" ] || { echo "ERROR: DATABASE_URL not found in $WH env"; exit 1; } echo "Reusing network $APPNET (from $WH)" # Build a READ-ONLY DATABASE_URL: app DB host:port/dbname + analytics_ro creds. RO_PW=$(cat "${ANALYTICS_RO_PW_FILE:-$HOME/.analytics_ro.pw}" 2>/dev/null || true) [ -n "$RO_PW" ] || { echo "ERROR: ~/.analytics_ro.pw missing — run scripts/bootstrap_analytics_ro.sh first"; exit 1; } HOSTPART="${SRC_DB_URL#*@}" # host:port/dbname[?params] RO_DB_URL="postgresql://analytics_ro:${RO_PW}@${HOSTPART}" # Build the image from this repo (SKIP_BUILD=1 reuses the existing image for a # labels/env-only change — no new code is pulled in). if [ "${SKIP_BUILD:-0}" = "1" ]; then echo "SKIP_BUILD=1 — reusing existing $IMAGE (no rebuild)." docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "ERROR: $IMAGE not present"; exit 1; } else echo "Building $IMAGE ..." docker build -t "$IMAGE" . fi # Minimal env (read-only DSN + auth only — no Tracksolid ingestion secrets). { echo "DATABASE_URL=${RO_DB_URL}"; echo "MCP_AUTH_TOKENS=${MCP_AUTH_TOKENS}"; } > "$ENV_FILE" chmod 600 "$ENV_FILE" docker rm -f "$NAME" 2>/dev/null || true docker run -d --name "$NAME" --restart unless-stopped \ --network "$APPNET" \ --env-file "$ENV_FILE" \ --log-opt max-size=10m --log-opt max-file=5 \ --label 'traefik.enable=true' \ --label 'traefik.docker.network=coolify' \ --label 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https' \ --label 'traefik.http.middlewares.fleetmcp-ratelimit.ratelimit.average=30' \ --label 'traefik.http.middlewares.fleetmcp-ratelimit.ratelimit.burst=60' \ --label "traefik.http.routers.http-0-fleetmcp.entryPoints=http" \ --label "traefik.http.routers.http-0-fleetmcp.middlewares=redirect-to-https" \ --label "traefik.http.routers.http-0-fleetmcp.rule=${RULE}" \ --label "traefik.http.routers.https-0-fleetmcp.entryPoints=https" \ --label "traefik.http.routers.https-0-fleetmcp.rule=${RULE}" \ --label "traefik.http.routers.https-0-fleetmcp.middlewares=fleetmcp-ratelimit" \ --label "traefik.http.routers.https-0-fleetmcp.tls=true" \ --label "traefik.http.routers.https-0-fleetmcp.tls.certresolver=letsencrypt" \ --label "traefik.http.services.fleetmcp.loadbalancer.server.port=${PORT}" \ "$IMAGE" docker network connect coolify "$NAME" 2>/dev/null || true rm -f "$ENV_FILE" sleep 5 echo "== container =="; docker ps --filter name="$NAME" --format "{{.Names}} | {{.Status}}" echo "== DB role (expect analytics_ro) =="; docker exec "$NAME" sh -lc 'printenv DATABASE_URL | sed -E "s#://([^:]+):[^@]+@#://\1:@#"' echo "== health =="; docker exec "$NAME" python -c "import urllib.request; print(urllib.request.urlopen('http://localhost:${PORT}/healthz').read().decode())" 2>&1 | head