405 lines
14 KiB
Bash
405 lines
14 KiB
Bash
#!/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 <<EOF
|
|
Usage: $0 [OPTIONS]
|
|
|
|
Options:
|
|
--non-interactive Skip prompts. All .env files must already be filled.
|
|
--deploy-searxng Also deploy SearXNG from modules/web/deploy/metasearch.
|
|
By default, bootstrap assumes SearXNG is already running.
|
|
--skip-smoke Skip post-deploy smoke tests.
|
|
-h, --help Show this help and exit.
|
|
|
|
Examples:
|
|
$0 Full interactive bootstrap
|
|
$0 --deploy-searxng Also deploy SearXNG
|
|
$0 --non-interactive Just deploy using existing .env files
|
|
EOF
|
|
exit 0
|
|
;;
|
|
*) die "Unknown argument: $arg" ;;
|
|
esac
|
|
done
|
|
|
|
# ---------- prereqs ----------
|
|
step "Checking prerequisites"
|
|
|
|
command -v docker >/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 <example-path> <output-path> <VAR=VALUE ...>
|
|
# 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 <name>"
|
|
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 <<EOF
|
|
|
|
${C_BOLD}Services${C_RESET}
|
|
────────────────────────────────────────────────────────
|
|
Dashboard UI http://localhost:51300
|
|
Web API http://localhost:51100
|
|
Dashboard DB localhost:15432 (internal only in prod)
|
|
|
|
${C_BOLD}Containers${C_RESET}
|
|
────────────────────────────────────────────────────────
|
|
$(docker ps --format " {{.Names}} ({{.Status}})" | grep -E "didiAI-(dashboard|web-api)" | sort)
|
|
|
|
EOF
|
|
|
|
if [[ -n "$ADMIN_TOKEN" ]]; then
|
|
cat <<EOF
|
|
${C_BOLD}Admin Bearer token${C_RESET} (save this!)
|
|
────────────────────────────────────────────────────────
|
|
${C_GREEN}$ADMIN_TOKEN${C_RESET}
|
|
|
|
Usage:
|
|
curl -H "Authorization: Bearer $ADMIN_TOKEN" \\
|
|
-X PUT http://localhost:51300/api/config/web.premium.strategy \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{"value":"parallel"}'
|
|
|
|
EOF
|
|
fi
|
|
|
|
cat <<EOF
|
|
${C_BOLD}Next steps${C_RESET}
|
|
────────────────────────────────────────────────────────
|
|
1. Open http://localhost:51300 in your browser
|
|
2. Verify providers on /providers page
|
|
3. Adjust runtime config on /config page
|
|
4. Read DEPLOYMENT.md for details + troubleshooting
|
|
EOF
|