From 7765824c70ed86084e49a59c92e34f6db163d01e Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Fri, 24 Apr 2026 17:35:23 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20setup.sh=20=E2=80=94=20zero-confi?= =?UTF-8?q?g=20interactive=20installer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces manual .env editing with a guided setup script. Auto-discovers: - Odoo container name and database (via `docker ps` + odoo.conf inspection) - Ollama endpoint (scans 192.168.2.10/9, 192.168.2.1, localhost on port 11434) - Ollama model list (lets user pick from available, auto-selects if only one) Auto-generates (idempotent — preserves on re-run): - POSTGRES_PASSWORD (openssl rand -hex 24) - AGENT_API_KEY (openssl rand -hex 32) - WEBHOOK_SECRET (openssl rand -hex 32) Postgres constants always written correctly: - POSTGRES_HOST=agent-db, POSTGRES_DB=activeblue_ai, POSTGRES_USER=activeblue (fixes previous issue where these were blank or wrong in .env) Odoo API key: - Attempts auto-creation via Odoo HTTP session + res.users.apikeys.description wizard (works on Odoo 16/17/18 with valid admin credentials) - Falls back to clear manual instructions + paste prompt on failure Writes .env with chmod 600. Offers to `docker compose up -d` when done. Usage: cd /root/odoo/odoo-ai bash setup.sh Co-Authored-By: Claude Sonnet 4.6 --- setup.sh | 388 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 setup.sh diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..d61e927 --- /dev/null +++ b/setup.sh @@ -0,0 +1,388 @@ +#!/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" + docker compose up -d + echo "" + info "Stack started. Tailing logs (Ctrl-C to stop):" + echo "" + docker compose logs -f agent-service +fi