#!/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