#!/usr/bin/env bash # ============================================================================= # ml-projects bootstrap # # Provisions a fresh CPU-only machine with: # - dashboard (monitoring + runtime config, PostgreSQL-backed) # - web (search + gather, tier-based routing) # # Assumes you already have: # - SearXNG running (or will deploy ./modules/web/deploy/metasearch) # - An LLM endpoint on a GPU machine (vLLM / llama.cpp) reachable by URL # # Usage: # ./bootstrap.sh # interactive (prompts for everything) # ./bootstrap.sh --non-interactive # no prompts, uses existing .env files # ./bootstrap.sh --help # ============================================================================= set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" cd "$REPO_ROOT" # ---------- colors ---------- if [[ -t 1 ]]; then C_RESET='\033[0m' C_BOLD='\033[1m' C_RED='\033[31m' C_GREEN='\033[32m' C_YELLOW='\033[33m' C_BLUE='\033[34m' C_CYAN='\033[36m' else C_RESET='' C_BOLD='' C_RED='' C_GREEN='' C_YELLOW='' C_BLUE='' C_CYAN='' fi step() { printf "\n${C_BOLD}${C_BLUE}==>${C_RESET} ${C_BOLD}%s${C_RESET}\n" "$1"; } info() { printf " ${C_CYAN}%s${C_RESET}\n" "$1"; } ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$1"; } warn() { printf " ${C_YELLOW}⚠${C_RESET} %s\n" "$1"; } fail() { printf " ${C_RED}✗${C_RESET} %s\n" "$1" >&2; } die() { fail "$1"; exit 1; } # ---------- args ---------- INTERACTIVE=1 DEPLOY_SEARXNG=0 SKIP_SMOKE=0 for arg in "$@"; do case "$arg" in --non-interactive) INTERACTIVE=0 ;; --deploy-searxng) DEPLOY_SEARXNG=1 ;; --skip-smoke) SKIP_SMOKE=1 ;; --help|-h) cat </dev/null 2>&1 || die "docker not found" docker compose version >/dev/null 2>&1 || die "docker compose v2 not found" ok "docker $(docker --version | awk '{print $3}' | tr -d ',')" ok "docker compose $(docker compose version --short)" if ! docker info >/dev/null 2>&1; then die "docker daemon not running or current user has no access" fi ok "docker daemon reachable" # ---------- network ---------- step "Ensuring 'deploy_default' Docker network exists" if docker network inspect deploy_default >/dev/null 2>&1; then ok "deploy_default network exists" else docker network create deploy_default >/dev/null ok "deploy_default network created" fi # ---------- helpers ---------- gen_password() { # URL-safe 24-char password if command -v openssl >/dev/null 2>&1; then openssl rand -base64 24 | tr -d '/+=' | head -c 24 else head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24 fi } prompt() { # prompt VAR_NAME "Prompt text" "default" local var="$1" label="$2" default="${3:-}" answer="" if [[ $INTERACTIVE -eq 0 ]]; then return 0 fi if [[ -n "$default" ]]; then read -rp " $label [$default]: " answer answer="${answer:-$default}" else read -rp " $label: " answer fi eval "$var=\"\$answer\"" } prompt_secret() { # same as prompt but stdin is not echoed local var="$1" label="$2" answer="" if [[ $INTERACTIVE -eq 0 ]]; then return 0 fi read -rsp " $label: " answer; echo eval "$var=\"\$answer\"" } render_env() { # render_env # Rewrites lines matching ^#?\s*KEY= to KEY=VALUE. Leaves other lines alone. local example="$1" output="$2" shift 2 cp "$example" "$output" for pair in "$@"; do local key="${pair%%=*}" local val="${pair#*=}" # Escape for sed replacement local esc esc=$(printf '%s' "$val" | sed 's/[&/\]/\\&/g') # Match either "KEY=..." or "# KEY=..." if grep -qE "^#?\s*${key}=" "$output"; then sed -i -E "s|^#?\s*${key}=.*|${key}=${esc}|" "$output" else printf '\n%s=%s\n' "$key" "$val" >> "$output" fi done } # ---------- collect config ---------- WEB_ENV_PATH="$REPO_ROOT/modules/web/deploy/.env" DASHBOARD_ENV_PATH="$REPO_ROOT/modules/dashboard/deploy/.env" if [[ $INTERACTIVE -eq 1 ]]; then step "Collecting configuration" info "Press Enter to accept defaults. Leave paid API keys blank to skip." echo # LLM on the GPU machine prompt LLM_HOST "GPU host IP or hostname (e.g. 10.11.10.42)" "localhost" prompt LLM_PORT "LLM inference API port" "14011" prompt VLLM_PORT "Raw vLLM port (for dashboard health checks)" "14001" LLM_URL="http://${LLM_HOST}:${LLM_PORT}" VLLM_URL="http://${LLM_HOST}:${VLLM_PORT}" ok "LLM URL: $LLM_URL" ok "vLLM URL: $VLLM_URL" # SearXNG echo if [[ $DEPLOY_SEARXNG -eq 1 ]]; then SEARXNG_HOST="didiAI-web-searxng" SEARXNG_PORT="8080" info "Will deploy SearXNG internally ($SEARXNG_HOST:$SEARXNG_PORT)" else prompt SEARXNG_HOST "SearXNG container name or host" "didiAI-web-searxng" prompt SEARXNG_PORT "SearXNG port" "8080" fi SEARXNG_URL="http://${SEARXNG_HOST}:${SEARXNG_PORT}" ok "SearXNG URL: $SEARXNG_URL" # External URLs echo prompt WEB_EXT "Public-facing Web API URL" "http://localhost:51100" prompt DASH_EXT "Public-facing Dashboard URL" "http://localhost:51300" # Paid provider keys echo info "Premium tier API keys (leave blank to skip):" prompt_secret SERPAPI_KEY "SerpAPI key (blank to skip)" prompt_secret TAVILY_KEY "Tavily key" prompt_secret BRAVE_KEY "Brave key" prompt_secret LINKUP_KEY "LinkUp key" prompt_secret OPENROUTER_KEY "OpenRouter key" # DB password echo prompt USE_RANDOM_DB_PW "Generate random Postgres password? (y/n)" "y" if [[ "${USE_RANDOM_DB_PW,,}" == "y" ]]; then DB_PASSWORD="$(gen_password)" ok "Generated DB password: $DB_PASSWORD" else prompt_secret DB_PASSWORD "Postgres password for dashboard DB" fi else step "Non-interactive mode — using existing .env files" [[ -f "$WEB_ENV_PATH" ]] || die "Missing $WEB_ENV_PATH" [[ -f "$DASHBOARD_ENV_PATH" ]] || die "Missing $DASHBOARD_ENV_PATH" ok "Existing .env files found" fi # ---------- render .env files (interactive only) ---------- if [[ $INTERACTIVE -eq 1 ]]; then step "Rendering .env files" render_env \ "$REPO_ROOT/modules/web/.env.example" \ "$WEB_ENV_PATH" \ "WEB_SEARXNG_BASE_URL=$SEARXNG_URL" \ "WEB_EXTERNAL_URL=$WEB_EXT" \ "WEB_LLM_BASE_URL=$LLM_URL" \ "WEB_VISION_BASE_URL=$LLM_URL" \ "WEB_DASHBOARD_URL=http://didiAI-dashboard:51300" \ ${SERPAPI_KEY:+"WEB_SERPAPI_API_KEY=$SERPAPI_KEY"} \ ${TAVILY_KEY:+"WEB_TAVILY_API_KEY=$TAVILY_KEY"} \ ${BRAVE_KEY:+"WEB_BRAVE_API_KEY=$BRAVE_KEY"} \ ${LINKUP_KEY:+"WEB_LINKUP_API_KEY=$LINKUP_KEY"} \ ${OPENROUTER_KEY:+"WEB_OPENROUTER_API_KEY=$OPENROUTER_KEY"} ok "Wrote $WEB_ENV_PATH" render_env \ "$REPO_ROOT/modules/dashboard/.env.example" \ "$DASHBOARD_ENV_PATH" \ "DASHBOARD_DB_PASSWORD=$DB_PASSWORD" \ "DASHBOARD_EXTERNAL_URL=$DASH_EXT" \ "DASHBOARD_SEARXNG_URL=$SEARXNG_URL" \ "DASHBOARD_LLM_API_URL=$LLM_URL" \ "DASHBOARD_VLLM_QWEN_URL=$VLLM_URL" \ ${SERPAPI_KEY:+"DASHBOARD_SERPAPI_API_KEY=$SERPAPI_KEY"} \ ${TAVILY_KEY:+"DASHBOARD_TAVILY_API_KEY=$TAVILY_KEY"} \ ${BRAVE_KEY:+"DASHBOARD_BRAVE_API_KEY=$BRAVE_KEY"} \ ${LINKUP_KEY:+"DASHBOARD_LINKUP_API_KEY=$LINKUP_KEY"} \ ${OPENROUTER_KEY:+"DASHBOARD_OPENROUTER_API_KEY=$OPENROUTER_KEY"} ok "Wrote $DASHBOARD_ENV_PATH" fi # ---------- optional SearXNG ---------- if [[ $DEPLOY_SEARXNG -eq 1 ]]; then step "Deploying SearXNG" if [[ -d "$REPO_ROOT/modules/web/deploy/metasearch" ]]; then (cd "$REPO_ROOT/modules/web/deploy/metasearch" && docker compose up -d) ok "SearXNG stack up" else warn "modules/web/deploy/metasearch not found — skipping" fi fi # ---------- deploy dashboard ---------- step "Deploying dashboard (PostgreSQL + UI on port 51300)" (cd "$REPO_ROOT/modules/dashboard/deploy" && docker compose --profile dashboard up -d --build) ok "Dashboard containers started" info "Waiting for dashboard to become healthy..." for i in $(seq 1 30); do if curl -sfm 2 http://localhost:51300/health >/dev/null 2>&1; then ok "Dashboard healthy" break fi sleep 2 if [[ $i -eq 30 ]]; then fail "Dashboard did not become healthy within 60s" docker logs --tail 50 didiAI-dashboard || true exit 1 fi done # ---------- deploy web ---------- step "Deploying web-api (port 51100)" (cd "$REPO_ROOT/modules/web/deploy" && docker compose --profile api up -d --build) ok "Web API containers started" info "Waiting for web-api to become healthy..." for i in $(seq 1 30); do if curl -sfm 2 http://localhost:51100/health >/dev/null 2>&1; then ok "Web API healthy" break fi sleep 2 if [[ $i -eq 30 ]]; then fail "Web API did not become healthy within 60s" docker logs --tail 50 didiAI-web-api || true exit 1 fi done # ---------- create admin user ---------- ADMIN_TOKEN="" if [[ $INTERACTIVE -eq 1 ]]; then step "Creating dashboard admin user" prompt ADMIN_NAME "Admin username" "admin" prompt ADMIN_EMAIL "Admin email (optional)" "" set +e ADMIN_OUTPUT=$(docker exec -i didiAI-dashboard python -m dashboard.cli \ create-user "$ADMIN_NAME" \ ${ADMIN_EMAIL:+--email "$ADMIN_EMAIL"} \ --role admin 2>&1) ADMIN_RC=$? set -e if [[ $ADMIN_RC -eq 0 ]]; then ADMIN_TOKEN=$(echo "$ADMIN_OUTPUT" | grep -oE '[A-Za-z0-9_-]{32,}' | tail -1) ok "Admin user '$ADMIN_NAME' created" else warn "Admin user creation failed (maybe already exists). You can create one later:" warn " docker exec didiAI-dashboard python -m dashboard.cli create-user " fi fi # ---------- smoke tests ---------- if [[ $SKIP_SMOKE -eq 0 ]]; then step "Running smoke tests" # Dashboard if curl -sfm 3 http://localhost:51300/health | grep -q '"status":"healthy"'; then ok "Dashboard /health" else warn "Dashboard /health unexpected response" fi # Web API if curl -sfm 3 http://localhost:51100/health >/dev/null 2>&1; then ok "Web API /health" else warn "Web API /health failed" fi # Providers live endpoint PROV_COUNT=$(curl -sfm 10 http://localhost:51300/api/stats/providers 2>/dev/null | grep -oE '"name"' | wc -l || echo 0) if [[ $PROV_COUNT -gt 0 ]]; then ok "Dashboard sees $PROV_COUNT providers" else warn "Dashboard provider probe returned no data (LLM host may be unreachable)" fi # Free tier search — only if SearXNG is reachable FREE_CODE=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 15 \ -X POST http://localhost:51100/v1/search \ -H "Content-Type: application/json" \ -d '{"queries":["bootstrap test"],"max_results":3}' || echo "fail") if [[ "$FREE_CODE" == "200" ]]; then ok "Free tier search works" else warn "Free tier search returned $FREE_CODE (check SearXNG connectivity)" fi fi # ---------- summary ---------- step "Bootstrap complete" cat <