mg-mcp/deploy/deploy.sh
DJP e463c27663 Initial mg-mcp: Mailgun MCP server (Streamable HTTPS) for optical-dev
Containerized FastAPI + FastMCP server exposing send_email tool, backed
by Mailgun (mg.oliver.solutions). Bearer-token auth. Deployable to
/opt/mg-mcp/ on optical-dev.oliver.solutions behind the shared Apache vhost,
following the same pattern as adeo-maturity-tool / oliver-sales-ops-platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:05:38 -04:00

239 lines
7.2 KiB
Bash
Executable file

#!/usr/bin/env bash
# mg-mcp — deploy script.
#
# Idempotent. Safe to re-run on the dev server.
# Public URL: https://optical-dev.oliver.solutions/mg-mcp/
#
# Server layout (mirrors /opt/oliver-sales-ops-platform/, /opt/adeo-maturity-tool/, …):
# /opt/mg-mcp/ — repo + docker-compose
#
# What it does:
# 1. Sanity (docker, git, compose v2 on PATH).
# 2. Verify required secrets are present in .env (does NOT generate them
# — Mailgun key + bearer key must be filled in by hand).
# 3. Auto-pick a free host port (9080-9099); persist to .env so re-deploys
# keep the same port.
# 4. Render deploy/apache-mg-mcp.conf from .tmpl with the chosen port.
# 5. git pull (--no-pull to skip).
# 6. docker compose build && up -d (--no-build to skip).
# 7. Poll /api/health until ready.
# 8. Print URLs + apache-reload reminder.
#
# Usage:
# ./deploy/deploy.sh # full deploy
# ./deploy/deploy.sh --no-pull # skip git pull
# ./deploy/deploy.sh --no-build # skip docker rebuild
# ./deploy/deploy.sh --logs # tail app logs after deploy
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPOSE_PROJECT="mg-mcp"
URL_PATH="/mg-mcp"
cd "$REPO_ROOT"
log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; }
ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*"; }
DO_PULL=1
DO_BUILD=1
TAIL_LOGS=0
for arg in "$@"; do
case "$arg" in
--no-pull) DO_PULL=0 ;;
--no-build) DO_BUILD=0 ;;
--logs) TAIL_LOGS=1 ;;
--help|-h)
sed -n '2,/^set/p' "$0" | grep -E '^# ' | sed 's/^# //'
exit 0
;;
*)
err "Unknown flag: $arg (try --help)"
exit 2
;;
esac
done
# 1. Sanity
[[ -f docker-compose.yml ]] || { err "docker-compose.yml not found in $REPO_ROOT"; exit 1; }
command -v docker >/dev/null 2>&1 || { err "docker not on PATH"; exit 1; }
command -v git >/dev/null 2>&1 || { err "git not on PATH"; exit 1; }
docker compose version >/dev/null 2>&1 || { err "docker compose v2 not found"; exit 1; }
unset COMPOSE_PROFILES
# ---------- helpers ----------
port_in_use() {
local port=$1
local pid=""
if command -v lsof >/dev/null 2>&1; then
pid=$( { lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true; } | awk 'NR>1 {print $2}' | head -1 )
else
pid=$( { ss -ltnp "sport = :$port" 2>/dev/null || true; } | awk -F'pid=' 'NR>1 {print $2}' | cut -d, -f1 | head -1 )
fi
[[ -n "$pid" ]]
}
# Echo the compose project name of the docker container publishing $port,
# or empty if no docker container holds it.
port_owner_project() {
local port=$1
docker ps --format '{{.Label "com.docker.compose.project"}}|{{.Ports}}' 2>/dev/null \
| awk -F'|' -v p=":${port}->" '$2 ~ p {print $1; exit}'
}
# True if port is free for us: nothing listening, OR our own compose project
# already publishes it (so re-deploys don't shuffle ports).
port_available_for_us() {
local port=$1
if ! port_in_use "$port"; then return 0; fi
[[ "$(port_owner_project "$port")" == "$COMPOSE_PROJECT" ]]
}
find_free_port() {
local preferred=$1
local start=$2
local end=$3
if port_available_for_us "$preferred"; then
printf '%s' "$preferred"
return 0
fi
local p
for ((p=start; p<=end; p++)); do
if port_available_for_us "$p"; then
printf '%s' "$p"
return 0
fi
done
return 1
}
set_env_var() {
local key=$1
local value=$2
local file="${REPO_ROOT}/.env"
if grep -q "^${key}=" "$file" 2>/dev/null; then
sed -i.bak "s#^${key}=.*#${key}=${value}#" "$file"
rm -f "${file}.bak"
else
printf '%s=%s\n' "$key" "$value" >> "$file"
fi
}
get_env_var() {
grep -E "^${1}=" "${REPO_ROOT}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true
}
# ---------- 2. Verify secrets ----------
if [[ ! -f "${REPO_ROOT}/.env" ]]; then
err ".env not found. Copy .env.example → .env and fill in MAILGUN_API_KEY + MCP_BEARER_KEY first."
exit 1
fi
MISSING=()
for key in MAILGUN_API_KEY MAILGUN_DOMAIN MAILGUN_FROM MCP_BEARER_KEY; do
val=$(get_env_var "$key")
if [[ -z "$val" || "$val" == replace-with-* ]]; then
MISSING+=("$key")
fi
done
if (( ${#MISSING[@]} > 0 )); then
err "These .env values are missing or still set to placeholder:"
for k in "${MISSING[@]}"; do err " - $k"; done
err "Edit ${REPO_ROOT}/.env and re-run."
exit 1
fi
# ---------- 3. Pick port ----------
DEFAULT_APP_PORT=9080
APP_PORT=$(get_env_var MG_MCP_PORT); APP_PORT=${APP_PORT:-$DEFAULT_APP_PORT}
PREV_APP_PORT="$APP_PORT"
log "Resolving host port (preferred: $APP_PORT)…"
NEW_APP_PORT=$(find_free_port "$APP_PORT" 9080 9099) || NEW_APP_PORT=""
if [[ -z "$NEW_APP_PORT" ]]; then
err "Could not find a free port in 9080-9099."
exit 1
fi
[[ "$NEW_APP_PORT" != "$APP_PORT" ]] && warn "port $APP_PORT taken by another app → using $NEW_APP_PORT"
APP_PORT=$NEW_APP_PORT
set_env_var MG_MCP_PORT "$APP_PORT"
ok "Port: $APP_PORT (persisted to .env)"
# ---------- 4. Render apache-mg-mcp.conf from template ----------
APACHE_TMPL="$REPO_ROOT/deploy/apache-mg-mcp.conf.tmpl"
APACHE_CONF="$REPO_ROOT/deploy/apache-mg-mcp.conf"
if [[ -f "$APACHE_TMPL" ]]; then
sed "s#__APP_PORT__#${APP_PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF"
ok "Rendered apache-mg-mcp.conf with app port $APP_PORT"
else
err "apache-mg-mcp.conf.tmpl missing — cannot render Apache config."
exit 1
fi
# ---------- 5. git pull ----------
if (( DO_PULL )); then
log "git pull origin main"
git pull --ff-only origin main
fi
# ---------- 6. Docker build + up ----------
if (( DO_BUILD )); then
log "docker compose build"
docker compose build
fi
log "docker compose up -d"
docker compose up -d
# ---------- 7. Health poll ----------
log "Waiting for /api/health on :$APP_PORT (max 60s)…"
for i in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
ok "Healthy"
break
fi
sleep 2
if (( i == 30 )); then
err "App did not become healthy within 60s. Recent logs:"
docker compose logs app --tail 60 || true
exit 1
fi
done
# ---------- 8. Report ----------
ok "Deploy complete."
echo
echo " Local health: http://127.0.0.1:${APP_PORT}/api/health"
echo " Public URL: https://optical-dev.oliver.solutions${URL_PATH}/"
echo " MCP endpoint: https://optical-dev.oliver.solutions${URL_PATH}/mcp"
echo " Port: $APP_PORT"
echo
echo " Apache include line for the merged vhost:"
echo " Include $REPO_ROOT/deploy/apache-mg-mcp.conf"
if [[ "$APP_PORT" != "$PREV_APP_PORT" ]] || ! grep -qF "$REPO_ROOT/deploy/apache-mg-mcp.conf" /etc/apache2/sites-enabled/*.conf 2>/dev/null; then
echo
warn "App port changed (or first deploy). Reload Apache to pick up the new ProxyPass:"
echo " sudo apachectl configtest && sudo systemctl reload apache2"
fi
echo
if (( TAIL_LOGS )); then
log "Tailing app logs (Ctrl-C to stop)…"
docker compose logs -f app
fi