From effc60297c8c62a508ceaea86e380fa63997ca3d Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 31 May 2026 16:00:34 -0700 Subject: [PATCH] feat(install): add a one-line curl|bash installer 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. --- .gitignore | 5 + run.sh | 418 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+) create mode 100755 run.sh diff --git a/.gitignore b/.gitignore index 73ec2e4b..3c8d3f49 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ build/ .venv/ venv/ .env +# Local compose overrides (e.g. run.sh's node-count limiter, bootstrap output) +compose.override.yaml +compose.override.yml +docker-compose.override.yaml +docker-compose.override.yml *.so .mypy_cache/ .ruff_cache/ diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..cda7c895 --- /dev/null +++ b/run.sh @@ -0,0 +1,418 @@ +#!/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/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 </dev/tty + read -r ans /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" </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 "$@"