#!/usr/bin/env bash # Put kind, tilt and kubectl on a machine that has none of them. # # The single file companion to rigmini.sh, for the same reason: rig installs its # toolchain from ctrl/deps.sh reading ctrl/versions.env, and neither of those is # going to a fresh AWS WorkSpace. The pins live inline here instead. # # What it will not do, deliberately: # # * no sudo, no apt, no yum. It writes into $OUT_BIN (default ~/.local/bin) # and, for compose only, a symlink under ~/.docker/cli-plugins — both in # your own home. Everything needing root — installing Docker, joining the # docker group, raising inotify limits — is REPORTED for you to decide on. # That is what makes it safe to run on a machine that already works. # * no unverified download. Every artifact is checked against a SHA256 taken # from the publisher's own release list. A mismatch aborts. # * no guessing at another architecture. See ARCHITECTURE below. # # Two tiers, because "install the toolchain" is not one decision: # # core kubectl, jq — talk to a cluster someone else runs. Nothing that # creates one. The right answer on a managed or corporate machine. # dev core plus kind, tilt and ctlptl — build clusters and hot-reload # into them. The default, and what you want on a workspace of your own. # # Usage: # rigdeps.sh detect report the host, change nothing # rigdeps.sh list the pinned versions and where they come from # rigdeps.sh install [core|dev] detect, download, verify, install, report # rigdeps.sh fetch [core|dev] [--to DIR] download + verify only # rigdeps.sh verify run what is installed and see if it works set -euo pipefail OUT_BIN="${OUT_BIN:-$HOME/.local/bin}" # ── the pinned toolchain ─────────────────────────────────────────────────── # # ARCHITECTURE. These checksums are the upstream-published SHA256 of the # **linux/amd64** artifact and of nothing else. An arm64 WorkSpace bundle needs # a different binary with a different checksum, and this script refuses rather # than reusing these — a checksum that is merely plausible is worse than none, # because it turns a verified download into a ceremony. # # To bump a version, or to add arm64: take the checksum from the release's own # published list, never from a download you did. # # curl -sSL https://github.com///releases/download//checksums.txt # # kubectl publishes its own instead, at .sha256. KIND_VERSION=v0.32.0 KIND_SHA256=50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54 KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/kind-linux-amd64" KUBECTL_VERSION=v1.36.3 KUBECTL_SHA256=ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336 KUBECTL_URL="https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" TILT_VERSION=0.37.6 TILT_SHA256=e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6 TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v${TILT_VERSION}/tilt.${TILT_VERSION}.linux.x86_64.tar.gz" # ctlptl creates a kind cluster WITH a local registry wired in, which is what # keeps images off docker.io — an unqualified image name resolves to # docker.io/library/, and there is nothing structural stopping a push there. CTLPTL_VERSION=0.9.4 CTLPTL_SHA256=c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v${CTLPTL_VERSION}/ctlptl.${CTLPTL_VERSION}.linux.x86_64.tar.gz" # Upstream's static build. Debian's jq is linked against libjq/libonig, which is # fine on Debian and not portable anywhere else. JQ_VERSION=1.8.2 JQ_SHA256=b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f JQ_URL="https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64" # Distro docker packages ship the daemon and CLI but frequently not this, so # `docker compose up` fails with "unknown command" on an otherwise working # Docker. It is a CLI plugin: the binary is found by name in a plugin directory, # which is why install_compose_plugin links it into ~/.docker/cli-plugins. COMPOSE_VERSION=5.5.1 COMPOSE_SHA256=db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576 COMPOSE_URL="https://github.com/docker/compose/releases/download/v${COMPOSE_VERSION}/docker-compose-linux-x86_64" CORE_TOOLS="kubectl jq" DEV_TOOLS="kind tilt ctlptl docker-compose" # No helm: every rig addon installs with `kubectl apply -f`, so nothing has ever # invoked it. Add it the day something actually needs a chart. # Collected as we go, printed by report_manual() at the very end. Anything that # needs root or a decision lands here instead of being done. MANUAL=() # ── platform ─────────────────────────────────────────────────────────────── # 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. EOF exit 1 ;; Linux) ;; *) echo "$(uname -s) is not Linux. These are linux binaries; nothing here" >&2 echo "would run even if it downloaded." >&2 exit 1 ;; esac } arch() { case "$(uname -m)" in x86_64|amd64) echo amd64 ;; aarch64|arm64) echo arm64 ;; *) uname -m ;; esac } # The pins above are amd64. Rather than download something that cannot execute # and let it fail as "cannot execute binary file: Exec format error", say so # here and hand over the commands that produce the right checksums. require_amd64() { local a; a=$(arch) [ "$a" = "amd64" ] && return 0 cat >&2 </dev/null; } # ── the tools this script itself needs ───────────────────────────────────── # A fresh minimal image may genuinely have neither curl nor wget. Find out once, # up front, rather than half way through the first download. DL="" pick_downloader() { if command -v curl >/dev/null 2>&1; then DL=curl elif command -v wget >/dev/null 2>&1; then DL=wget else echo "neither curl nor wget is installed, so nothing can be downloaded." >&2 echo "Install one first: $(pkg_install_cmd curl)" >&2 exit 1 fi } download() { local url="$1" out="$2" case "$DL" in curl) curl -fsSL --retry 3 -o "$out" "$url" ;; wget) wget -q --tries=3 -O "$out" "$url" ;; esac } # sha256sum is coreutils; shasum is the perl one that turns up on stripped # images. Verification is not optional, so if neither exists that is fatal. SHA="" pick_sha() { if command -v sha256sum >/dev/null 2>&1; then SHA=sha256sum elif command -v shasum >/dev/null 2>&1; then SHA="shasum -a 256" else echo "no sha256sum and no shasum — downloads could not be verified." >&2 echo "Refusing to install unverified binaries." >&2 exit 1 fi } # ── package manager, for the instructions only ───────────────────────────── # This never runs a package manager. It names one so the reported action is # something you can paste, on the distro you are actually on — an apt line on # Amazon Linux 2 is a wrong answer dressed up as help. pkg_install_cmd() { local pkg="$1" if command -v apt-get >/dev/null 2>&1; then echo "sudo apt-get update && sudo apt-get install -y $pkg" elif command -v dnf >/dev/null 2>&1; then echo "sudo dnf install -y $pkg" elif command -v yum >/dev/null 2>&1; then echo "sudo yum install -y $pkg" elif command -v zypper >/dev/null 2>&1; then echo "sudo zypper install -y $pkg" elif command -v apk >/dev/null 2>&1; then echo "sudo apk add $pkg" else echo "install '$pkg' with this system's package manager" fi } docker_pkg() { # Debian and Ubuntu call it docker.io; the RPM distros call it docker. if command -v apt-get >/dev/null 2>&1; then echo docker.io; else echo docker; fi } # ── detect ───────────────────────────────────────────────────────────────── detect() { echo "host" echo " kernel $(uname -r)" echo " arch $(arch) ($(uname -m))" [ -r /etc/os-release ] && \ echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' /etc/os-release)" if is_wsl; then echo " platform WSL"; else echo " platform native linux"; fi 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 cluster will struggle here." echo " rigmini.sh says how much this box will actually give you." fi echo " install to $OUT_BIN" detect_libc detect_prereqs detect_docker detect_inotify return 0 } # tilt is the one binary here that needs a recent glibc. MEASURED, not guessed: # tilt 0.37.6 on Amazon Linux 2 (glibc 2.26) fails with # # /lib64/libc.so.6: version `GLIBC_2.34' not found (required by .../tilt) # # which names a symbol rather than the problem. Amazon Linux 2 is a stock # WorkSpaces bundle, so this is the likely case, not an exotic one. Report the # version now; `verify` catches the actual failure after installing. detect_libc() { local v="" if command -v ldd >/dev/null 2>&1; then v=$(ldd --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+$' || true) fi if [ -z "$v" ]; then echo " libc unknown (no ldd) — 'verify' is the real test" return 0 fi echo " libc glibc $v" if [ "$(printf '%s\n2.34\n' "$v" | sort -V | head -1)" != "2.34" ]; then echo " ! older than glibc 2.34, which tilt needs. kubectl, kind, jq and" echo " ctlptl are static or libc-only and work here; tilt will not start." echo " Install the core tier, or run tilt from a container." fi return 0 } # What this script needs to do its own job. Reported here so `detect` answers # "will install work?" instead of leaving you to find out one download in. # Amazon Linux 2 ships without tar, which is exactly the surprise this catches. detect_prereqs() { local missing="" if command -v curl >/dev/null 2>&1; then echo " download curl" elif command -v wget >/dev/null 2>&1; then echo " download wget" else echo " ! no curl and no wget — nothing can be downloaded"; missing+=" curl" fi if command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1; then echo " checksums ok" else echo " ! no sha256sum or shasum — downloads could not be verified" missing+=" coreutils" fi if command -v tar >/dev/null 2>&1 && command -v gzip >/dev/null 2>&1; then echo " archives tar + gzip" else echo " ! no tar/gzip — tilt and ctlptl ship as tarballs, so the dev tier" echo " cannot be unpacked. The core tier is two bare binaries and is fine." missing+=" tar gzip" fi if [ -n "$missing" ]; then MANUAL+=("Install what this script needs to run at all: $(pkg_install_cmd "${missing# }")") fi return 0 } detect_docker() { # kind builds a cluster out of containers. Without a reachable daemon, # everything here installs perfectly and then does nothing. if ! command -v docker >/dev/null 2>&1; then if [ -S /var/run/docker.sock ]; then echo " docker socket present, no cli" return 0 fi echo " ! docker not installed — kind has nothing to build a cluster in" MANUAL+=("Install Docker. It is the one real prerequisite, and the only thing here that needs root: $(pkg_install_cmd "$(docker_pkg)") sudo systemctl enable --now docker sudo usermod -aG docker \"\$USER\" then log out and back in, so the new group applies to your shell.") return 0 fi if docker info >/dev/null 2>&1; then echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null)" # Distro packages routinely omit the compose plugin, so a working # daemon says nothing about whether `docker compose up` will run. if docker compose version >/dev/null 2>&1; then echo " compose $(docker compose version --short 2>/dev/null)" else echo " ! no 'docker compose' plugin — compose files will not start." echo " The dev tier installs one; no root needed." fi 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 here the # latter returns 1 when the count is zero, and `set -e` kills the # caller. That is the fresh-machine case, where it does most harm. if [ "$n" -gt 0 ]; then echo " - $n kind node container(s) already running" fi else echo " ! docker cli present but the daemon is unreachable" MANUAL+=("Start Docker, or add yourself to the docker group: sudo systemctl enable --now docker sudo usermod -aG docker \"\$USER\" # then log out and back in") fi return 0 } # kind and tilt both watch large trees. Distro defaults are far too low and the # failure mode is silent: tilt simply stops noticing that files changed. 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 " ! low — tilt will silently stop seeing file changes" MANUAL+=("Raise the inotify limits (needs root): 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 return 0 } # ── fetch ────────────────────────────────────────────────────────────────── verify_sha() { local file="$1" want="$2" name="$3" got got=$($SHA "$file" | awk '{print $1}') if [ "$got" != "$want" ]; then echo >&2 echo "CHECKSUM MISMATCH for $name — not installing it." >&2 echo " expected $want" >&2 echo " got $got" >&2 echo >&2 echo "Either the pin in this script is stale, or what arrived is not what" >&2 echo "the publisher released. Neither is worth guessing about." >&2 rm -f "$file" exit 1 fi } # fetch_bin — a bare binary fetch_bin() { local name="$1" url="$2" sha="$3" dest="$4" local tmp="$dest/.$name.tmp" printf ' %-8s ' "$name" download "$url" "$tmp" verify_sha "$tmp" "$sha" "$name" mv "$tmp" "$dest/$name" chmod +x "$dest/$name" echo "ok" } # fetch_tgz # Archive layouts differ, so the caller says which. tilt and ctlptl both ship # the binary at the archive root, hence strip=0. fetch_tgz() { local name="$1" url="$2" sha="$3" dest="$4" inner="$5" strip="$6" local tmp="$dest/.$name.tgz" printf ' %-8s ' "$name" download "$url" "$tmp" verify_sha "$tmp" "$sha" "$name" # --no-same-owner: some archives ship as uid 1001, and extracting as root # would otherwise restore an owner that is not you. tar -xzf "$tmp" -C "$dest" --strip-components="$strip" --no-same-owner "$inner" rm -f "$tmp" chmod +x "$dest/$name" echo "ok" } fetch() { local dest="$OUT_BIN" tier="dev" while [ $# -gt 0 ]; do case "$1" in --to) dest="${2:?--to needs a directory}"; shift 2 ;; core|dev) tier="$1"; shift ;; *) echo "unknown argument: $1" >&2; exit 1 ;; esac done mkdir -p "$dest" if ! command -v tar >/dev/null 2>&1 && [ "$tier" = "dev" ]; then echo "tar is missing, and tilt and ctlptl ship as tarballs." >&2 echo " $(pkg_install_cmd tar)" >&2 echo "Or install the core tier, which is two bare binaries: $0 install core" >&2 exit 1 fi echo "fetching '$tier' into $dest (verifying every checksum)" 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 fetch_bin docker-compose "$COMPOSE_URL" "$COMPOSE_SHA256" "$dest" fi return 0 } # A copy in OUT_BIN only gives you `docker-compose`. The hyphenated form is the # retired v1 spelling; every compose file written in the last few years assumes # `docker compose`, and that resolves plugins by name from this directory. install_compose_plugin() { local src="$OUT_BIN/docker-compose" dir="$HOME/.docker/cli-plugins" [ -x "$src" ] || return 0 mkdir -p "$dir" if [ -e "$dir/docker-compose" ] && [ ! -L "$dir/docker-compose" ]; then echo echo " ! $dir/docker-compose exists and is not a symlink — left alone" MANUAL+=("Something already installs the compose plugin at $dir/docker-compose To use the pinned build instead: ln -sf $src $dir/docker-compose") return 0 fi ln -sfn "$src" "$dir/docker-compose" echo echo " compose plugin linked into $dir" return 0 } # ── verify ───────────────────────────────────────────────────────────────── tier_tools() { [ "$1" = "core" ] && echo "$CORE_TOOLS" || echo "$CORE_TOOLS $DEV_TOOLS"; } # Downloading a verified binary proves it is the right file, not that this # machine can run it. On an old distro tilt fails here, with a linker error # about a missing symbol, and finding that out now beats finding out during a # first cluster build. verify_tools() { local tier="${1:-dev}" b bin out rc broke=0 echo "checking that each one actually runs" for b in $(tier_tools "$tier"); do bin="$OUT_BIN/$b" if [ ! -x "$bin" ]; then printf ' %-14s not installed\n' "$b" continue fi # Not piped into `head`. With `pipefail` set, a tool that prints more # than one line gets SIGPIPE when head closes the pipe, and the # pipeline reports 141 — so a working kubectl was announced as "does # not run here", with its own correct version string as the evidence. # Take the first line afterwards, from the string. rc=0 case "$b" in kubectl) out=$("$bin" version --client 2>&1) || rc=$? ;; jq) out=$("$bin" --version 2>&1) || rc=$? ;; *) out=$("$bin" version 2>&1) || rc=$? ;; esac out=${out%%$'\n'*} if [ "$rc" -eq 0 ]; then printf ' %-14s %s\n' "$b" "$out" else printf ' ! %-12s does not run here: %s\n' "$b" "$out" broke=1 fi done if [ "$broke" -eq 1 ]; then echo echo " A binary that downloads and verifies but will not start is almost" echo " always this distro's libc being older than the release needs." echo " 'detect' prints the glibc version. The core tier (kubectl + jq)" echo " has no such dependency and will work regardless." fi return 0 } # ── install ──────────────────────────────────────────────────────────────── # Installing into a directory early in PATH silently replaces whatever the # machine was already using, which on a shared or corporate machine can break # unrelated work — kubectl more than one minor away from its cluster is the # common one. Say so; never decide it. warn_shadowing() { local b existing shadowed="" paths="" tier="${1:-dev}" case ":${PATH}:" in *":$OUT_BIN:"*) ;; *) return 0 ;; # not on PATH, so nothing is being shadowed yet esac for b in $(tier_tools "$tier"); do [ -x "$OUT_BIN/$b" ] || continue 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+=$(printf ' %-14s %s' "$b" "$existing")$'\n' # Only the shadowing copies are the user's to remove. Listing the whole # tier would delete tools that shadow nothing and are the only copy. paths+="$OUT_BIN/$b " done [ -n "$shadowed" ] || return 0 echo echo " ! these were already installed elsewhere and are now shadowed:" printf '%s' "$shadowed" MANUAL+=("Decide which toolchain wins. To keep the previous one: rm -f ${paths% } Or keep both, and let the existing one win by putting $OUT_BIN last on PATH instead of first: export PATH=\"\$PATH:$OUT_BIN\"") return 0 } report_manual() { echo if [ ${#MANUAL[@]} -eq 0 ]; then echo "nothing left to do by hand." return 0 fi echo "host actions this cannot perform (${#MANUAL[@]}):" echo local n=1 m for m in "${MANUAL[@]}"; do echo " $n. $m" echo n=$((n + 1)) done return 0 } install() { local tier="${1:-dev}" detect echo fetch "$tier" # An `if`, not `[ ] && ...`: on the core tier the test fails, and under # `set -e` a bare failing test here would end the run silently. if [ "$tier" = "dev" ]; then install_compose_plugin fi echo verify_tools "$tier" warn_shadowing "$tier" if [ "$tier" = "core" ]; then echo echo " core tier: no kind, tilt, ctlptl or compose. '$0 install dev' adds them." fi case ":${PATH}:" in *":$OUT_BIN:"*) ;; *) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc: export PATH=\"${OUT_BIN}:\$PATH\" then: source ~/.bashrc") ;; esac report_manual if [ "$tier" = "dev" ]; then echo "Once Docker is reachable and this is on PATH:" echo echo " kind create cluster --name scratch" echo " kubectl cluster-info --context kind-scratch" echo " kind delete cluster --name scratch" echo echo "That round trip is the real test that this machine can host a rig." fi return 0 } list() { echo "pinned, linux/amd64 only:" printf ' %-14s %s\n' kubectl "$KUBECTL_VERSION" printf ' %-14s %s\n' jq "$JQ_VERSION" printf ' %-14s %s\n' kind "$KIND_VERSION" printf ' %-14s %s\n' tilt "$TILT_VERSION" printf ' %-14s %s\n' ctlptl "$CTLPTL_VERSION" printf ' %-14s %s\n' docker-compose "$COMPOSE_VERSION" echo echo " core = $CORE_TOOLS" echo " dev = $CORE_TOOLS $DEV_TOOLS" echo echo "Checksums are pinned in the block at the top of this file. To bump one," echo "take the new checksum from the publisher's own release list — the header" echo "comment has the exact commands." return 0 } # ── main ─────────────────────────────────────────────────────────────────── require_linux # Read the command, THEN shift — and shift only if there is something there. # A bare `shift` with no positional parameters returns 1, and under `set -e` # that ended the script before a single line was printed: running this with no # arguments at all, the documented default, did nothing and said nothing. cmd="${1:-install}" [ $# -gt 0 ] && shift case "$cmd" in detect) detect; report_manual ;; list) list ;; verify) verify_tools "${1:-dev}" ;; fetch) require_amd64; pick_downloader; pick_sha; fetch "$@" ;; install) require_amd64; pick_downloader; pick_sha; install "${1:-dev}" ;; *) echo "usage: $0 [detect|list|install|fetch|verify]" >&2 echo " install [core|dev] (default dev)" >&2 echo " fetch [core|dev] [--to DIR]" >&2 echo " OUT_BIN= overrides the install directory" >&2 exit 1 ;; esac