mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 01:19:38 +02:00
Interactive bash installer (install_trustgraph.sh) that detects hardware, recommends an LLM mode (OpenAI or Ollama), installs missing prerequisites via Homebrew, sets up a Python venv, runs the test suite, generates a deployment via npx @trustgraph/config, starts the Docker Compose stack, health-checks the API gateway, and opens the Workbench UI. Includes README.dev-install.md with usage documentation covering CLI options, environment variables, LLM mode selection, non-interactive/CI usage, uninstall, and troubleshooting. Currently macOS only.
2603 lines
80 KiB
Bash
2603 lines
80 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
set -Eeuo pipefail
|
|
|
|
APP_NAME="TrustGraph"
|
|
DEFAULT_API_URL="http://localhost:8088/"
|
|
DEFAULT_UI_URL="http://localhost:8888"
|
|
DEFAULT_INSTALL_DIR="trustgraph-deploy"
|
|
DEFAULT_OLLAMA_MODEL="granite4:350m"
|
|
DEFAULT_OLLAMA_EMBEDDINGS_MODEL="mxbai-embed-large"
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
INSTALL_DIR="${TG_INSTALL_DIR:-$SCRIPT_DIR/$DEFAULT_INSTALL_DIR}"
|
|
VENV_DIR="${TG_VENV_DIR:-$INSTALL_DIR/.venv}"
|
|
NLTK_DATA_DIR="${TG_NLTK_DATA_DIR:-$INSTALL_DIR/nltk_data}"
|
|
TIKTOKEN_CACHE_DIR_VALUE="${TIKTOKEN_CACHE_DIR:-$INSTALL_DIR/tiktoken_cache}"
|
|
PYTHON_BIN="python3"
|
|
API_URL="${TRUSTGRAPH_URL:-$DEFAULT_API_URL}"
|
|
UI_URL="${TRUSTGRAPH_UI_URL:-$DEFAULT_UI_URL}"
|
|
|
|
RUN_TESTS=1
|
|
AUTO_LAUNCH=1
|
|
NON_INTERACTIVE=0
|
|
DRY_RUN=0
|
|
YES=0
|
|
FRESH_INSTALL=0
|
|
REMOVE_ALL=0
|
|
USE_EXISTING_COMPOSE=""
|
|
HEALTH_TIMEOUT="${TG_HEALTH_TIMEOUT:-240}"
|
|
AUTH_CHECK_TIMEOUT="${TG_AUTH_CHECK_TIMEOUT:-45}"
|
|
|
|
AUTH_TOKEN="${TRUSTGRAPH_TOKEN:-}"
|
|
LLM_MODE=""
|
|
OPENAI_TOKEN_VALUE="${OPENAI_TOKEN:-}"
|
|
OPENAI_BASE_URL_VALUE="${OPENAI_BASE_URL:-https://api.openai.com/v1}"
|
|
OLLAMA_BASE_URL_VALUE="${OLLAMA_HOST:-${OLLAMA_BASE_URL:-}}"
|
|
OLLAMA_MODEL="${OLLAMA_MODEL:-$DEFAULT_OLLAMA_MODEL}"
|
|
OLLAMA_EMBEDDINGS_MODEL="${OLLAMA_EMBEDDINGS_MODEL:-$DEFAULT_OLLAMA_EMBEDDINGS_MODEL}"
|
|
|
|
HW_OS=""
|
|
HW_ARCH=""
|
|
HW_CPU_CORES="unknown"
|
|
HW_MEMORY_GB="unknown"
|
|
HW_GPU="none detected"
|
|
HW_CONTAINER_HINT=""
|
|
RECOMMENDED_LLM_MODE="openai"
|
|
RECOMMENDATION_REASON=""
|
|
COMPOSE_CMD=()
|
|
COLOR_RESET=""
|
|
COLOR_HEADING=""
|
|
COLOR_INFO=""
|
|
COLOR_WARN=""
|
|
COLOR_ERROR=""
|
|
COLOR_ACCENT=""
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
Usage: ./install_trustgraph.sh [options]
|
|
|
|
Guided local installer for TrustGraph. It detects the machine hardware,
|
|
recommends a local or hosted LLM path, asks for the few required values,
|
|
enumerates local Ollama models when relevant, runs the repo tests, generates
|
|
a deployment with the existing config tool, starts the stack, checks health,
|
|
and opens the Workbench UI.
|
|
|
|
Options:
|
|
--install-dir PATH Directory for generated deployment files.
|
|
--api-url URL API gateway URL for health checks.
|
|
--ui-url URL Workbench UI URL to open.
|
|
--use-existing-compose F Skip config generation and start this compose file.
|
|
--skip-tests Do not run the full pytest suite.
|
|
--no-launch Do not open the Workbench UI at the end.
|
|
--non-interactive Use defaults where possible. Best with --dry-run or
|
|
--use-existing-compose.
|
|
--yes Accept confirmation prompts.
|
|
--fresh Remove installer-managed files in --install-dir
|
|
before generating a new deployment.
|
|
--remove-all Uninstall the installer-managed deployment:
|
|
stop containers, remove compose volumes, and
|
|
delete only installer-managed files.
|
|
--dry-run Show detected hardware and planned defaults only.
|
|
-h, --help Show this help.
|
|
|
|
Environment defaults:
|
|
TRUSTGRAPH_TOKEN, TRUSTGRAPH_URL, OPENAI_TOKEN, OPENAI_BASE_URL,
|
|
OLLAMA_HOST, OLLAMA_BASE_URL, OLLAMA_MODEL, OLLAMA_EMBEDDINGS_MODEL,
|
|
TG_INSTALL_DIR, TG_VENV_DIR, TG_NLTK_DATA_DIR, TIKTOKEN_CACHE_DIR,
|
|
TG_HEALTH_TIMEOUT
|
|
USAGE
|
|
}
|
|
|
|
say() {
|
|
printf '\n%b%s%b\n' "$COLOR_HEADING" "$*" "$COLOR_RESET"
|
|
}
|
|
|
|
info() {
|
|
printf ' %b%s%b\n' "$COLOR_INFO" "$*" "$COLOR_RESET"
|
|
}
|
|
|
|
warn() {
|
|
printf '%bWarning:%b %s\n' "$COLOR_WARN" "$COLOR_RESET" "$*" >&2
|
|
}
|
|
|
|
die() {
|
|
printf '%bError:%b %s\n' "$COLOR_ERROR" "$COLOR_RESET" "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
command_exists() {
|
|
command -v "$1" >/dev/null 2>&1
|
|
}
|
|
|
|
spinner_enabled() {
|
|
[[ "${TG_NO_SPINNER:-0}" != "1" ]] && { [[ -t 2 ]] || [[ "${TG_FORCE_SPINNER:-0}" == "1" ]]; }
|
|
}
|
|
|
|
clear_spinner_line() {
|
|
printf '\r\033[K' >&2
|
|
}
|
|
|
|
run_with_spinner() {
|
|
local message="$1"
|
|
shift
|
|
local frames=('|' '/' '-' '\')
|
|
local frame=0
|
|
local pid
|
|
local status
|
|
|
|
if ! spinner_enabled; then
|
|
"$@"
|
|
return
|
|
fi
|
|
|
|
"$@" &
|
|
pid=$!
|
|
while kill -0 "$pid" 2>/dev/null; do
|
|
printf '\r %b%s%b %s' "$COLOR_ACCENT" "${frames[$frame]}" "$COLOR_RESET" "$message" >&2
|
|
frame=$(((frame + 1) % ${#frames[@]}))
|
|
sleep 0.2
|
|
done
|
|
|
|
if wait "$pid"; then
|
|
status=0
|
|
else
|
|
status=$?
|
|
fi
|
|
|
|
clear_spinner_line
|
|
if [[ "$status" -eq 0 ]]; then
|
|
info "Done: $message"
|
|
else
|
|
warn "Failed: $message"
|
|
fi
|
|
return "$status"
|
|
}
|
|
|
|
run_with_spinner_logged() {
|
|
local message="$1"
|
|
local log_file="$2"
|
|
shift 2
|
|
local frames=('|' '/' '-' '\')
|
|
local frame=0
|
|
local pid
|
|
local status
|
|
|
|
if ! spinner_enabled; then
|
|
"$@"
|
|
return
|
|
fi
|
|
|
|
mkdir -p "$(dirname "$log_file")"
|
|
"$@" >"$log_file" 2>&1 &
|
|
pid=$!
|
|
while kill -0 "$pid" 2>/dev/null; do
|
|
printf '\r %b%s%b %s' "$COLOR_ACCENT" "${frames[$frame]}" "$COLOR_RESET" "$message" >&2
|
|
frame=$(((frame + 1) % ${#frames[@]}))
|
|
sleep 0.2
|
|
done
|
|
|
|
if wait "$pid"; then
|
|
status=0
|
|
else
|
|
status=$?
|
|
fi
|
|
|
|
clear_spinner_line
|
|
if [[ "$status" -eq 0 ]]; then
|
|
info "Done: $message"
|
|
else
|
|
warn "Failed: $message"
|
|
warn "Last log lines from $log_file:"
|
|
tail -n 40 "$log_file" >&2 || true
|
|
fi
|
|
return "$status"
|
|
}
|
|
|
|
installer_log_file() {
|
|
local name="$1"
|
|
mkdir -p "$INSTALL_DIR/logs"
|
|
printf '%s/logs/%s.log\n' "$INSTALL_DIR" "$name"
|
|
}
|
|
|
|
command_to_text() {
|
|
local arg
|
|
local out=""
|
|
|
|
for arg in "$@"; do
|
|
if [[ -n "$out" ]]; then
|
|
out="$out "
|
|
fi
|
|
out="$out$(printf '%q' "$arg")"
|
|
done
|
|
|
|
printf '%s\n' "$out"
|
|
}
|
|
|
|
root_command_to_text() {
|
|
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
|
|
command_to_text "$@"
|
|
elif command_exists sudo; then
|
|
command_to_text sudo "$@"
|
|
else
|
|
command_to_text "$@"
|
|
fi
|
|
}
|
|
|
|
run_root_command() {
|
|
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
|
|
"$@"
|
|
elif command_exists sudo; then
|
|
sudo "$@"
|
|
else
|
|
warn "Could not find sudo. Run this installer as an administrator or install the prerequisite manually."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
confirm_install_command() {
|
|
local question="$1"
|
|
local command_text="$2"
|
|
|
|
info "Command: $command_text"
|
|
|
|
if [[ "$YES" -eq 1 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
|
return 1
|
|
fi
|
|
|
|
confirm "$question" 1
|
|
}
|
|
|
|
init_colors() {
|
|
if [[ -n "${NO_COLOR:-}" || ! -t 1 ]]; then
|
|
return
|
|
fi
|
|
|
|
if command_exists tput && tput colors >/dev/null 2>&1 && [[ "$(tput colors)" -ge 8 ]]; then
|
|
COLOR_RESET="$(tput sgr0)"
|
|
COLOR_HEADING="$(tput bold)$(tput setaf 6)"
|
|
COLOR_INFO="$(tput setaf 2)"
|
|
COLOR_WARN="$(tput setaf 3)"
|
|
COLOR_ERROR="$(tput bold)$(tput setaf 1)"
|
|
COLOR_ACCENT="$(tput bold)$(tput setaf 5)"
|
|
fi
|
|
}
|
|
|
|
print_banner() {
|
|
printf '\n%b+---------------------------+%b\n' "$COLOR_ACCENT" "$COLOR_RESET"
|
|
printf '%b| Touchgraph Easy Installer |%b\n' "$COLOR_ACCENT" "$COLOR_RESET"
|
|
printf '%b+---------------------------+%b\n' "$COLOR_ACCENT" "$COLOR_RESET"
|
|
}
|
|
|
|
parse_args() {
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--install-dir)
|
|
[[ $# -ge 2 ]] || die "--install-dir needs a path"
|
|
INSTALL_DIR="$2"
|
|
shift 2
|
|
;;
|
|
--api-url)
|
|
[[ $# -ge 2 ]] || die "--api-url needs a URL"
|
|
API_URL="$2"
|
|
shift 2
|
|
;;
|
|
--ui-url)
|
|
[[ $# -ge 2 ]] || die "--ui-url needs a URL"
|
|
UI_URL="$2"
|
|
shift 2
|
|
;;
|
|
--use-existing-compose)
|
|
[[ $# -ge 2 ]] || die "--use-existing-compose needs a file path"
|
|
USE_EXISTING_COMPOSE="$2"
|
|
shift 2
|
|
;;
|
|
--skip-tests)
|
|
RUN_TESTS=0
|
|
shift
|
|
;;
|
|
--no-launch)
|
|
AUTO_LAUNCH=0
|
|
shift
|
|
;;
|
|
--non-interactive)
|
|
NON_INTERACTIVE=1
|
|
shift
|
|
;;
|
|
--yes)
|
|
YES=1
|
|
shift
|
|
;;
|
|
--fresh)
|
|
FRESH_INSTALL=1
|
|
shift
|
|
;;
|
|
--remove-all)
|
|
REMOVE_ALL=1
|
|
shift
|
|
;;
|
|
--dry-run)
|
|
DRY_RUN=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
die "Unknown option: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
case "$API_URL" in
|
|
*/) ;;
|
|
*) API_URL="$API_URL/" ;;
|
|
esac
|
|
}
|
|
|
|
prompt_value() {
|
|
local label="$1"
|
|
local default="$2"
|
|
local helper="$3"
|
|
local answer=""
|
|
|
|
if [[ -n "$helper" ]]; then
|
|
printf ' %s\n' "$helper" >&2
|
|
fi
|
|
|
|
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
|
printf '%s\n' "$default"
|
|
return
|
|
fi
|
|
|
|
if [[ -n "$default" ]]; then
|
|
read -r -p "$label [$default]: " answer
|
|
printf '%s\n' "${answer:-$default}"
|
|
else
|
|
read -r -p "$label: " answer
|
|
printf '%s\n' "$answer"
|
|
fi
|
|
}
|
|
|
|
looks_like_embedding_ollama_model() {
|
|
local model
|
|
model="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')"
|
|
|
|
case "$model" in
|
|
*embed*|*embedding*|*nomic*|*mxbai*|*bge*|*e5*|*gte*|*minilm*|*snowflake-arctic*)
|
|
return 0
|
|
;;
|
|
*)
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
ollama_model_candidates() {
|
|
local kind="$1"
|
|
shift
|
|
local model
|
|
local selected=()
|
|
|
|
for model in "$@"; do
|
|
case "$kind" in
|
|
embeddings)
|
|
if looks_like_embedding_ollama_model "$model"; then
|
|
selected+=("$model")
|
|
fi
|
|
;;
|
|
chat)
|
|
if ! looks_like_embedding_ollama_model "$model"; then
|
|
selected+=("$model")
|
|
fi
|
|
;;
|
|
*)
|
|
selected+=("$model")
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ "${#selected[@]}" -eq 0 ]]; then
|
|
selected=("$@")
|
|
fi
|
|
|
|
for model in "${selected[@]}"; do
|
|
printf '%s\n' "$model"
|
|
done
|
|
}
|
|
|
|
ollama_api_bases_for_host() {
|
|
local base="${OLLAMA_BASE_URL_VALUE%/}"
|
|
base="${base%/v1}"
|
|
|
|
[[ -n "$base" ]] || base="http://localhost:11434"
|
|
printf '%s\n' "$base"
|
|
|
|
case "$base" in
|
|
*host.docker.internal*)
|
|
printf '%s\n' "${base//host.docker.internal/localhost}"
|
|
;;
|
|
*0.0.0.0*)
|
|
printf '%s\n' "${base//0.0.0.0/localhost}"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
list_ollama_models_from_cli_for_host() {
|
|
local host="${1:-}"
|
|
|
|
command_exists ollama || return 0
|
|
|
|
if [[ -n "$host" ]]; then
|
|
OLLAMA_HOST="$host" ollama list 2>/dev/null | awk 'NR > 1 && $1 != "" { print $1 }' || true
|
|
else
|
|
ollama list 2>/dev/null | awk 'NR > 1 && $1 != "" { print $1 }' || true
|
|
fi
|
|
}
|
|
|
|
list_ollama_models_from_cli() {
|
|
local base
|
|
|
|
list_ollama_models_from_cli_for_host
|
|
|
|
if [[ -n "$OLLAMA_BASE_URL_VALUE" ]]; then
|
|
while IFS= read -r base; do
|
|
[[ -n "$base" ]] || continue
|
|
list_ollama_models_from_cli_for_host "$base"
|
|
done < <(ollama_api_bases_for_host)
|
|
fi
|
|
}
|
|
|
|
list_ollama_models_from_api() {
|
|
command_exists curl || return 0
|
|
command_exists python3 || return 0
|
|
|
|
local base
|
|
local response
|
|
|
|
while IFS= read -r base; do
|
|
[[ -n "$base" ]] || continue
|
|
response="$(curl -fsS --max-time 2 "${base%/}/api/tags" 2>/dev/null || true)"
|
|
[[ -n "$response" ]] || continue
|
|
|
|
printf '%s' "$response" | python3 -c 'import json, sys
|
|
try:
|
|
data = json.load(sys.stdin)
|
|
except Exception:
|
|
raise SystemExit(0)
|
|
for model in data.get("models", []):
|
|
name = model.get("name") or model.get("model")
|
|
if name:
|
|
print(name)
|
|
' 2>/dev/null || true
|
|
done < <(ollama_api_bases_for_host)
|
|
}
|
|
|
|
list_ollama_models() {
|
|
{
|
|
list_ollama_models_from_cli
|
|
list_ollama_models_from_api
|
|
} | awk 'NF && !seen[$0]++'
|
|
}
|
|
|
|
ollama_model_name_matches() {
|
|
local installed="$1"
|
|
local target="$2"
|
|
|
|
[[ "$installed" == "$target" ]] && return 0
|
|
[[ "$target" != *:* && "$installed" == "$target:latest" ]] && return 0
|
|
[[ "$installed" != *:* && "$target" == "$installed:latest" ]] && return 0
|
|
|
|
return 1
|
|
}
|
|
|
|
find_reachable_ollama_cli_host() {
|
|
local base
|
|
|
|
command_exists ollama || return 1
|
|
|
|
if ollama list >/dev/null 2>&1; then
|
|
printf '\n'
|
|
return 0
|
|
fi
|
|
|
|
while IFS= read -r base; do
|
|
[[ -n "$base" ]] || continue
|
|
if OLLAMA_HOST="$base" ollama list >/dev/null 2>&1; then
|
|
printf '%s\n' "$base"
|
|
return 0
|
|
fi
|
|
done < <(ollama_api_bases_for_host)
|
|
|
|
return 1
|
|
}
|
|
|
|
ollama_model_available_via_cli_host() {
|
|
local host="$1"
|
|
local target="$2"
|
|
local model
|
|
|
|
while IFS= read -r model; do
|
|
ollama_model_name_matches "$model" "$target" && return 0
|
|
done < <(list_ollama_models_from_cli_for_host "$host")
|
|
|
|
return 1
|
|
}
|
|
|
|
pull_ollama_model() {
|
|
local host="$1"
|
|
local model="$2"
|
|
local log_file
|
|
log_file="$(installer_log_file "ollama-pull-${model//\//-}")"
|
|
|
|
if [[ -n "$host" ]]; then
|
|
run_with_spinner_logged "Downloading Ollama model $model" "$log_file" env OLLAMA_HOST="$host" ollama pull "$model"
|
|
else
|
|
run_with_spinner_logged "Downloading Ollama model $model" "$log_file" ollama pull "$model"
|
|
fi
|
|
}
|
|
|
|
wait_for_ollama_service() {
|
|
local timeout="${1:-30}"
|
|
local deadline=$((SECONDS + timeout))
|
|
|
|
while (( SECONDS < deadline )); do
|
|
if find_reachable_ollama_cli_host >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
start_ollama_service_if_possible() {
|
|
local command_text
|
|
local log_file="$INSTALL_DIR/ollama.log"
|
|
|
|
say "Ollama service is not running"
|
|
|
|
if [[ "$HW_OS" == "Darwin" ]] && command_exists open && [[ -d /Applications/Ollama.app ]]; then
|
|
command_text="$(command_to_text open -a Ollama)"
|
|
if confirm_install_command "Start the Ollama app now?" "$command_text"; then
|
|
open -a Ollama
|
|
wait_for_ollama_service 45
|
|
return
|
|
fi
|
|
fi
|
|
|
|
if command_exists brew; then
|
|
command_text="$(command_to_text brew services start ollama)"
|
|
if confirm_install_command "Start the Ollama service with Homebrew now?" "$command_text"; then
|
|
brew services start ollama
|
|
wait_for_ollama_service 45
|
|
return
|
|
fi
|
|
fi
|
|
|
|
command_text="$(command_to_text ollama serve) > $(printf '%q' "$log_file") 2>&1 &"
|
|
if confirm_install_command "Start Ollama in the background now?" "$command_text"; then
|
|
mkdir -p "$INSTALL_DIR"
|
|
nohup ollama serve > "$log_file" 2>&1 &
|
|
wait_for_ollama_service 45
|
|
return
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
offer_single_ollama_model_download() {
|
|
local kind="$1"
|
|
local default_model="$2"
|
|
local selected_model="$3"
|
|
local processor_label="$4"
|
|
local cli_host="$5"
|
|
local question
|
|
|
|
say "Preparing Ollama $kind model"
|
|
info "TrustGraph's Ollama $processor_label default is $default_model."
|
|
|
|
if ollama_model_available_via_cli_host "$cli_host" "$selected_model"; then
|
|
info "Ollama $kind model already available: $selected_model"
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$selected_model" == "$default_model" ]]; then
|
|
question="Download TrustGraph's preferred Ollama $kind model ($selected_model) now?"
|
|
else
|
|
question="Download the selected Ollama $kind model ($selected_model) now?"
|
|
fi
|
|
|
|
if confirm "$question" 1; then
|
|
info "Downloading $selected_model with Ollama. This may take a while."
|
|
if ! pull_ollama_model "$cli_host" "$selected_model"; then
|
|
die "Ollama could not download $selected_model. Try running: ollama pull $selected_model"
|
|
fi
|
|
else
|
|
warn "Skipping Ollama $kind model download. TrustGraph's Ollama processor will try to pull $selected_model on first use."
|
|
fi
|
|
}
|
|
|
|
offer_ollama_model_downloads() {
|
|
local cli_host
|
|
|
|
[[ "$LLM_MODE" == "ollama" ]] || return 0
|
|
|
|
if ! command_exists ollama; then
|
|
warn "Ollama was selected, but the ollama CLI was not found. Install Ollama and run: ollama pull $OLLAMA_MODEL && ollama pull $OLLAMA_EMBEDDINGS_MODEL"
|
|
return 0
|
|
fi
|
|
|
|
if ! cli_host="$(find_reachable_ollama_cli_host)"; then
|
|
start_ollama_service_if_possible || true
|
|
if ! cli_host="$(find_reachable_ollama_cli_host)"; then
|
|
warn "Ollama CLI is installed, but the Ollama service is not reachable. Start Ollama and run: ollama pull $OLLAMA_MODEL && ollama pull $OLLAMA_EMBEDDINGS_MODEL"
|
|
return 0
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$cli_host" ]]; then
|
|
info "Ollama service: $cli_host"
|
|
else
|
|
info "Ollama service: local Ollama default"
|
|
fi
|
|
|
|
offer_single_ollama_model_download \
|
|
"chat" \
|
|
"$DEFAULT_OLLAMA_MODEL" \
|
|
"$OLLAMA_MODEL" \
|
|
"text-completion" \
|
|
"$cli_host"
|
|
|
|
offer_single_ollama_model_download \
|
|
"embeddings" \
|
|
"$DEFAULT_OLLAMA_EMBEDDINGS_MODEL" \
|
|
"$OLLAMA_EMBEDDINGS_MODEL" \
|
|
"embeddings" \
|
|
"$cli_host"
|
|
}
|
|
|
|
prompt_ollama_model_choice() {
|
|
local label="$1"
|
|
local default="$2"
|
|
local kind="$3"
|
|
local helper="$4"
|
|
shift 4
|
|
local all_models=("$@")
|
|
local candidates=()
|
|
local options=()
|
|
local model
|
|
local answer
|
|
local idx
|
|
local found_default=0
|
|
local detected_default=""
|
|
|
|
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
|
printf '%s\n' "$default"
|
|
return
|
|
fi
|
|
|
|
if [[ -n "$helper" ]]; then
|
|
printf ' %s\n' "$helper" >&2
|
|
fi
|
|
|
|
if [[ "${#all_models[@]}" -eq 0 ]]; then
|
|
prompt_value \
|
|
"$label" \
|
|
"$default" \
|
|
"No local Ollama models were detected. Pull the recommended default with: ollama pull $default"
|
|
return
|
|
fi
|
|
|
|
while IFS= read -r model; do
|
|
[[ -n "$model" ]] && candidates+=("$model")
|
|
done < <(ollama_model_candidates "$kind" "${all_models[@]}")
|
|
|
|
if [[ "${#candidates[@]}" -eq 0 ]]; then
|
|
candidates=("${all_models[@]}")
|
|
fi
|
|
|
|
for model in "${candidates[@]}"; do
|
|
if ollama_model_name_matches "$model" "$default"; then
|
|
found_default=1
|
|
detected_default="$model"
|
|
break
|
|
fi
|
|
done
|
|
|
|
options+=("$default")
|
|
for model in "${candidates[@]}"; do
|
|
ollama_model_name_matches "$model" "$default" && continue
|
|
options+=("$model")
|
|
done
|
|
|
|
say "Local Ollama ${kind} model choices" >&2
|
|
if [[ "$found_default" -eq 1 ]]; then
|
|
if [[ "$detected_default" != "$default" ]]; then
|
|
info "1) $default (recommended, detected as $detected_default)" >&2
|
|
else
|
|
info "1) $default (recommended, detected)" >&2
|
|
fi
|
|
else
|
|
info "1) $default (recommended default, not detected locally)" >&2
|
|
fi
|
|
|
|
idx=2
|
|
for model in "${options[@]:1}"; do
|
|
info "$idx) $model" >&2
|
|
idx=$((idx + 1))
|
|
done
|
|
if [[ "$found_default" -eq 0 ]]; then
|
|
info "If you choose a missing model, the installer will offer to download it before startup." >&2
|
|
fi
|
|
info "Or type another model name, for example one you plan to pull before startup." >&2
|
|
|
|
read -r -p "$label [1: $default]: " answer
|
|
answer="${answer:-1}"
|
|
|
|
if [[ "$answer" =~ ^[0-9]+$ ]]; then
|
|
if (( answer >= 1 && answer <= ${#options[@]} )); then
|
|
printf '%s\n' "${options[$((answer - 1))]}"
|
|
return
|
|
fi
|
|
warn "Selection '$answer' is not in the list; using $default."
|
|
printf '%s\n' "$default"
|
|
return
|
|
fi
|
|
|
|
printf '%s\n' "$answer"
|
|
}
|
|
|
|
prompt_secret() {
|
|
local label="$1"
|
|
local default="$2"
|
|
local helper="$3"
|
|
local answer=""
|
|
local masked="${4:-}"
|
|
|
|
if [[ -z "$masked" && -n "$default" ]]; then
|
|
masked="set in environment"
|
|
elif [[ -z "$masked" ]]; then
|
|
masked="blank"
|
|
fi
|
|
|
|
if [[ -n "$helper" ]]; then
|
|
printf ' %s\n' "$helper" >&2
|
|
fi
|
|
|
|
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
|
printf '%s\n' "$default"
|
|
return
|
|
fi
|
|
|
|
read -r -s -p "$label [$masked]: " answer
|
|
printf '\n' >&2
|
|
printf '%s\n' "${answer:-$default}"
|
|
}
|
|
|
|
confirm() {
|
|
local question="$1"
|
|
local default_yes="$2"
|
|
local answer=""
|
|
local prompt="[y/N]"
|
|
|
|
if [[ "$YES" -eq 1 ]]; then
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
|
[[ "$default_yes" -eq 1 ]]
|
|
return
|
|
fi
|
|
|
|
if [[ "$default_yes" -eq 1 ]]; then
|
|
prompt="[Y/n]"
|
|
fi
|
|
|
|
read -r -p "$question $prompt " answer
|
|
answer="${answer:-}"
|
|
if [[ -z "$answer" ]]; then
|
|
[[ "$default_yes" -eq 1 ]]
|
|
return
|
|
fi
|
|
[[ "$answer" =~ ^[Yy] ]]
|
|
}
|
|
|
|
path_within_install_dir() {
|
|
local path="$1"
|
|
case "$path" in
|
|
"$INSTALL_DIR"/*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
safe_existing_path_within_install_dir() {
|
|
local path="$1"
|
|
local resolved_install
|
|
local resolved_parent
|
|
local resolved_path
|
|
local parent
|
|
local base
|
|
|
|
[[ -d "$INSTALL_DIR" ]] || return 1
|
|
[[ -e "$path" || -L "$path" ]] || return 1
|
|
|
|
resolved_install="$(cd "$INSTALL_DIR" && pwd -P)"
|
|
parent="$(dirname "$path")"
|
|
base="$(basename "$path")"
|
|
[[ -d "$parent" ]] || return 1
|
|
resolved_parent="$(cd "$parent" && pwd -P)"
|
|
resolved_path="$resolved_parent/$base"
|
|
|
|
case "$resolved_path" in
|
|
"$resolved_install"/*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
installer_artifact_paths() {
|
|
local candidates=(
|
|
"$INSTALL_DIR/deploy.zip"
|
|
"$INSTALL_DIR/deploy"
|
|
"$INSTALL_DIR/INSTALLATION.md"
|
|
"$INSTALL_DIR/trustgraph-installer.env"
|
|
"$INSTALL_DIR/iam-bootstrap.log"
|
|
"$INSTALL_DIR/ollama.log"
|
|
"$INSTALL_DIR/logs"
|
|
"$INSTALL_DIR/pip_cache"
|
|
)
|
|
local path
|
|
|
|
for path in "$VENV_DIR" "$NLTK_DATA_DIR" "$TIKTOKEN_CACHE_DIR_VALUE"; do
|
|
if path_within_install_dir "$path"; then
|
|
candidates+=("$path")
|
|
fi
|
|
done
|
|
|
|
for path in "${candidates[@]}"; do
|
|
if [[ -e "$path" || -L "$path" ]]; then
|
|
printf '%s\n' "$path"
|
|
fi
|
|
done
|
|
}
|
|
|
|
installer_artifacts_present() {
|
|
local path
|
|
while IFS= read -r path; do
|
|
[[ -n "$path" ]] && return 0
|
|
done < <(installer_artifact_paths)
|
|
return 1
|
|
}
|
|
|
|
assert_safe_cleanup_target() {
|
|
local resolved_install
|
|
local resolved_script
|
|
local resolved_home=""
|
|
|
|
[[ -n "$INSTALL_DIR" ]] || die "Install directory is empty; refusing cleanup."
|
|
case "$INSTALL_DIR" in
|
|
/|.|..) die "Install directory '$INSTALL_DIR' is too broad; refusing cleanup." ;;
|
|
esac
|
|
|
|
if [[ -d "$INSTALL_DIR" ]]; then
|
|
resolved_install="$(cd "$INSTALL_DIR" && pwd -P)"
|
|
else
|
|
return 0
|
|
fi
|
|
resolved_script="$(cd "$SCRIPT_DIR" && pwd -P)"
|
|
if [[ -n "${HOME:-}" && -d "$HOME" ]]; then
|
|
resolved_home="$(cd "$HOME" && pwd -P)"
|
|
fi
|
|
|
|
[[ "$resolved_install" != "$resolved_script" ]] || die "Install directory resolves to the source checkout; refusing cleanup."
|
|
[[ -z "$resolved_home" || "$resolved_install" != "$resolved_home" ]] || die "Install directory resolves to your home directory; refusing cleanup."
|
|
}
|
|
|
|
find_existing_compose_file() {
|
|
[[ -d "$INSTALL_DIR" ]] || return 0
|
|
|
|
find "$INSTALL_DIR/deploy" "$INSTALL_DIR" \
|
|
\( -name 'docker-compose.yaml' -o -name 'docker-compose.yml' -o -name 'compose.yaml' -o -name 'compose.yml' \) \
|
|
-type f 2>/dev/null | head -n 1
|
|
}
|
|
|
|
stop_previous_stack_if_possible() {
|
|
local compose_file
|
|
compose_file="$(find_existing_compose_file || true)"
|
|
[[ -n "$compose_file" ]] || return 0
|
|
|
|
if ! confirm "Stop any containers from the previous deployment first? Docker volumes will be kept." 1; then
|
|
info "Leaving any previous containers untouched."
|
|
return
|
|
fi
|
|
|
|
if ! detect_compose_command; then
|
|
warn "Could not find Docker Compose or podman-compose, so previous containers were not stopped."
|
|
return
|
|
fi
|
|
|
|
if "${COMPOSE_CMD[@]}" -f "$compose_file" down --remove-orphans; then
|
|
info "Stopped previous compose deployment using $compose_file"
|
|
else
|
|
warn "Could not stop previous compose deployment. Continuing with file cleanup only."
|
|
fi
|
|
}
|
|
|
|
remove_installer_artifacts_only() {
|
|
local path
|
|
|
|
say "Removing installer-managed files"
|
|
while IFS= read -r path; do
|
|
[[ -n "$path" ]] || continue
|
|
if ! safe_existing_path_within_install_dir "$path"; then
|
|
warn "Skipping cleanup path outside install directory: $path"
|
|
continue
|
|
fi
|
|
info "Removing $path"
|
|
rm -rf -- "$path"
|
|
done < <(installer_artifact_paths)
|
|
|
|
rmdir "$INSTALL_DIR" 2>/dev/null || true
|
|
}
|
|
|
|
cleanup_installer_artifacts() {
|
|
assert_safe_cleanup_target
|
|
stop_previous_stack_if_possible
|
|
remove_installer_artifacts_only
|
|
}
|
|
|
|
find_uninstall_compose_file() {
|
|
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
|
|
[[ -f "$USE_EXISTING_COMPOSE" ]] || die "Compose file does not exist: $USE_EXISTING_COMPOSE"
|
|
printf '%s\n' "$USE_EXISTING_COMPOSE"
|
|
return
|
|
fi
|
|
|
|
find_existing_compose_file || true
|
|
}
|
|
|
|
print_uninstall_plan() {
|
|
local compose_file="$1"
|
|
local path
|
|
local found=0
|
|
|
|
say "Uninstall plan"
|
|
info "Install directory: $INSTALL_DIR"
|
|
if [[ -n "$compose_file" ]]; then
|
|
info "Compose file: $compose_file"
|
|
info "Will stop containers and remove compose-managed volumes for this deployment."
|
|
else
|
|
info "No compose file was found, so no containers or volumes can be removed automatically."
|
|
fi
|
|
|
|
info "Installer-managed files to remove:"
|
|
while IFS= read -r path; do
|
|
[[ -n "$path" ]] || continue
|
|
found=1
|
|
info "- $path"
|
|
done < <(installer_artifact_paths)
|
|
if [[ "$found" -eq 0 ]]; then
|
|
info "- none found"
|
|
fi
|
|
|
|
info "Will not remove Docker/Podman, container images, external volumes, Ollama, Ollama models, or this source checkout."
|
|
}
|
|
|
|
remove_compose_stack_for_uninstall() {
|
|
local compose_file="$1"
|
|
|
|
[[ -n "$compose_file" ]] || return 0
|
|
|
|
say "Stopping TrustGraph containers and volumes"
|
|
if ! detect_compose_command; then
|
|
warn "Could not find Docker Compose or podman-compose, so containers and volumes were not removed."
|
|
return
|
|
fi
|
|
|
|
if "${COMPOSE_CMD[@]}" -f "$compose_file" down --remove-orphans --volumes; then
|
|
info "Removed compose containers, networks, and compose-managed volumes."
|
|
return
|
|
fi
|
|
|
|
warn "Compose did not accept the volume removal command; trying to stop containers without removing volumes."
|
|
if "${COMPOSE_CMD[@]}" -f "$compose_file" down --remove-orphans; then
|
|
warn "Containers were stopped, but compose volumes may remain."
|
|
else
|
|
warn "Could not stop the compose deployment. Installer-managed files will still be removed."
|
|
fi
|
|
}
|
|
|
|
remove_all_installation() {
|
|
local compose_file
|
|
|
|
assert_safe_cleanup_target
|
|
compose_file="$(find_uninstall_compose_file || true)"
|
|
|
|
print_uninstall_plan "$compose_file"
|
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
say "Dry run complete"
|
|
return 0
|
|
fi
|
|
|
|
if [[ -z "$compose_file" ]] && ! installer_artifacts_present; then
|
|
say "Nothing to remove"
|
|
info "No installer-managed files or compose deployment were found."
|
|
return 0
|
|
fi
|
|
|
|
if ! confirm "Remove the TrustGraph deployment listed above?" 0; then
|
|
die "Uninstall cancelled."
|
|
fi
|
|
|
|
remove_compose_stack_for_uninstall "$compose_file"
|
|
remove_installer_artifacts_only
|
|
|
|
say "TrustGraph installer-managed deployment removed"
|
|
info "Ollama models were left in place because they may be shared with other tools."
|
|
}
|
|
|
|
handle_existing_install() {
|
|
local path
|
|
local found=0
|
|
|
|
[[ -z "$USE_EXISTING_COMPOSE" ]] || return 0
|
|
installer_artifacts_present || return 0
|
|
|
|
say "Existing installer output detected"
|
|
info "Install directory: $INSTALL_DIR"
|
|
while IFS= read -r path; do
|
|
[[ -n "$path" ]] || continue
|
|
found=1
|
|
info "Found: $path"
|
|
done < <(installer_artifact_paths)
|
|
|
|
[[ "$found" -eq 1 ]] || return 0
|
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
if [[ "$FRESH_INSTALL" -eq 1 ]]; then
|
|
info "Dry run: --fresh would remove the files listed above."
|
|
else
|
|
info "Dry run: existing files would be kept unless you choose --fresh."
|
|
fi
|
|
return 0
|
|
fi
|
|
|
|
if [[ "$FRESH_INSTALL" -eq 1 ]]; then
|
|
cleanup_installer_artifacts
|
|
return 0
|
|
fi
|
|
|
|
if confirm "Treat this as a fresh install and delete only the installer-managed files listed above?" 0; then
|
|
cleanup_installer_artifacts
|
|
else
|
|
info "Continuing with the existing installer output."
|
|
fi
|
|
}
|
|
|
|
load_saved_answers() {
|
|
local env_file="$INSTALL_DIR/trustgraph-installer.env"
|
|
|
|
[[ -f "$env_file" ]] || return 0
|
|
|
|
local current_api_url="$API_URL"
|
|
local current_ui_url="$UI_URL"
|
|
local current_auth_token="$AUTH_TOKEN"
|
|
local current_venv_dir="$VENV_DIR"
|
|
local current_nltk_data_dir="$NLTK_DATA_DIR"
|
|
local current_tiktoken_cache_dir="$TIKTOKEN_CACHE_DIR_VALUE"
|
|
local current_llm_mode="$LLM_MODE"
|
|
local current_openai_base_url="$OPENAI_BASE_URL_VALUE"
|
|
local current_openai_token="$OPENAI_TOKEN_VALUE"
|
|
local current_ollama_base_url="$OLLAMA_BASE_URL_VALUE"
|
|
local current_ollama_model="$OLLAMA_MODEL"
|
|
local current_ollama_embeddings_model="$OLLAMA_EMBEDDINGS_MODEL"
|
|
|
|
# The file is generated by this installer with shell-escaped exports and 0600 permissions.
|
|
# shellcheck disable=SC1090
|
|
source "$env_file"
|
|
|
|
if [[ "$current_api_url" == "$DEFAULT_API_URL" && -n "${TRUSTGRAPH_URL:-}" ]]; then
|
|
API_URL="$TRUSTGRAPH_URL"
|
|
else
|
|
API_URL="$current_api_url"
|
|
fi
|
|
|
|
if [[ "$current_ui_url" == "$DEFAULT_UI_URL" && -n "${TRUSTGRAPH_UI_URL:-}" ]]; then
|
|
UI_URL="$TRUSTGRAPH_UI_URL"
|
|
else
|
|
UI_URL="$current_ui_url"
|
|
fi
|
|
|
|
if [[ -z "$current_auth_token" && -n "${TRUSTGRAPH_TOKEN:-}" ]]; then
|
|
AUTH_TOKEN="$TRUSTGRAPH_TOKEN"
|
|
elif [[ -z "$current_auth_token" && -n "${IAM_BOOTSTRAP_TOKEN:-}" ]]; then
|
|
AUTH_TOKEN="$IAM_BOOTSTRAP_TOKEN"
|
|
else
|
|
AUTH_TOKEN="$current_auth_token"
|
|
fi
|
|
|
|
if [[ -z "${TG_VENV_DIR:-}" && -n "${current_venv_dir:-}" ]]; then
|
|
VENV_DIR="$current_venv_dir"
|
|
elif [[ -n "${TG_VENV_DIR:-}" ]]; then
|
|
VENV_DIR="$TG_VENV_DIR"
|
|
fi
|
|
|
|
if [[ -z "${TG_NLTK_DATA_DIR:-}" && -n "$current_nltk_data_dir" ]]; then
|
|
NLTK_DATA_DIR="$current_nltk_data_dir"
|
|
elif [[ -n "${TG_NLTK_DATA_DIR:-}" ]]; then
|
|
NLTK_DATA_DIR="$TG_NLTK_DATA_DIR"
|
|
fi
|
|
|
|
if [[ -z "${TIKTOKEN_CACHE_DIR:-}" && -n "$current_tiktoken_cache_dir" ]]; then
|
|
TIKTOKEN_CACHE_DIR_VALUE="$current_tiktoken_cache_dir"
|
|
elif [[ -n "${TIKTOKEN_CACHE_DIR:-}" ]]; then
|
|
TIKTOKEN_CACHE_DIR_VALUE="$TIKTOKEN_CACHE_DIR"
|
|
fi
|
|
|
|
if [[ -z "$current_llm_mode" && -n "${TRUSTGRAPH_LLM_MODE:-}" ]]; then
|
|
LLM_MODE="$TRUSTGRAPH_LLM_MODE"
|
|
else
|
|
LLM_MODE="$current_llm_mode"
|
|
fi
|
|
|
|
if [[ "$current_openai_base_url" == "https://api.openai.com/v1" && -n "${OPENAI_BASE_URL:-}" ]]; then
|
|
OPENAI_BASE_URL_VALUE="$OPENAI_BASE_URL"
|
|
else
|
|
OPENAI_BASE_URL_VALUE="$current_openai_base_url"
|
|
fi
|
|
|
|
if [[ -z "$current_openai_token" && -n "${OPENAI_TOKEN:-}" ]]; then
|
|
OPENAI_TOKEN_VALUE="$OPENAI_TOKEN"
|
|
else
|
|
OPENAI_TOKEN_VALUE="$current_openai_token"
|
|
fi
|
|
|
|
if [[ -z "$current_ollama_base_url" ]]; then
|
|
OLLAMA_BASE_URL_VALUE="${OLLAMA_HOST:-${OLLAMA_BASE_URL:-}}"
|
|
else
|
|
OLLAMA_BASE_URL_VALUE="$current_ollama_base_url"
|
|
fi
|
|
|
|
if [[ "$current_ollama_model" == "$DEFAULT_OLLAMA_MODEL" && -n "${OLLAMA_MODEL:-}" ]]; then
|
|
OLLAMA_MODEL="$OLLAMA_MODEL"
|
|
else
|
|
OLLAMA_MODEL="$current_ollama_model"
|
|
fi
|
|
|
|
if [[ "$current_ollama_embeddings_model" == "$DEFAULT_OLLAMA_EMBEDDINGS_MODEL" && -n "${OLLAMA_EMBEDDINGS_MODEL:-}" ]]; then
|
|
OLLAMA_EMBEDDINGS_MODEL="$OLLAMA_EMBEDDINGS_MODEL"
|
|
else
|
|
OLLAMA_EMBEDDINGS_MODEL="$current_ollama_embeddings_model"
|
|
fi
|
|
|
|
case "$API_URL" in
|
|
*/) ;;
|
|
*) API_URL="$API_URL/" ;;
|
|
esac
|
|
|
|
info "Loaded saved answers from $env_file"
|
|
}
|
|
|
|
bytes_to_gb() {
|
|
local bytes="$1"
|
|
awk "BEGIN { printf \"%.0f\", $bytes / 1024 / 1024 / 1024 }"
|
|
}
|
|
|
|
detect_hardware() {
|
|
HW_OS="$(uname -s 2>/dev/null || printf 'unknown')"
|
|
HW_ARCH="$(uname -m 2>/dev/null || printf 'unknown')"
|
|
|
|
if [[ "$HW_OS" == "Darwin" ]]; then
|
|
HW_CPU_CORES="$(sysctl -n hw.logicalcpu 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || python3 -c 'import os; print(os.cpu_count() or "unknown")' 2>/dev/null || printf 'unknown')"
|
|
local mem_bytes
|
|
mem_bytes="$(sysctl -n hw.memsize 2>/dev/null || true)"
|
|
if [[ -z "$mem_bytes" ]] && command_exists python3; then
|
|
mem_bytes="$(python3 -c 'import os; print(os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE"))' 2>/dev/null || true)"
|
|
fi
|
|
if [[ -n "$mem_bytes" ]]; then
|
|
HW_MEMORY_GB="$(bytes_to_gb "$mem_bytes")"
|
|
fi
|
|
if [[ "$HW_ARCH" == "arm64" ]]; then
|
|
HW_GPU="Apple Silicon unified GPU"
|
|
fi
|
|
HW_CONTAINER_HINT="Docker Desktop or Podman Desktop works well on macOS."
|
|
elif [[ "$HW_OS" == "Linux" ]]; then
|
|
HW_CPU_CORES="$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || python3 -c 'import os; print(os.cpu_count() or "unknown")' 2>/dev/null || printf 'unknown')"
|
|
if [[ -r /proc/meminfo ]]; then
|
|
local mem_kb
|
|
mem_kb="$(awk '/MemTotal/ { print $2 }' /proc/meminfo)"
|
|
if [[ -n "$mem_kb" ]]; then
|
|
HW_MEMORY_GB="$(awk "BEGIN { printf \"%.0f\", $mem_kb / 1024 / 1024 }")"
|
|
fi
|
|
fi
|
|
if command_exists nvidia-smi; then
|
|
HW_GPU="$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null | head -n 1 || true)"
|
|
[[ -n "$HW_GPU" ]] || HW_GPU="NVIDIA GPU detected"
|
|
elif command_exists lspci; then
|
|
HW_GPU="$(lspci 2>/dev/null | awk 'BEGIN{IGNORECASE=1} /VGA|3D|Display/ {print; exit}')"
|
|
[[ -n "$HW_GPU" ]] || HW_GPU="none detected"
|
|
fi
|
|
HW_CONTAINER_HINT="Docker Engine, Docker Desktop, or Podman can run the compose stack."
|
|
else
|
|
HW_CONTAINER_HINT="Use Docker or Podman with compose support."
|
|
fi
|
|
}
|
|
|
|
is_number() {
|
|
[[ "$1" =~ ^[0-9]+$ ]]
|
|
}
|
|
|
|
choose_recommendations() {
|
|
local mem=0
|
|
local cores=0
|
|
|
|
if is_number "$HW_MEMORY_GB"; then
|
|
mem="$HW_MEMORY_GB"
|
|
fi
|
|
if is_number "$HW_CPU_CORES"; then
|
|
cores="$HW_CPU_CORES"
|
|
fi
|
|
|
|
if [[ -z "$OLLAMA_BASE_URL_VALUE" ]]; then
|
|
if [[ "$HW_OS" == "Darwin" ]]; then
|
|
OLLAMA_BASE_URL_VALUE="http://host.docker.internal:11434"
|
|
else
|
|
OLLAMA_BASE_URL_VALUE="http://localhost:11434"
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$LLM_MODE" ]]; then
|
|
RECOMMENDED_LLM_MODE="$LLM_MODE"
|
|
RECOMMENDATION_REASON="Using the LLM provider saved from the previous installer run."
|
|
return
|
|
fi
|
|
|
|
if (( mem >= 16 )) && { [[ "$HW_GPU" != "none detected" ]] || (( cores >= 8 )); }; then
|
|
RECOMMENDED_LLM_MODE="ollama"
|
|
RECOMMENDATION_REASON="This machine looks comfortable for a small local Ollama model."
|
|
elif (( mem >= 8 )); then
|
|
RECOMMENDED_LLM_MODE="openai"
|
|
RECOMMENDATION_REASON="Local Ollama may work with a small model, but a hosted OpenAI-compatible endpoint is smoother on this hardware."
|
|
else
|
|
RECOMMENDED_LLM_MODE="openai"
|
|
RECOMMENDATION_REASON="Memory looks tight for local LLMs, so a hosted OpenAI-compatible endpoint is the friendlier default."
|
|
fi
|
|
|
|
if [[ -n "${OPENAI_TOKEN:-}" && "${OPENAI_TOKEN:-}" != "ollama" ]]; then
|
|
RECOMMENDED_LLM_MODE="openai"
|
|
RECOMMENDATION_REASON="OPENAI_TOKEN is already set, so the hosted/OpenAI-compatible path is ready to use."
|
|
fi
|
|
}
|
|
|
|
print_hardware_summary() {
|
|
say "Detected hardware"
|
|
info "OS: $HW_OS"
|
|
info "Architecture: $HW_ARCH"
|
|
info "CPU cores: $HW_CPU_CORES"
|
|
info "Memory: $HW_MEMORY_GB GB"
|
|
info "GPU: $HW_GPU"
|
|
info "$HW_CONTAINER_HINT"
|
|
|
|
say "Recommended install shape"
|
|
info "LLM path: $RECOMMENDED_LLM_MODE"
|
|
info "$RECOMMENDATION_REASON"
|
|
info "Default Workbench UI: $UI_URL"
|
|
info "Default API gateway: $API_URL"
|
|
}
|
|
|
|
generate_token() {
|
|
if command_exists openssl; then
|
|
printf 'tg_%s\n' "$(openssl rand -base64 24 | tr '+/' '-_' | tr -d '=')"
|
|
elif command_exists python3; then
|
|
python3 -c 'import secrets; print("tg_" + secrets.token_urlsafe(24))'
|
|
else
|
|
die "Need openssl or python3 to generate a secure TrustGraph API key."
|
|
fi
|
|
}
|
|
|
|
ensure_compliant_api_key() {
|
|
local token="$1"
|
|
|
|
if [[ "$token" == tg_* ]]; then
|
|
printf '%s\n' "$token"
|
|
return
|
|
fi
|
|
|
|
warn "TrustGraph API keys must start with 'tg_'; the provided value will not authenticate at the gateway."
|
|
|
|
if [[ "$NON_INTERACTIVE" -eq 1 ]]; then
|
|
warn "Non-interactive mode: replacing the non-compliant key with a generated TrustGraph API key."
|
|
generate_token
|
|
return
|
|
fi
|
|
|
|
if confirm "Generate a compliant TrustGraph API key now?" 1; then
|
|
generate_token
|
|
return
|
|
fi
|
|
|
|
die "TrustGraph API key must start with 'tg_'."
|
|
}
|
|
|
|
collect_answers() {
|
|
local generated_token
|
|
local token_default
|
|
local token_mask
|
|
generated_token="$(generate_token)"
|
|
if [[ -n "$AUTH_TOKEN" ]]; then
|
|
token_default="$AUTH_TOKEN"
|
|
token_mask="set in environment"
|
|
else
|
|
token_default="$generated_token"
|
|
token_mask="generated tg_ key"
|
|
fi
|
|
AUTH_TOKEN="$(prompt_secret \
|
|
"TrustGraph admin/bootstrap API key" \
|
|
"$token_default" \
|
|
"Recommendation: press Enter to use a generated TrustGraph API key beginning with tg_; it will be stored in the installer env file with restricted permissions." \
|
|
"$token_mask")"
|
|
AUTH_TOKEN="$(ensure_compliant_api_key "$AUTH_TOKEN")"
|
|
|
|
LLM_MODE="$(prompt_value \
|
|
"LLM provider: ollama, openai, or none" \
|
|
"${LLM_MODE:-$RECOMMENDED_LLM_MODE}" \
|
|
"Recommendation: $RECOMMENDED_LLM_MODE. $RECOMMENDATION_REASON")"
|
|
LLM_MODE="$(printf '%s' "$LLM_MODE" | tr '[:upper:]' '[:lower:]')"
|
|
|
|
case "$LLM_MODE" in
|
|
ollama)
|
|
OLLAMA_BASE_URL_VALUE="$(prompt_value \
|
|
"Ollama base URL" \
|
|
"$OLLAMA_BASE_URL_VALUE" \
|
|
"If Ollama runs on your laptop and TrustGraph runs in Docker, host.docker.internal is usually the right host on macOS/Windows.")"
|
|
local ollama_models=()
|
|
local ollama_model
|
|
if [[ "$NON_INTERACTIVE" -ne 1 ]]; then
|
|
while IFS= read -r ollama_model; do
|
|
[[ -n "$ollama_model" ]] && ollama_models+=("$ollama_model")
|
|
done < <(list_ollama_models)
|
|
fi
|
|
if [[ "${#ollama_models[@]}" -gt 0 ]]; then
|
|
OLLAMA_MODEL="$(prompt_ollama_model_choice \
|
|
"Ollama chat model" \
|
|
"$OLLAMA_MODEL" \
|
|
"chat" \
|
|
"Recommendation from the local Ollama processor defaults: $DEFAULT_OLLAMA_MODEL for a quick first run." \
|
|
"${ollama_models[@]}")"
|
|
OLLAMA_EMBEDDINGS_MODEL="$(prompt_ollama_model_choice \
|
|
"Ollama embeddings model" \
|
|
"$OLLAMA_EMBEDDINGS_MODEL" \
|
|
"embeddings" \
|
|
"Recommendation from the local Ollama embeddings defaults: $DEFAULT_OLLAMA_EMBEDDINGS_MODEL." \
|
|
"${ollama_models[@]}")"
|
|
else
|
|
OLLAMA_MODEL="$(prompt_ollama_model_choice \
|
|
"Ollama chat model" \
|
|
"$OLLAMA_MODEL" \
|
|
"chat" \
|
|
"Recommendation from the local Ollama processor defaults: $DEFAULT_OLLAMA_MODEL for a quick first run.")"
|
|
OLLAMA_EMBEDDINGS_MODEL="$(prompt_ollama_model_choice \
|
|
"Ollama embeddings model" \
|
|
"$OLLAMA_EMBEDDINGS_MODEL" \
|
|
"embeddings" \
|
|
"Recommendation from the local Ollama embeddings defaults: $DEFAULT_OLLAMA_EMBEDDINGS_MODEL.")"
|
|
fi
|
|
OPENAI_BASE_URL_VALUE="${OLLAMA_BASE_URL_VALUE%/}/v1"
|
|
OPENAI_TOKEN_VALUE="${OPENAI_TOKEN_VALUE:-ollama}"
|
|
;;
|
|
openai)
|
|
OPENAI_BASE_URL_VALUE="$(prompt_value \
|
|
"OpenAI-compatible base URL" \
|
|
"$OPENAI_BASE_URL_VALUE" \
|
|
"Use https://api.openai.com/v1 for OpenAI, or your provider's OpenAI-compatible /v1 endpoint.")"
|
|
OPENAI_TOKEN_VALUE="$(prompt_secret \
|
|
"OpenAI-compatible API key" \
|
|
"$OPENAI_TOKEN_VALUE" \
|
|
"Press Enter to reuse OPENAI_TOKEN if set; leave blank only if your endpoint does not require a key.")"
|
|
;;
|
|
none|skip)
|
|
LLM_MODE="none"
|
|
warn "Continuing without an LLM key. The platform can start, but agent/RAG calls will need an LLM configured later."
|
|
;;
|
|
*)
|
|
warn "Unknown LLM provider '$LLM_MODE'; using '$RECOMMENDED_LLM_MODE'."
|
|
LLM_MODE="$RECOMMENDED_LLM_MODE"
|
|
;;
|
|
esac
|
|
|
|
INSTALL_DIR="$(prompt_value \
|
|
"Installer output directory" \
|
|
"$INSTALL_DIR" \
|
|
"This keeps deploy.zip, compose files, logs, and saved answers together.")"
|
|
|
|
if [[ -z "${TG_VENV_DIR:-}" ]]; then
|
|
VENV_DIR="$INSTALL_DIR/.venv"
|
|
fi
|
|
if [[ -z "${TG_NLTK_DATA_DIR:-}" ]]; then
|
|
NLTK_DATA_DIR="$INSTALL_DIR/nltk_data"
|
|
fi
|
|
if [[ -z "${TIKTOKEN_CACHE_DIR:-}" ]]; then
|
|
TIKTOKEN_CACHE_DIR_VALUE="$INSTALL_DIR/tiktoken_cache"
|
|
fi
|
|
}
|
|
|
|
print_plan_summary() {
|
|
say "Install plan"
|
|
info "Install directory: $INSTALL_DIR"
|
|
info "Python venv: $VENV_DIR"
|
|
info "NLTK data: $NLTK_DATA_DIR"
|
|
info "Tokenizer cache: $TIKTOKEN_CACHE_DIR_VALUE"
|
|
info "Run all tests: $([[ "$RUN_TESTS" -eq 1 ]] && printf yes || printf no)"
|
|
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
|
|
info "Compose file: $USE_EXISTING_COMPOSE"
|
|
else
|
|
info "Config generator: npx @trustgraph/config"
|
|
fi
|
|
info "LLM provider: $LLM_MODE"
|
|
if [[ "$LLM_MODE" == "ollama" ]]; then
|
|
info "Ollama URL: $OLLAMA_BASE_URL_VALUE"
|
|
info "Ollama model: $OLLAMA_MODEL"
|
|
info "Ollama embeddings model: $OLLAMA_EMBEDDINGS_MODEL"
|
|
elif [[ "$LLM_MODE" == "openai" ]]; then
|
|
info "OpenAI-compatible URL: $OPENAI_BASE_URL_VALUE"
|
|
fi
|
|
info "Health check timeout: ${HEALTH_TIMEOUT}s"
|
|
info "Autolaunch UI: $([[ "$AUTO_LAUNCH" -eq 1 ]] && printf yes || printf no)"
|
|
}
|
|
|
|
detect_compose_command() {
|
|
if command_exists docker && docker compose version >/dev/null 2>&1; then
|
|
COMPOSE_CMD=(docker compose)
|
|
elif command_exists docker-compose; then
|
|
COMPOSE_CMD=(docker-compose)
|
|
elif command_exists podman-compose; then
|
|
COMPOSE_CMD=(podman-compose)
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
wait_for_docker_ready() {
|
|
local timeout="${1:-60}"
|
|
local deadline=$((SECONDS + timeout))
|
|
|
|
while (( SECONDS < deadline )); do
|
|
if docker info >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
wait_for_podman_ready() {
|
|
local timeout="${1:-60}"
|
|
local deadline=$((SECONDS + timeout))
|
|
|
|
while (( SECONDS < deadline )); do
|
|
if podman info >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
return 1
|
|
}
|
|
|
|
start_docker_runtime_if_possible() {
|
|
local command_text
|
|
|
|
say "Docker is installed but not running"
|
|
|
|
if [[ "$HW_OS" == "Darwin" ]] && command_exists open && [[ -d /Applications/Docker.app ]]; then
|
|
command_text="$(command_to_text open -a Docker)"
|
|
if confirm_install_command "Start Docker Desktop now?" "$command_text"; then
|
|
open -a Docker
|
|
wait_for_docker_ready 90
|
|
return
|
|
fi
|
|
fi
|
|
|
|
if command_exists systemctl; then
|
|
command_text="$(root_command_to_text systemctl start docker)"
|
|
if confirm_install_command "Start the Docker service now?" "$command_text"; then
|
|
run_root_command systemctl start docker
|
|
wait_for_docker_ready 60
|
|
return
|
|
fi
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
start_podman_runtime_if_possible() {
|
|
local command_text
|
|
|
|
say "Podman is installed but not running"
|
|
|
|
if [[ "$HW_OS" == "Darwin" ]] && command_exists podman; then
|
|
command_text="$(command_to_text podman machine init) && $(command_to_text podman machine start)"
|
|
if confirm_install_command "Start a local Podman machine now?" "$command_text"; then
|
|
podman machine init >/dev/null 2>&1 || true
|
|
podman machine start
|
|
wait_for_podman_ready 90
|
|
return
|
|
fi
|
|
fi
|
|
|
|
if command_exists systemctl; then
|
|
command_text="$(command_to_text systemctl --user start podman.socket)"
|
|
if confirm_install_command "Start the user Podman socket now?" "$command_text"; then
|
|
systemctl --user start podman.socket
|
|
wait_for_podman_ready 30
|
|
return
|
|
fi
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
check_container_runtime_ready() {
|
|
case "${COMPOSE_CMD[0]}" in
|
|
docker|docker-compose)
|
|
if ! docker info >/dev/null 2>&1; then
|
|
start_docker_runtime_if_possible || true
|
|
docker info >/dev/null 2>&1 || die "Docker is installed, but the Docker daemon is not reachable. Start Docker Desktop or Docker Engine and run this installer again."
|
|
fi
|
|
;;
|
|
podman-compose)
|
|
if ! podman info >/dev/null 2>&1; then
|
|
start_podman_runtime_if_possible || true
|
|
podman info >/dev/null 2>&1 || die "Podman is installed, but the Podman service is not reachable. Start Podman Desktop or the Podman machine and run this installer again."
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
|
|
install_with_brew() {
|
|
local label="$1"
|
|
shift
|
|
local command_text
|
|
local log_file
|
|
command_text="$(command_to_text brew install "$@")"
|
|
log_file="$(installer_log_file "brew-install-${label// /-}")"
|
|
|
|
if confirm_install_command "Install $label with Homebrew now?" "$command_text"; then
|
|
run_with_spinner_logged "Installing $label with Homebrew" "$log_file" brew install "$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_with_apt() {
|
|
local label="$1"
|
|
shift
|
|
local command_text
|
|
command_text="$(root_command_to_text apt-get update) && $(root_command_to_text apt-get install -y "$@")"
|
|
|
|
if confirm_install_command "Install $label with apt now?" "$command_text"; then
|
|
run_root_command apt-get update
|
|
run_root_command apt-get install -y "$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_with_dnf() {
|
|
local label="$1"
|
|
shift
|
|
local command_text
|
|
command_text="$(root_command_to_text dnf install -y "$@")"
|
|
|
|
if confirm_install_command "Install $label with dnf now?" "$command_text"; then
|
|
run_root_command dnf install -y "$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_with_yum() {
|
|
local label="$1"
|
|
shift
|
|
local command_text
|
|
command_text="$(root_command_to_text yum install -y "$@")"
|
|
|
|
if confirm_install_command "Install $label with yum now?" "$command_text"; then
|
|
run_root_command yum install -y "$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_with_pacman() {
|
|
local label="$1"
|
|
shift
|
|
local command_text
|
|
command_text="$(root_command_to_text pacman -Sy --noconfirm "$@")"
|
|
|
|
if confirm_install_command "Install $label with pacman now?" "$command_text"; then
|
|
run_root_command pacman -Sy --noconfirm "$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_with_zypper() {
|
|
local label="$1"
|
|
shift
|
|
local command_text
|
|
command_text="$(root_command_to_text zypper install -y "$@")"
|
|
|
|
if confirm_install_command "Install $label with zypper now?" "$command_text"; then
|
|
run_root_command zypper install -y "$@"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_python3_prerequisite() {
|
|
if command_exists brew; then
|
|
install_with_brew "Python 3" python
|
|
elif command_exists apt-get; then
|
|
install_with_apt "Python 3" python3 python3-venv python3-pip
|
|
elif command_exists dnf; then
|
|
install_with_dnf "Python 3" python3 python3-pip
|
|
elif command_exists yum; then
|
|
install_with_yum "Python 3" python3 python3-pip
|
|
elif command_exists pacman; then
|
|
install_with_pacman "Python 3" python
|
|
elif command_exists zypper; then
|
|
install_with_zypper "Python 3" python3 python3-pip python3-venv
|
|
else
|
|
warn "No supported package manager was found. Install Python 3 manually, then run this installer again."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_python_venv_prerequisite() {
|
|
if command_exists apt-get; then
|
|
install_with_apt "Python venv support" python3-venv
|
|
elif command_exists zypper; then
|
|
install_with_zypper "Python venv support" python3-venv
|
|
elif command_exists brew || command_exists dnf || command_exists yum || command_exists pacman; then
|
|
info "Python venv support is usually bundled with the Python package on this platform."
|
|
return 1
|
|
else
|
|
warn "Install Python's venv module manually, then run this installer again."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_basic_tool_prerequisite() {
|
|
local tool="$1"
|
|
|
|
if command_exists brew; then
|
|
install_with_brew "$tool" "$tool"
|
|
elif command_exists apt-get; then
|
|
install_with_apt "$tool" "$tool"
|
|
elif command_exists dnf; then
|
|
install_with_dnf "$tool" "$tool"
|
|
elif command_exists yum; then
|
|
install_with_yum "$tool" "$tool"
|
|
elif command_exists pacman; then
|
|
install_with_pacman "$tool" "$tool"
|
|
elif command_exists zypper; then
|
|
install_with_zypper "$tool" "$tool"
|
|
else
|
|
warn "No supported package manager was found. Install $tool manually, then run this installer again."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_node_prerequisite() {
|
|
if command_exists brew; then
|
|
install_with_brew "Node.js and npx" node
|
|
elif command_exists apt-get; then
|
|
install_with_apt "Node.js and npx" nodejs npm
|
|
elif command_exists dnf; then
|
|
install_with_dnf "Node.js and npx" nodejs npm
|
|
elif command_exists yum; then
|
|
install_with_yum "Node.js and npx" nodejs npm
|
|
elif command_exists pacman; then
|
|
install_with_pacman "Node.js and npx" nodejs npm
|
|
elif command_exists zypper; then
|
|
install_with_zypper "Node.js and npx" nodejs npm
|
|
else
|
|
warn "No supported package manager was found. Install Node.js/npm manually, then run this installer again."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
start_podman_machine_if_needed() {
|
|
[[ "$HW_OS" == "Darwin" ]] || return 0
|
|
command_exists podman || return 0
|
|
|
|
if podman info >/dev/null 2>&1; then
|
|
return 0
|
|
fi
|
|
|
|
if ! confirm_install_command \
|
|
"Start a local Podman machine now?" \
|
|
"$(command_to_text podman machine init) && $(command_to_text podman machine start)"; then
|
|
return 1
|
|
fi
|
|
|
|
podman machine init >/dev/null 2>&1 || true
|
|
podman machine start
|
|
}
|
|
|
|
install_compose_prerequisite() {
|
|
if command_exists docker && ! docker compose version >/dev/null 2>&1; then
|
|
if command_exists brew; then
|
|
install_with_brew "Docker Compose" docker-compose
|
|
elif command_exists apt-get; then
|
|
install_with_apt "Docker Compose plugin" docker-compose-plugin
|
|
elif command_exists dnf; then
|
|
install_with_dnf "Docker Compose plugin" docker-compose-plugin
|
|
elif command_exists yum; then
|
|
install_with_yum "Docker Compose plugin" docker-compose-plugin
|
|
elif command_exists pacman; then
|
|
install_with_pacman "Docker Compose" docker-compose
|
|
elif command_exists zypper; then
|
|
install_with_zypper "Docker Compose" docker-compose
|
|
else
|
|
warn "Install Docker Compose manually, then run this installer again."
|
|
return 1
|
|
fi
|
|
return
|
|
fi
|
|
|
|
if command_exists podman && ! command_exists podman-compose; then
|
|
if command_exists brew; then
|
|
install_with_brew "podman-compose" podman-compose
|
|
elif command_exists apt-get; then
|
|
install_with_apt "podman-compose" podman-compose
|
|
elif command_exists dnf; then
|
|
install_with_dnf "podman-compose" podman-compose
|
|
elif command_exists yum; then
|
|
install_with_yum "podman-compose" podman-compose
|
|
elif command_exists pacman; then
|
|
install_with_pacman "podman-compose" podman-compose
|
|
elif command_exists zypper; then
|
|
install_with_zypper "podman-compose" podman-compose
|
|
else
|
|
warn "Install podman-compose manually, then run this installer again."
|
|
return 1
|
|
fi
|
|
start_podman_machine_if_needed || true
|
|
return
|
|
fi
|
|
|
|
if command_exists brew; then
|
|
info "Docker Desktop also works well. The CLI-friendly fallback is Podman plus podman-compose."
|
|
install_with_brew "Podman and podman-compose" podman podman-compose
|
|
start_podman_machine_if_needed || true
|
|
elif command_exists apt-get; then
|
|
install_with_apt "Podman and podman-compose" podman podman-compose
|
|
elif command_exists dnf; then
|
|
install_with_dnf "Podman and podman-compose" podman podman-compose
|
|
elif command_exists yum; then
|
|
install_with_yum "Podman and podman-compose" podman podman-compose
|
|
elif command_exists pacman; then
|
|
install_with_pacman "Podman and podman-compose" podman podman-compose
|
|
elif command_exists zypper; then
|
|
install_with_zypper "Podman and podman-compose" podman podman-compose
|
|
else
|
|
warn "Install Docker Desktop, Docker Engine with Compose, or Podman with podman-compose, then run this installer again."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
install_ollama_prerequisite() {
|
|
local command_text
|
|
|
|
if command_exists brew; then
|
|
install_with_brew "Ollama" ollama
|
|
elif [[ "$HW_OS" == "Linux" ]] && command_exists curl; then
|
|
command_text="curl -fsSL https://ollama.com/install.sh | sh"
|
|
info "This uses Ollama's official Linux install script."
|
|
if confirm_install_command "Install Ollama now?" "$command_text"; then
|
|
sh -c "$command_text"
|
|
else
|
|
return 1
|
|
fi
|
|
else
|
|
warn "Install Ollama from https://ollama.com/download, then run this installer again."
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
ensure_python3_available() {
|
|
command_exists python3 && return 0
|
|
|
|
say "Python 3 is missing"
|
|
install_python3_prerequisite || die "Python 3 is required to run tests and helper CLIs."
|
|
command_exists python3 || die "Python 3 was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
|
|
}
|
|
|
|
ensure_python_venv_available() {
|
|
python3 -m venv --help >/dev/null 2>&1 && return 0
|
|
|
|
say "Python venv support is missing"
|
|
install_python_venv_prerequisite || die "Python venv support is required to create the installer environment."
|
|
python3 -m venv --help >/dev/null 2>&1 || die "Python venv support is still unavailable. Open a new terminal or install python3-venv manually."
|
|
}
|
|
|
|
ensure_basic_tool_available() {
|
|
local tool="$1"
|
|
local reason="$2"
|
|
|
|
command_exists "$tool" && return 0
|
|
|
|
say "$tool is missing"
|
|
info "$reason"
|
|
install_basic_tool_prerequisite "$tool" || die "$tool is required. Install it manually, then run this installer again."
|
|
command_exists "$tool" || die "$tool was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
|
|
}
|
|
|
|
ensure_npx_available() {
|
|
[[ -n "$USE_EXISTING_COMPOSE" ]] && return 0
|
|
command_exists npx && return 0
|
|
|
|
say "npx is missing"
|
|
info "npx is required for the existing TrustGraph config generator: npx @trustgraph/config."
|
|
install_node_prerequisite || die "npx is required. Install Node.js/npm manually, then run this installer again."
|
|
command_exists npx || die "npx was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
|
|
}
|
|
|
|
ensure_compose_available() {
|
|
detect_compose_command && return 0
|
|
|
|
say "Container compose support is missing"
|
|
info "TrustGraph runs as a compose stack. Docker Compose or podman-compose is required."
|
|
install_compose_prerequisite || die "Docker Compose or podman-compose is required to start TrustGraph."
|
|
detect_compose_command || die "Compose support was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
|
|
}
|
|
|
|
ensure_ollama_available_if_needed() {
|
|
[[ "$LLM_MODE" == "ollama" ]] || return 0
|
|
command_exists ollama && return 0
|
|
|
|
say "Ollama is missing"
|
|
info "Ollama was selected for local LLMs, so the Ollama CLI and service are needed before model setup."
|
|
install_ollama_prerequisite || die "Ollama is required for the selected local LLM path. Install it manually, then run this installer again."
|
|
command_exists ollama || die "Ollama was not found after installation. Open a new terminal or add it to PATH, then run this installer again."
|
|
}
|
|
|
|
preflight() {
|
|
say "Checking prerequisites"
|
|
|
|
ensure_python3_available
|
|
ensure_python_venv_available
|
|
ensure_basic_tool_available unzip "unzip is required to unpack deploy.zip from the config generator."
|
|
ensure_basic_tool_available curl "curl is required for startup health checks and local service probes."
|
|
ensure_npx_available
|
|
ensure_compose_available
|
|
ensure_ollama_available_if_needed
|
|
check_container_runtime_ready
|
|
|
|
info "Compose command: ${COMPOSE_CMD[*]}"
|
|
info "Python: $(python3 --version 2>&1)"
|
|
if command_exists npx; then
|
|
info "npx: $(npx --version 2>/dev/null || printf unknown)"
|
|
fi
|
|
}
|
|
|
|
write_env_file() {
|
|
mkdir -p "$INSTALL_DIR"
|
|
local env_file="$INSTALL_DIR/trustgraph-installer.env"
|
|
local grafana_admin_password="${GF_SECURITY_ADMIN_PASSWORD:-${GRAFANA_ADMIN_PASSWORD:-$AUTH_TOKEN}}"
|
|
|
|
umask 077
|
|
{
|
|
printf 'export TRUSTGRAPH_URL=%q\n' "$API_URL"
|
|
printf 'export TRUSTGRAPH_UI_URL=%q\n' "$UI_URL"
|
|
printf 'export TRUSTGRAPH_TOKEN=%q\n' "$AUTH_TOKEN"
|
|
printf 'export TRUSTGRAPH_BOOTSTRAP_TOKEN=%q\n' "$AUTH_TOKEN"
|
|
printf 'export IAM_BOOTSTRAP_TOKEN=%q\n' "$AUTH_TOKEN"
|
|
printf 'export GF_SECURITY_ADMIN_PASSWORD=%q\n' "$grafana_admin_password"
|
|
printf 'export TG_VENV_DIR=%q\n' "$VENV_DIR"
|
|
printf 'export TG_NLTK_DATA_DIR=%q\n' "$NLTK_DATA_DIR"
|
|
printf 'export NLTK_DATA=%q\n' "$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}"
|
|
printf 'export TIKTOKEN_CACHE_DIR=%q\n' "$TIKTOKEN_CACHE_DIR_VALUE"
|
|
printf 'export TRUSTGRAPH_LLM_MODE=%q\n' "$LLM_MODE"
|
|
printf 'export OPENAI_BASE_URL=%q\n' "$OPENAI_BASE_URL_VALUE"
|
|
printf 'export OPENAI_TOKEN=%q\n' "$OPENAI_TOKEN_VALUE"
|
|
printf 'export OLLAMA_HOST=%q\n' "$OLLAMA_BASE_URL_VALUE"
|
|
printf 'export OLLAMA_BASE_URL=%q\n' "$OLLAMA_BASE_URL_VALUE"
|
|
printf 'export OLLAMA_MODEL=%q\n' "$OLLAMA_MODEL"
|
|
printf 'export OLLAMA_EMBEDDINGS_MODEL=%q\n' "$OLLAMA_EMBEDDINGS_MODEL"
|
|
} > "$env_file"
|
|
chmod 600 "$env_file"
|
|
|
|
info "Saved answers to $env_file"
|
|
}
|
|
|
|
prepare_python_env() {
|
|
say "Preparing Python environment"
|
|
mkdir -p "$INSTALL_DIR"
|
|
|
|
if [[ ! -x "$VENV_DIR/bin/python" ]]; then
|
|
info "Creating venv at $VENV_DIR"
|
|
run_with_spinner "Creating Python venv" python3 -m venv "$VENV_DIR"
|
|
else
|
|
info "Using existing venv at $VENV_DIR"
|
|
fi
|
|
|
|
PYTHON_BIN="$VENV_DIR/bin/python"
|
|
export PATH="$VENV_DIR/bin:$PATH"
|
|
info "Python venv: $($PYTHON_BIN --version 2>&1)"
|
|
}
|
|
|
|
ensure_version_files() {
|
|
local version="${TRUSTGRAPH_LOCAL_VERSION:-2.5.0}"
|
|
local specs=(
|
|
"trustgraph-base/trustgraph/base_version.py:trustgraph.base_version"
|
|
"trustgraph-flow/trustgraph/flow_version.py:trustgraph.flow_version"
|
|
"trustgraph-vertexai/trustgraph/vertexai_version.py:trustgraph.vertexai_version"
|
|
"trustgraph-bedrock/trustgraph/bedrock_version.py:trustgraph.bedrock_version"
|
|
"trustgraph-embeddings-hf/trustgraph/embeddings_hf_version.py:trustgraph.embeddings_hf_version"
|
|
"trustgraph-cli/trustgraph/cli_version.py:trustgraph.cli_version"
|
|
"trustgraph-ocr/trustgraph/ocr_version.py:trustgraph.ocr_version"
|
|
"trustgraph-unstructured/trustgraph/unstructured_version.py:trustgraph.unstructured_version"
|
|
"trustgraph-mcp/trustgraph/mcp_version.py:trustgraph.mcp_version"
|
|
"trustgraph/trustgraph/trustgraph_version.py:trustgraph.trustgraph_version"
|
|
)
|
|
|
|
say "Ensuring local package version files"
|
|
for spec in "${specs[@]}"; do
|
|
local file="${spec%%:*}"
|
|
mkdir -p "$(dirname "$SCRIPT_DIR/$file")"
|
|
printf '__version__ = "%s"\n' "$version" > "$SCRIPT_DIR/$file"
|
|
info "Set $file to $version"
|
|
done
|
|
}
|
|
|
|
local_package_pythonpath() {
|
|
local package_dirs=(
|
|
"$SCRIPT_DIR/trustgraph-flow"
|
|
"$SCRIPT_DIR/trustgraph-embeddings-hf"
|
|
"$SCRIPT_DIR/trustgraph-base"
|
|
"$SCRIPT_DIR/trustgraph-cli"
|
|
"$SCRIPT_DIR/trustgraph-bedrock"
|
|
"$SCRIPT_DIR/trustgraph-ocr"
|
|
"$SCRIPT_DIR/trustgraph-unstructured"
|
|
"$SCRIPT_DIR/trustgraph-mcp"
|
|
"$SCRIPT_DIR/trustgraph-vertexai"
|
|
"$SCRIPT_DIR/trustgraph"
|
|
)
|
|
local joined=""
|
|
local dir
|
|
|
|
for dir in "${package_dirs[@]}"; do
|
|
if [[ -d "$dir" ]]; then
|
|
if [[ -n "$joined" ]]; then
|
|
joined="$joined:$dir"
|
|
else
|
|
joined="$dir"
|
|
fi
|
|
fi
|
|
done
|
|
|
|
printf '%s\n' "$joined"
|
|
}
|
|
|
|
ensure_python_build_tools() {
|
|
say "Preparing Python build tools"
|
|
local pip_cache_dir="$INSTALL_DIR/pip_cache"
|
|
mkdir -p "$pip_cache_dir"
|
|
|
|
if ! "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then
|
|
local ensurepip_log
|
|
ensurepip_log="$(installer_log_file "python-ensurepip")"
|
|
info "Installing pip into the Python venv"
|
|
run_with_spinner_logged \
|
|
"Installing pip" \
|
|
"$ensurepip_log" \
|
|
"$PYTHON_BIN" -m ensurepip --upgrade \
|
|
|| die "Could not install pip into the Python venv."
|
|
fi
|
|
|
|
if "$PYTHON_BIN" - <<'PY' >/dev/null 2>&1
|
|
import setuptools.build_meta
|
|
PY
|
|
then
|
|
info "Python build backend available: setuptools.build_meta"
|
|
return
|
|
fi
|
|
|
|
local log_file
|
|
log_file="$(installer_log_file "pip-build-tools")"
|
|
info "Installing setuptools and wheel into the Python venv"
|
|
run_with_spinner_logged \
|
|
"Installing Python build tools" \
|
|
"$log_file" \
|
|
env \
|
|
PIP_CACHE_DIR="$pip_cache_dir" \
|
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
"$PYTHON_BIN" -m pip install "setuptools>=61" wheel \
|
|
|| die "Could not install setuptools/wheel. Check $log_file, then re-run the installer."
|
|
}
|
|
|
|
install_test_packages() {
|
|
say "Installing local Python packages for tests"
|
|
local pip_cache_dir="$INSTALL_DIR/pip_cache"
|
|
mkdir -p "$pip_cache_dir"
|
|
ensure_python_build_tools
|
|
|
|
local package_dirs=(
|
|
trustgraph-base
|
|
trustgraph-cli
|
|
trustgraph-flow
|
|
trustgraph-vertexai
|
|
trustgraph-bedrock
|
|
trustgraph-embeddings-hf
|
|
trustgraph-ocr
|
|
trustgraph-unstructured
|
|
trustgraph-mcp
|
|
)
|
|
|
|
for package_dir in "${package_dirs[@]}"; do
|
|
if [[ -d "$SCRIPT_DIR/$package_dir" ]]; then
|
|
local log_file
|
|
log_file="$(installer_log_file "pip-${package_dir}")"
|
|
info "Installing $package_dir"
|
|
run_with_spinner_logged \
|
|
"Installing $package_dir" \
|
|
"$log_file" \
|
|
env \
|
|
PIP_CACHE_DIR="$pip_cache_dir" \
|
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
"$PYTHON_BIN" -m pip install --no-build-isolation "$SCRIPT_DIR/$package_dir"
|
|
fi
|
|
done
|
|
|
|
if [[ -f "$SCRIPT_DIR/tests/requirements.txt" ]]; then
|
|
local log_file
|
|
log_file="$(installer_log_file "pip-test-requirements")"
|
|
info "Installing test requirements"
|
|
run_with_spinner_logged \
|
|
"Installing test requirements" \
|
|
"$log_file" \
|
|
env \
|
|
PIP_CACHE_DIR="$pip_cache_dir" \
|
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
"$PYTHON_BIN" -m pip install -r "$SCRIPT_DIR/tests/requirements.txt"
|
|
fi
|
|
}
|
|
|
|
ensure_tokenizer_cache() {
|
|
say "Preparing tokenizer cache"
|
|
mkdir -p "$TIKTOKEN_CACHE_DIR_VALUE"
|
|
info "tiktoken cache: $TIKTOKEN_CACHE_DIR_VALUE"
|
|
|
|
TIKTOKEN_CACHE_DIR="$TIKTOKEN_CACHE_DIR_VALUE" "$PYTHON_BIN" - <<'PY'
|
|
import tiktoken
|
|
|
|
tiktoken.get_encoding("cl100k_base")
|
|
print(" Cached tiktoken encoding: cl100k_base")
|
|
PY
|
|
}
|
|
|
|
ensure_nltk_data() {
|
|
say "Preparing NLTK tokenizer data"
|
|
mkdir -p "$NLTK_DATA_DIR"
|
|
info "NLTK data: $NLTK_DATA_DIR"
|
|
|
|
TG_NLTK_DATA_DIR="$NLTK_DATA_DIR" \
|
|
NLTK_DATA="$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}" \
|
|
"$PYTHON_BIN" - <<'PY'
|
|
import os
|
|
import nltk
|
|
|
|
target = os.environ["TG_NLTK_DATA_DIR"]
|
|
if target not in nltk.data.path:
|
|
nltk.data.path.insert(0, target)
|
|
|
|
resources = (
|
|
("punkt", "tokenizers/punkt"),
|
|
("punkt_tab", "tokenizers/punkt_tab"),
|
|
("averaged_perceptron_tagger_eng", "taggers/averaged_perceptron_tagger_eng"),
|
|
)
|
|
|
|
for package, resource in resources:
|
|
try:
|
|
nltk.data.find(resource)
|
|
except LookupError:
|
|
print(f" Downloading NLTK resource: {package}")
|
|
if not nltk.download(package, download_dir=target, quiet=True):
|
|
raise SystemExit(f"Could not download NLTK resource: {package}")
|
|
else:
|
|
print(f" NLTK resource already available: {package}")
|
|
PY
|
|
}
|
|
|
|
run_all_tests() {
|
|
if [[ "$RUN_TESTS" -ne 1 ]]; then
|
|
warn "Skipping tests because --skip-tests was supplied."
|
|
return
|
|
fi
|
|
|
|
prepare_python_env
|
|
ensure_version_files
|
|
install_test_packages
|
|
ensure_tokenizer_cache
|
|
ensure_nltk_data
|
|
|
|
say "Running all tests"
|
|
info "Command: $PYTHON_BIN -m pytest tests"
|
|
local test_log
|
|
test_log="$(installer_log_file "pytest")"
|
|
if spinner_enabled; then
|
|
info "Test output log: $test_log"
|
|
fi
|
|
(
|
|
cd "$SCRIPT_DIR"
|
|
run_with_spinner_logged \
|
|
"Running pytest tests" \
|
|
"$test_log" \
|
|
env \
|
|
INSTALL_TRUSTGRAPH_SOURCE_ONLY= \
|
|
TG_NO_SPINNER= \
|
|
TG_FORCE_SPINNER= \
|
|
NLTK_DATA="$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}" \
|
|
TIKTOKEN_CACHE_DIR="$TIKTOKEN_CACHE_DIR_VALUE" \
|
|
TRUSTGRAPH_CASSANDRA_SKIP_ON_UNREADY=1 \
|
|
"$PYTHON_BIN" -m pytest tests
|
|
)
|
|
}
|
|
|
|
show_config_guidance() {
|
|
say "Before the config wizard starts"
|
|
info "Choose a Docker/Podman compose deployment for local installation."
|
|
info "Keep the Workbench UI enabled; the existing UI default is port 8888."
|
|
info "Use the bundled infrastructure defaults: Cassandra, Qdrant, Garage, and RabbitMQ/Pulsar as offered."
|
|
if [[ -n "$AUTH_TOKEN" ]]; then
|
|
info "For IAM/auth, use token/bootstrap-token mode when offered."
|
|
info "Admin/bootstrap API key to enter if asked: $AUTH_TOKEN"
|
|
else
|
|
info "For IAM/auth, use token/bootstrap-token mode when offered and paste the API key saved by this installer."
|
|
fi
|
|
if [[ "$LLM_MODE" == "ollama" ]]; then
|
|
info "For LLMs, choose Ollama or an OpenAI-compatible endpoint and use $OLLAMA_BASE_URL_VALUE."
|
|
elif [[ "$LLM_MODE" == "openai" ]]; then
|
|
info "For LLMs, choose OpenAI/OpenAI-compatible and use $OPENAI_BASE_URL_VALUE."
|
|
else
|
|
info "You can skip LLM configuration now and add it later in the Workbench."
|
|
fi
|
|
}
|
|
|
|
run_config_generator() {
|
|
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
|
|
return
|
|
fi
|
|
|
|
mkdir -p "$INSTALL_DIR"
|
|
|
|
if [[ -f "$INSTALL_DIR/deploy.zip" ]]; then
|
|
if confirm "Existing deploy.zip found in $INSTALL_DIR. Reuse it and skip the config wizard?" 1; then
|
|
info "Using existing deployment archive: $INSTALL_DIR/deploy.zip"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
show_config_guidance
|
|
|
|
if ! confirm "Start the TrustGraph config wizard now?" 1; then
|
|
die "Config generation cancelled."
|
|
fi
|
|
|
|
say "Running TrustGraph config generator"
|
|
(
|
|
cd "$INSTALL_DIR"
|
|
TRUSTGRAPH_TOKEN="$AUTH_TOKEN" \
|
|
TRUSTGRAPH_BOOTSTRAP_TOKEN="$AUTH_TOKEN" \
|
|
OPENAI_TOKEN="$OPENAI_TOKEN_VALUE" \
|
|
OPENAI_BASE_URL="$OPENAI_BASE_URL_VALUE" \
|
|
OLLAMA_HOST="$OLLAMA_BASE_URL_VALUE" \
|
|
OLLAMA_BASE_URL="$OLLAMA_BASE_URL_VALUE" \
|
|
OLLAMA_MODEL="$OLLAMA_MODEL" \
|
|
OLLAMA_EMBEDDINGS_MODEL="$OLLAMA_EMBEDDINGS_MODEL" \
|
|
NLTK_DATA="$NLTK_DATA_DIR${NLTK_DATA:+:$NLTK_DATA}" \
|
|
TIKTOKEN_CACHE_DIR="$TIKTOKEN_CACHE_DIR_VALUE" \
|
|
npx @trustgraph/config
|
|
)
|
|
}
|
|
|
|
find_compose_file() {
|
|
if [[ -n "$USE_EXISTING_COMPOSE" ]]; then
|
|
[[ -f "$USE_EXISTING_COMPOSE" ]] || die "Compose file does not exist: $USE_EXISTING_COMPOSE"
|
|
printf '%s\n' "$USE_EXISTING_COMPOSE"
|
|
return
|
|
fi
|
|
|
|
local deploy_zip="$INSTALL_DIR/deploy.zip"
|
|
local unpack_dir="$INSTALL_DIR/deploy"
|
|
|
|
[[ -f "$deploy_zip" ]] || die "The config generator did not create $deploy_zip"
|
|
|
|
rm -rf "$unpack_dir"
|
|
mkdir -p "$unpack_dir"
|
|
unzip -oq "$deploy_zip" -d "$unpack_dir"
|
|
|
|
local compose_file
|
|
compose_file="$(find "$unpack_dir" "$INSTALL_DIR" \
|
|
\( -name 'docker-compose.yaml' -o -name 'docker-compose.yml' -o -name 'compose.yaml' -o -name 'compose.yml' \) \
|
|
-type f | head -n 1)"
|
|
|
|
[[ -n "$compose_file" ]] || die "Could not find a compose file in $deploy_zip"
|
|
printf '%s\n' "$compose_file"
|
|
}
|
|
|
|
compose_dir_for() {
|
|
local compose_file="$1"
|
|
(cd "$(dirname "$compose_file")" && pwd -P)
|
|
}
|
|
|
|
compose_env_file_for() {
|
|
local compose_file="$1"
|
|
local compose_dir
|
|
|
|
compose_dir="$(compose_dir_for "$compose_file")"
|
|
printf '%s/.env\n' "$compose_dir"
|
|
}
|
|
|
|
write_compose_env_file() {
|
|
local compose_file="$1"
|
|
local compose_env_file
|
|
local grafana_admin_password="${GF_SECURITY_ADMIN_PASSWORD:-${GRAFANA_ADMIN_PASSWORD:-$AUTH_TOKEN}}"
|
|
|
|
[[ -n "$AUTH_TOKEN" ]] || die "TrustGraph API key is empty; cannot create compose environment."
|
|
|
|
compose_env_file="$(compose_env_file_for "$compose_file")"
|
|
umask 077
|
|
{
|
|
printf 'TRUSTGRAPH_TOKEN=%s\n' "$AUTH_TOKEN"
|
|
printf 'TRUSTGRAPH_BOOTSTRAP_TOKEN=%s\n' "$AUTH_TOKEN"
|
|
printf 'IAM_BOOTSTRAP_TOKEN=%s\n' "$AUTH_TOKEN"
|
|
printf 'GF_SECURITY_ADMIN_PASSWORD=%s\n' "$grafana_admin_password"
|
|
printf 'OLLAMA_HOST=%s\n' "$OLLAMA_BASE_URL_VALUE"
|
|
printf 'OLLAMA_BASE_URL=%s\n' "$OLLAMA_BASE_URL_VALUE"
|
|
printf 'OLLAMA_MODEL=%s\n' "$OLLAMA_MODEL"
|
|
printf 'OLLAMA_EMBEDDINGS_MODEL=%s\n' "$OLLAMA_EMBEDDINGS_MODEL"
|
|
} > "$compose_env_file"
|
|
chmod 600 "$compose_env_file"
|
|
|
|
info "Compose environment: $compose_env_file"
|
|
}
|
|
|
|
start_stack() {
|
|
local compose_file="$1"
|
|
local compose_dir
|
|
local compose_name
|
|
local log_file
|
|
|
|
say "Starting TrustGraph"
|
|
info "Compose file: $compose_file"
|
|
write_compose_env_file "$compose_file"
|
|
|
|
compose_dir="$(compose_dir_for "$compose_file")"
|
|
compose_name="$(basename "$compose_file")"
|
|
log_file="$(installer_log_file "compose-up")"
|
|
case "$log_file" in
|
|
/*) ;;
|
|
*) log_file="$SCRIPT_DIR/$log_file" ;;
|
|
esac
|
|
|
|
(
|
|
cd "$compose_dir"
|
|
run_with_spinner_logged \
|
|
"Starting TrustGraph containers" \
|
|
"$log_file" \
|
|
"${COMPOSE_CMD[@]}" -f "$compose_name" up -d
|
|
)
|
|
}
|
|
|
|
http_status() {
|
|
local url="$1"
|
|
curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "$url" 2>/dev/null || true
|
|
}
|
|
|
|
http_status_with_bearer() {
|
|
local url="$1"
|
|
local token="$2"
|
|
|
|
curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \
|
|
-H "Authorization: Bearer $token" \
|
|
"$url" 2>/dev/null || true
|
|
}
|
|
|
|
sha256_text() {
|
|
local value="$1"
|
|
|
|
printf '%s' "$value" | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())'
|
|
}
|
|
|
|
repair_local_iam_api_key() {
|
|
local compose_file="$1"
|
|
local compose_dir
|
|
local compose_name
|
|
local key_hash
|
|
local key_suffix
|
|
local user_id
|
|
local username
|
|
local key_id
|
|
local prefix
|
|
local cql
|
|
local log_file
|
|
|
|
[[ -n "$compose_file" && -f "$compose_file" ]] || return 1
|
|
[[ -n "$AUTH_TOKEN" ]] || return 1
|
|
command_exists python3 || return 1
|
|
[[ "${#COMPOSE_CMD[@]}" -gt 0 ]] || return 1
|
|
|
|
key_hash="$(sha256_text "$AUTH_TOKEN")"
|
|
key_suffix="${key_hash:0:12}"
|
|
user_id="installer-admin-$key_suffix"
|
|
username="installer-admin-$key_suffix"
|
|
key_id="installer-key-$key_suffix"
|
|
prefix="$(printf '%s' "${AUTH_TOKEN:0:7}" | tr -cd 'a-zA-Z0-9_-')"
|
|
compose_dir="$(compose_dir_for "$compose_file")"
|
|
compose_name="$(basename "$compose_file")"
|
|
log_file="$(installer_log_file "iam-key-repair")"
|
|
|
|
cql="
|
|
USE iam;
|
|
INSERT INTO iam_users (id, workspace, username, name, email, password_hash, roles, enabled, must_change_password, created)
|
|
VALUES ('$user_id', 'default', '$username', 'Installer Admin', '', 'installer-repair', {'admin'}, true, false, toTimestamp(now()));
|
|
INSERT INTO iam_users_by_username (workspace, username, user_id)
|
|
VALUES ('default', '$username', '$user_id');
|
|
INSERT INTO iam_api_keys (key_hash, id, user_id, name, prefix, expires, created, last_used)
|
|
VALUES ('$key_hash', '$key_id', '$user_id', 'installer-repair', '$prefix', null, toTimestamp(now()), null);
|
|
"
|
|
|
|
say "Repairing local IAM API key"
|
|
info "Adding the saved installer key to the local installer-managed IAM database."
|
|
mkdir -p "$(dirname "$log_file")"
|
|
if (
|
|
cd "$compose_dir"
|
|
printf '%s\n' "$cql" | "${COMPOSE_CMD[@]}" -f "$compose_name" exec -T cassandra cqlsh
|
|
) >"$log_file" 2>&1; then
|
|
info "Local IAM API key repair completed."
|
|
return 0
|
|
fi
|
|
|
|
warn "Local IAM API key repair failed. Last log lines from $log_file:"
|
|
tail -n 40 "$log_file" >&2 || true
|
|
return 1
|
|
}
|
|
|
|
wait_for_gateway() {
|
|
local deadline=$((SECONDS + HEALTH_TIMEOUT))
|
|
local next_notice=$((SECONDS + 15))
|
|
local status=""
|
|
|
|
say "Waiting for API gateway"
|
|
info "Checking $API_URL for up to ${HEALTH_TIMEOUT}s."
|
|
while (( SECONDS < deadline )); do
|
|
status="$(http_status "$API_URL")"
|
|
if [[ "$status" == "200" || "$status" == "401" || "$status" == "404" ]]; then
|
|
info "API gateway is responding with HTTP $status"
|
|
return 0
|
|
fi
|
|
if (( SECONDS >= next_notice )); then
|
|
info "Still waiting; last HTTP status was ${status:-connection failed}."
|
|
next_notice=$((SECONDS + 15))
|
|
fi
|
|
sleep 3
|
|
done
|
|
|
|
die "API gateway did not respond at $API_URL within ${HEALTH_TIMEOUT}s"
|
|
}
|
|
|
|
verify_api_key_authentication() {
|
|
local compose_file="${1:-}"
|
|
local deadline=$((SECONDS + AUTH_CHECK_TIMEOUT))
|
|
local metrics_url="${API_URL%/}/api/metrics/query?query=processor_info"
|
|
local status=""
|
|
|
|
[[ -n "$AUTH_TOKEN" ]] || return 0
|
|
|
|
say "Checking API key authentication"
|
|
info "The API gateway root can return HTTP 404; that is normal. This checks an authenticated endpoint."
|
|
|
|
while :; do
|
|
status="$(http_status_with_bearer "$metrics_url" "$AUTH_TOKEN")"
|
|
case "$status" in
|
|
200)
|
|
info "Installer API key authenticated at the API gateway."
|
|
return 0
|
|
;;
|
|
401|403)
|
|
;;
|
|
"")
|
|
;;
|
|
*)
|
|
info "Authentication probe returned HTTP $status; continuing to the full health checks."
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
(( SECONDS >= deadline )) && break
|
|
sleep 3
|
|
done
|
|
|
|
if [[ "$status" == "401" || "$status" == "403" ]]; then
|
|
if [[ -n "$compose_file" ]] && repair_local_iam_api_key "$compose_file"; then
|
|
status="$(http_status_with_bearer "$metrics_url" "$AUTH_TOKEN")"
|
|
if [[ "$status" == "200" ]]; then
|
|
info "Installer API key authenticated after local IAM repair."
|
|
return 0
|
|
fi
|
|
fi
|
|
warn "The API gateway is running, but it rejected the installer API key."
|
|
info "Configured installer API key: $AUTH_TOKEN"
|
|
info "Saved environment: $INSTALL_DIR/trustgraph-installer.env"
|
|
warn "This usually means compose volumes contain IAM data from an earlier install. Run ./install_trustgraph.sh --remove-all to remove the installer-managed deployment and compose volumes, then reinstall; or rerun with the original TRUSTGRAPH_TOKEN if you know it."
|
|
return 1
|
|
fi
|
|
|
|
warn "Could not confirm API key authentication yet; continuing to the full health checks."
|
|
}
|
|
|
|
bootstrap_iam_if_available() {
|
|
local bootstrap_output=""
|
|
local log_file="$INSTALL_DIR/iam-bootstrap.log"
|
|
|
|
if ! command_exists tg-bootstrap-iam; then
|
|
warn "tg-bootstrap-iam is not on PATH; using the installer API key for health checks."
|
|
return
|
|
fi
|
|
|
|
say "Checking IAM bootstrap"
|
|
if bootstrap_output="$(tg-bootstrap-iam --api-url "$API_URL" 2>"$log_file")"; then
|
|
if [[ -n "$bootstrap_output" ]]; then
|
|
AUTH_TOKEN="$bootstrap_output"
|
|
info "Captured the first-run admin API key from IAM bootstrap."
|
|
write_env_file
|
|
fi
|
|
else
|
|
info "IAM bootstrap did not issue a new key; this is normal for token mode or an already-bootstrapped system."
|
|
info "Details: $log_file"
|
|
fi
|
|
}
|
|
|
|
verify_system() {
|
|
local verify_cmd=()
|
|
|
|
if command_exists tg-verify-system-status; then
|
|
verify_cmd=(tg-verify-system-status)
|
|
elif "$PYTHON_BIN" -c 'import trustgraph.cli.verify_system_status' >/dev/null 2>&1; then
|
|
verify_cmd=("$PYTHON_BIN" -m trustgraph.cli.verify_system_status)
|
|
else
|
|
say "Verifying TrustGraph health"
|
|
info "API gateway: $API_URL"
|
|
info "Workbench UI: $UI_URL"
|
|
[[ "$(http_status "$API_URL")" =~ ^(200|401|404)$ ]] || die "API gateway health check failed."
|
|
[[ "$(http_status "${UI_URL%/}/index.html")" == "200" ]] || warn "Workbench UI did not return HTTP 200 yet."
|
|
return
|
|
fi
|
|
|
|
say "Verifying TrustGraph health"
|
|
verify_cmd+=(
|
|
--api-url "$API_URL"
|
|
--ui-url "$UI_URL"
|
|
--global-timeout "$HEALTH_TIMEOUT"
|
|
)
|
|
if [[ -n "$AUTH_TOKEN" ]]; then
|
|
verify_cmd+=(--token "$AUTH_TOKEN")
|
|
fi
|
|
|
|
"${verify_cmd[@]}"
|
|
}
|
|
|
|
launch_ui() {
|
|
if [[ "$AUTO_LAUNCH" -ne 1 ]]; then
|
|
info "Workbench UI autolaunch disabled."
|
|
return
|
|
fi
|
|
|
|
say "Opening Workbench UI"
|
|
if command_exists open; then
|
|
open "$UI_URL"
|
|
elif command_exists xdg-open; then
|
|
xdg-open "$UI_URL"
|
|
elif command_exists wslview; then
|
|
wslview "$UI_URL"
|
|
else
|
|
warn "Could not find a browser launcher. Open this URL manually: $UI_URL"
|
|
return
|
|
fi
|
|
info "Workbench UI: $UI_URL"
|
|
}
|
|
|
|
print_ready_summary() {
|
|
local auth_status="${1:-0}"
|
|
|
|
if [[ "$auth_status" -eq 0 ]]; then
|
|
say "TrustGraph is ready"
|
|
else
|
|
say "TrustGraph started with an authentication warning"
|
|
fi
|
|
info "Workbench UI: $UI_URL"
|
|
info "API gateway: $API_URL"
|
|
if [[ -n "$AUTH_TOKEN" ]]; then
|
|
info "Admin/bootstrap API key: $AUTH_TOKEN"
|
|
fi
|
|
info "Saved environment: $INSTALL_DIR/trustgraph-installer.env"
|
|
}
|
|
|
|
main() {
|
|
parse_args "$@"
|
|
cd "$SCRIPT_DIR"
|
|
init_colors
|
|
|
|
print_banner
|
|
if [[ "$REMOVE_ALL" -eq 1 ]]; then
|
|
say "$APP_NAME guided uninstaller"
|
|
load_saved_answers
|
|
remove_all_installation
|
|
return 0
|
|
fi
|
|
|
|
say "$APP_NAME guided installer"
|
|
handle_existing_install
|
|
load_saved_answers
|
|
detect_hardware
|
|
choose_recommendations
|
|
print_hardware_summary
|
|
|
|
collect_answers
|
|
print_plan_summary
|
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
say "Dry run complete"
|
|
return 0
|
|
fi
|
|
|
|
if ! confirm "Proceed with this install plan?" 1; then
|
|
die "Install cancelled."
|
|
fi
|
|
|
|
preflight
|
|
offer_ollama_model_downloads
|
|
write_env_file
|
|
run_all_tests
|
|
run_config_generator
|
|
|
|
local compose_file
|
|
compose_file="$(find_compose_file)"
|
|
start_stack "$compose_file"
|
|
wait_for_gateway
|
|
bootstrap_iam_if_available
|
|
local auth_status=0
|
|
local verify_status=0
|
|
|
|
verify_api_key_authentication "$compose_file" || auth_status=$?
|
|
if [[ "$auth_status" -eq 0 ]]; then
|
|
verify_system || verify_status=$?
|
|
else
|
|
warn "Skipping authenticated health checks because the configured API key was rejected."
|
|
fi
|
|
launch_ui
|
|
|
|
print_ready_summary "$auth_status"
|
|
|
|
if [[ "$auth_status" -ne 0 ]]; then
|
|
return "$auth_status"
|
|
fi
|
|
if [[ "$verify_status" -ne 0 ]]; then
|
|
return "$verify_status"
|
|
fi
|
|
}
|
|
|
|
if [[ "${INSTALL_TRUSTGRAPH_SOURCE_ONLY:-0}" != "1" ]]; then
|
|
main "$@"
|
|
fi
|