Files
odoo-ai/setup.sh
Carlos Garcia 6d15859779 fix(setup.sh): detect stale DB volume, run Alembic migrations on startup
- Before bringing up the stack, check if the agent-db volume exists but
  is missing the expected database (left from a previous broken run with
  wrong POSTGRES_DB). Offer to wipe and re-init automatically.
- After docker compose up, wait for pg_isready then run
  `alembic upgrade head` inside the agent container so tables are created
  before the agent attempts to use them.
- Restart agent-service after migrations so it connects to a fully
  initialized database on its first attempt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:39:51 -04:00

429 lines
19 KiB
Bash

#!/usr/bin/env bash
# setup.sh — Configure ActiveBlue AI agent service
#
# Run once (or re-run to update) from /root/odoo/odoo-ai/ on the miaai host.
# Auto-discovers: Odoo container/DB, Ollama URL/model, Postgres service name.
# Auto-generates: Postgres password, AGENT_API_KEY, WEBHOOK_SECRET.
# Preserves : all secrets on re-run (never regenerates existing values).
# Prompts only : when Odoo API key can't be auto-created.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
# ── colours ────────────────────────────────────────────────────────────────────
G='\033[0;32m' Y='\033[1;33m' R='\033[0;31m' B='\033[0;34m' N='\033[0m'
info() { echo -e "${G}[✓]${N} $*"; }
warn() { echo -e "${Y}[!]${N} $*"; }
ask() { echo -e "${B}[?]${N} $*"; }
die() { echo -e "${R}[✗]${N} $*" >&2; exit 1; }
gen() { openssl rand -hex "${1:-32}"; }
# ── load existing .env (preserves secrets on re-run) ──────────────────────────
declare -A E=()
if [[ -f "$ENV_FILE" ]]; then
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// }" ]] && continue
key="${line%%=*}"
val="${line#*=}"
E["$key"]="$val"
done < "$ENV_FILE"
info "Loaded existing .env — secrets will be preserved"
fi
# Helper: return existing value or empty
ex() { echo "${E[$1]:-}"; }
echo ""
echo "┌─────────────────────────────────────────────┐"
echo "│ ActiveBlue AI Agent — Setup │"
echo "└─────────────────────────────────────────────┘"
echo ""
# ══════════════════════════════════════════════════════════════════════════════
# 1. POSTGRESQL
# ══════════════════════════════════════════════════════════════════════════════
info "Step 1/4 PostgreSQL"
POSTGRES_HOST="agent-db" # always the Docker service name on activeblue-net
POSTGRES_PORT="5432"
POSTGRES_DB="activeblue_ai"
POSTGRES_USER="activeblue"
if [[ -n "$(ex POSTGRES_PASSWORD)" ]]; then
POSTGRES_PASSWORD="$(ex POSTGRES_PASSWORD)"
info " password : (existing, preserved)"
else
POSTGRES_PASSWORD="$(gen 24)"
info " password : (generated)"
fi
POSTGRES_POOL_MIN="${E[POSTGRES_POOL_MIN]:-2}"
POSTGRES_POOL_MAX="${E[POSTGRES_POOL_MAX]:-10}"
echo " host=${POSTGRES_HOST} db=${POSTGRES_DB} user=${POSTGRES_USER}"
# ══════════════════════════════════════════════════════════════════════════════
# 2. ODOO
# ══════════════════════════════════════════════════════════════════════════════
info "Step 2/4 Odoo"
# ── 2a. URL — find running Odoo container ─────────────────────────────────────
ODOO_CONTAINER=""
while IFS= read -r name; do
[[ -z "$name" ]] && continue
ODOO_CONTAINER="$name"
break
done < <(docker ps --filter name=odoo-web --format '{{.Names}}' 2>/dev/null)
if [[ -n "$ODOO_CONTAINER" ]]; then
ODOO_URL="http://${ODOO_CONTAINER}:8069"
info " container : $ODOO_CONTAINER$ODOO_URL"
elif [[ -n "$(ex ODOO_URL)" ]]; then
ODOO_URL="$(ex ODOO_URL)"
info " URL : $ODOO_URL (from existing .env)"
else
warn " No running Odoo container found"
ask " Odoo internal URL [http://odoo-web-1:8069]:"
read -rp " > " ODOO_URL
ODOO_URL="${ODOO_URL:-http://odoo-web-1:8069}"
fi
# ── 2b. Database name ─────────────────────────────────────────────────────────
ODOO_DB="$(ex ODOO_DB)"
if [[ -z "$ODOO_DB" && -n "$ODOO_CONTAINER" ]]; then
# Try reading from odoo.conf inside the container
ODOO_DB="$(docker exec "$ODOO_CONTAINER" \
grep -oP '(?i)(?<=^db_name\s=\s).*' /etc/odoo/odoo.conf 2>/dev/null \
| head -1 | tr -d '[:space:]' || true)"
fi
if [[ -z "$ODOO_DB" ]]; then
# Ask Odoo's database list endpoint
ODOO_DB="$(curl -sf --connect-timeout 3 \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"call","id":1,"params":{}}' \
"${ODOO_URL}/web/database/list" 2>/dev/null \
| grep -oP '"result":\["\K[^"]+' | head -1 || true)"
fi
if [[ -z "$ODOO_DB" ]]; then
ask " Odoo database name [odoo]:"
read -rp " > " ODOO_DB
ODOO_DB="${ODOO_DB:-odoo}"
fi
info " database : $ODOO_DB"
ODOO_CALLBACK_URL="${E[ODOO_CALLBACK_URL]:-${ODOO_URL}/activeblue_ai/result}"
# ── 2c. API key — auto-create via Odoo HTTP session ───────────────────────────
ODOO_API_KEY="$(ex ODOO_API_KEY)"
if [[ -z "$ODOO_API_KEY" ]]; then
info " API key : attempting auto-creation..."
ask " Odoo admin username [admin]:"
read -rp " > " _ODOO_USER
_ODOO_USER="${_ODOO_USER:-admin}"
ask " Odoo admin password:"
read -rsp " > " _ODOO_PASS; echo
ODOO_API_KEY="$(python3 - "$ODOO_URL" "$ODOO_DB" "$_ODOO_USER" "$_ODOO_PASS" 2>/dev/null <<'PYEOF' || true
import json, sys, urllib.request, urllib.error
url, db, user, pwd = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor())
def rpc(path, payload):
body = json.dumps(payload).encode()
req = urllib.request.Request(
f"{url}{path}", data=body,
headers={"Content-Type": "application/json"})
with opener.open(req, timeout=10) as r:
return json.loads(r.read())
# Authenticate
r = rpc("/web/session/authenticate", {
"jsonrpc": "2.0", "method": "call", "id": 1,
"params": {"db": db, "login": user, "password": pwd}})
uid = (r.get("result") or {}).get("uid")
if not uid:
sys.exit(1)
# Create an API-key description record (Odoo 16/17/18 transient wizard)
r2 = rpc("/web/dataset/call_kw", {
"jsonrpc": "2.0", "method": "call", "id": 2,
"params": {
"model": "res.users.apikeys.description",
"method": "create",
"args": [[{"name": "activeblue-ai-agent"}]],
"kwargs": {"context": {}}}})
desc_id = r2.get("result")
if not desc_id:
sys.exit(1)
# Generate the key
r3 = rpc("/web/dataset/call_kw", {
"jsonrpc": "2.0", "method": "call", "id": 3,
"params": {
"model": "res.users.apikeys.description",
"method": "make_key",
"args": [[desc_id]],
"kwargs": {"context": {}}}})
result = r3.get("result") or {}
key = (result.get("api_key")
or result.get("key")
or (result.get("value") or {}).get("api_key")
or "")
if key:
print(key)
PYEOF
)"
if [[ -n "$ODOO_API_KEY" ]]; then
info " API key : created automatically"
else
warn " Auto-creation failed — create one manually:"
echo ""
echo " 1. Open ${ODOO_URL}/odoo/settings → enable Developer Mode"
echo " 2. Settings → Technical → API Keys → New"
echo " 3. Name: activeblue-ai-agent → Generate → copy the key"
echo ""
ask " Paste the API key:"
read -rp " > " ODOO_API_KEY
[[ -z "$ODOO_API_KEY" ]] && die "ODOO_API_KEY is required"
fi
else
info " API key : (existing, preserved)"
fi
# ══════════════════════════════════════════════════════════════════════════════
# 3. OLLAMA
# ══════════════════════════════════════════════════════════════════════════════
info "Step 3/4 Ollama"
OLLAMA_URL="$(ex OLLAMA_URL)"
if [[ -z "$OLLAMA_URL" ]]; then
info " scanning common addresses..."
for _host in 192.168.2.10 192.168.2.9 192.168.2.1 127.0.0.1; do
if curl -sf --connect-timeout 2 "http://${_host}:11434/api/tags" >/dev/null 2>&1; then
OLLAMA_URL="http://${_host}:11434"
info " found : $OLLAMA_URL"
break
fi
done
fi
if [[ -z "$OLLAMA_URL" ]]; then
warn " Ollama not found on common addresses"
ask " Ollama URL [http://192.168.2.10:11434]:"
read -rp " > " OLLAMA_URL
OLLAMA_URL="${OLLAMA_URL:-http://192.168.2.10:11434}"
fi
# Verify reachability and get model list
OLLAMA_MODEL="$(ex OLLAMA_MODEL)"
_TAGS="$(curl -sf --connect-timeout 3 "${OLLAMA_URL}/api/tags" 2>/dev/null || true)"
if [[ -n "$_TAGS" ]]; then
mapfile -t _MODELS < <(echo "$_TAGS" | grep -oP '"name":"\K[^"]+' 2>/dev/null || true)
if [[ ${#_MODELS[@]} -eq 0 ]]; then
warn " Ollama reachable but no models pulled"
echo " Run on the Ollama host: ollama pull llama3.1:8b"
OLLAMA_MODEL="${OLLAMA_MODEL:-llama3.1:8b}"
elif [[ -n "$OLLAMA_MODEL" ]]; then
info " model : $OLLAMA_MODEL (existing, preserved)"
elif [[ ${#_MODELS[@]} -eq 1 ]]; then
OLLAMA_MODEL="${_MODELS[0]}"
info " model : $OLLAMA_MODEL (only model available)"
else
info " available models:"
for i in "${!_MODELS[@]}"; do
printf " [%d] %s\n" $((i+1)) "${_MODELS[$i]}"
done
ask " Select model number [1]:"
read -rp " > " _CHOICE
_CHOICE="${_CHOICE:-1}"
if [[ "$_CHOICE" =~ ^[0-9]+$ ]] && (( _CHOICE >= 1 && _CHOICE <= ${#_MODELS[@]} )); then
OLLAMA_MODEL="${_MODELS[$((_CHOICE-1))]}"
else
OLLAMA_MODEL="${_MODELS[0]}"
fi
info " model : $OLLAMA_MODEL"
fi
else
warn " Ollama unreachable at $OLLAMA_URL (check it's running and bound to 0.0.0.0)"
OLLAMA_MODEL="${OLLAMA_MODEL:-llama3.1:8b}"
fi
OLLAMA_TIMEOUT="${E[OLLAMA_TIMEOUT]:-120}"
OLLAMA_MAX_CONCURRENT="${E[OLLAMA_MAX_CONCURRENT]:-2}"
# ══════════════════════════════════════════════════════════════════════════════
# 4. SERVICE SECRETS & MISC
# ══════════════════════════════════════════════════════════════════════════════
info "Step 4/4 Service secrets"
if [[ -n "$(ex AGENT_API_KEY)" ]]; then
AGENT_API_KEY="$(ex AGENT_API_KEY)"
info " AGENT_API_KEY : (existing)"
else
AGENT_API_KEY="$(gen 32)"
info " AGENT_API_KEY : (generated)"
fi
if [[ -n "$(ex WEBHOOK_SECRET)" ]]; then
WEBHOOK_SECRET="$(ex WEBHOOK_SECRET)"
info " WEBHOOK_SECRET : (existing)"
else
WEBHOOK_SECRET="$(gen 32)"
info " WEBHOOK_SECRET : (generated)"
fi
LLM_PRIVACY_MODE="${E[LLM_PRIVACY_MODE]:-local}"
AGENT_SERVICE_PORT="${E[AGENT_SERVICE_PORT]:-8001}"
ANTHROPIC_API_KEY="${E[ANTHROPIC_API_KEY]:-}"
CLAUDE_MODEL="${E[CLAUDE_MODEL]:-claude-sonnet-4-6}"
CLAUDE_TIMEOUT="${E[CLAUDE_TIMEOUT]:-120}"
CLAUDE_MAX_CONCURRENT="${E[CLAUDE_MAX_CONCURRENT]:-2}"
LOG_LEVEL="${E[LOG_LEVEL]:-INFO}"
LOG_FORMAT="${E[LOG_FORMAT]:-json}"
DISPATCH_RATE_LIMIT_PER_USER="${E[DISPATCH_RATE_LIMIT_PER_USER]:-10}"
DISPATCH_RATE_WINDOW_SECONDS="${E[DISPATCH_RATE_WINDOW_SECONDS]:-60}"
ACCESS_CONTROL_ENABLED="${E[ACCESS_CONTROL_ENABLED]:-true}"
DIRECTIVE_TIMEOUT_MINUTES="${E[DIRECTIVE_TIMEOUT_MINUTES]:-10}"
LOKI_URL="${E[LOKI_URL]:-}"
ALLOWED_CALLBACK_IP="${E[ALLOWED_CALLBACK_IP]:-}"
# Per-agent backend overrides (empty = use LLM_PRIVACY_MODE default)
for _AGENT in FINANCE ACCOUNTING CRM SALES PROJECT ELEARNING EXPENSES EMPLOYEES; do
eval "AGENT_BACKEND_${_AGENT}=\"\${E[AGENT_BACKEND_${_AGENT}]:-}\""
done
# ══════════════════════════════════════════════════════════════════════════════
# WRITE .env
# ══════════════════════════════════════════════════════════════════════════════
cat > "$ENV_FILE" << EOF
# Generated by setup.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)
# Re-run ./setup.sh to update — existing secrets are preserved automatically.
# ── PostgreSQL (agent DB) ───────────────────────────────────────────────────
POSTGRES_HOST=${POSTGRES_HOST}
POSTGRES_PORT=${POSTGRES_PORT}
POSTGRES_DB=${POSTGRES_DB}
POSTGRES_USER=${POSTGRES_USER}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_POOL_MIN=${POSTGRES_POOL_MIN}
POSTGRES_POOL_MAX=${POSTGRES_POOL_MAX}
# ── Odoo ────────────────────────────────────────────────────────────────────
ODOO_URL=${ODOO_URL}
ODOO_DB=${ODOO_DB}
ODOO_API_KEY=${ODOO_API_KEY}
ODOO_CALLBACK_URL=${ODOO_CALLBACK_URL}
# ── Ollama ──────────────────────────────────────────────────────────────────
OLLAMA_URL=${OLLAMA_URL}
OLLAMA_MODEL=${OLLAMA_MODEL}
OLLAMA_TIMEOUT=${OLLAMA_TIMEOUT}
OLLAMA_MAX_CONCURRENT=${OLLAMA_MAX_CONCURRENT}
# ── Privacy mode (HIPAA — keep 'local' for patient-adjacent data) ────────────
# local = Ollama only | hybrid = Ollama default, Claude per-agent | cloud = Claude only
LLM_PRIVACY_MODE=${LLM_PRIVACY_MODE}
# ── Anthropic Claude (only used when LLM_PRIVACY_MODE != local) ─────────────
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
CLAUDE_MODEL=${CLAUDE_MODEL}
CLAUDE_TIMEOUT=${CLAUDE_TIMEOUT}
CLAUDE_MAX_CONCURRENT=${CLAUDE_MAX_CONCURRENT}
# ── Per-agent backend overrides (ollama|claude, empty = use LLM_PRIVACY_MODE)
AGENT_BACKEND_MASTER=${E[AGENT_BACKEND_MASTER]:-}
AGENT_BACKEND_FINANCE=${AGENT_BACKEND_FINANCE}
AGENT_BACKEND_ACCOUNTING=${AGENT_BACKEND_ACCOUNTING}
AGENT_BACKEND_CRM=${AGENT_BACKEND_CRM}
AGENT_BACKEND_SALES=${AGENT_BACKEND_SALES}
AGENT_BACKEND_PROJECT=${AGENT_BACKEND_PROJECT}
AGENT_BACKEND_ELEARNING=${AGENT_BACKEND_ELEARNING}
AGENT_BACKEND_EXPENSES=${AGENT_BACKEND_EXPENSES}
AGENT_BACKEND_EMPLOYEES=${AGENT_BACKEND_EMPLOYEES}
# ── Agent service ───────────────────────────────────────────────────────────
AGENT_SERVICE_PORT=${AGENT_SERVICE_PORT}
AGENT_API_KEY=${AGENT_API_KEY}
WEBHOOK_SECRET=${WEBHOOK_SECRET}
ALLOWED_CALLBACK_IP=${ALLOWED_CALLBACK_IP}
# ── Logging ─────────────────────────────────────────────────────────────────
LOG_LEVEL=${LOG_LEVEL}
LOG_FORMAT=${LOG_FORMAT}
LOKI_URL=${LOKI_URL}
# ── Rate limiting ───────────────────────────────────────────────────────────
DISPATCH_RATE_LIMIT_PER_USER=${DISPATCH_RATE_LIMIT_PER_USER}
DISPATCH_RATE_WINDOW_SECONDS=${DISPATCH_RATE_WINDOW_SECONDS}
ACCESS_CONTROL_ENABLED=${ACCESS_CONTROL_ENABLED}
DIRECTIVE_TIMEOUT_MINUTES=${DIRECTIVE_TIMEOUT_MINUTES}
EOF
chmod 600 "$ENV_FILE"
echo ""
echo "┌─────────────────────────────────────────────┐"
echo "│ Setup complete │"
echo "└─────────────────────────────────────────────┘"
echo ""
echo " Postgres : postgresql://${POSTGRES_USER}:***@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
echo " Odoo : ${ODOO_URL} db=${ODOO_DB}"
echo " Ollama : ${OLLAMA_URL} model=${OLLAMA_MODEL}"
echo " Privacy : ${LLM_PRIVACY_MODE}"
echo ""
# ── Optional: bring up the stack now ──────────────────────────────────────────
ask "Start the agent stack now? [Y/n]"
read -rp " > " _START
if [[ "${_START:-Y}" =~ ^[Yy] ]]; then
cd "$SCRIPT_DIR"
# If the DB volume exists but was initialized with a different POSTGRES_DB,
# the database won't exist inside it. Detect this and offer a clean wipe.
_VOL="$(docker volume ls -q --filter name=odoo-ai_agent-db-data 2>/dev/null | head -1)"
if [[ -n "$_VOL" ]]; then
# Spin up just the DB to check
docker compose up -d agent-db >/dev/null 2>&1
sleep 3
if ! docker exec activeblue-agent-db psql -U "$POSTGRES_USER" -lqt 2>/dev/null \
| cut -d'|' -f1 | grep -qw "$POSTGRES_DB"; then
warn "DB volume exists but database '${POSTGRES_DB}' is missing (left from a previous broken run)"
ask " Wipe the agent-db volume and re-init? [Y/n]"
read -rp " > " _WIPE
if [[ "${_WIPE:-Y}" =~ ^[Yy] ]]; then
docker compose down -v
info " Volume wiped"
fi
fi
fi
docker compose up -d
info "Waiting for agent-db to be ready..."
for _i in $(seq 1 15); do
if docker exec activeblue-agent-db pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
>/dev/null 2>&1; then
break
fi
sleep 2
done
# Run Alembic migrations so tables exist before the agent needs them
info "Running database migrations..."
if docker exec activeblue-agent \
alembic -c agent_service/migrations/alembic.ini upgrade head 2>&1; then
info "Migrations applied"
else
warn "Migration command failed — tables may be missing. Check alembic output above."
fi
# Restart agent so it picks up a healthy DB on its first connect attempt
docker compose restart agent-service
echo ""
info "Stack started. Tailing logs (Ctrl-C to stop):"
echo ""
docker compose logs -f agent-service
fi