Files
Patrick Buckley 04b3e8e36f feat(compose): let bare-metal turnstone-servers join the cluster (incl. mTLS)
A turnstone-server running outside the compose network ("bare-metal", e.g. a
local-GPU box) couldn't fully join: it can't resolve the in-cluster console
(console:8090) to enroll its mTLS cert, and SearxNG was unreachable for
web_search. Only Postgres was published.

Publish the console's plain-HTTP ACME endpoint (:8090) and SearxNG (:8081)
alongside Postgres, all bound via one knob TURNSTONE_HOST_IP (default 127.0.0.1
-- nothing new on the LAN; set it to the host's LAN IP for a node on another
machine). Postgres keeps honoring the legacy POSTGRES_BIND as a fallback, so
existing .env files don't break.

The node's TLS client now honors TURNSTONE_CONSOLE_URL so a bare-metal node can
point at the published ACME endpoint instead of the unreachable in-cluster name
(empty = in-cluster service discovery, unchanged).

Docs (docker.md, tls.md), the run.sh-generated .env, and the bootstrap wizard
updated to match. The advertised host is the cert's primary SAN and the console
collector dials it back, so mTLS hostname verification holds both ways.
2026-06-15 04:24:23 -07:00

421 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
# Bind published bare-metal ports (Postgres, console ACME, SearxNG) to this
# interface. 127.0.0.1 = same-host only; set your LAN IP to join from another box.
TURNSTONE_HOST_IP=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 "$@"