mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
effc60297c
run.sh autodetects Ubuntu/Debian, Fedora/RHEL, Arch, and WSL; ensures git and Docker; clones, builds, picks free ports (Caddy prefers 443, Postgres 5432), generates a .env with a JWT secret and Postgres password, asks how many nodes to run, and starts the stack. The node count persists via an auto-loaded compose.override.yaml, so a later plain `docker compose up -d` keeps it; a fresh clone with no override still starts all 10. Ignores the generated override.
419 lines
16 KiB
Bash
Executable File
419 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# Turnstone one-line installer.
|
|
#
|
|
# curl -fsSL https://raw.githubusercontent.com/turnstonelabs/turnstone/main/run.sh | bash
|
|
#
|
|
# Autodetects your distro (Ubuntu/Debian, Fedora/RHEL, Arch, and WSL on any of
|
|
# them) and:
|
|
# 1. ensures git is installed, then clones the repo
|
|
# 2. ensures Docker + the compose plugin are installed and the daemon is usable
|
|
# 3. asks how many server nodes to run (1-10)
|
|
# 4. builds the image
|
|
# 5. picks free host ports for Caddy (prefers 443) and PostgreSQL
|
|
# 6. writes a .env with a generated JWT secret + Postgres password
|
|
# 7. pins the node count and runs `docker compose up -d`, then prints how to
|
|
# finish setup in the UI
|
|
#
|
|
# Re-running is safe: it updates the checkout and keeps an existing .env.
|
|
#
|
|
# Env overrides:
|
|
# TURNSTONE_DIR where to clone (default: $HOME/turnstone)
|
|
# TURNSTONE_REPO git URL (default: https://github.com/turnstonelabs/turnstone.git)
|
|
|
|
set -Eeuo pipefail
|
|
|
|
REPO_URL="${TURNSTONE_REPO:-https://github.com/turnstonelabs/turnstone.git}"
|
|
INSTALL_DIR="${TURNSTONE_DIR:-$HOME/turnstone}"
|
|
|
|
# -- output helpers -----------------------------------------------------------
|
|
if [ -t 1 ]; then
|
|
BOLD=$'\033[1m'; DIM=$'\033[2m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'
|
|
RED=$'\033[31m'; RESET=$'\033[0m'
|
|
else
|
|
BOLD=""; DIM=""; GREEN=""; YELLOW=""; RED=""; RESET=""
|
|
fi
|
|
info() { printf '%s==>%s %s\n' "$GREEN" "$RESET" "$*"; }
|
|
warn() { printf '%swarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2; }
|
|
die() { printf '%serror:%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }
|
|
have() { command -v "$1" >/dev/null 2>&1; }
|
|
|
|
# Any *unhandled* failure (set -Ee) lands here with an actionable note instead of
|
|
# a bare non-zero exit. `die` exits explicitly and does not trigger this.
|
|
on_error() {
|
|
local rc=$?
|
|
printf '\n%serror:%s the installer stopped unexpectedly (exit %s). The output above shows why.\n' \
|
|
"$RED" "$RESET" "$rc" >&2
|
|
printf ' Fix the issue and re-run this script — it resumes the parts already done.\n' >&2
|
|
}
|
|
trap on_error ERR
|
|
|
|
# Ask a yes/no question, reading from the terminal even under `curl | bash`
|
|
# (where stdin is the script). Defaults to "$2" when non-interactive.
|
|
ask() {
|
|
local prompt="$1" default="${2:-y}" ans hint
|
|
[ "$default" = y ] && hint="Y/n" || hint="y/N"
|
|
if [ ! -r /dev/tty ]; then
|
|
warn "non-interactive shell; assuming '$default' for: $prompt"
|
|
[ "$default" = y ]; return
|
|
fi
|
|
printf '%s%s%s [%s] ' "$BOLD" "$prompt" "$RESET" "$hint" >/dev/tty
|
|
read -r ans </dev/tty || ans=""
|
|
ans="${ans:-$default}"
|
|
case "$ans" in [Yy]*) return 0 ;; *) return 1 ;; esac
|
|
}
|
|
|
|
# -- distro / package manager detection --------------------------------------
|
|
OS_ID=""; OS_LIKE=""; PKG=""; IS_WSL=0; SUDO=""
|
|
|
|
detect_os() {
|
|
if [ -r /etc/os-release ]; then
|
|
# shellcheck disable=SC1091
|
|
. /etc/os-release
|
|
OS_ID="${ID:-}"; OS_LIKE="${ID_LIKE:-}"
|
|
fi
|
|
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then
|
|
IS_WSL=1
|
|
fi
|
|
case " $OS_ID $OS_LIKE " in
|
|
*" arch "*|*manjaro*) PKG=pacman ;;
|
|
*" ubuntu "*|*" debian "*) PKG=apt ;;
|
|
*" fedora "*|*" rhel "*|*" centos "*) PKG=dnf ;;
|
|
*) if have apt-get; then PKG=apt
|
|
elif have dnf; then PKG=dnf
|
|
elif have yum; then PKG=yum
|
|
elif have pacman; then PKG=pacman
|
|
fi ;;
|
|
esac
|
|
[ -n "$PKG" ] || die "could not detect a supported package manager (apt/dnf/yum/pacman). Install git + Docker manually, then re-run."
|
|
[ "$PKG" = dnf ] && ! have dnf && have yum && PKG=yum
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
have sudo || die "this script needs root for package installs — install sudo or run as root."
|
|
SUDO="sudo"
|
|
fi
|
|
local where="$OS_ID"; [ "$IS_WSL" -eq 1 ] && where="$OS_ID (WSL)"
|
|
info "Detected ${where:-linux} — using ${PKG}."
|
|
}
|
|
|
|
pkg_install() {
|
|
info "Installing: $*"
|
|
case "$PKG" in
|
|
apt) $SUDO apt-get update -y && $SUDO apt-get install -y "$@" ;;
|
|
dnf) $SUDO dnf install -y "$@" ;;
|
|
yum) $SUDO yum install -y "$@" ;;
|
|
pacman) $SUDO pacman -Sy --needed --noconfirm "$@" ;;
|
|
esac
|
|
}
|
|
|
|
# -- git ----------------------------------------------------------------------
|
|
ensure_git() {
|
|
have git && return
|
|
warn "git is not installed."
|
|
ask "Install git now?" y || die "git is required to clone the repo."
|
|
pkg_install git || die "git installation failed — see the output above."
|
|
have git || die "git installation reported success but 'git' is not on PATH."
|
|
}
|
|
|
|
clone_repo() {
|
|
if [ -d "$INSTALL_DIR/.git" ]; then
|
|
info "Updating existing checkout at $INSTALL_DIR"
|
|
git -C "$INSTALL_DIR" pull --ff-only || warn "could not fast-forward; using the existing checkout."
|
|
else
|
|
[ -e "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ] \
|
|
&& die "$INSTALL_DIR exists and is not a turnstone checkout. Set TURNSTONE_DIR to an empty path."
|
|
info "Cloning $REPO_URL into $INSTALL_DIR"
|
|
# Skip Git LFS smudge — the LFS objects are only diagram PNGs, not needed to run.
|
|
GIT_LFS_SKIP_SMUDGE=1 git clone --depth 1 "$REPO_URL" "$INSTALL_DIR"
|
|
fi
|
|
}
|
|
|
|
# -- docker -------------------------------------------------------------------
|
|
DOCKER="docker"
|
|
|
|
install_docker() {
|
|
case "$PKG" in
|
|
apt|dnf|yum)
|
|
info "Installing Docker via the official get.docker.com script"
|
|
curl -fsSL https://get.docker.com | $SUDO sh ;;
|
|
pacman)
|
|
pkg_install docker docker-compose ;;
|
|
esac
|
|
# Best-effort: start the daemon and let the current user run docker.
|
|
if have systemctl; then
|
|
$SUDO systemctl enable --now docker 2>/dev/null || true
|
|
fi
|
|
if [ "$(id -u)" -ne 0 ] && getent group docker >/dev/null 2>&1; then
|
|
$SUDO usermod -aG docker "$USER" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
start_docker_daemon() {
|
|
if have systemctl; then
|
|
$SUDO systemctl start docker 2>/dev/null || true
|
|
elif have service; then
|
|
$SUDO service docker start 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# Resolve how to invoke docker (direct / via sudo) and make sure the daemon runs.
|
|
ensure_docker_usable() {
|
|
if ! have docker; then
|
|
warn "Docker is not installed."
|
|
ask "Install Docker now?" y || die "Docker is required."
|
|
install_docker
|
|
have docker || die "Docker installation failed."
|
|
fi
|
|
|
|
docker info >/dev/null 2>&1 && { DOCKER="docker"; return; }
|
|
|
|
# Maybe the daemon just isn't running yet.
|
|
start_docker_daemon
|
|
docker info >/dev/null 2>&1 && { DOCKER="docker"; return; }
|
|
|
|
# Maybe we lack permission (not in the docker group yet, group change not
|
|
# active in this shell) — fall back to sudo for this run.
|
|
if [ "$(id -u)" -ne 0 ] && have sudo && sudo docker info >/dev/null 2>&1; then
|
|
DOCKER="sudo docker"
|
|
warn "Using 'sudo docker' for this run. To drop the sudo: 'sudo usermod -aG docker $USER' then log out and back in."
|
|
return
|
|
fi
|
|
|
|
if [ "$IS_WSL" -eq 1 ]; then
|
|
die "Docker isn't usable inside WSL. Install Docker Desktop on Windows and enable WSL integration for this distro (Settings -> Resources -> WSL integration), then re-run."
|
|
fi
|
|
die "Docker is installed but not usable — the daemon may be stopped or you lack permission. Try: 'sudo systemctl start docker', then re-run."
|
|
}
|
|
|
|
ensure_compose() {
|
|
$DOCKER compose version >/dev/null 2>&1 && return
|
|
warn "The Docker Compose v2 plugin is missing."
|
|
case "$PKG" in
|
|
apt|dnf|yum) ask "Install docker-compose-plugin?" y && pkg_install docker-compose-plugin || true ;;
|
|
pacman) ask "Install docker-compose?" y && pkg_install docker-compose || true ;;
|
|
esac
|
|
$DOCKER compose version >/dev/null 2>&1 \
|
|
|| die "'docker compose' is unavailable. Install Docker Compose v2 and re-run."
|
|
}
|
|
|
|
# -- node count ---------------------------------------------------------------
|
|
NODE_COUNT=10
|
|
|
|
pick_node_count() {
|
|
if [ ! -r /dev/tty ]; then
|
|
info "Non-interactive shell — starting the recommended 10-node cluster."
|
|
return
|
|
fi
|
|
cat >/dev/tty <<EOF
|
|
|
|
${BOLD}How many server nodes? (1-10)${RESET}
|
|
The full 10-node cluster is recommended — it best shows off routing across
|
|
nodes. Each node uses on the order of a few hundred MB of RAM at idle; MCP
|
|
stdio servers that a node launches can raise that. Pick fewer on a small
|
|
machine — you can always add nodes later.
|
|
EOF
|
|
local ans
|
|
while :; do
|
|
printf '%sNodes [default 10]:%s ' "$BOLD" "$RESET" >/dev/tty
|
|
read -r ans </dev/tty || ans=""
|
|
ans="${ans:-10}"
|
|
case "$ans" in
|
|
[1-9]|10) NODE_COUNT="$ans"; break ;;
|
|
*) printf 'Please enter a whole number from 1 to 10.\n' >/dev/tty ;;
|
|
esac
|
|
done
|
|
info "Will start ${NODE_COUNT} server node(s)."
|
|
}
|
|
|
|
# Pin the chosen count: park nodes above NODE_COUNT behind the "extra" profile in
|
|
# an auto-loaded compose.override.yaml, so a later *plain* `docker compose up -d`
|
|
# keeps the same count (Compose merges compose.override.yaml automatically). A
|
|
# full (10) choice removes the file. Never clobbers an override we didn't write.
|
|
OVERRIDE_MARKER="# turnstone run.sh — node-count limiter (safe to delete)"
|
|
write_node_override() {
|
|
local f="$INSTALL_DIR/compose.override.yaml" k
|
|
if [ -f "$f" ] && ! head -1 "$f" 2>/dev/null | grep -qF "$OVERRIDE_MARKER"; then
|
|
warn "$f exists and isn't managed by this installer — leaving it as-is."
|
|
warn "A plain 'docker compose up -d' may not match your ${NODE_COUNT}-node choice."
|
|
return
|
|
fi
|
|
if [ "$NODE_COUNT" -ge 10 ]; then
|
|
rm -f "$f"
|
|
return
|
|
fi
|
|
{
|
|
echo "$OVERRIDE_MARKER"
|
|
echo "# Keeps 'docker compose up -d' at ${NODE_COUNT} node(s). Nodes above"
|
|
echo "# node-${NODE_COUNT} are parked behind the 'extra' profile:"
|
|
echo "# docker compose --profile extra up -d # start all 10"
|
|
echo "# docker compose up -d node-$((NODE_COUNT + 1)) # start one more"
|
|
echo "services:"
|
|
for k in $(seq $((NODE_COUNT + 1)) 10); do
|
|
printf ' node-%s: { profiles: ["extra"] }\n' "$k"
|
|
done
|
|
} >"$f"
|
|
info "Pinned ${NODE_COUNT} node(s) in $f (a later 'docker compose up -d' honors it)."
|
|
}
|
|
|
|
# -- ports --------------------------------------------------------------------
|
|
port_in_use() {
|
|
local p="$1"
|
|
if have ss; then
|
|
ss -ltnH 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${p}\$" && return 0 || return 1
|
|
fi
|
|
if have lsof; then
|
|
lsof -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 && return 0 || return 1
|
|
fi
|
|
# bash fallback: a successful connect means something is listening.
|
|
(exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null && { exec 3>&- 3<&-; return 0; }
|
|
return 1
|
|
}
|
|
port_free() { ! port_in_use "$1"; }
|
|
|
|
random_high_port() {
|
|
local p
|
|
for _ in $(seq 1 25); do
|
|
p=$(( (RANDOM % 64000) + 1024 ))
|
|
port_free "$p" && { echo "$p"; return; }
|
|
done
|
|
echo "" # caller handles
|
|
}
|
|
|
|
pick_caddy_port() {
|
|
# Prefer 443; rootless Docker can't bind privileged ports, so skip it there.
|
|
if [ "$ROOTLESS" -eq 0 ] && port_free 443; then echo 443; return; fi
|
|
port_free 8443 && { echo 8443; return; }
|
|
local p; p="$(random_high_port)"
|
|
[ -n "$p" ] && { echo "$p"; return; }
|
|
echo 8443
|
|
}
|
|
|
|
pick_pg_port() {
|
|
port_free 5432 && { echo 5432; return; }
|
|
local p; p="$(random_high_port)"
|
|
[ -n "$p" ] && { echo "$p"; return; }
|
|
echo 5432
|
|
}
|
|
|
|
# -- secrets / .env -----------------------------------------------------------
|
|
gen_hex() {
|
|
# $1 = number of bytes → 2*$1 hex chars
|
|
if have openssl; then openssl rand -hex "$1"
|
|
elif have python3; then python3 -c 'import secrets,sys; print(secrets.token_hex(int(sys.argv[1])))' "$1"
|
|
else head -c "$1" /dev/urandom | od -An -tx1 | tr -d ' \n'
|
|
fi
|
|
}
|
|
|
|
_env_get() { # _env_get KEY DEFAULT → value from $INSTALL_DIR/.env, else DEFAULT
|
|
local v
|
|
v="$(grep -E "^$1=" "$INSTALL_DIR/.env" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
|
printf '%s' "${v:-$2}"
|
|
}
|
|
|
|
# Resolve CADDY_PORT + PG_PORT and ensure a .env exists. On re-run the existing
|
|
# .env (secrets + ports) is reused so the rest of the script reports the ports
|
|
# the stack actually binds, not freshly-picked ones.
|
|
prepare_env() {
|
|
if [ -f "$INSTALL_DIR/.env" ]; then
|
|
info "Keeping existing $INSTALL_DIR/.env (secrets and ports preserved)."
|
|
chmod 600 "$INSTALL_DIR/.env" 2>/dev/null || true
|
|
CADDY_PORT="$(_env_get CONSOLE_HTTPS_PORT 8443)"
|
|
PG_PORT="$(_env_get POSTGRES_PORT 5432)"
|
|
return
|
|
fi
|
|
CADDY_PORT="$(pick_caddy_port)"
|
|
PG_PORT="$(pick_pg_port)"
|
|
local jwt pgpw
|
|
jwt="$(gen_hex 32)" # 64 hex chars — comfortably over the 32-char minimum
|
|
pgpw="$(gen_hex 18)" # hex keeps it URL-safe in the Postgres DSN
|
|
# Write 0600 from the start (umask scoped to the subshell so it doesn't leak).
|
|
(
|
|
umask 077
|
|
cat >"$INSTALL_DIR/.env" <<EOF
|
|
# Generated by run.sh — keep this private.
|
|
TURNSTONE_JWT_SECRET=$jwt
|
|
POSTGRES_USER=turnstone
|
|
POSTGRES_PASSWORD=$pgpw
|
|
POSTGRES_BIND=127.0.0.1
|
|
POSTGRES_PORT=$PG_PORT
|
|
CONSOLE_HTTPS_PORT=$CADDY_PORT
|
|
EOF
|
|
)
|
|
chmod 600 "$INSTALL_DIR/.env"
|
|
info "Wrote $INSTALL_DIR/.env (generated JWT secret + Postgres password, mode 600)."
|
|
}
|
|
|
|
# -- summary ------------------------------------------------------------------
|
|
print_done() {
|
|
local url scale
|
|
[ "$CADDY_PORT" = 443 ] && url="https://localhost" || url="https://localhost:${CADDY_PORT}"
|
|
if [ "$NODE_COUNT" -lt 10 ]; then
|
|
scale="${NODE_COUNT} of 10 nodes — pinned in compose.override.yaml, so a plain
|
|
'docker compose up -d' keeps it. Start all 10 anytime:
|
|
${DIM}cd $INSTALL_DIR && $DOCKER compose --profile extra up -d${RESET}"
|
|
else
|
|
scale="all 10 nodes. To run fewer, stop some:
|
|
${DIM}cd $INSTALL_DIR && $DOCKER compose stop node-8 node-9 node-10${RESET}"
|
|
fi
|
|
cat <<EOF
|
|
|
|
${GREEN}${BOLD}Turnstone is running${RESET} (${NODE_COUNT} node$([ "$NODE_COUNT" = 1 ] || echo s)).
|
|
|
|
Dashboard ${BOLD}${url}${RESET}
|
|
Caddy serves it with its own local CA, so your browser warns once.
|
|
Trust it (optional):
|
|
${DIM}cd $INSTALL_DIR && $DOCKER compose exec caddy cat /data/caddy/pki/authorities/local/root.crt${RESET}
|
|
|
|
Finish setup
|
|
1. Create the first admin user:
|
|
${DIM}cd $INSTALL_DIR && $DOCKER compose exec node-1 turnstone-admin create-user --username admin --name "Admin"${RESET}
|
|
2. Open ${url}, log in, and add a model backend in the ${BOLD}Models${RESET} tab —
|
|
a local server (vLLM / llama.cpp / Ollama) or an OpenAI / Anthropic / Gemini key.
|
|
Nodes boot without a model and pick it up live; no restart needed.
|
|
|
|
Scale Running ${scale}
|
|
|
|
Manage ${DIM}cd $INSTALL_DIR${RESET}
|
|
${DIM}$DOCKER compose ps${RESET} status
|
|
${DIM}$DOCKER compose logs -f${RESET} logs
|
|
${DIM}$DOCKER compose down${RESET} stop (add -v to wipe data)
|
|
|
|
Config $INSTALL_DIR/.env (generated secrets + ports)
|
|
EOF
|
|
}
|
|
|
|
# -- main ---------------------------------------------------------------------
|
|
main() {
|
|
printf '%s%sTurnstone installer%s\n\n' "$BOLD" "$GREEN" "$RESET"
|
|
|
|
detect_os
|
|
ensure_git
|
|
clone_repo
|
|
ensure_docker_usable
|
|
ensure_compose
|
|
|
|
ROOTLESS=0
|
|
$DOCKER info 2>/dev/null | grep -qi 'rootless' && ROOTLESS=1
|
|
|
|
pick_node_count
|
|
|
|
info "Building the image (first run pulls dependencies — this can take a few minutes)…"
|
|
if ! ( cd "$INSTALL_DIR" && $DOCKER compose build ); then
|
|
die "image build failed (see output above). Common causes: low memory or disk, or the Docker daemon stopped. Free up resources and re-run — it resumes."
|
|
fi
|
|
|
|
prepare_env
|
|
info "Ports — dashboard (Caddy): ${CADDY_PORT}, PostgreSQL: 127.0.0.1:${PG_PORT}"
|
|
write_node_override
|
|
|
|
info "Starting the stack…"
|
|
# Plain `up -d` (honoring compose.override.yaml) + --remove-orphans so a
|
|
# re-run that lowers the count also stops the now-excluded nodes.
|
|
if ! ( cd "$INSTALL_DIR" && $DOCKER compose up -d --remove-orphans ); then
|
|
die "the stack failed to start (see output above). Inspect logs: cd $INSTALL_DIR && $DOCKER compose logs"
|
|
fi
|
|
|
|
print_done
|
|
}
|
|
|
|
main "$@"
|