#!/usr/bin/env bash # Toolchain installer: detect the host, install a pinned toolchain onto it, then # report what it could not do. # # It never runs the cluster, never uses sudo or apt, and writes only into # $OUT_BIN (default ~/.local/bin). Everything that would touch the host proper — # systemd, inotify limits, .wslconfig, docker group — is REPORTED for a human to # decide on, never performed. That is what makes it safe to run on a machine that # already has a working setup. # # Usage (normally via `make deps`, or directly): # deps.sh detect # report host facts only, change nothing # deps.sh fetch [core|dev] [--to DIR] # download + verify into DIR # deps.sh install [core|dev] # detect, fetch, install, report # # Tiers: 'core' is kubectl + jq (talk to a cluster); 'dev' adds kind and tilt # Default is dev. # # Runs both inside the installer container and bare on a host. Inside the # container, host files are read through $HOST_ROOT (mount / as :ro); bare, it # falls back to /. set -euo pipefail # Keep the caller's cwd so a relative --to resolves where the user expects, # not against ctrl/ once we've moved. INVOKED_FROM="$PWD" cd "$(dirname "$0")" source ./versions.env # Resolve a possibly-relative path against the caller's original directory. abspath() { case "$1" in /*) echo "$1" ;; *) echo "$INVOKED_FROM/$1" ;; esac } OUT_BIN="${OUT_BIN:-$HOME/.local/bin}" HOST_ROOT="${HOST_ROOT:-/}" DEPS_SOURCE="${DEPS_SOURCE:-upstream}" DEPS_ARTIFACTORY_URL="${DEPS_ARTIFACTORY_URL:-}" BAKED_BIN="${BAKED_BIN:-/opt/rig/bin}" # Collected by detect(), printed by report_manual() at the very end. MANUAL=() # Host FILES (/etc/..., /mnt/c/...) must be read through the mount. Kernel-level # facts (kernel version, meminfo, inotify) are shared with the container, so the # container's own view is already the host's. host_file() { local p="${1#/}" if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then echo "$HOST_ROOT/$p" else echo "/$p" fi } # ── detect ───────────────────────────────────────────────────────────────── # Windows outside WSL — Git Bash, MSYS, Cygwin — looks close enough to work and # then fails in a pile of confusing ways: no /proc, no docker socket, none of # the tooling. Detectable, so name it instead. require_linux() { case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) cat >&2 <<'EOF' This has to run inside WSL, not Git Bash / MSYS / Cygwin. If WSL is not installed yet, from an elevated PowerShell or Command Prompt: wsl --install That enables Windows features and needs a reboot, so it is not something this script will do for you. Afterwards, open the Linux shell it installs and run this from there. See "Starting from plain Windows" in README.md. EOF exit 1 ;; esac } is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; } detect() { echo "host" echo " kernel $(uname -r)" local osr; osr=$(host_file /etc/os-release) [ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")" local total_kb avail_kb total_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) avail_kb=$(awk '/^MemAvailable:/{print $2}' /proc/meminfo) printf " memory %d GB total, %d GB available\n" \ $((total_kb / 1024 / 1024)) $((avail_kb / 1024 / 1024)) if [ $((avail_kb / 1024 / 1024)) -lt 4 ]; then echo " ! under 4 GB available — a multi-node profile will struggle." echo " 'make cluster list' shows the others; 'make cluster free' stops them." fi detect_wsl detect_filesystem detect_docker detect_inotify } detect_wsl() { if ! is_wsl; then echo " platform native linux" return fi echo " platform WSL" # systemd is off by default in WSL, and the ingress/DNS paths that use a # host service need it. Enabling it requires a Windows-side restart, which # cannot be issued from inside the distro. local wc; wc=$(host_file /etc/wsl.conf) if [ -r "$wc" ] && grep -qE '^\s*systemd\s*=\s*true' "$wc"; then echo " systemd enabled in wsl.conf" else echo " ! systemd not enabled in /etc/wsl.conf" MANUAL+=("Enable systemd — add to /etc/wsl.conf: [boot] systemd=true then from a WINDOWS terminal (not this shell): wsl --shutdown") fi # WSL regenerates /etc/resolv.conf on every boot, which silently reverts any # local DNS setup. if [ -r "$wc" ] && grep -qE '^\s*generateResolvConf\s*=\s*false' "$wc"; then echo " resolv.conf pinned (generateResolvConf=false)" else echo " - resolv.conf is WSL-generated; DNS_MODE=dnsmasq would be reverted on reboot" fi local wcfg wcfg=$(ls "$HOST_ROOT"/mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true) if [ -n "$wcfg" ] && grep -qE '^\s*memory\s*=' "$wcfg"; then echo " wslconfig memory set: $(grep -E '^\s*memory\s*=' "$wcfg" | tr -d ' ')" else MANUAL+=("Cap/raise the WSL VM memory — see what is set versus what booted: make mem status It prints the edit to make and the command to apply it.") fi } # Not a path check: /mnt is an ordinary mount point and an ext4 disk mounted # there is perfectly fine. What matters is the filesystem. The Windows drives # arrive as 9p (WSL2) or drvfs (WSL1); network and fuse mounts behave the same # way. None of them deliver inotify events, so anything watching files goes # quiet without saying why. watch_hostile_fs() { local dir="$1" fstype fstype=$(findmnt -no FSTYPE --target "$dir" 2>/dev/null || true) [ -n "$fstype" ] || fstype=$(stat -f -c %T "$dir" 2>/dev/null || true) case "$fstype" in 9p|v9fs|drvfs|cifs|smb3|nfs|nfs4|fuse.sshfs|fuseblk) echo "$fstype" ;; *) echo "" ;; esac } detect_filesystem() { local root fstype root=$(cd .. && pwd -P) fstype=$(watch_hostile_fs "$root") if [ -n "$fstype" ]; then echo " ! this directory is on $fstype — file watching will not work" MANUAL+=("Move this onto the local disk. Nothing watching files sees changes on a $fstype mount, and everything else is slower: cp -r \"$root\" ~/ && cd ~/$(basename "$root")") else echo " filesystem $root ($(findmnt -no FSTYPE --target "$root" 2>/dev/null || echo local))" fi } detect_docker() { # Reachability of the daemon is the real question, and the CLI is only how # we ask it. Note that when this runs inside the installer container, Docker # necessarily exists on the host — otherwise nothing would be executing — # so a missing CLI in here is an installer packaging bug, not a host problem. if ! command -v docker >/dev/null 2>&1; then if [ -S /var/run/docker.sock ]; then echo " docker socket present (no cli in this context)" else echo " ! docker not found and no socket at /var/run/docker.sock" MANUAL+=("Install Docker — the one true prerequisite: sudo apt-get install -y docker.io && sudo usermod -aG docker \"\$USER\" then log out and back in.") fi return fi if docker info >/dev/null 2>&1; then echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null)" local n n=$(docker ps --filter "label=io.x-k8s.kind.cluster" --format '{{.Names}}' 2>/dev/null | wc -l) # Must be an `if`, not `[ ] && echo`: as the last statement in this # function the latter returns 1 when the count is zero, and `set -e` # then kills the caller. That is the fresh-machine case — no clusters # yet — so the bug only ever shows up where it does most harm. if [ "$n" -gt 0 ]; then echo " - $n kind node container(s) already running; see 'make cluster list'" fi else echo " ! docker cli present but the daemon is unreachable" MANUAL+=("Start Docker, or add yourself to the docker group: sudo usermod -aG docker \"\$USER\" # then log out and back in") fi } # kind and Tilt both watch large trees. WSL ships defaults (8192/128) far too low, # and the failure mode is silent: Tilt simply stops noticing file changes. detect_inotify() { local w i w=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null || echo 0) i=$(cat /proc/sys/fs/inotify/max_user_instances 2>/dev/null || echo 0) echo " inotify watches=$w instances=$i" if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then echo " ! inotify limits are low — Tilt will silently stop noticing file changes" MANUAL+=("Raise inotify limits (needs root on the host): echo -e 'fs.inotify.max_user_watches=524288\\nfs.inotify.max_user_instances=512' \\ | sudo tee /etc/sysctl.d/99-rig.conf sudo sysctl --system") fi } # ── fetch ────────────────────────────────────────────────────────────────── # Resolve where a given artifact comes from, honouring DEPS_SOURCE. resolve_url() { local upstream="$1" case "$DEPS_SOURCE" in upstream) echo "$upstream" ;; artifactory) if [ -z "$DEPS_ARTIFACTORY_URL" ]; then echo "DEPS_SOURCE=artifactory but DEPS_ARTIFACTORY_URL is empty" >&2 exit 1 fi echo "${DEPS_ARTIFACTORY_URL%/}/$(basename "$upstream")" ;; *) echo "unsupported DEPS_SOURCE '$DEPS_SOURCE' for a download" >&2; exit 1 ;; esac } verify() { local file="$1" want="$2" name="$3" got got=$(sha256sum "$file" | awk '{print $1}') if [ "$got" != "$want" ]; then echo "checksum mismatch for $name" >&2 echo " expected $want" >&2 echo " got $got" >&2 exit 1 fi } # fetch_bin — a bare binary fetch_bin() { local name="$1" url="$2" sha="$3" dest="$4" local tmp="$dest/.$name.tmp" echo " fetching $name" curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")" verify "$tmp" "$sha" "$name" mv "$tmp" "$dest/$name" chmod +x "$dest/$name" } # fetch_tgz # Archive layouts differ — tilt's is flat (the binary at the root, strip=0), # others nest it a directory down — so the caller says which. fetch_tgz() { local name="$1" url="$2" sha="$3" dest="$4" inner="$5" strip="$6" local tmp="$dest/.$name.tgz" echo " fetching $name" curl -fsSL --retry 3 -o "$tmp" "$(resolve_url "$url")" verify "$tmp" "$sha" "$name" # --no-same-owner: extracting as root would otherwise restore the uid/gid # baked into the archive (some ship as uid 1001), leaving a binary the host # user does not own. tar -xzf "$tmp" -C "$dest" --strip-components="$strip" --no-same-owner "$inner" rm -f "$tmp" chmod +x "$dest/$name" } # The installer runs as root so it can reach the docker socket, which means # everything it writes into a mounted volume lands root-owned and unusable from # the host. Hand it back to whoever owns the mount point (the host user created # that directory before mounting it). fix_ownership() { local dir="$1" [ -d "$dir" ] || return 0 local owner="${HOST_UID:-}:${HOST_GID:-}" if [ "$owner" = ":" ]; then owner=$(stat -c '%u:%g' "$dir") fi [ "$owner" = "0:0" ] && return 0 chown -R "$owner" "$dir" 2>/dev/null || true } # Two tiers, because not every machine should get cluster tooling. # # core kubectl, jq — talk to a cluster someone else runs. Nothing that # creates one. Appropriate on a managed or corporate-issued machine # where development tools are not wanted by default. # dev core plus kind and tilt — build clusters and hot-reload into them. # # The split exists because "install the toolchain" is not one decision: on a # managed workspace the right answer is kubectl and nothing else. CORE_TOOLS="kubectl jq" # No helm: every addon installs with `kubectl apply -f `, so nothing here # has ever invoked it. Add it back the day something actually needs a chart. # # ctlptl is 'dev' rather than 'core' for the same reason kind is: core is "talk # to a cluster someone else runs", and ctlptl builds them. It earns its place # because it is what wires a cluster to a local registry — without one, an # unqualified image name resolves to docker.io/library/ and there is # nothing structural stopping a push there. DEV_TOOLS="kind tilt ctlptl" fetch() { local dest="$OUT_BIN" tier="${TIER:-dev}" while [ $# -gt 0 ]; do case "$1" in --to) dest="$2"; shift 2 ;; core|dev) tier="$1"; shift ;; *) echo "unknown argument: $1" >&2; exit 1 ;; esac done dest="$(abspath "$dest")" mkdir -p "$dest" TIER="$tier" if [ "$DEPS_SOURCE" = "baked" ]; then echo "installing baked binaries from $BAKED_BIN" cp -a "$BAKED_BIN"/. "$dest"/ fix_ownership "$dest" return fi echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)" fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest" fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest" if [ "$tier" = "dev" ]; then fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest" fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0 fetch_tgz ctlptl "$CTLPTL_URL" "$CTLPTL_SHA256" "$dest" ctlptl 0 fi fix_ownership "$dest" # kind writes the kubeconfig as root too; hand that back as well when it's # a mounted host directory rather than container-local state. fix_ownership "${KUBE_DIR:-/out/kube}" } # ── install ──────────────────────────────────────────────────────────────── report_manual() { echo if [ ${#MANUAL[@]} -eq 0 ]; then echo "nothing left to do by hand." return fi echo "host actions this cannot perform (${#MANUAL[@]}):" echo local n=1 for m in "${MANUAL[@]}"; do echo " $n. $m" echo n=$((n + 1)) done } # Installing into a directory that sits early in PATH silently replaces whatever # the machine was already using — which on a shared or client machine can break # unrelated work (kubectl more than one minor away from a cluster is the common # one). Say so; never decide it for them. tier_tools() { [ "$1" = "core" ] && echo "$CORE_TOOLS" || echo "$CORE_TOOLS $DEV_TOOLS"; } warn_shadowing() { local b existing shadowed="" tier="${1:-dev}" for b in $(tier_tools "$tier"); do [ -x "$OUT_BIN/$b" ] || continue # Where would this resolve if OUT_BIN weren't in the way? existing=$(PATH=$(echo "$PATH" | tr ':' '\n' | grep -vx "$OUT_BIN" | paste -sd:) \ command -v "$b" 2>/dev/null || true) [ -n "$existing" ] || continue [ "$existing" = "$OUT_BIN/$b" ] && continue shadowed+=" $b $existing"$'\n' done [ -n "$shadowed" ] || return 0 case ":${PATH}:" in *":$OUT_BIN:"*) ;; *) return 0 ;; # not on PATH yet, so nothing is being shadowed esac echo echo " ! these were already installed elsewhere and are now shadowed by $OUT_BIN:" printf '%s' "$shadowed" echo " Other projects on this machine will pick up the new versions." MANUAL+=("Decide which toolchain wins. To keep the previous one, remove what was just installed: rm -f $(for b in $(tier_tools "$tier"); do printf '%s ' "$OUT_BIN/$b"; done) Or install somewhere private instead: OUT_BIN=\$PWD/def/bin make deps # then put that dir first in PATH") } install() { local tier="${1:-dev}" detect echo fetch "$tier" echo echo "installed to $OUT_BIN ($tier):" for b in $(tier_tools "$tier"); do [ -x "$OUT_BIN/$b" ] && echo " $b" done if [ "$tier" = "core" ]; then echo " (no kind/tilt — 'make deps dev' adds them)" fi warn_shadowing "$tier" case ":${PATH}:" in *":$OUT_BIN:"*) ;; *) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc: export PATH=\"${OUT_BIN}:\$PATH\"") ;; esac report_manual } # ── main ─────────────────────────────────────────────────────────────────── require_linux case "${1:-install}" in detect) detect; report_manual ;; fetch) shift; fetch "$@" ;; install) shift; install "${1:-dev}" ;; *) echo "usage: $0 [detect|fetch|install]" >&2; exit 1 ;; esac