288 lines
7.2 KiB
Bash
Executable File
288 lines
7.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT="${ROOT_OVERRIDE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
ENV_FILE="${ENV_FILE:-${ROOT}/.env}"
|
|
MODFILE="${MODFILE:-${ROOT}/Modelfile}"
|
|
SQLAI_BIN="${SQLAI_BIN:-${ROOT}/bin/sqlai}"
|
|
OLLAMA_BIN="${OLLAMA_BIN:-ollama}"
|
|
LOG_FILE=""
|
|
LOCK_FD=""
|
|
|
|
_ts_prefix(){ date -Is; }
|
|
ts(){ _ts_prefix; }
|
|
|
|
log(){ echo "[$(ts)] $*"; }
|
|
ok(){ log "OK $*"; }
|
|
warn(){ log "WARN $*"; }
|
|
fail(){
|
|
local msg="$1"
|
|
local code="${2:-1}"
|
|
log "FAIL $msg"
|
|
exit "$code"
|
|
}
|
|
|
|
need_cmd(){
|
|
command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" 10
|
|
}
|
|
|
|
init_log(){
|
|
local name="$1"
|
|
local log_dir="${ROOT}/logs"
|
|
mkdir -p "$log_dir"
|
|
LOG_FILE="${log_dir}/${name}-$(date -Iseconds).log"
|
|
export LOG_FILE
|
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
|
}
|
|
|
|
acquire_lock(){
|
|
local name="${1:-jr-sql-ai-native.lock}"
|
|
local wait_seconds="${LOCK_WAIT_SECONDS:-600}"
|
|
local lock_dir="${ROOT}/.locks"
|
|
mkdir -p "$lock_dir"
|
|
local lock_path="${lock_dir}/${name}"
|
|
|
|
if command -v flock >/dev/null 2>&1; then
|
|
exec 8>"$lock_path"
|
|
flock -w "$wait_seconds" 8 || fail "could not acquire lock ${lock_path}" 11
|
|
LOCK_FD="8"
|
|
return 0
|
|
fi
|
|
|
|
local i
|
|
for ((i=0; i<wait_seconds; i++)); do
|
|
if mkdir "$lock_path.lock" 2>/dev/null; then
|
|
trap 'rmdir "'$lock_path'.lock" >/dev/null 2>&1 || true' EXIT
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
fail "could not acquire lock ${lock_path}.lock" 11
|
|
}
|
|
|
|
load_env(){
|
|
if [[ ! -f "$ENV_FILE" && -f "${ROOT}/.env.example" ]]; then
|
|
cp -n "${ROOT}/.env.example" "$ENV_FILE" || true
|
|
fi
|
|
|
|
if [[ -f "$ENV_FILE" ]]; then
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
source "$ENV_FILE"
|
|
set +a
|
|
fi
|
|
|
|
: "${OLLAMA_URL:=http://127.0.0.1:11434}"
|
|
: "${EXPERT_MODEL:=jr-sql-expert}"
|
|
: "${BASE_MODEL:=}"
|
|
: "${EXTRA_MODELS:=}"
|
|
: "${OLLAMA_BIN:=ollama}"
|
|
: "${OLLAMA_AUTOSTART:=1}"
|
|
: "${OLLAMA_SERVE_FALLBACK:=1}"
|
|
: "${OLLAMA_SYSTEMD_SERVICE:=ollama.service}"
|
|
: "${OLLAMA_SYSTEMD_SCOPE:=auto}"
|
|
: "${OLLAMA_API_MAX_WAIT:=120}"
|
|
: "${OLLAMA_API_START_WAIT:=45}"
|
|
export OLLAMA_URL EXPERT_MODEL BASE_MODEL EXTRA_MODELS OLLAMA_BIN OLLAMA_AUTOSTART OLLAMA_SERVE_FALLBACK OLLAMA_SYSTEMD_SERVICE OLLAMA_SYSTEMD_SCOPE OLLAMA_API_MAX_WAIT OLLAMA_API_START_WAIT
|
|
|
|
export OLLAMA_HOST="$OLLAMA_URL"
|
|
}
|
|
|
|
is_json(){
|
|
python -c 'import json,sys; json.load(sys.stdin)' >/dev/null 2>&1
|
|
}
|
|
|
|
http_get(){
|
|
local path="$1"
|
|
curl -fsS --connect-timeout 3 --max-time 20 "${OLLAMA_URL}${path}"
|
|
}
|
|
|
|
wait_for_ollama_api(){
|
|
local max_wait="${1:-120}"
|
|
local i
|
|
for ((i=1; i<=max_wait; i++)); do
|
|
if http_get "/api/tags" >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
fetch_tags(){
|
|
http_get "/api/tags"
|
|
}
|
|
|
|
fetch_version(){
|
|
http_get "/api/version"
|
|
}
|
|
|
|
model_in_tags(){
|
|
local model="$1"
|
|
local tags="$2"
|
|
TAGS_JSON="$tags" python - "$model" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
m = sys.argv[1]
|
|
obj = json.loads(os.environ['TAGS_JSON'])
|
|
names = {it.get('name') for it in obj.get('models', []) if it.get('name')}
|
|
ok = (
|
|
m in names or
|
|
f"{m}:latest" in names or
|
|
(m.endswith(':latest') and m[:-7] in names)
|
|
)
|
|
sys.exit(0 if ok else 1)
|
|
PY
|
|
}
|
|
|
|
render_modelfile(){
|
|
local out_file="$1"
|
|
[[ -f "$MODFILE" ]] || fail "Modelfile not found at $MODFILE" 20
|
|
[[ -n "$BASE_MODEL" ]] || fail "BASE_MODEL is empty" 21
|
|
|
|
python - "$MODFILE" "$out_file" "$BASE_MODEL" <<'PY'
|
|
from pathlib import Path
|
|
import sys
|
|
src = Path(sys.argv[1]).read_text(encoding='utf-8')
|
|
out = src.replace('${BASE_MODEL}', sys.argv[3])
|
|
Path(sys.argv[2]).write_text(out, encoding='utf-8')
|
|
PY
|
|
}
|
|
|
|
ollama_cmd(){
|
|
OLLAMA_HOST="$OLLAMA_URL" "$OLLAMA_BIN" "$@"
|
|
}
|
|
|
|
ollama_service_exists(){
|
|
local scope="$1"
|
|
if [[ "$scope" == "user" ]]; then
|
|
systemctl --user cat "$OLLAMA_SYSTEMD_SERVICE" >/dev/null 2>&1
|
|
else
|
|
systemctl cat "$OLLAMA_SYSTEMD_SERVICE" >/dev/null 2>&1
|
|
fi
|
|
}
|
|
|
|
start_ollama_via_systemd(){
|
|
command -v systemctl >/dev/null 2>&1 || return 1
|
|
|
|
if [[ "$OLLAMA_SYSTEMD_SCOPE" == "auto" || "$OLLAMA_SYSTEMD_SCOPE" == "user" ]]; then
|
|
if ollama_service_exists user; then
|
|
log "starting Ollama via systemd user service ${OLLAMA_SYSTEMD_SERVICE}"
|
|
systemctl --user start "$OLLAMA_SYSTEMD_SERVICE"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
if [[ "$OLLAMA_SYSTEMD_SCOPE" == "auto" || "$OLLAMA_SYSTEMD_SCOPE" == "system" ]]; then
|
|
if ollama_service_exists system; then
|
|
log "starting Ollama via systemd system service ${OLLAMA_SYSTEMD_SERVICE}"
|
|
systemctl start "$OLLAMA_SYSTEMD_SERVICE"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
start_ollama_fallback(){
|
|
[[ "$OLLAMA_SERVE_FALLBACK" == "1" ]] || return 1
|
|
|
|
if pgrep -fa '(^|/)ollama( |$).*serve' >/dev/null 2>&1; then
|
|
warn "ollama serve process already exists but API is not reachable yet"
|
|
return 0
|
|
fi
|
|
|
|
local serve_log="${ROOT}/logs/ollama-serve-fallback.log"
|
|
mkdir -p "${ROOT}/logs"
|
|
log "starting Ollama via detached fallback process (${OLLAMA_BIN} serve)"
|
|
nohup env OLLAMA_HOST="$OLLAMA_URL" "$OLLAMA_BIN" serve >>"$serve_log" 2>&1 &
|
|
return 0
|
|
}
|
|
|
|
ensure_ollama_ready(){
|
|
local max_wait="${1:-$OLLAMA_API_MAX_WAIT}"
|
|
local start_wait="${2:-$OLLAMA_API_START_WAIT}"
|
|
|
|
if wait_for_ollama_api 3; then
|
|
return 0
|
|
fi
|
|
|
|
[[ "$OLLAMA_AUTOSTART" == "1" ]] || return 1
|
|
|
|
if start_ollama_via_systemd; then
|
|
wait_for_ollama_api "$start_wait" && return 0
|
|
warn "Ollama service start issued, but API is still not reachable"
|
|
fi
|
|
|
|
if start_ollama_fallback; then
|
|
wait_for_ollama_api "$max_wait" && return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
iter_models(){
|
|
if [[ -n "$BASE_MODEL" ]]; then
|
|
printf '%s\n' "$BASE_MODEL"
|
|
fi
|
|
if [[ -n "$EXTRA_MODELS" ]]; then
|
|
printf '%s\n' "$EXTRA_MODELS" | tr ' ' '\n' | sed '/^$/d'
|
|
fi
|
|
}
|
|
|
|
pull_configured_models(){
|
|
local model
|
|
while IFS= read -r model; do
|
|
[[ -n "$model" ]] || continue
|
|
log "pulling model ${model}"
|
|
ollama_cmd pull "$model"
|
|
done < <(iter_models)
|
|
}
|
|
|
|
check_for_legacy_docker_ollama(){
|
|
if ! command -v docker >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
if docker ps --format '{{.Names}}' | grep -qx 'ollama'; then
|
|
fail "legacy docker container 'ollama' is still running; run cleanup-docker-ollama.sh first" 30
|
|
fi
|
|
|
|
if docker ps -a --format '{{.Names}}' | grep -qx 'ollama'; then
|
|
warn "legacy docker container 'ollama' still exists in stopped state; cleanup recommended"
|
|
fi
|
|
|
|
if docker images --format '{{.Repository}}:{{.Tag}}' | grep -qx 'ollama/ollama:latest'; then
|
|
warn "legacy docker image ollama/ollama:latest still exists; cleanup recommended"
|
|
fi
|
|
}
|
|
|
|
warmup_sqlai(){
|
|
if [[ -x "$SQLAI_BIN" ]]; then
|
|
log "warmup via ${SQLAI_BIN}"
|
|
"$SQLAI_BIN" ask --text "Warmup: reply with exactly 'OK'." --no-metrics || warn "warmup failed"
|
|
else
|
|
warn "${SQLAI_BIN} not executable; skipping warmup"
|
|
fi
|
|
}
|
|
|
|
verify_models_available(){
|
|
local tags
|
|
tags="$(fetch_tags)"
|
|
printf '%s' "$tags" | is_json || fail "/api/tags did not return valid JSON" 40
|
|
|
|
[[ -z "$BASE_MODEL" ]] || model_in_tags "$BASE_MODEL" "$tags" || fail "base model missing: ${BASE_MODEL}" 41
|
|
model_in_tags "$EXPERT_MODEL" "$tags" || fail "expert model missing: ${EXPERT_MODEL}" 42
|
|
}
|
|
|
|
print_header(){
|
|
echo "================================================================================"
|
|
log "$1 ROOT=$ROOT"
|
|
}
|
|
|
|
print_footer(){
|
|
log "$1"
|
|
echo "================================================================================"
|
|
}
|