From 29e30693ea3c524e553cb48178c017e9b74b192c Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Wed, 16 Sep 2026 19:08:46 -0300 Subject: [PATCH] rig updates --- rig/Makefile | 13 +- rig/ctrl/Dockerfile.deps | 14 +- rig/ctrl/check.sh | 11 +- rig/ctrl/deps.sh | 259 ++++++- rig/ctrl/lib/config.sh | 93 ++- rig/ctrl/mem.sh | 736 +++++++++++++++--- rig/ctrl/pins.sh | 57 -- rig/ctrl/selftest.sh | 43 +- rig/ctrl/standalone.sh | 341 +++++++++ rig/standalone/Makefile | 43 -- rig/standalone/README.md | 85 +-- rig/standalone/client/Makefile | 23 + rig/standalone/client/rigdeps.sh | 958 +++++++++++++++++++++++ rig/standalone/client/rigmini.sh | 859 +++++++++++++++++++++ rig/standalone/data/Makefile | 23 + rig/standalone/data/rigdeps.sh | 963 ++++++++++++++++++++++++ rig/standalone/data/rigmini.sh | 864 +++++++++++++++++++++ rig/standalone/minimal/Makefile | 23 + rig/standalone/minimal/rigdeps.sh | 958 +++++++++++++++++++++++ rig/standalone/{ => minimal}/rigmini.sh | 294 +++++++- rig/standalone/offline/Makefile | 23 + rig/standalone/offline/rigdeps.sh | 958 +++++++++++++++++++++++ rig/standalone/offline/rigmini.sh | 859 +++++++++++++++++++++ rig/standalone/rigdeps.sh | 637 ---------------- 24 files changed, 8160 insertions(+), 977 deletions(-) delete mode 100755 rig/ctrl/pins.sh create mode 100644 rig/ctrl/standalone.sh delete mode 100644 rig/standalone/Makefile create mode 100644 rig/standalone/client/Makefile create mode 100755 rig/standalone/client/rigdeps.sh create mode 100755 rig/standalone/client/rigmini.sh create mode 100644 rig/standalone/data/Makefile create mode 100755 rig/standalone/data/rigdeps.sh create mode 100755 rig/standalone/data/rigmini.sh create mode 100644 rig/standalone/minimal/Makefile create mode 100755 rig/standalone/minimal/rigdeps.sh rename rig/standalone/{ => minimal}/rigmini.sh (68%) create mode 100644 rig/standalone/offline/Makefile create mode 100755 rig/standalone/offline/rigdeps.sh create mode 100755 rig/standalone/offline/rigmini.sh delete mode 100755 rig/standalone/rigdeps.sh diff --git a/rig/Makefile b/rig/Makefile index 34389fd..0f095f1 100644 --- a/rig/Makefile +++ b/rig/Makefile @@ -50,7 +50,7 @@ $(eval $(ARGS):;@:) .PHONY: $(ARGS) endif -.PHONY: help setup check selftest mem deps deps-image pins cluster registry addons ports \ +.PHONY: help setup check selftest mem deps deps-image standalone cluster registry addons ports \ docs tilt \ kind-up kind-down kind-reset tilt-up tilt-down @@ -66,19 +66,22 @@ check: ## is this machine ready? reports, never fixes bash ctrl/check.sh # The counterpart to check: that one asks about the MACHINE and never fails, -# this one asks about RIG and exits 1, the way pins does. The checks are written +# this one asks about RIG and exits 1, as `make standalone check` does. Checks are written # as the decisions they defend, so a failure names what is being undone. selftest: ## does rig still do what it says? exits 1 if not bash ctrl/selftest.sh -mem: ## memory, and any cap holding it [status|backup|restore] +mem: ## memory, its caps, what it survives [status|push|all|backup|restore] bash ctrl/mem.sh $(or $(ARGS),status) deps: ## install the toolchain [core|dev] (default dev) bash ctrl/deps.sh install $(or $(ARGS),dev) -pins: ## standalone/rigdeps.sh still installs what rig pins? - bash ctrl/pins.sh +# The one-file versions of rig's tools, one folder per profile, for machines the +# full rig is not going to. Generated from rig as it is, never edited by hand; +# `check` is what selftest runs to catch a kit left behind by a change to rig. +standalone: ## generate standalone// kits [write|check] (default write) + bash ctrl/standalone.sh $(or $(ARGS),write) deps-image: ## build the installer image [full] docker build -f ctrl/Dockerfile.deps \ diff --git a/rig/ctrl/Dockerfile.deps b/rig/ctrl/Dockerfile.deps index c434759..8493814 100644 --- a/rig/ctrl/Dockerfile.deps +++ b/rig/ctrl/Dockerfile.deps @@ -24,23 +24,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates curl jq graphviz python3 docker-cli \ && rm -rf /var/lib/apt/lists/* +# The installer is the generated standalone kit, not deps.sh plus the files it +# reads. A kit is one file with its pins frozen in and is proven to run with +# nothing else from rig present — which is exactly what an image needs, and +# `make standalone` keeps it current. Pins are the same in every profile's kit. +ARG PROFILE=minimal WORKDIR /work -COPY ctrl/versions.env /work/ctrl/versions.env -COPY ctrl/deps.sh /work/ctrl/deps.sh -RUN chmod +x /work/ctrl/deps.sh +COPY standalone/${PROFILE}/rigdeps.sh /work/rigdeps.sh +RUN chmod +x /work/rigdeps.sh # Defaults; every one is overridable with -e at run time. ENV DEPS_SOURCE=upstream \ OUT_BIN=/out/bin \ HOST_ROOT=/host -ENTRYPOINT ["/work/ctrl/deps.sh"] +ENTRYPOINT ["/work/rigdeps.sh"] CMD ["install"] # --------------------------------------------------------------------------- # deps-full — same image, binaries baked in, works with no network at all. FROM deps AS deps-full -RUN /work/ctrl/deps.sh fetch --to /opt/rig/bin +RUN /work/rigdeps.sh fetch --to /opt/rig/bin ENV DEPS_SOURCE=baked \ BAKED_BIN=/opt/rig/bin diff --git a/rig/ctrl/check.sh b/rig/ctrl/check.sh index 1c16051..bd5995c 100755 --- a/rig/ctrl/check.sh +++ b/rig/ctrl/check.sh @@ -46,13 +46,10 @@ mb_of() { END { if (!found) printf "0" }' "${MEMINFO:-/proc/meminfo}" } -# What one node costs, measured rather than guessed. On 2026-09-11 a minimal -# control-plane node ran at 620 MiB idle and ~728 MiB with a small mock, plus -# 16 MiB for the local registry — ~745 MiB of working set. 800 rounds that up, -# and agrees with the 800 MB observed independently on a larger rig. Worker -# nodes carry no etcd or apiserver and are lighter, so for a multi-node shape -# this errs high. It is the cluster alone: whatever you deploy comes on top. -NODE_MB=800 +# NODE_MB — what one node costs — comes from load_config (lib/config.sh), where +# its measurement is recorded. It lives there, not here, because the memory tool +# and every standalone kit need the same number: a copy of it is how rigmini.sh +# came to say 2 GB per node long after rig had measured 800 MB. # Every running container's working set in MB, tagged with the kind cluster it # belongs to ('-' when it is not kind). docker stats reports usage minus page diff --git a/rig/ctrl/deps.sh b/rig/ctrl/deps.sh index 95de60c..4af1a81 100755 --- a/rig/ctrl/deps.sh +++ b/rig/ctrl/deps.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# rig:standalone rigdeps detect # Toolchain installer: detect the host, install a pinned toolchain onto it, then # report what it could not do. # @@ -10,6 +11,8 @@ # # Usage (normally via `make deps`, or directly): # deps.sh detect # report host facts only, change nothing +# deps.sh list # the pinned versions +# deps.sh verify [core|dev] # run what is installed and see if it works # deps.sh fetch [core|dev] [--to DIR] # download + verify into DIR # deps.sh install [core|dev] # detect, fetch, install, report # @@ -27,7 +30,11 @@ set -euo pipefail INVOKED_FROM="$PWD" cd "$(dirname "$0")" -source ./versions.env +# Pins arrive through load_config like every other setting, not by sourcing +# versions.env here. That is what lets `make standalone` freeze them into a +# one-file installer: configuration has exactly one way in. +source ./lib/config.sh +load_config # Resolve a possibly-relative path against the caller's original directory. abspath() { @@ -66,6 +73,91 @@ host_file() { fi } +# ── the tools this script itself needs ───────────────────────────────────── + +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 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 +} + +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 ───────────────────────────────────────────────────────────────── # Windows outside WSL — Git Bash, MSYS, Cygwin — looks close enough to work and @@ -96,6 +188,7 @@ is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; } detect() { echo "host" echo " kernel $(uname -r)" + echo " arch $(arch) ($(uname -m))" local osr; osr=$(host_file /etc/os-release) [ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")" @@ -123,6 +216,9 @@ detect() { 2) echo " overcommit 2 strict — an allocation fails honestly instead of killing later" ;; esac + echo " install to $OUT_BIN" + detect_libc + detect_prereqs detect_wsl detect_filesystem detect_docker @@ -200,6 +296,64 @@ detect_filesystem() { fi } +# 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() { # 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 @@ -210,9 +364,12 @@ detect_docker() { 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.") + MANUAL+=("Install Docker — the one true 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.") fi return fi @@ -271,7 +428,7 @@ resolve_url() { verify() { local file="$1" want="$2" name="$3" got - got=$(sha256sum "$file" | awk '{print $1}') + got=$($SHA "$file" | awk '{print $1}') if [ "$got" != "$want" ]; then echo "checksum mismatch for $name" >&2 echo " expected $want" >&2 @@ -285,7 +442,7 @@ 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")" + download "$(resolve_url "$url")" "$tmp" verify "$tmp" "$sha" "$name" mv "$tmp" "$dest/$name" chmod +x "$dest/$name" @@ -298,7 +455,7 @@ 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")" + download "$(resolve_url "$url")" "$tmp" 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 @@ -511,6 +668,66 @@ report_manual() { # 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. +# 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 +} + +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 +} + tier_tools() { [ "$1" = "core" ] && echo "$CORE_TOOLS" || echo "$CORE_TOOLS $DEV_TOOLS"; } warn_shadowing() { @@ -613,9 +830,29 @@ install() { require_linux -case "${1:-install}" in +# 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 + +# Baked mode copies binaries already in the image, so it needs no downloader. +need_downloads() { + require_amd64 + if [ "$DEPS_SOURCE" != baked ]; then pick_downloader; fi + pick_sha +} + +case "$cmd" in detect) detect; report_manual ;; - fetch) shift; fetch "$@" ;; - install) shift; install "${1:-dev}" ;; - *) echo "usage: $0 [detect|fetch|install]" >&2; exit 1 ;; + list) list ;; + verify) verify_tools "${1:-dev}" ;; + fetch) need_downloads; fetch "$@" ;; + install) need_downloads; install "${1:-dev}" ;; + *) echo "usage: $0 [detect|list|verify|fetch|install]" >&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 diff --git a/rig/ctrl/lib/config.sh b/rig/ctrl/lib/config.sh index de4f9af..26b6212 100644 --- a/rig/ctrl/lib/config.sh +++ b/rig/ctrl/lib/config.sh @@ -61,7 +61,10 @@ load_config() { set -a source ./versions.env - [ -f ./.env ] && source ./.env + # RIG_PORTABLE skips the machine-local layer. config_snapshot sets it, so a + # generated standalone kit never carries this machine's .env — which holds + # local values and, by its own description, secrets. + if [ -z "${RIG_PORTABLE:-}" ] && [ -f ./.env ]; then source ./.env; fi set +a # Re-apply overrides now so PROFILE is the caller's before we pick the file. @@ -76,7 +79,7 @@ load_config() { set -a source "./env.d/${profile}.env" - [ -f ./.env ] && source ./.env + if [ -z "${RIG_PORTABLE:-}" ] && [ -f ./.env ]; then source ./.env; fi set +a _config_restore "$saved" @@ -138,6 +141,17 @@ load_config() { # changed afterwards — both would mislead if the numbers drifted. NODES=$(grep -c '^ - role:' "$KIND_CONFIG_PATH") if grep -q 'audit-policy-file' "$KIND_CONFIG_PATH"; then AUDIT=on; else AUDIT=off; fi + + # What one node costs, measured rather than guessed. On 2026-09-11 a minimal + # control-plane node ran at 620 MiB idle and ~728 MiB with a small mock, plus + # 16 MiB for the local registry — ~745 MiB of working set. 800 rounds that up, + # and agrees with the 800 MB observed independently on a larger rig. Worker + # nodes carry no etcd or apiserver and are lighter, so for a multi-node shape + # this errs high. It is the cluster alone: whatever you deploy comes on top. + # + # Here rather than in check.sh because the memory tool and every standalone + # kit need the same figure. + NODE_MB=800 } # Render a cluster shape to stdout. sed rather than envsubst: envsubst is @@ -167,3 +181,78 @@ _config_restore() { # line would otherwise make this return 1 and trip `set -e` in the caller. return 0 } + +# ── what a standalone kit needs to know ──────────────────────────────────── +# Two questions the kit generator (ctrl/standalone.sh) asks, so that it never has +# to know how configuration is stored. Where profiles live, which files are +# layered and what is derived are this file's business and can change freely; +# the generator only calls these. + +# Every profile rig can be run as, one per line. +config_profiles() { + local f + for f in ./env.d/*.env; do + [ -e "$f" ] || continue + f=${f##*/}; echo "${f%.env}" + done +} + +# The resolved configuration for one profile, as `declare -p` lines — exactly +# what load_config leaves behind, minus the machine-local layer. A kit freezes +# this in place of load_config, so it carries rig's decisions for that profile +# and nothing about the machine it was generated on. +# +# Found by difference, not by a list: whatever load_config sets today, it sets. +# A list here would be one more place to forget a variable. +config_snapshot() { + local _rig_snap_profile="$1" + ( + # Nothing from the caller's shell may leak into a kit. + for _rig_snap_n in $CONFIG_OVERRIDABLE; do unset "$_rig_snap_n"; done + declare -A _rig_snap_was=() + for _rig_snap_n in $(compgen -v); do + _rig_snap_was[$_rig_snap_n]="${!_rig_snap_n-}" + done + PROFILE="$_rig_snap_profile" RIG_PORTABLE=1 load_config >/dev/null + for _rig_snap_n in $(compgen -v); do + case "$_rig_snap_n" in + _rig_snap_*|RIG_PORTABLE|BASH*|FUNCNAME|PIPESTATUS|LINENO|RANDOM|SRANDOM|\ + SECONDS|EPOCH*|HISTCMD|COLUMNS|LINES|PWD|OLDPWD|_|SHLVL|OPTIND|OPTERR) continue ;; + esac + if [ -z "${_rig_snap_was[$_rig_snap_n]+x}" ] \ + || [ "${_rig_snap_was[$_rig_snap_n]}" != "${!_rig_snap_n-}" ]; then + declare -p "$_rig_snap_n" + fi + done + ) +} + +# A replacement for load_config with one profile's resolution frozen in, printed +# as a function definition for a standalone kit to carry. The generator embeds +# whatever this prints and interprets none of it, so what "frozen" means stays +# rig's decision. +# +# It keeps load_config's one stated rule: the caller's env wins for anything in +# CONFIG_OVERRIDABLE. A kit therefore behaves like rig — `OUT_BIN=... rigdeps.sh` +# still works — rather than like a copy with everything pinned. +# +# What freezing does give up, knowingly: values DERIVED from an overridable one +# are fixed at generation. Override CLUSTER and the ports stay the ones derived +# for the original name. Re-deriving would mean carrying the layering itself, +# which is exactly what a kit exists not to need. +config_freeze() { + local snap + snap=$(config_snapshot "$1") || return 1 + cat <<'EOF' +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done +EOF + printf '%s\n' "$snap" | sed -E 's/^declare --* / declare -g /; s/^declare -([a-zA-Z]+) / declare -g\1 /' + cat <<'EOF' + _config_restore "$saved" +} +EOF +} diff --git a/rig/ctrl/mem.sh b/rig/ctrl/mem.sh index fa1ea54..4475063 100755 --- a/rig/ctrl/mem.sh +++ b/rig/ctrl/mem.sh @@ -1,24 +1,46 @@ #!/usr/bin/env bash -# What memory this machine has, what is left, and — where there is one — what -# cap is holding it there. +# rig:standalone rigmini status +# How much memory this machine will actually give you before something dies — +# rig's memory tool, and (generated from this file) the standalone rigmini.sh. # -# Runs on native Linux and under WSL, because rig is developed on one and used -# on the other. The difference is not cosmetic: on WSL the memory you see is a -# VM allocation that can be raised, and the commonest failure is raising it -# without restarting, so the number on disk and the number in /proc disagree. -# On native Linux there is no such cap and pretending otherwise sends you to a -# file that does not exist. +# There are two numbers and they are rarely the same. `status` reports what the +# machine ADVERTISES and what is quietly capping it. `push` finds what it will +# SURVIVE, by allocating until it stops. `all` does both and weighs the result +# against what this profile's cluster needs. # -# This reports and instructs. It never writes a .wslconfig — applying one costs -# a full VM restart that takes every shell, mount and container with it, and -# choosing that moment is yours. +# The gap between them is the whole reason this exists. Under WSL the cap lives +# in .wslconfig; in a container or a managed workspace it is a cgroup limit, and +# there /proc/meminfo reports the HOST's memory while the kernel kills you at a +# fraction of it. A script that only read MemTotal would confidently report 32 GB +# on a box that OOMs at 2. # -# `backup` exists so `restore` has something to read: back up, hand-edit -# following the printed instruction, restore if it goes wrong. Both are -# WSL-only, because .wslconfig is the only thing here worth backing up. +# Runs on native Linux and under WSL. On WSL the memory you see is a VM +# allocation that can be raised, and the commonest failure is raising it without +# restarting — so status compares what .wslconfig says with what actually booted. # -# Usage: mem.sh status | backup | restore +# Reports and instructs. It never raises a limit, frees anything or installs a +# package. The one write it can make is `backup`, which copies .wslconfig beside +# itself, so that `restore` has something to put back after a hand edit. +# +# Usage: +# mem.sh status what it has, what caps it +# mem.sh push [--to GB] [--to-oom] climb until it stops +# mem.sh all [--budget GB] both, then the verdict +# mem.sh backup | restore .wslconfig, WSL only set -euo pipefail +cd "$(dirname "$0")" +source ./lib/config.sh + +# ── defaults ─────────────────────────────────────────────────────────────── + +STEP_MB=0 # per allocation; 0 means scale it to the ceiling. See push(). +STEP_EXPLICIT=no # whether --step was given, which turns the scaling off. +TO_MB="" # --to: stop here regardless. Empty means no hard cap. +TO_OOM=no # --to-oom: opt in to running until the kernel intervenes. +BUDGET_GB="" # --budget; empty means what this profile's cluster needs, from rig. +BUDGET_EXPLICIT=no # whether --budget was given, which retires the guess below. + +# ── 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 @@ -36,26 +58,150 @@ If WSL is not installed yet, from an elevated PowerShell or Command Prompt: 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 + + # Everything below reads /proc. Without it there is nothing to measure, and + # failing here beats printing a page of empty fields. + if [ ! -r /proc/meminfo ]; then + echo "no readable /proc/meminfo — this needs a Linux kernel." >&2 + echo "On macOS or a BSD none of the numbers below exist." >&2 + exit 1 + fi } is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; } -require_wsl() { - if ! is_wsl; then - echo "$1 acts on .wslconfig, which only exists under WSL." >&2 - echo "This is native Linux — there is no VM allocation to save or roll back." >&2 - echo "Use 'mem.sh status' to see what the machine actually has." >&2 - exit 1 +is_container() { + [ -f /.dockerenv ] && return 0 + grep -qE '(docker|containerd|kubepods|lxc|podman)' /proc/1/cgroup 2>/dev/null +} + +platform() { + if is_wsl; then echo WSL + elif is_container; then echo container + else echo "native linux" fi } +# ── reading memory ───────────────────────────────────────────────────────── + mb() { echo $(( $(awk "/^$1:/{print \$2}" /proc/meminfo) / 1024 )); } +# MemAvailable arrived in kernel 3.14. Older kernels — and they turn up on +# corporate images — need the estimate it replaced, which is worse but not wrong. +avail_meminfo_mb() { + if grep -q '^MemAvailable:' /proc/meminfo; then + mb MemAvailable + else + awk '/^(MemFree|Buffers|Cached):/{t+=$2} END{print int(t/1024)}' /proc/meminfo + fi +} + +# Where a cgroup records this cgroup's own limit and usage. Set once by +# find_cgroup, because every later reading needs both and hunting for the files +# on each call would be the slow part of the poll loop. +CG_MAX_FILE="" +CG_CUR_FILE="" +CG_VERSION="" + +find_cgroup() { + local rel + + # Inside a container the cgroup namespace makes the top of the tree BE the + # container's own cgroup, so the unqualified path is already the right one. + # On a host it is the root cgroup, which is never limited — hence the second + # attempt via /proc/self/cgroup, which names the slice this shell is in. + if [ -r /sys/fs/cgroup/memory.max ]; then + CG_VERSION=v2 + CG_MAX_FILE=/sys/fs/cgroup/memory.max + CG_CUR_FILE=/sys/fs/cgroup/memory.current + elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + CG_VERSION=v1 + CG_MAX_FILE=/sys/fs/cgroup/memory/memory.limit_in_bytes + CG_CUR_FILE=/sys/fs/cgroup/memory/memory.usage_in_bytes + fi + + rel=$(awk -F: '$1=="0"{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] && [ -r "/sys/fs/cgroup${rel}/memory.max" ]; then + CG_VERSION=v2 + CG_MAX_FILE="/sys/fs/cgroup${rel}/memory.max" + CG_CUR_FILE="/sys/fs/cgroup${rel}/memory.current" + return 0 + fi + + rel=$(awk -F: '$2 ~ /(^|,)memory(,|$)/{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] \ + && [ -r "/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" ]; then + CG_VERSION=v1 + CG_MAX_FILE="/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" + CG_CUR_FILE="/sys/fs/cgroup/memory${rel}/memory.usage_in_bytes" + fi + return 0 +} + +# The cap in MB, or "" when there is none worth reporting. v2 spells unlimited +# "max"; v1 spells it as a number near 2^63, which is why this compares against +# MemTotal rather than testing for a magic value — a "limit" above the machine's +# own memory is not a limit, however it is written. +cgroup_cap_mb() { + local raw cap + [ -n "$CG_MAX_FILE" ] && [ -r "$CG_MAX_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_MAX_FILE" 2>/dev/null || echo max) + [ "$raw" = "max" ] && { echo ""; return 0; } + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + cap=$((raw / 1024 / 1024)) + [ "$cap" -ge "$(mb MemTotal)" ] && { echo ""; return 0; } + echo "$cap" +} + +cgroup_used_mb() { + local raw + [ -n "$CG_CUR_FILE" ] && [ -r "$CG_CUR_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_CUR_FILE" 2>/dev/null || echo "") + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((raw / 1024 / 1024)) +} + +# ulimit -v is a per-process address-space cap. It stops YOU long before the box +# does, and because it is inherited from a login shell it is easy to hit without +# knowing it is set. +ulimit_v_mb() { + local v; v=$(ulimit -v 2>/dev/null || echo unlimited) + [ "$v" = "unlimited" ] && { echo ""; return 0; } + case "$v" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((v / 1024)) +} + +# The number everything else is about: the lowest of the things that can stop +# you. Printed at the end of `status` and used as the sanity bound in `push`. +effective_ceiling_mb() { + local c; c=$(mb MemTotal) + local cap; cap=$(cgroup_cap_mb) + local ul; ul=$(ulimit_v_mb) + [ -n "$cap" ] && [ "$cap" -lt "$c" ] && c="$cap" + [ -n "$ul" ] && [ "$ul" -lt "$c" ] && c="$ul" + echo "$c" +} + +# How much room is left RIGHT NOW, from whichever accounting actually governs. +# In a capped container /proc/meminfo describes the host and is worse than +# useless for this — it would report tens of gigabytes free on a box that is one +# allocation from being killed. +headroom_mb() { + local cap used + cap=$(cgroup_cap_mb) + used=$(cgroup_used_mb) + if [ -n "$cap" ] && [ -n "$used" ]; then + echo $(( cap - used )) + else + avail_meminfo_mb + fi +} + +# ── status ───────────────────────────────────────────────────────────────── + # /mnt/c/Users can hold several real accounts — a renamed login leaves the old # directory behind — so picking the first alphabetically is a coin toss. Ask # Windows, then fall back to whichever profile actually owns a config. @@ -66,110 +212,222 @@ wslconfig_path() { ""|*%*) ;; *) winpath=$(wslpath -u "$profile" 2>/dev/null || true) if [ -n "$winpath" ] && [ -d "$winpath" ]; then - echo "$winpath/.wslconfig"; return + echo "$winpath/.wslconfig"; return 0 fi ;; esac - found=$(ls -d /mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true) - if [ -n "$found" ]; then echo "$found"; return; fi - - echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2 - ls -d /mnt/c/Users/*/ 2>/dev/null \ - | grep -viE "/(All Users|Default|Default User|Public)/$" | sed "s/^/ /" >&2 - exit 1 -} - -configured_memory() { - [ -r "$1" ] || { echo ""; return; } - sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$1" | tail -1 | tr -d '[:space:]' -} - -# "9GB" / "8192MB" / "9G" -> MB, so it can be compared with /proc/meminfo. -to_mb() { - local v="${1^^}" n - n=$(echo "$v" | tr -dc '0-9') - [ -n "$n" ] || { echo ""; return; } - case "$v" in - *GB|*G) echo $(( n * 1024 )) ;; - *MB|*M) echo "$n" ;; - *) echo $(( n / 1024 / 1024 )) ;; - esac + [ -n "$found" ] && echo "$found" + return 0 } hogs() { - echo "holding the most:" - ps -eo rss,comm --sort=-rss 2>/dev/null | awk 'NR>1 && NR<=6 {printf " %6.0f MB %s\n", $1/1024, $2}' + echo " holding the most:" + ps -eo rss,comm --sort=-rss 2>/dev/null \ + | awk 'NR>1 && NR<=6 {printf " %6.0f MB %s\n", $1/1024, $2}' + return 0 } status() { - local total avail swap_total swap_free - total=$(mb MemTotal); avail=$(mb MemAvailable) + local total avail swap_total swap_free cap ul cur + + echo "host" + echo " platform $(platform)" + echo " kernel $(uname -r)" + [ -r /etc/os-release ] && \ + echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' /etc/os-release)" + echo " cpu $(getconf _NPROCESSORS_ONLN 2>/dev/null || echo '?') online, load $(cut -d' ' -f1-3 /proc/loadavg)" + + # ── the caps first, because they decide what the totals below are worth ── + echo + echo "caps" + cap=$(cgroup_cap_mb) + if [ -n "$cap" ]; then + cur=$(cgroup_used_mb) + echo " cgroup ${cap} MB (${CG_VERSION}, ${CG_CUR_FILE##*/} says ${cur:-?} MB used)" + echo " ! /proc/meminfo below describes the HOST, not this cgroup." + echo " $(mb MemTotal) MB total is not yours; ${cap} MB is." + elif [ -n "$CG_VERSION" ]; then + echo " cgroup none (${CG_VERSION} present, no memory limit set)" + else + echo " cgroup no memory controller found" + fi + + ul=$(ulimit_v_mb) + if [ -n "$ul" ]; then + echo " ! ulimit -v ${ul} MB — a per-process cap, inherited from your shell" + echo " it stops this process long before the machine runs out" + else + echo " ulimit -v unlimited" + fi + + # overcommit_memory=0 is the default heuristic: a large allocation is + # granted on a guess, and the reckoning arrives later as an OOM kill rather + # than as a failed malloc. It is why `push` touches every page it asks for. + local om or_ + om=$(cat /proc/sys/vm/overcommit_memory 2>/dev/null || echo '?') + or_=$(cat /proc/sys/vm/overcommit_ratio 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess," ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit," ;; + 2) echo " overcommit 2 strict (ratio ${or_}%) — allocation fails honestly instead of killing later," ;; + *) echo " overcommit ${om}" ;; + esac + [ "$om" != "?" ] && echo " so RSS is the number to trust, not what a process asked for" + + # ── what it says it has ── + total=$(mb MemTotal); avail=$(avail_meminfo_mb) swap_total=$(mb SwapTotal); swap_free=$(mb SwapFree) + echo + echo "memory" + echo " total ${total} MB" + echo " available ${avail} MB" + echo " swap ${swap_total} MB ($(( swap_total - swap_free )) MB used)" + if [ "$swap_total" -eq 0 ]; then + echo " - no swap: this box has no cushion. It goes from fine to OOM-killed" + echo " with nothing in between, which is the abrupt failure you get in a VM." + fi - if is_wsl; then - local cfg conf conf_mb - cfg=$(wslconfig_path) - conf=$(configured_memory "$cfg") - echo "platform WSL" - echo "config $cfg" - if [ -n "$conf" ]; then - conf_mb=$(to_mb "$conf") - echo "configured $conf (${conf_mb} MB)" - else - conf_mb="" - echo "configured (no memory= set — WSL defaults to 50% of host RAM, or 8GB, whichever is less)" - fi - echo "booted ${total} MB" - echo "available ${avail} MB" - echo "swap ${swap_total} MB ($(( swap_total - swap_free )) MB used)" - - if [ -n "$conf_mb" ]; then - # The VM reports a little less than allocated; 15% covers the kernel - # without calling every healthy machine a mismatch. - if [ "$total" -lt $(( conf_mb * 85 / 100 )) ]; then - echo - echo "! configured ${conf_mb} MB but booted ${total} MB." - echo " The change has not been applied. From a WINDOWS terminal:" - echo - echo " wsl --shutdown" - echo - echo " then start the distro again." + # postgres puts its shared buffers in /dev/shm. Docker's default is 64 MB, + # and the resulting failure names neither shm nor the size. + if [ -d /dev/shm ]; then + local shm; shm=$(df -Pm /dev/shm 2>/dev/null | awk 'NR==2{print $2}') + if [ -n "$shm" ]; then + if [ "$shm" -le 64 ]; then + echo " ! /dev/shm ${shm} MB — postgres puts shared memory here and 64 MB" + echo " is docker's default. Raise it with --shm-size when postgres fails." + else + echo " /dev/shm ${shm} MB" fi - else - echo - echo "To raise it, add to $cfg on the Windows side:" - echo - echo " [wsl2]" - echo " memory=8GB" - echo - echo "then, from a WINDOWS terminal: wsl --shutdown" fi + fi + + echo + echo "disk" + local d + for d in / /tmp /var/lib/docker; do + [ -d "$d" ] || continue + df -Pm "$d" 2>/dev/null | awk -v p="$d" 'NR==2{printf " %-12s %s MB free of %s MB\n", p, $4, $2}' + done + + # kind and Tilt both watch large trees, and the failure mode is silent: + # they simply stop noticing file changes. Cheap to report while we are here. + 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 + echo "tooling" + echo " inotify watches=$w instances=$i" + if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then + echo " ! low — anything watching files will silently stop seeing changes" + fi + if ! command -v docker >/dev/null 2>&1; then + if [ -S /var/run/docker.sock ]; then + echo " docker socket present, no cli" + else + echo " docker not installed" + fi + elif docker info >/dev/null 2>&1; then local n - n=$(ls "$cfg".*.bak 2>/dev/null | wc -l) - [ "$n" -gt 0 ] && echo "backups $n (newest: $(ls -t "$cfg".*.bak 2>/dev/null | head -1))" + n=$(docker ps -q 2>/dev/null | wc -l) + echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null), ${n} container(s) running" else - echo "platform native linux" - echo "total ${total} MB" - echo "available ${avail} MB" - echo "swap ${swap_total} MB ($(( swap_total - swap_free )) MB used)" - echo - echo "No VM allocation to raise here — this is the machine's own memory." - echo "If it is tight the levers are freeing something or adding swap." + echo " ! docker cli present but the daemon is unreachable" fi - # Under a fifth left is worth naming wherever you are running. - if [ "$avail" -lt $(( total / 5 )) ]; then + # WSL keeps its cap on the Windows side, in a file this shell can read but + # not usefully apply — the change costs a full VM restart. Report it, and + # report the commonest mistake, which is editing it and not restarting. + if is_wsl; then + local cfg conf conf_mb n + cfg=$(wslconfig_path) + echo + echo "wsl" + if [ -z "$cfg" ]; then + echo " ! cannot tell which Windows profile owns .wslconfig" + else + echo " config $cfg" + conf=$(configured_memory "$cfg") + if [ -n "$conf" ]; then + conf_mb=$(to_mb "$conf") + echo " configured $conf (${conf_mb} MB), booted ${total} MB" + # The VM reports a little less than allocated; 15% covers the + # kernel without calling every healthy machine a mismatch. + if [ -n "$conf_mb" ] && [ "$total" -lt $(( conf_mb * 85 / 100 )) ]; then + echo " ! configured ${conf_mb} MB but booted ${total} MB — not applied yet." + echo " From a WINDOWS terminal: wsl --shutdown then start the distro again." + fi + else + echo " configured no memory= set (WSL defaults to 50% of host RAM, or 8 GB," + echo " whichever is less). To raise it, add on the Windows side:" + echo " [wsl2]" + echo " memory=8GB" + echo " then from a WINDOWS terminal: wsl --shutdown" + fi + n=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$n" -gt 0 ]; then + echo " backups $n (newest: $(ls -t "$cfg".*.bak 2>/dev/null | head -1))" + fi + fi + else echo - hogs + echo " - native linux: no VM allocation to raise. If memory is tight the levers" + echo " are freeing something or adding swap." fi + + echo + echo "effective ceiling $(effective_ceiling_mb) MB" + echo " the lowest of MemTotal, the cgroup cap and ulimit -v. What the box" + echo " claims. 'push' measures what it will actually hand over." + + [ "$avail" -lt $(( total / 5 )) ] && { echo; hogs; } return 0 } +# ── .wslconfig ───────────────────────────────────────────────────────────── + +require_wsl() { + if ! is_wsl; then + echo "$1 acts on .wslconfig, which only exists under WSL." >&2 + echo "This is native Linux — there is no VM allocation to save or roll back." >&2 + echo "Use 'status' to see what the machine actually has." >&2 + exit 1 + fi +} + +# backup and restore act on the file, so unlike status they must not guess. +wslconfig_required() { + local cfg; cfg=$(wslconfig_required) + if [ -z "$cfg" ]; then + echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2 + ls -d /mnt/c/Users/*/ 2>/dev/null \ + | grep -viE "/(All Users|Default|Default User|Public)/$" | sed "s/^/ /" >&2 + exit 1 + fi + echo "$cfg" +} + +configured_memory() { + [ -r "$1" ] || { echo ""; return; } + sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$1" | tail -1 | tr -d '[:space:]' +} + +# "9GB" / "8192MB" / "9G" -> MB, so it can be compared with /proc/meminfo. +to_mb() { + local v="${1^^}" n + n=$(echo "$v" | tr -dc '0-9') + [ -n "$n" ] || { echo ""; return; } + case "$v" in + *GB|*G) echo $(( n * 1024 )) ;; + *MB|*M) echo "$n" ;; + *) echo $(( n / 1024 / 1024 )) ;; + esac +} + backup() { require_wsl backup local cfg dest - cfg=$(wslconfig_path) + cfg=$(wslconfig_required) [ -r "$cfg" ] || { echo "nothing to back up: $cfg does not exist" >&2; exit 1; } # Timestamped and never overwritten: a backup that can destroy itself on a # second run is not a backup. @@ -183,7 +441,7 @@ backup() { restore() { require_wsl restore local cfg newest count - cfg=$(wslconfig_path) + cfg=$(wslconfig_required) newest=$(ls -t "$cfg".*.bak 2>/dev/null | head -1 || true) [ -n "$newest" ] || { echo "no backups found beside $cfg" >&2; exit 1; } @@ -223,11 +481,279 @@ restore() { echo "restored. From a WINDOWS terminal: wsl --shutdown" } +# ── push ─────────────────────────────────────────────────────────────────── + +STATE="" +CHILD="" + +cleanup() { + if [ -n "$CHILD" ] && kill -0 "$CHILD" 2>/dev/null; then + kill -KILL "$CHILD" 2>/dev/null || true + wait "$CHILD" 2>/dev/null || true + fi + [ -n "$STATE" ] && rm -f "$STATE" + return 0 +} + +# The child allocates and stops itself; the parent only watches. That split is +# the point: under --to-oom the allocating process is expected to be killed, and +# something has to survive to say how far it got. +allocator() { + # Raise our own OOM score to the maximum so the kernel picks THIS process + # first. Raising needs no privilege (only lowering does). Without it, the + # kernel is free to choose your shell, your ssh session or dockerd — on a + # box you are still using, that is not an acceptable coin toss. + echo 1000 > "/proc/$BASHPID/oom_score_adj" 2>/dev/null || true + + local arr=() held=0 i=0 rss swapped avail first_swap=0 + local bytes=$((STEP_MB * 1024 * 1024)) + local swap_used_start + swap_used_start=$(( $(mb SwapTotal) - $(mb SwapFree) )) + + while :; do + # Written STRAIGHT INTO the array element. The obvious spelling — + # build one chunk and `arr+=("$chunk")` — costs three copies per step, + # not one: the template stays resident, expanding "$chunk" makes a + # temporary word, and the append makes the element. A 128 MB step then + # needs 384 MB transiently, and on a small box it is killed on the + # first append while reporting a third of the true ceiling. + # + # printf -v into a subscript also means every page is written, so it is + # resident rather than merely promised — the only kind of allocation + # that measures anything under heuristic overcommit. + printf -v "arr[$i]" '%*s' "$bytes" '' + i=$((i + 1)); held=$((held + STEP_MB)) + + rss=$(awk '/^VmRSS:/{print int($2/1024)}' "/proc/$BASHPID/status" 2>/dev/null || echo 0) + avail=$(headroom_mb) + swapped=$(( $(mb SwapTotal) - $(mb SwapFree) - swap_used_start )) + [ "$swapped" -lt 0 ] && swapped=0 + + printf '%8s MB held rss %7s MB headroom %7s MB swap +%s MB\n' \ + "$held" "$rss" "$avail" "$swapped" + printf '%s %s %s %s\n' "$held" "$rss" "$avail" "$swapped" >> "$STATE" + + # Worth calling out separately from the ceiling: this is where the box + # stops being fast and starts being unusable, which for a scheduler is + # a different and earlier problem than being killed. + if [ "$swapped" -gt 0 ] && [ "$first_swap" -eq 0 ]; then + first_swap=$held + echo " - first swap page at ${held} MB — past here it works but crawls" + echo "swapat $held" >> "$STATE" + fi + + if [ -n "$TO_MB" ] && [ "$held" -ge "$TO_MB" ]; then + echo "stop reached-the-cap" >> "$STATE"; return 0 + fi + if [ "$TO_OOM" = no ] && [ "$avail" -lt "$FLOOR_MB" ]; then + echo "stop floor" >> "$STATE"; return 0 + fi + done +} + +push() { + local total ceiling rc=0 last held rss swapat stop + total=$(mb MemTotal) + ceiling=$(effective_ceiling_mb) + + # A step is worth about a sixty-fourth of the ceiling: enough resolution to + # find the edge, few enough lines to read, and small enough that the + # transient cost of one allocation never dominates a small box. A fixed + # size cannot do all three — 128 MB is fine on 16 GB and absurd on 512 MB. + if [ "$STEP_EXPLICIT" = no ]; then + STEP_MB=$(( ceiling / 64 )) + [ "$STEP_MB" -lt 4 ] && STEP_MB=4 + [ "$STEP_MB" -gt 256 ] && STEP_MB=256 + fi + + # Stop with a cushion rather than riding it to the kill. How big a cushion + # depends on what it is protecting. Under a cgroup cap, running out kills + # only this container's own processes, so it need cover no more than the + # shell that prints the result — and a 512 MB cushion on a 1 GB box would + # halve the answer. On a host there is everything else to protect, and the + # OOM killer does not promise to pick the process that caused the problem. + if [ -n "$(cgroup_cap_mb)" ]; then FLOOR_MB=64; else FLOOR_MB=512; fi + [ $(( ceiling / 20 )) -gt "$FLOOR_MB" ] && FLOOR_MB=$(( ceiling / 20 )) + + STATE=$(mktemp "${TMPDIR:-/tmp}/rigmini.XXXXXX") + trap cleanup EXIT + # INT kills the child and lets the summary below print anyway, so an + # impatient Ctrl-C still tells you how far it got — and, more importantly, + # still gives the memory back. + trap 'echo; echo " interrupted"; echo "stop interrupted" >> "$STATE"; [ -n "$CHILD" ] && kill -KILL "$CHILD" 2>/dev/null || true' INT + + echo "push" + echo " step ${STEP_MB} MB per allocation, every page touched" + echo " ceiling ${ceiling} MB claimed" + if [ -n "$TO_MB" ]; then + echo " stopping at ${TO_MB} MB (--to)" + elif [ "$TO_OOM" = yes ]; then + echo " ! stopping only when the kernel stops it (--to-oom)" + echo " the allocating child is marked as the preferred OOM victim," + echo " but nothing about an OOM kill is entirely polite. Not on a box" + echo " running anything you mind losing." + else + echo " stopping when headroom drops below ${FLOOR_MB} MB" + fi + echo + + allocator & + CHILD=$! + wait "$CHILD" || rc=$? + CHILD="" + trap - INT + + last=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 || true) + held=$(echo "$last" | awk '{print $1}') + rss=$(echo "$last" | awk '{print $2}') + swapat=$(awk '/^swapat/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + stop=$(awk '/^stop/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + + echo + if [ -z "$held" ]; then + echo " ! nothing was allocated. Even one ${STEP_MB} MB chunk failed —" + echo " try a smaller --step, or check ulimit -v in 'status'." + return 1 + fi + + echo " reached ${rss:-$held} MB resident" + [ -n "$swapat" ] && echo " swapping from ${swapat} MB" + + case "$stop" in + reached-the-cap) + echo " outcome stopped at the --to cap, not at a limit." + echo " The box held ${TO_MB} MB without complaint; there is more." ;; + floor) + echo " outcome stopped with a cushion intact, by choice." + echo " The real ceiling is higher — --to-oom finds it, at the" + echo " cost of an actual OOM kill." ;; + interrupted) + echo " outcome interrupted at ${rss:-$held} MB — where you stopped it," + echo " not where the box did." ;; + *) + # No stop line means the child did not decide to stop: it was ended. + if [ "$rc" -ge 128 ]; then + echo " outcome the child was killed (signal $((rc - 128))) at ${rss:-$held} MB." + elif [ "$rc" -ne 0 ]; then + echo " outcome the allocation failed at ${rss:-$held} MB (exit ${rc})." + echo " bash could not get the next chunk — an honest malloc" + echo " failure rather than a kill. That is the strict-overcommit" + echo " or ulimit path." + else + echo " outcome ended at ${rss:-$held} MB." + fi + local ev + ev=$(dmesg 2>/dev/null | tail -80 | grep -iE 'oom-kill|killed process' | tail -1 || true) + if [ -n "$ev" ]; then + echo " kernel ${ev#*] }" + else + echo " - dmesg is unreadable here (dmesg_restrict, or no privilege)," + echo " so the kill cannot be confirmed from this side. The number stands." + fi ;; + esac + + # The gap between the claim and the measurement is the finding — but only + # when the BOX chose where to stop. An empty $stop means the child was ended + # rather than deciding to end; anything else (--to, the floor) is a stop we + # asked for, and flagging those as short of the ceiling would put a warning + # on every deliberately small run. + local got="${rss:-$held}" + echo + if [ -z "$stop" ] && [ "$got" -lt $(( ceiling * 70 / 100 )) ]; then + echo " ! claimed ${ceiling} MB, gave up ${got} MB — under 70% of it." + echo " Something is taking the difference. 'status' names the candidates:" + echo " a cgroup cap, ulimit -v, or memory already resident." + fi + return 0 +} + +# ── all ──────────────────────────────────────────────────────────────────── + +all() { + status + echo + echo "────────────────────────────────────────────────────────────" + echo + push + + local got budget_mb ceiling + load_config + if [ -n "$BUDGET_GB" ]; then + budget_mb=$(( BUDGET_GB * 1024 )) + else + budget_mb=$(( NODES * NODE_MB )) + fi + ceiling=$(effective_ceiling_mb) + got=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 | awk '{print $2}' || true) + [ -n "$got" ] || got=0 + + echo + echo "verdict" + if [ -n "$BUDGET_GB" ]; then + echo " budget ${budget_mb} MB (--budget)" + else + # rig's own figure for this profile: nodes times what one node costs. + # Addons carry no memory figure in rig yet, so this is the cluster alone + # and whatever you deploy comes on top. --budget once you know that too. + echo " budget ${budget_mb} MB — profile ${PROFILE_NAME}: ${NODES} node(s) x ${NODE_MB} MB," + echo " the cluster alone; your workload comes on top (--budget GB)" + fi + echo " measured ${got} MB handed over" + + if [ "$got" -ge "$budget_mb" ]; then + echo " fits, with $(( got - budget_mb )) MB spare." + if [ "$got" -lt $(( budget_mb * 130 / 100 )) ]; then + echo " - under 30% spare is thin once a workload runs on top: memory use" + echo " is spiky, and the spikes are what get killed." + fi + else + echo " ! short by $(( budget_mb - got )) MB." + if [ "$ceiling" -ge "$budget_mb" ]; then + echo " The box CLAIMS enough (${ceiling} MB) but did not deliver it." + echo " Free something, or read the caps section again." + else + echo " The box does not have it to give. A bigger machine, or a profile" + echo " with fewer nodes." + fi + fi + return 0 +} + +# ── main ─────────────────────────────────────────────────────────────────── + +parse_flags() { + while [ $# -gt 0 ]; do + case "$1" in + --to) TO_MB=$(( ${2:?--to needs a value in GB} * 1024 )); shift 2 ;; + --to-mb) TO_MB="${2:?--to-mb needs a value in MB}"; shift 2 ;; + --step) STEP_MB="${2:?--step needs a value in MB}"; STEP_EXPLICIT=yes; shift 2 ;; + --to-oom) TO_OOM=yes; shift ;; + --budget) BUDGET_GB="${2:?--budget needs a value in GB}"; BUDGET_EXPLICIT=yes; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac + done + if [ "$TO_OOM" = yes ] && [ -n "$TO_MB" ]; then + echo "--to and --to-oom contradict each other: one stops early, the other" >&2 + echo "refuses to stop at all. Pick one." >&2 + exit 1 + fi + return 0 +} + require_linux +find_cgroup + +cmd="${1:-status}" +[ $# -gt 0 ] && shift -case "${1:-status}" in - status) status ;; +case "$cmd" in + status) parse_flags "$@"; status ;; + push) parse_flags "$@"; push ;; + all) parse_flags "$@"; all ;; backup) backup ;; restore) restore ;; - *) echo "usage: $0 [status|backup|restore]" >&2; exit 1 ;; + *) echo "usage: $0 [status|push|all|backup|restore]" >&2 + echo " push [--to GB] [--to-mb MB] [--step MB] [--to-oom]" >&2 + echo " all [--budget GB]" >&2 + exit 1 ;; esac diff --git a/rig/ctrl/pins.sh b/rig/ctrl/pins.sh deleted file mode 100755 index af72dc6..0000000 --- a/rig/ctrl/pins.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# Do the standalone scripts still install what rig pins? -# -# standalone/rigdeps.sh carries its toolchain pins inline, because it exists for -# a machine that will never have ctrl/versions.env. That makes two copies of the -# same versions and checksums, and two copies drift the day one is edited and -# the other forgotten. This is the check that notices. -# -# ctrl/versions.env is the source of truth. Only the keys rigdeps.sh itself -# defines are compared: versions.env also pins addon images (cert-manager, -# metallb, metrics-server) that rigdeps.sh never installs, and demanding those -# would make this fail forever for no reason. -# -# Exits non-zero on any mismatch — unlike the host checks, this one is a test. -# -# Usage: pins.sh -set -euo pipefail -cd "$(dirname "$0")" - -SOURCE=./versions.env -COPY=../standalone/rigdeps.sh - -[ -r "$COPY" ] || { echo "no $COPY to compare" >&2; exit 1; } - -# KEY=value for the pin keys a file defines, quotes stripped. awk rather than a -# grep regex, which is not the same program everywhere. -pins() { - awk -F= '/^[A-Z_]+_(VERSION|SHA256)=/ { - v = substr($0, index($0, "=") + 1); gsub(/^["\x27]|["\x27]$/, "", v) - print $1 "=" v }' "$1" -} - -echo "pins: standalone/rigdeps.sh against ctrl/versions.env" -bad=0 -while IFS='=' read -r key copy_val; do - [ -n "$key" ] || continue - src_val=$(pins "$SOURCE" | sed -n "s/^${key}=//p" | head -1) - if [ -z "$src_val" ]; then - printf " ! %-16s in rigdeps.sh but not in versions.env\n" "$key" - bad=1 - elif [ "$src_val" = "$copy_val" ]; then - printf " %-16s %s\n" "$key" "$( [ ${#src_val} -gt 20 ] && echo "${src_val:0:12}…" || echo "$src_val" )" - else - printf " ! %-16s versions.env %s\n" "$key" "$src_val" - printf " %-16s rigdeps.sh %s\n" "" "$copy_val" - bad=1 - fi -done < <(pins "$COPY") - -echo -if [ "$bad" -eq 0 ]; then - echo "in step — rigdeps.sh installs exactly what rig pins." -else - echo "DRIFT. versions.env is the source of truth: copy the differing lines from it" - echo "into standalone/rigdeps.sh, taking checksums from the publisher's release list." - exit 1 -fi diff --git a/rig/ctrl/selftest.sh b/rig/ctrl/selftest.sh index c4954e0..74322d3 100755 --- a/rig/ctrl/selftest.sh +++ b/rig/ctrl/selftest.sh @@ -11,7 +11,7 @@ # Scope, on purpose: # - no cluster, no docker, no network. It must be cheap enough to actually run. # - it asserts about RIG. `make check` asserts about the MACHINE and never -# fails; this exits 1, the way `make pins` does. +# fails; this exits 1, the way `make standalone check` does. # - what actually deploys is not testable here. `tilt ci` stays a manual step. # # Usage: make selftest (or: bash ctrl/selftest.sh) @@ -168,24 +168,37 @@ check "guards on the variable" "1" "$(grep -c 'allow_k8s_contexts(CTX)' Tiltfil check "asks ports.sh for facts" "1" "$(grep -c "local('bash ports.sh active'" Tiltfile)" -note "the standalone Makefile only calls real verbs" -# That file exists so nothing wrapping these scripts has to GUESS how to call -# them. A generated Makefile once did guess: `rigmini.sh on`, which is not a -# verb, and a bare `rigdeps.sh` for "check and report", which installs. So each -# target's default verb must be one the script's own dispatch accepts — read -# from the script's `case`, not from a list here that could drift from it. +note "standalone kits are generated, current, and call only real verbs" +# The kits under standalone// are rig flattened into single files, one +# per profile. A kit left behind by a change to rig is exactly the drift they +# replaced — rigmini.sh once said 2 GB per node long after rig measured 800 MB — +# so a stale kit fails here rather than waiting to be noticed on another machine. +check "every kit matches what rig generates now" "yes" \ + "$(bash standalone.sh check >/dev/null 2>&1 && echo yes || echo "no — run make standalone")" + +# Each kit's Makefile exists so nothing wrapping these scripts has to GUESS how to +# call them. A generated Makefile once did guess: `rigmini.sh on`, not a verb, +# and a bare `rigdeps.sh` for "check and report", which installs. So every +# target's default verb must be one its script's own dispatch accepts — read +# from that dispatch, not from a list here that could drift from it. verbs_of() { sed -n '/^case "\$cmd" in/,/^esac/p' "$1" | grep -oE '^ [a-z]+\)' | tr -d ' )' } -for pair in deps:rigdeps.sh mem:rigmini.sh; do - target=${pair%%:*}; script=${pair##*:} - verb="$(make --no-print-directory -s -n -f ../standalone/Makefile "$target" 2>/dev/null \ - | awk -v s="$script" 'index($0, s) { print $NF; exit }')" - check "make $target -> $script ${verb:-?}, a verb it accepts" "yes" \ - "$(verbs_of "../standalone/$script" | grep -qx "$verb" && echo yes || echo "no: '$verb'")" +kits=0 +for mk in ../standalone/*/Makefile; do + [ -f "$mk" ] || continue + kit=$(dirname "$mk"); kits=$((kits + 1)) + for target in $(grep -oE '^[a-z][a-z-]*:' "$mk" | tr -d ':' | grep -vx help); do + line="$(make --no-print-directory -s -n -f "$mk" "$target" 2>/dev/null | head -1)" + script=$(basename "$(printf '%s' "$line" | awk '{print $2}')") + verb=$(printf '%s' "$line" | awk '{print $NF}') + check "$(basename "$kit"): make $target -> $script $verb, a verb it accepts" "yes" \ + "$(verbs_of "$kit/$script" | grep -qx "$verb" && echo yes || echo "no: '$verb'")" + done + check "$(basename "$kit"): no \`mini\` target, which already means minimal footprint" "0" \ + "$(grep -cE '^mini:' "$mk")" done -check "no \`mini\` target, which already means minimal footprint" "0" \ - "$(grep -cE '^mini:' ../standalone/Makefile)" +check "there is a kit for every profile" "$(config_profiles | wc -l)" "$kits" note "optional — needs tilt and this rig's cluster" diff --git a/rig/ctrl/standalone.sh b/rig/ctrl/standalone.sh new file mode 100644 index 0000000..1027d32 --- /dev/null +++ b/rig/ctrl/standalone.sh @@ -0,0 +1,341 @@ +#!/usr/bin/env bash +# Generate the standalone kits: single-file versions of rig's own tools, one +# folder per profile, for machines the full rig is not going to. +# +# A kit is a pure function of rig as it is right now. It gains nothing rig lacks +# and loses nothing rig has — improve rig, regenerate, and every kit follows. +# Nothing in standalone// is ever edited by hand. +# +# What this file does NOT know, on purpose: which tools rig has, what they are +# called, how its libraries are split, where configuration lives or what it +# contains. Rig will change shape — scripts get split, renamed and grow new +# libraries — and a generator that encoded today's layout would quietly produce +# a wrong kit the first time it did. So this works from a contract a script opts +# into, and from nothing else: +# +# 1. A marker comment, alone on a line near the top, declares an entry point: +# (hash) rig:standalone +# The default verb must only REPORT: it is run as a smoke test. +# 2. Every `source` an entry point makes names a .sh file by a path that +# resolves relative to the entry point. Libraries may source further +# libraries however they like — bash follows those itself. +# 3. Configuration enters through `load_config`, and the libraries provide +# `config_profiles` and `config_freeze ` — the latter prints a +# replacement load_config with that profile resolved. How config is layered, +# stored, derived or frozen is rig's business; this only asks, and embeds +# the answer without interpreting it. +# +# Bash does the resolving, not a parser here. Libraries are sourced in a clean +# shell and read back with `declare -f` and `declare -p`, so any structure bash +# can load, this can flatten. +# +# And every kit is PROVEN to stand alone before it is written: no `source` left, +# no path into rig's tree in its code, `bash -n` clean, and its default verb run +# in an empty directory with nothing from rig present. A shape this has never +# seen either passes that, or generation stops and names the kit, the file, the +# line and what is wrong. It never writes a kit that only looks finished. +# +# Usage: +# standalone.sh write generate every kit into standalone// +# standalone.sh check generate into a scratch dir and fail if any kit differs +set -euo pipefail +cd "$(dirname "$0")" + +CTRL="$PWD" +ROOT="$(cd .. && pwd)" +OUT="$ROOT/standalone" +SELF_REL="ctrl/${0##*/}" + +GENERATED_TAG="GENERATED by make standalone — do not edit" +FROZEN_OPEN="# ── configuration, frozen" +FROZEN_CLOSE="# ── end of frozen configuration" + +refuse() { echo >&2; echo "standalone: refusing — $*" >&2; exit 1; } + +# A clean bash with nothing from the caller's shell in it. What the kit carries +# must not depend on who ran the generator or what they had exported. +clean_bash() { env -i PATH="$PATH" HOME="$HOME" bash --noprofile --norc "$@"; } + +# ── 1. entry points ──────────────────────────────────────────────────────── +entries() { + grep -rlE --include='*.sh' '^# rig:standalone [a-z0-9-]+ [a-z0-9-]+' . 2>/dev/null \ + | sed 's|^\./||' | LC_ALL=C sort +} +marker_of() { # entry -> "kit verb" + sed -nE 's/^# rig:standalone ([a-z0-9-]+) ([a-z0-9-]+).*/\1 \2/p' "$1" | head -1 +} + +# ── 2. the libraries an entry point sources ──────────────────────────────── +# Only the entry point's own `source` lines are read as text. Everything those +# libraries pull in is resolved by bash when they are sourced in step 3. +libs_of() { # entry -> one resolved lib path per line, relative to ctrl/ + local entry="$1" dir line n path + dir=$(dirname "$entry") + while IFS=: read -r n line; do + path=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*(source|\.)[[:space:]]+//; s/[[:space:]]+(#.*)?$//') + path=${path#\"}; path=${path%\"}; path=${path#\'}; path=${path%\'} + case "$path" in + *'$'*) refuse "$entry:$n sources '$path' — a path with a variable in it cannot be resolved; name the file" ;; + esac + case "$path" in + *.sh) ;; + *) refuse "$entry:$n sources '$path' directly — only libraries (.sh) may be sourced; configuration has to enter through load_config" ;; + esac + path="$dir/${path#./}"; path=${path#./} + [ -f "$path" ] || refuse "$entry:$n sources '$path', which does not exist" + printf '%s\n' "$path" + done < <(grep -nE '^[[:space:]]*(source|\.)[[:space:]]+[^=]' "$entry" || true) +} + +# Into the global array `libs`. Not `mapfile < <(libs_of ...)`: a refusal inside +# a process substitution only ends that subshell, so generation would carry on +# past it and fail later with a message about something else entirely. +libs_into() { + local out + out=$(libs_of "$1") || exit 1 + libs=() + [ -n "$out" ] && mapfile -t libs <<< "$out" + return 0 +} + +# ── 3. what the libraries define, read back from bash itself ─────────────── +# The frozen config replaces load_config, and the generator's own two questions +# are useless inside a kit, so none of the three is carried. +lib_defs() { # entry lib... -> declare -p globals, then declare -f functions + local entry="$1"; shift + ( cd "$(dirname "$entry")" && clean_bash -c ' + skip_var() { case "$1" in BASH*|FUNCNAME|PIPESTATUS|LINENO|RANDOM|SRANDOM|SECONDS|EPOCH*|HISTCMD|COLUMNS|LINES|PWD|OLDPWD|_|SHLVL|OPTIND|OPTERR|IFS|PS4|PATH|HOME|v|f|l|before_v|before_f) return 0 ;; esac; return 1; } + before_v=" $(compgen -v | tr "\n" " ") " + before_f=" $(compgen -A function | tr "\n" " ") " + for l in "$@"; do source "$l" || { echo "__FAIL__ sourcing $l" ; exit 1; }; done + for v in $(compgen -v); do + skip_var "$v" && continue + case "$before_v" in *" $v "*) continue ;; esac + declare -p "$v" + done + for f in $(compgen -A function); do + case "$before_f" in *" $f "*) continue ;; esac + case "$f" in skip_var|load_config|config_profiles|config_snapshot|config_freeze) continue ;; esac + declare -f "$f" + done + ' _ "$@" ) || refuse "$entry: its libraries could not be sourced cleanly" +} + +# ── 4. ask rig for profiles and resolved config ──────────────────────────── +ask() { # entry lib... -- function args... -> that function's stdout + local entry="$1"; shift + local libs=() a + while [ $# -gt 0 ] && [ "$1" != -- ]; do libs+=("$1"); shift; done + shift + ( cd "$(dirname "$entry")" && clean_bash -c ' + n=0; for a in "$@"; do n=$((n+1)); [ "$a" = -- ] && break; done + for l in "${@:1:$((n-1))}"; do source "$l"; done + shift "$n" + declare -F "$1" >/dev/null || exit 3 + "$@" + ' _ "${libs[@]}" -- "$@" ) +} + +# ── 5. assemble one kit file ─────────────────────────────────────────────── +assemble() { # entry profile out-file lib... + local entry="$1" profile="$2" dest="$3"; shift 3 + local libs=("$@") calls_config=no + grep -qE '(^|[^A-Za-z0-9_])load_config([^A-Za-z0-9_]|$)' "$entry" && calls_config=yes + + { + echo '#!/usr/bin/env bash' + echo "# $GENERATED_TAG" + echo "#" + echo "# $(basename "$dest") for profile '$profile', flattened from:" + echo "# ctrl/$entry" + local l; for l in ${libs[@]+"${libs[@]}"}; do echo "# ctrl/$l"; done + echo "# Edit those and run \`make standalone\`. Changes made here are lost, and" + echo "# \`make selftest\` fails while this file differs from what rig generates." + echo + + if [ ${#libs[@]} -gt 0 ]; then + echo "# ── from the libraries ──" + lib_defs "$entry" "${libs[@]}" + echo + fi + + if [ "$calls_config" = yes ]; then + local frozen + frozen=$(ask "$entry" ${libs[@]+"${libs[@]}"} -- config_freeze "$profile") \ + || refuse "ctrl/$entry calls load_config, but its libraries do not answer config_freeze for '$profile'" + echo "$FROZEN_OPEN for profile '$profile' ──" + printf '%s\n' "$frozen" + echo "$FROZEN_CLOSE ──" + echo + fi + + echo "# ── ctrl/$entry ──" + # The entry point itself, minus its shebang and marker, with each source + # line it made replaced by a note — what it sourced is already above. + awk ' + NR == 1 && /^#!/ { next } + /^# rig:standalone / { next } + /^[[:space:]]*(source|\.)[[:space:]]+[^=]/ { print "# (sourced library inlined above)"; next } + { print } + ' "$entry" + } > "$dest" + chmod +x "$dest" +} + +# ── 6. the kit's Makefile, from the markers ──────────────────────────────── +verbs_of() { # entry -> its top-level dispatch arms + awk '/^case / { inb=1; next } /^esac/ { inb=0 } inb && match($0, /^ [a-z][a-z-]*\)/) { v=substr($0, 5, RLENGTH-5); printf "%s%s", (n++ ? "|" : ""), v }' "$1" +} +makefile() { # out-dir entry... + local dir="$1"; shift + local e kit verb target verbs targets="" + for e in "$@"; do targets+=" $(basename "$e" .sh)"; done + { + echo "# $GENERATED_TAG" + echo "#" + echo "# Shorthand for the scripts beside it; they run without it. Every target" + echo "# calls a verb its script accepts — read from that script's own dispatch." + echo + echo 'HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))' + echo 'ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))' + echo 'ifneq ($(ARGS),)' + echo '$(eval $(ARGS):;@:)' + echo '.PHONY: $(ARGS)' + echo 'endif' + echo + echo '.DEFAULT_GOAL := help' + echo ".PHONY: help$targets" + echo + echo 'help: ## list targets' + printf '\t%s\n' "@grep -hE '^[a-z][a-z-]*:.*?##' \$(MAKEFILE_LIST) | sed 's/:.*##/\\t/' | expand -t16" + for e in "$@"; do + read -r kit verb <<< "$(marker_of "$e")" + target=$(basename "$e" .sh) + verbs=$(verbs_of "$e") + echo + printf '%-30s ## %s.sh [%s] (default %s)\n' "$target:" "$kit" "${verbs:-?}" "$verb" + printf '\tbash $(HERE)%s.sh $(or $(ARGS),%s)\n' "$kit" "$verb" + done + } > "$dir/Makefile" +} + +# ── 7. prove a kit stands alone ──────────────────────────────────────────── +verify_kit() { # dir profile entry... + local dir="$1" profile="$2"; shift 2 + local e kit verb f bad smoke rc + for e in "$@"; do + read -r kit verb <<< "$(marker_of "$e")" + f="$dir/$kit.sh" + + bash -n "$f" 2>/dev/null || refuse "$profile/$kit.sh does not parse: $(bash -n "$f" 2>&1 | head -1)" + + # Code only: comments are free to mention anything, and the frozen block + # is data — a value that happens to hold a path is harmless unless code + # opens it, and opening it is what the smoke run below would catch. + bad=$(awk -v fz_open="$FROZEN_OPEN" -v fz_close="$FROZEN_CLOSE" ' + index($0, fz_open) == 1 { fz=1; next } + index($0, fz_close) == 1 { fz=0; next } + fz || /^[[:space:]]*#/ { next } + /^[[:space:]]*(source|\.)[[:space:]]+[^=]/ { printf "%d: still sources: %s\n", NR, $0; next } + # Rig-relative only. The preceding character may not be "/", so an + # absolute system path such as /var/lib/docker is not mistaken for + # rig lib/; an explicit ./ or ../ prefix is matched on its own. + /(^|[^A-Za-z0-9_.\/])(ctrl\/|lib\/|env\.d\/)|\.\.?\/(ctrl\/|lib\/|env\.d\/)|versions\.env|(^|[^A-Za-z0-9_])\.env([^A-Za-z0-9_]|$)/ { + printf "%d: refers into rig'"'"'s tree: %s\n", NR, $0 + }' "$f" | head -3) + [ -z "$bad" ] || refuse "$profile/$kit.sh does not stand alone —"$'\n'"$(printf '%s\n' "$bad" | sed 's/^/ line /')" + done + + # The real test: a folder holding only this kit, and nothing else from rig. + smoke=$(mktemp -d) + cp "$dir"/* "$smoke"/ + for e in "$@"; do + read -r kit verb <<< "$(marker_of "$e")" + rc=0 + out=$( (cd "$smoke" && timeout 120 bash "./$kit.sh" "$verb") 2>&1 ) || rc=$? + if [ "$rc" -ne 0 ]; then + rm -rf "$smoke" + refuse "$profile/$kit.sh $verb exits $rc in an empty directory:"$'\n'"$(printf '%s\n' "$out" | tail -5 | sed 's/^/ /')" + fi + done + ( cd "$smoke" && make -s help >/dev/null ) || { rm -rf "$smoke"; refuse "$profile/Makefile: make help fails"; } + rm -rf "$smoke" +} + +# ── generate ─────────────────────────────────────────────────────────────── +generate() { # into-dir + local into="$1" e profiles="" p kit verb libs + local -a all_entries=() + while IFS= read -r e; do all_entries+=("$e"); done < <(entries) + [ ${#all_entries[@]} -gt 0 ] || refuse "no script under ctrl/ carries a '# rig:standalone ' marker" + + # Profiles come from whichever entry point's libraries can answer for them. + for e in "${all_entries[@]}"; do + libs_into "$e" + profiles=$(ask "$e" ${libs[@]+"${libs[@]}"} -- config_profiles 2>/dev/null) && [ -n "$profiles" ] && break + profiles="" + done + [ -n "$profiles" ] || refuse "no entry point's libraries answer config_profiles, so there is nothing to generate a kit per" + + for p in $profiles; do + mkdir -p "$into/$p" + for e in "${all_entries[@]}"; do + read -r kit verb <<< "$(marker_of "$e")" + libs_into "$e" + assemble "$e" "$p" "$into/$p/$kit.sh" ${libs[@]+"${libs[@]}"} + done + makefile "$into/$p" "${all_entries[@]}" + verify_kit "$into/$p" "$p" "${all_entries[@]}" + echo " $p: $(cd "$into/$p" && ls | tr '\n' ' ')" + done +} + +# A kit folder is ours if its Makefile says so. Anything else under standalone/ +# is left alone, so a hand-written file there is never swept away. +is_generated_dir() { grep -qF "$GENERATED_TAG" "$1/Makefile" 2>/dev/null; } + +cmd="${1:-write}" +[ $# -gt 0 ] && shift + +case "$cmd" in + write) + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + echo "generating kits from rig's current tree" + generate "$tmp" + mkdir -p "$OUT" + for d in "$OUT"/*/; do + d=${d%/}; [ -d "$d" ] || continue + if is_generated_dir "$d" && [ ! -d "$tmp/${d##*/}" ]; then + echo " removed $(basename "$d") — no such profile any more" + rm -rf "$d" + fi + done + for d in "$tmp"/*/; do + d=${d%/} + rm -rf "$OUT/${d##*/}" + cp -r "$d" "$OUT/${d##*/}" + done + echo "wrote standalone// — every kit verified to stand alone" + ;; + check) + tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT + generate "$tmp" >/dev/null + stale=0 + for d in "$tmp"/*/; do + d=${d%/}; p=${d##*/} + if ! diff -rq "$d" "$OUT/$p" >/dev/null 2>&1; then + echo "stale: standalone/$p — $(diff -rq "$d" "$OUT/$p" 2>&1 | head -1)" + stale=1 + fi + done + for d in "$OUT"/*/; do + d=${d%/}; [ -d "$d" ] || continue + if is_generated_dir "$d" && [ ! -d "$tmp/${d##*/}" ]; then + echo "stale: standalone/${d##*/} — no such profile any more"; stale=1 + fi + done + [ "$stale" -eq 0 ] || { echo "run: make standalone"; exit 1; } + echo "every kit is current" + ;; + *) echo "usage: $SELF_REL [write|check]" >&2; exit 1 ;; +esac diff --git a/rig/standalone/Makefile b/rig/standalone/Makefile deleted file mode 100644 index 15e7fc3..0000000 --- a/rig/standalone/Makefile +++ /dev/null @@ -1,43 +0,0 @@ -# Optional shorthand for the two standalone scripts. They run without it: -# `bash rigdeps.sh detect` and `bash rigmini.sh status` are the whole interface, -# and this file only spells those out so nobody has to guess them. -# -# Guessing is what went wrong before. A Makefile generated around these scripts -# invented `make mini` -> `rigmini.sh on`, a verb that does not exist, and a bare -# `rigdeps.sh` for "check and report", which actually installs. So every target -# here calls only a verb the script accepts, and ctrl/selftest.sh checks that. -# -# Works wherever the three files sit together — the scripts are found beside -# this Makefile, not in the current directory, so `make -f path/Makefile` works. -# -# Usage: -# make deps report the host and toolchain; changes nothing -# make deps install [dev] download, verify and install into ~/.local/bin -# make mem advertised memory and what caps it; safe -# make mem push allocates until it stops — not on a box you need - -HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) - -# Words after the target become the script's verb and arguments. -ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) -ifneq ($(ARGS),) -$(eval $(ARGS):;@:) -.PHONY: $(ARGS) -endif - -.DEFAULT_GOAL := help -.PHONY: help deps mem - -help: ## list targets - @grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 - -# Defaults to `detect`, not `install`: on a machine you are still evaluating, -# the bare command should report. Installing is a word you type. -deps: ## toolchain [detect|list|verify|install|fetch] (default detect) - bash $(HERE)rigdeps.sh $(or $(ARGS),detect) - -# `mem`, matching the full rig's `make mem`, and deliberately NOT `mini`: that -# word already means "minimal footprint" in the projects that use this, and one -# name pointing at two jobs is how the invented target happened. -mem: ## memory and its caps [status|push|all] (default status) - bash $(HERE)rigmini.sh $(or $(ARGS),status) diff --git a/rig/standalone/README.md b/rig/standalone/README.md index a5aa7a5..ca9c43c 100644 --- a/rig/standalone/README.md +++ b/rig/standalone/README.md @@ -1,69 +1,44 @@ -# standalone — single files for a machine the full rig is not going to +# standalone — rig as single files, one folder per profile -Each script here does one of rig's jobs without the rest of the tree. Copy one -file onto a machine, run it, read the output. Nothing to clone, nothing to -install first. +**Everything in the `/` folders here is generated. Do not edit it.** +It is rig's own tools flattened into single self-contained files, with one +profile's configuration resolved in, for a machine the full rig is not going to. -| file | does | full-rig equivalent | -| --- | --- | --- | -| `rigdeps.sh` | installs kind, kubectl, tilt, ctlptl, jq and docker compose at rig's pins, checksum-verified, no sudo | `make deps` (`ctrl/deps.sh`) | -| `rigmini.sh` | reports how much memory the machine *advertises* and what caps it; `push` measures what it will actually *survive* | `make mem`, and the memory section of `make check` | - -**These are transitional.** Where the full rig is installed, use its own -targets instead; they read `ctrl/versions.env` and the profile, which these -cannot. - -## Why single files - -`rigdeps.sh` carries its pins inline, because `ctrl/versions.env` is not on the -machine it is for. That makes two copies of the same versions and checksums. -`make pins` compares them and fails on any difference — `ctrl/versions.env` is -the source of truth. - -`rigmini.sh` exists because on a container or managed workspace `/proc/meminfo` -reports the *host's* memory while a cgroup cap kills processes at a fraction of -it. `status` reads the caps; `push` allocates until something stops it. - -## Use - -**These two files are the whole setup.** No folder to create, no PATH to edit by -hand, nothing else to download first. The toolchain goes into `~/.local/bin`, -which Ubuntu already puts on PATH at login once the directory exists — so a new -shell after `install` is all it takes. If it is not on PATH, `install` says so -and prints the one line to add. - -```bash -bash rigdeps.sh detect # report, change nothing -bash rigdeps.sh install dev # install into ~/.local/bin -bash rigmini.sh status # advertised memory and caps; safe -bash rigmini.sh push # allocates until it stops — not on a machine you need +``` +standalone//rigdeps.sh rig's toolchain installer (ctrl/deps.sh) +standalone//rigmini.sh rig's memory tool (ctrl/mem.sh) +standalone//Makefile shorthand for calling them ``` -The `Makefile` beside them is **optional shorthand** for exactly those calls — -copy it along or don't; the scripts do not need it: +One folder per file in `ctrl/env.d/`. Pick the profile you mean to run and copy +that folder; nothing else from rig is needed. The scripts run without the +Makefile. ```bash -make deps # = rigdeps.sh detect (reports; installing is `make deps install`) -make mem # = rigmini.sh status +bash rigdeps.sh detect # report the host and toolchain; changes nothing +bash rigdeps.sh install dev # download, verify, install into ~/.local/bin +bash rigmini.sh status # advertised memory and what caps it; safe +bash rigmini.sh all # measure, then weigh it against this profile +make deps / make mem # the same, via the Makefile ``` -If you wrap these scripts in a Makefile of your own, copy the calls from that -file rather than guessing them. `rigmini.sh` measures memory; it has no `on` or -`off`, and toggling a heavy service off for a smaller footprint is a job for the -project's own manifests, not for this script. `make selftest` in the full rig -fails if this Makefile ever calls a verb its script does not accept. +`rigmini.sh push` and `all` deliberately consume memory. Run `status` first, and +only run them somewhere other processes may be squeezed. + +## Why generated -If an earlier setup already put these tools in some other directory on PATH, -remove that directory and the line that added it — do not rely on `install` to -notice. It only reports shadowing once `~/.local/bin` is itself on PATH, which on -a fresh machine it is not until the next login. After a new shell, check which -copy wins: +These used to be hand-kept copies, and they drifted: the standalone memory tool +said 2 GB per node long after rig had measured 800 MB. Now a kit is a pure +function of rig. It gains nothing rig lacks; improve rig and every kit follows. ```bash -command -v kind kubectl tilt # each should be ~/.local/bin/... +make standalone # regenerate every kit +make standalone check # fail if any kit differs from what rig generates now ``` -One toolchain, in the one place everyone else will also look. +`make selftest` runs the check, so a kit left behind by a change to rig fails there +rather than on the machine it was copied to. -`rigmini.sh push` deliberately consumes memory. Run `status` first, and only run -`push` somewhere it is acceptable for other processes to be squeezed. +How the generator stays correct as rig changes shape — it knows no file, function +or variable names, only a marker, a sourcing rule and two config questions, and it +proves each kit stands alone before writing it — is in `ctrl/standalone.sh`. diff --git a/rig/standalone/client/Makefile b/rig/standalone/client/Makefile new file mode 100644 index 0000000..56ac921 --- /dev/null +++ b/rig/standalone/client/Makefile @@ -0,0 +1,23 @@ +# GENERATED by make standalone — do not edit +# +# Shorthand for the scripts beside it; they run without it. Every target +# calls a verb its script accepts — read from that script's own dispatch. + +HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +.PHONY: $(ARGS) +endif + +.DEFAULT_GOAL := help +.PHONY: help deps mem + +help: ## list targets + @grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +deps: ## rigdeps.sh [detect|list|verify|fetch|install] (default detect) + bash $(HERE)rigdeps.sh $(or $(ARGS),detect) + +mem: ## rigmini.sh [status|push|all|backup|restore] (default status) + bash $(HERE)rigmini.sh $(or $(ARGS),status) diff --git a/rig/standalone/client/rigdeps.sh b/rig/standalone/client/rigdeps.sh new file mode 100755 index 0000000..a058acb --- /dev/null +++ b/rig/standalone/client/rigdeps.sh @@ -0,0 +1,958 @@ +#!/usr/bin/env bash +# GENERATED by make standalone — do not edit +# +# rigdeps.sh for profile 'client', flattened from: +# ctrl/deps.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'client' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="metallb cert-manager metrics-server" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="on" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.client.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.client.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.client.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="3" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx PROFILE_NAME="client" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="mirror" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/deps.sh ── +# 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 list # the pinned versions +# deps.sh verify [core|dev] # run what is installed and see if it works +# 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")" + +# Pins arrive through load_config like every other setting, not by sourcing +# versions.env here. That is what lets `make standalone` freeze them into a +# one-file installer: configuration has exactly one way in. +# (sourced library inlined above) +load_config + +# 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. +# A /proc/meminfo field in MB, 0 if the field is absent. MEMINFO exists so the +# tight and does-not-fit branches can be exercised against a real machine's +# numbers from somewhere else; in normal use it is always /proc/meminfo. +mb_of() { + awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } + END { if (!found) printf "0" }' "${MEMINFO:-/proc/meminfo}" +} + +host_file() { + local p="${1#/}" + if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then + echo "$HOST_ROOT/$p" + else + echo "/$p" + fi +} + +# ── the tools this script itself needs ───────────────────────────────────── + +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 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 +} + +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 ───────────────────────────────────────────────────────────────── + +# 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)" + echo " arch $(arch) ($(uname -m))" + + local osr; osr=$(host_file /etc/os-release) + [ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")" + + # In MB. Whole gigabytes lose nearly half a GB on exactly the machines where + # it matters: 1874 MB available used to print as "1 GB". Facts only — whether + # that is enough depends on the profile, which check.sh knows and this does not. + local total_mb avail_mb swap_total_mb swap_used_mb om + total_mb=$(mb_of MemTotal) + avail_mb=$(mb_of MemAvailable) + swap_total_mb=$(mb_of SwapTotal) + swap_used_mb=$(( swap_total_mb - $(mb_of SwapFree) )) + printf " memory %d MB total, %d MB available\n" "$total_mb" "$avail_mb" + if [ "$swap_total_mb" -gt 0 ]; then + printf " swap %d MB used of %d MB\n" "$swap_used_mb" "$swap_total_mb" + fi + + # How the kernel answers an allocation it cannot really satisfy. With 1 it + # always says yes and settles up later with the OOM killer, so a cluster that + # starts cleanly can still lose processes afterwards. + om=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess" ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit" ;; + 2) echo " overcommit 2 strict — an allocation fails honestly instead of killing later" ;; + esac + + echo " install to $OUT_BIN" + detect_libc + detect_prereqs + detect_wsl + detect_filesystem + detect_docker + detect_inotify + detect_toolchain +} + +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 +} + +# 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() { + # 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, 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.") + 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=$($SHA "$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" + download "$(resolve_url "$url")" "$tmp" + 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" + download "$(resolve_url "$url")" "$tmp" + 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. +# +# docker-compose is 'dev' for the same reason, and is here because the distro +# docker packages ship the daemon and CLI but frequently not the compose +# plugin — so `docker compose up` fails with "unknown command" on an otherwise +# working Docker, and nothing about that message names the missing piece. +DEV_TOOLS="kind tilt ctlptl docker-compose" + +# ── what is already on this machine ─────────────────────────────────────── +# +# A tool already on PATH at its pinned version is left where it is. Without +# this, install downloads a second copy into OUT_BIN and then reports the first +# one as shadowed — noise, and wrong, when both are the same version. That is +# the normal state of any machine someone set up by hand, whatever directory +# they happened to choose. + +pin_of() { + case "$1" in + kubectl) echo "$KUBECTL_VERSION" ;; + jq) echo "$JQ_VERSION" ;; + kind) echo "$KIND_VERSION" ;; + tilt) echo "$TILT_VERSION" ;; + ctlptl) echo "$CTLPTL_VERSION" ;; + docker-compose) echo "$COMPOSE_VERSION" ;; + esac +} + +# The version string a binary reports. Each tool spells the question +# differently, and kubectl has to be told --client or it goes looking for a +# server to ask. +reported_version() { + local tool="$1" path="$2" + case "$tool" in + kubectl) "$path" version --client 2>/dev/null ;; + jq) "$path" --version 2>/dev/null ;; + *) "$path" version 2>/dev/null ;; + esac +} + +# Does the binary at PATH report PIN? Matched as a whole version token, so +# 0.37.6 never matches 10.37.60, with the leading v optional either side: kind +# says v0.32.0, jq says jq-1.8.2, and tilt says v0.37.6 against a pin of 0.37.6. +# +# Bash's own regex rather than grep, deliberately. grep is not the same program +# on every machine — some builds reject patterns that others accept — and a +# failed grep inside a count reads exactly like a zero. +version_matches() { + local tool="$1" path="$2" pin="$3" out v re + out=$(reported_version "$tool" "$path") || return 1 + v="${pin#v}" + v="${v//./\\.}" + re="(^|[^0-9.])v?${v}([^0-9.]|\$)" + [[ $out =~ $re ]] +} + +# DEPS_ONLY narrows a fetch to the tools it names. Unset means the whole tier, +# which is what an explicit `deps.sh fetch` always gets: "download these into +# DIR" must not quietly skip something because this machine happens to have it. +# Only install() sets it, to what detect_toolchain found missing or mismatched. +want() { [ -z "${DEPS_ONLY:-}" ] || [[ " $DEPS_ONLY " == *" $1 "* ]]; } + +# Every tool in the tier with its state, probed once and reported once. What +# still needs fetching is left in TOOLCHAIN_NEED for install() to act on. +TOOLCHAIN_NEED="" +detect_toolchain() { + local tier="${TIER:-dev}" b pin path found + TOOLCHAIN_NEED="" + echo + echo "toolchain (pinned, tier '$tier')" + for b in $(tier_tools "$tier"); do + pin=$(pin_of "$b") + path=$(command -v "$b" 2>/dev/null || true) + # compose is the one tool that is normally NOT a binary on PATH. It is a + # docker CLI plugin, so a machine where `docker compose` works perfectly + # has no `docker-compose` to find — and probing only PATH would report it + # missing and re-download a copy that is already there. That is the exact + # noise the version-aware skip exists to prevent, so ask docker instead. + if [ "$b" = docker-compose ] && [ -z "$path" ]; then + if found=$(docker compose version --short 2>/dev/null) && [ -n "$found" ]; then + if [ "${found#v}" = "${pin#v}" ]; then + printf " %-8s %-9s %s\n" "$b" "$pin" "docker cli plugin" + else + printf " ! %-8s wants %s, the docker cli plugin reports '%s'\n" \ + "$b" "$pin" "$found" + TOOLCHAIN_NEED+="$b " + fi + continue + fi + fi + if [ -z "$path" ]; then + printf " - %-8s %-9s not found\n" "$b" "$pin" + TOOLCHAIN_NEED+="$b " + elif version_matches "$b" "$path" "$pin"; then + printf " %-8s %-9s %s\n" "$b" "$pin" "$path" + else + found=$(reported_version "$b" "$path" 2>/dev/null | head -1 || true) + printf " ! %-8s wants %s, %s reports '%s'\n" "$b" "$pin" "$path" "$found" + TOOLCHAIN_NEED+="$b " + fi + done + if [ -z "$TOOLCHAIN_NEED" ]; then + echo " every pinned tool is already on PATH — nothing to fetch" + else + echo " 'make deps' fetches only: ${TOOLCHAIN_NEED% }" + fi +} + +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 + + if [ -n "${DEPS_ONLY:-}" ]; then + echo "fetching ${DEPS_ONLY% } (source: $DEPS_SOURCE)" + else + echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)" + fi + if want kubectl; then fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"; fi + if want jq; then fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"; fi + if [ "$tier" = "dev" ]; then + if want kind; then fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"; fi + if want tilt; then fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0; fi + if want ctlptl; then fetch_tgz ctlptl "$CTLPTL_URL" "$CTLPTL_SHA256" "$dest" ctlptl 0; fi + if want docker-compose; then + fetch_bin docker-compose "$COMPOSE_URL" "$COMPOSE_SHA256" "$dest" + fi + 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. +# 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 +} + +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 +} + +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 + # The same version in both places is not a conflict: nothing changes for + # any other project whichever copy PATH happens to find first. + if version_matches "$b" "$existing" "$(pin_of "$b")"; then continue; fi + 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") +} + +# A copy in OUT_BIN only gives you `docker-compose`. That hyphenated form is the +# retired v1 spelling; every compose file written in the last few years assumes +# `docker compose`, which resolves plugins BY NAME out of a plugin directory. +# So the binary is fetched like any other and then linked, in your own home — +# no root, and nothing outside it. +install_compose_plugin() { + local src="$OUT_BIN/docker-compose" dir="$HOME/.docker/cli-plugins" + [ -x "$src" ] || return 0 + mkdir -p "$dir" + # Something else already owns that name — docker-desktop and some distro + # packages install a real file there. Overwriting it would take the plugin + # away from whatever put it there, so say so and let the user decide. + if [ -e "$dir/docker-compose" ] && [ ! -L "$dir/docker-compose" ]; then + MANUAL+=("Something already installs the compose plugin at + $dir/docker-compose + To use rig's pinned build instead: + ln -sf $src $dir/docker-compose") + return 0 + fi + ln -sfn "$src" "$dir/docker-compose" + echo " compose plugin -> $dir/docker-compose" + return 0 +} + +install() { + local tier="${1:-dev}" b + TIER="$tier" + detect + + # detect_toolchain has already probed PATH. Fetch only what it found missing + # or at the wrong version; a tool already present at its pin stays where it is. + if [ -n "$TOOLCHAIN_NEED" ]; then + echo + DEPS_ONLY="$TOOLCHAIN_NEED" fetch "$tier" + echo + echo "installed to $OUT_BIN ($tier):" + for b in $TOOLCHAIN_NEED; do + if [ -x "$OUT_BIN/$b" ]; then echo " $b"; fi + done + if [ "$tier" = "core" ]; then + echo " (no kind/tilt — 'make deps dev' adds them)" + fi + # Only when compose was one of the things fetched: linking a binary + # that is already satisfied elsewhere on PATH would point the plugin at + # a copy rig did not install. + case " $TOOLCHAIN_NEED " in + *" docker-compose "*) install_compose_plugin ;; + esac + + # Only worth saying when something actually landed in OUT_BIN. When every + # tool was satisfied elsewhere, OUT_BIN may reasonably be off PATH, and + # telling the user to add it would be advice to fix nothing. + case ":${PATH}:" in + *":$OUT_BIN:"*) ;; + *) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc: + export PATH=\"${OUT_BIN}:\$PATH\"") ;; + esac + fi + warn_shadowing "$tier" + + report_manual +} + +# ── 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 + +# Baked mode copies binaries already in the image, so it needs no downloader. +need_downloads() { + require_amd64 + if [ "$DEPS_SOURCE" != baked ]; then pick_downloader; fi + pick_sha +} + +case "$cmd" in + detect) detect; report_manual ;; + list) list ;; + verify) verify_tools "${1:-dev}" ;; + fetch) need_downloads; fetch "$@" ;; + install) need_downloads; install "${1:-dev}" ;; + *) echo "usage: $0 [detect|list|verify|fetch|install]" >&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 diff --git a/rig/standalone/client/rigmini.sh b/rig/standalone/client/rigmini.sh new file mode 100755 index 0000000..31a0220 --- /dev/null +++ b/rig/standalone/client/rigmini.sh @@ -0,0 +1,859 @@ +#!/usr/bin/env bash +# GENERATED by make standalone — do not edit +# +# rigmini.sh for profile 'client', flattened from: +# ctrl/mem.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'client' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="metallb cert-manager metrics-server" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="on" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.client.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.client.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.client.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="3" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx PROFILE_NAME="client" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="mirror" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/mem.sh ── +# How much memory this machine will actually give you before something dies — +# rig's memory tool, and (generated from this file) the standalone rigmini.sh. +# +# There are two numbers and they are rarely the same. `status` reports what the +# machine ADVERTISES and what is quietly capping it. `push` finds what it will +# SURVIVE, by allocating until it stops. `all` does both and weighs the result +# against what this profile's cluster needs. +# +# The gap between them is the whole reason this exists. Under WSL the cap lives +# in .wslconfig; in a container or a managed workspace it is a cgroup limit, and +# there /proc/meminfo reports the HOST's memory while the kernel kills you at a +# fraction of it. A script that only read MemTotal would confidently report 32 GB +# on a box that OOMs at 2. +# +# Runs on native Linux and under WSL. On WSL the memory you see is a VM +# allocation that can be raised, and the commonest failure is raising it without +# restarting — so status compares what .wslconfig says with what actually booted. +# +# Reports and instructs. It never raises a limit, frees anything or installs a +# package. The one write it can make is `backup`, which copies .wslconfig beside +# itself, so that `restore` has something to put back after a hand edit. +# +# Usage: +# mem.sh status what it has, what caps it +# mem.sh push [--to GB] [--to-oom] climb until it stops +# mem.sh all [--budget GB] both, then the verdict +# mem.sh backup | restore .wslconfig, WSL only +set -euo pipefail +cd "$(dirname "$0")" +# (sourced library inlined above) + +# ── defaults ─────────────────────────────────────────────────────────────── + +STEP_MB=0 # per allocation; 0 means scale it to the ceiling. See push(). +STEP_EXPLICIT=no # whether --step was given, which turns the scaling off. +TO_MB="" # --to: stop here regardless. Empty means no hard cap. +TO_OOM=no # --to-oom: opt in to running until the kernel intervenes. +BUDGET_GB="" # --budget; empty means what this profile's cluster needs, from rig. +BUDGET_EXPLICIT=no # whether --budget was given, which retires the guess below. + +# ── 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 ;; + esac + + # Everything below reads /proc. Without it there is nothing to measure, and + # failing here beats printing a page of empty fields. + if [ ! -r /proc/meminfo ]; then + echo "no readable /proc/meminfo — this needs a Linux kernel." >&2 + echo "On macOS or a BSD none of the numbers below exist." >&2 + exit 1 + fi +} + +is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; } + +is_container() { + [ -f /.dockerenv ] && return 0 + grep -qE '(docker|containerd|kubepods|lxc|podman)' /proc/1/cgroup 2>/dev/null +} + +platform() { + if is_wsl; then echo WSL + elif is_container; then echo container + else echo "native linux" + fi +} + +# ── reading memory ───────────────────────────────────────────────────────── + +mb() { echo $(( $(awk "/^$1:/{print \$2}" /proc/meminfo) / 1024 )); } + +# MemAvailable arrived in kernel 3.14. Older kernels — and they turn up on +# corporate images — need the estimate it replaced, which is worse but not wrong. +avail_meminfo_mb() { + if grep -q '^MemAvailable:' /proc/meminfo; then + mb MemAvailable + else + awk '/^(MemFree|Buffers|Cached):/{t+=$2} END{print int(t/1024)}' /proc/meminfo + fi +} + +# Where a cgroup records this cgroup's own limit and usage. Set once by +# find_cgroup, because every later reading needs both and hunting for the files +# on each call would be the slow part of the poll loop. +CG_MAX_FILE="" +CG_CUR_FILE="" +CG_VERSION="" + +find_cgroup() { + local rel + + # Inside a container the cgroup namespace makes the top of the tree BE the + # container's own cgroup, so the unqualified path is already the right one. + # On a host it is the root cgroup, which is never limited — hence the second + # attempt via /proc/self/cgroup, which names the slice this shell is in. + if [ -r /sys/fs/cgroup/memory.max ]; then + CG_VERSION=v2 + CG_MAX_FILE=/sys/fs/cgroup/memory.max + CG_CUR_FILE=/sys/fs/cgroup/memory.current + elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + CG_VERSION=v1 + CG_MAX_FILE=/sys/fs/cgroup/memory/memory.limit_in_bytes + CG_CUR_FILE=/sys/fs/cgroup/memory/memory.usage_in_bytes + fi + + rel=$(awk -F: '$1=="0"{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] && [ -r "/sys/fs/cgroup${rel}/memory.max" ]; then + CG_VERSION=v2 + CG_MAX_FILE="/sys/fs/cgroup${rel}/memory.max" + CG_CUR_FILE="/sys/fs/cgroup${rel}/memory.current" + return 0 + fi + + rel=$(awk -F: '$2 ~ /(^|,)memory(,|$)/{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] \ + && [ -r "/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" ]; then + CG_VERSION=v1 + CG_MAX_FILE="/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" + CG_CUR_FILE="/sys/fs/cgroup/memory${rel}/memory.usage_in_bytes" + fi + return 0 +} + +# The cap in MB, or "" when there is none worth reporting. v2 spells unlimited +# "max"; v1 spells it as a number near 2^63, which is why this compares against +# MemTotal rather than testing for a magic value — a "limit" above the machine's +# own memory is not a limit, however it is written. +cgroup_cap_mb() { + local raw cap + [ -n "$CG_MAX_FILE" ] && [ -r "$CG_MAX_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_MAX_FILE" 2>/dev/null || echo max) + [ "$raw" = "max" ] && { echo ""; return 0; } + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + cap=$((raw / 1024 / 1024)) + [ "$cap" -ge "$(mb MemTotal)" ] && { echo ""; return 0; } + echo "$cap" +} + +cgroup_used_mb() { + local raw + [ -n "$CG_CUR_FILE" ] && [ -r "$CG_CUR_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_CUR_FILE" 2>/dev/null || echo "") + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((raw / 1024 / 1024)) +} + +# ulimit -v is a per-process address-space cap. It stops YOU long before the box +# does, and because it is inherited from a login shell it is easy to hit without +# knowing it is set. +ulimit_v_mb() { + local v; v=$(ulimit -v 2>/dev/null || echo unlimited) + [ "$v" = "unlimited" ] && { echo ""; return 0; } + case "$v" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((v / 1024)) +} + +# The number everything else is about: the lowest of the things that can stop +# you. Printed at the end of `status` and used as the sanity bound in `push`. +effective_ceiling_mb() { + local c; c=$(mb MemTotal) + local cap; cap=$(cgroup_cap_mb) + local ul; ul=$(ulimit_v_mb) + [ -n "$cap" ] && [ "$cap" -lt "$c" ] && c="$cap" + [ -n "$ul" ] && [ "$ul" -lt "$c" ] && c="$ul" + echo "$c" +} + +# How much room is left RIGHT NOW, from whichever accounting actually governs. +# In a capped container /proc/meminfo describes the host and is worse than +# useless for this — it would report tens of gigabytes free on a box that is one +# allocation from being killed. +headroom_mb() { + local cap used + cap=$(cgroup_cap_mb) + used=$(cgroup_used_mb) + if [ -n "$cap" ] && [ -n "$used" ]; then + echo $(( cap - used )) + else + avail_meminfo_mb + fi +} + +# ── status ───────────────────────────────────────────────────────────────── + +# /mnt/c/Users can hold several real accounts — a renamed login leaves the old +# directory behind — so picking the first alphabetically is a coin toss. Ask +# Windows, then fall back to whichever profile actually owns a config. +wslconfig_path() { + local profile winpath found + profile=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d "\r\n" || true) + case "$profile" in + ""|*%*) ;; + *) winpath=$(wslpath -u "$profile" 2>/dev/null || true) + if [ -n "$winpath" ] && [ -d "$winpath" ]; then + echo "$winpath/.wslconfig"; return 0 + fi ;; + esac + found=$(ls -d /mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true) + [ -n "$found" ] && echo "$found" + return 0 +} + +hogs() { + echo " holding the most:" + ps -eo rss,comm --sort=-rss 2>/dev/null \ + | awk 'NR>1 && NR<=6 {printf " %6.0f MB %s\n", $1/1024, $2}' + return 0 +} + +status() { + local total avail swap_total swap_free cap ul cur + + echo "host" + echo " platform $(platform)" + echo " kernel $(uname -r)" + [ -r /etc/os-release ] && \ + echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' /etc/os-release)" + echo " cpu $(getconf _NPROCESSORS_ONLN 2>/dev/null || echo '?') online, load $(cut -d' ' -f1-3 /proc/loadavg)" + + # ── the caps first, because they decide what the totals below are worth ── + echo + echo "caps" + cap=$(cgroup_cap_mb) + if [ -n "$cap" ]; then + cur=$(cgroup_used_mb) + echo " cgroup ${cap} MB (${CG_VERSION}, ${CG_CUR_FILE##*/} says ${cur:-?} MB used)" + echo " ! /proc/meminfo below describes the HOST, not this cgroup." + echo " $(mb MemTotal) MB total is not yours; ${cap} MB is." + elif [ -n "$CG_VERSION" ]; then + echo " cgroup none (${CG_VERSION} present, no memory limit set)" + else + echo " cgroup no memory controller found" + fi + + ul=$(ulimit_v_mb) + if [ -n "$ul" ]; then + echo " ! ulimit -v ${ul} MB — a per-process cap, inherited from your shell" + echo " it stops this process long before the machine runs out" + else + echo " ulimit -v unlimited" + fi + + # overcommit_memory=0 is the default heuristic: a large allocation is + # granted on a guess, and the reckoning arrives later as an OOM kill rather + # than as a failed malloc. It is why `push` touches every page it asks for. + local om or_ + om=$(cat /proc/sys/vm/overcommit_memory 2>/dev/null || echo '?') + or_=$(cat /proc/sys/vm/overcommit_ratio 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess," ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit," ;; + 2) echo " overcommit 2 strict (ratio ${or_}%) — allocation fails honestly instead of killing later," ;; + *) echo " overcommit ${om}" ;; + esac + [ "$om" != "?" ] && echo " so RSS is the number to trust, not what a process asked for" + + # ── what it says it has ── + total=$(mb MemTotal); avail=$(avail_meminfo_mb) + swap_total=$(mb SwapTotal); swap_free=$(mb SwapFree) + echo + echo "memory" + echo " total ${total} MB" + echo " available ${avail} MB" + echo " swap ${swap_total} MB ($(( swap_total - swap_free )) MB used)" + if [ "$swap_total" -eq 0 ]; then + echo " - no swap: this box has no cushion. It goes from fine to OOM-killed" + echo " with nothing in between, which is the abrupt failure you get in a VM." + fi + + # postgres puts its shared buffers in /dev/shm. Docker's default is 64 MB, + # and the resulting failure names neither shm nor the size. + if [ -d /dev/shm ]; then + local shm; shm=$(df -Pm /dev/shm 2>/dev/null | awk 'NR==2{print $2}') + if [ -n "$shm" ]; then + if [ "$shm" -le 64 ]; then + echo " ! /dev/shm ${shm} MB — postgres puts shared memory here and 64 MB" + echo " is docker's default. Raise it with --shm-size when postgres fails." + else + echo " /dev/shm ${shm} MB" + fi + fi + fi + + echo + echo "disk" + local d + for d in / /tmp /var/lib/docker; do + [ -d "$d" ] || continue + df -Pm "$d" 2>/dev/null | awk -v p="$d" 'NR==2{printf " %-12s %s MB free of %s MB\n", p, $4, $2}' + done + + # kind and Tilt both watch large trees, and the failure mode is silent: + # they simply stop noticing file changes. Cheap to report while we are here. + 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 + echo "tooling" + echo " inotify watches=$w instances=$i" + if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then + echo " ! low — anything watching files will silently stop seeing changes" + fi + + if ! command -v docker >/dev/null 2>&1; then + if [ -S /var/run/docker.sock ]; then + echo " docker socket present, no cli" + else + echo " docker not installed" + fi + elif docker info >/dev/null 2>&1; then + local n + n=$(docker ps -q 2>/dev/null | wc -l) + echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null), ${n} container(s) running" + else + echo " ! docker cli present but the daemon is unreachable" + fi + + # WSL keeps its cap on the Windows side, in a file this shell can read but + # not usefully apply — the change costs a full VM restart. Report it, and + # report the commonest mistake, which is editing it and not restarting. + if is_wsl; then + local cfg conf conf_mb n + cfg=$(wslconfig_path) + echo + echo "wsl" + if [ -z "$cfg" ]; then + echo " ! cannot tell which Windows profile owns .wslconfig" + else + echo " config $cfg" + conf=$(configured_memory "$cfg") + if [ -n "$conf" ]; then + conf_mb=$(to_mb "$conf") + echo " configured $conf (${conf_mb} MB), booted ${total} MB" + # The VM reports a little less than allocated; 15% covers the + # kernel without calling every healthy machine a mismatch. + if [ -n "$conf_mb" ] && [ "$total" -lt $(( conf_mb * 85 / 100 )) ]; then + echo " ! configured ${conf_mb} MB but booted ${total} MB — not applied yet." + echo " From a WINDOWS terminal: wsl --shutdown then start the distro again." + fi + else + echo " configured no memory= set (WSL defaults to 50% of host RAM, or 8 GB," + echo " whichever is less). To raise it, add on the Windows side:" + echo " [wsl2]" + echo " memory=8GB" + echo " then from a WINDOWS terminal: wsl --shutdown" + fi + n=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$n" -gt 0 ]; then + echo " backups $n (newest: $(ls -t "$cfg".*.bak 2>/dev/null | head -1))" + fi + fi + else + echo + echo " - native linux: no VM allocation to raise. If memory is tight the levers" + echo " are freeing something or adding swap." + fi + + echo + echo "effective ceiling $(effective_ceiling_mb) MB" + echo " the lowest of MemTotal, the cgroup cap and ulimit -v. What the box" + echo " claims. 'push' measures what it will actually hand over." + + [ "$avail" -lt $(( total / 5 )) ] && { echo; hogs; } + return 0 +} + +# ── .wslconfig ───────────────────────────────────────────────────────────── + +require_wsl() { + if ! is_wsl; then + echo "$1 acts on .wslconfig, which only exists under WSL." >&2 + echo "This is native Linux — there is no VM allocation to save or roll back." >&2 + echo "Use 'status' to see what the machine actually has." >&2 + exit 1 + fi +} + +# backup and restore act on the file, so unlike status they must not guess. +wslconfig_required() { + local cfg; cfg=$(wslconfig_required) + if [ -z "$cfg" ]; then + echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2 + ls -d /mnt/c/Users/*/ 2>/dev/null \ + | grep -viE "/(All Users|Default|Default User|Public)/$" | sed "s/^/ /" >&2 + exit 1 + fi + echo "$cfg" +} + +configured_memory() { + [ -r "$1" ] || { echo ""; return; } + sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$1" | tail -1 | tr -d '[:space:]' +} + +# "9GB" / "8192MB" / "9G" -> MB, so it can be compared with /proc/meminfo. +to_mb() { + local v="${1^^}" n + n=$(echo "$v" | tr -dc '0-9') + [ -n "$n" ] || { echo ""; return; } + case "$v" in + *GB|*G) echo $(( n * 1024 )) ;; + *MB|*M) echo "$n" ;; + *) echo $(( n / 1024 / 1024 )) ;; + esac +} + +backup() { + require_wsl backup + local cfg dest + cfg=$(wslconfig_required) + [ -r "$cfg" ] || { echo "nothing to back up: $cfg does not exist" >&2; exit 1; } + # Timestamped and never overwritten: a backup that can destroy itself on a + # second run is not a backup. + dest="${cfg}.$(date +%Y%m%d-%H%M%S).bak" + cp "$cfg" "$dest" + echo "backed up $dest" + echo + echo "Edit $cfg by hand, then from a WINDOWS terminal: wsl --shutdown" +} + +restore() { + require_wsl restore + local cfg newest count + cfg=$(wslconfig_required) + newest=$(ls -t "$cfg".*.bak 2>/dev/null | head -1 || true) + [ -n "$newest" ] || { echo "no backups found beside $cfg" >&2; exit 1; } + + echo "restoring $newest" + echo " -> $cfg" + echo + + # Newest is the right default — undo the last edit — but if you backed up + # *after* editing, the state you want is older. Show the rest so a no-op + # restore is obviously a no-op rather than a mystery. + count=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$count" -gt 1 ]; then + echo "$count backups exist, newest first:" + ls -t "$cfg".*.bak | sed 's/^/ /' + echo " (restoring the newest; copy another by hand to pick an older one)" + echo + fi + + if [ -r "$cfg" ]; then + echo "what changes:" + if diff "$cfg" "$newest" > /tmp/mem.diff 2>&1 && [ ! -s /tmp/mem.diff ]; then + echo " nothing — that backup is identical to the current config" + else + sed 's/^/ /' /tmp/mem.diff + fi + rm -f /tmp/mem.diff + echo + fi + + printf "proceed? [y/N] " + read -r reply + case "$reply" in + y|Y|yes|Yes) ;; + *) echo "left alone"; return 0 ;; + esac + cp "$newest" "$cfg" + echo "restored. From a WINDOWS terminal: wsl --shutdown" +} + +# ── push ─────────────────────────────────────────────────────────────────── + +STATE="" +CHILD="" + +cleanup() { + if [ -n "$CHILD" ] && kill -0 "$CHILD" 2>/dev/null; then + kill -KILL "$CHILD" 2>/dev/null || true + wait "$CHILD" 2>/dev/null || true + fi + [ -n "$STATE" ] && rm -f "$STATE" + return 0 +} + +# The child allocates and stops itself; the parent only watches. That split is +# the point: under --to-oom the allocating process is expected to be killed, and +# something has to survive to say how far it got. +allocator() { + # Raise our own OOM score to the maximum so the kernel picks THIS process + # first. Raising needs no privilege (only lowering does). Without it, the + # kernel is free to choose your shell, your ssh session or dockerd — on a + # box you are still using, that is not an acceptable coin toss. + echo 1000 > "/proc/$BASHPID/oom_score_adj" 2>/dev/null || true + + local arr=() held=0 i=0 rss swapped avail first_swap=0 + local bytes=$((STEP_MB * 1024 * 1024)) + local swap_used_start + swap_used_start=$(( $(mb SwapTotal) - $(mb SwapFree) )) + + while :; do + # Written STRAIGHT INTO the array element. The obvious spelling — + # build one chunk and `arr+=("$chunk")` — costs three copies per step, + # not one: the template stays resident, expanding "$chunk" makes a + # temporary word, and the append makes the element. A 128 MB step then + # needs 384 MB transiently, and on a small box it is killed on the + # first append while reporting a third of the true ceiling. + # + # printf -v into a subscript also means every page is written, so it is + # resident rather than merely promised — the only kind of allocation + # that measures anything under heuristic overcommit. + printf -v "arr[$i]" '%*s' "$bytes" '' + i=$((i + 1)); held=$((held + STEP_MB)) + + rss=$(awk '/^VmRSS:/{print int($2/1024)}' "/proc/$BASHPID/status" 2>/dev/null || echo 0) + avail=$(headroom_mb) + swapped=$(( $(mb SwapTotal) - $(mb SwapFree) - swap_used_start )) + [ "$swapped" -lt 0 ] && swapped=0 + + printf '%8s MB held rss %7s MB headroom %7s MB swap +%s MB\n' \ + "$held" "$rss" "$avail" "$swapped" + printf '%s %s %s %s\n' "$held" "$rss" "$avail" "$swapped" >> "$STATE" + + # Worth calling out separately from the ceiling: this is where the box + # stops being fast and starts being unusable, which for a scheduler is + # a different and earlier problem than being killed. + if [ "$swapped" -gt 0 ] && [ "$first_swap" -eq 0 ]; then + first_swap=$held + echo " - first swap page at ${held} MB — past here it works but crawls" + echo "swapat $held" >> "$STATE" + fi + + if [ -n "$TO_MB" ] && [ "$held" -ge "$TO_MB" ]; then + echo "stop reached-the-cap" >> "$STATE"; return 0 + fi + if [ "$TO_OOM" = no ] && [ "$avail" -lt "$FLOOR_MB" ]; then + echo "stop floor" >> "$STATE"; return 0 + fi + done +} + +push() { + local total ceiling rc=0 last held rss swapat stop + total=$(mb MemTotal) + ceiling=$(effective_ceiling_mb) + + # A step is worth about a sixty-fourth of the ceiling: enough resolution to + # find the edge, few enough lines to read, and small enough that the + # transient cost of one allocation never dominates a small box. A fixed + # size cannot do all three — 128 MB is fine on 16 GB and absurd on 512 MB. + if [ "$STEP_EXPLICIT" = no ]; then + STEP_MB=$(( ceiling / 64 )) + [ "$STEP_MB" -lt 4 ] && STEP_MB=4 + [ "$STEP_MB" -gt 256 ] && STEP_MB=256 + fi + + # Stop with a cushion rather than riding it to the kill. How big a cushion + # depends on what it is protecting. Under a cgroup cap, running out kills + # only this container's own processes, so it need cover no more than the + # shell that prints the result — and a 512 MB cushion on a 1 GB box would + # halve the answer. On a host there is everything else to protect, and the + # OOM killer does not promise to pick the process that caused the problem. + if [ -n "$(cgroup_cap_mb)" ]; then FLOOR_MB=64; else FLOOR_MB=512; fi + [ $(( ceiling / 20 )) -gt "$FLOOR_MB" ] && FLOOR_MB=$(( ceiling / 20 )) + + STATE=$(mktemp "${TMPDIR:-/tmp}/rigmini.XXXXXX") + trap cleanup EXIT + # INT kills the child and lets the summary below print anyway, so an + # impatient Ctrl-C still tells you how far it got — and, more importantly, + # still gives the memory back. + trap 'echo; echo " interrupted"; echo "stop interrupted" >> "$STATE"; [ -n "$CHILD" ] && kill -KILL "$CHILD" 2>/dev/null || true' INT + + echo "push" + echo " step ${STEP_MB} MB per allocation, every page touched" + echo " ceiling ${ceiling} MB claimed" + if [ -n "$TO_MB" ]; then + echo " stopping at ${TO_MB} MB (--to)" + elif [ "$TO_OOM" = yes ]; then + echo " ! stopping only when the kernel stops it (--to-oom)" + echo " the allocating child is marked as the preferred OOM victim," + echo " but nothing about an OOM kill is entirely polite. Not on a box" + echo " running anything you mind losing." + else + echo " stopping when headroom drops below ${FLOOR_MB} MB" + fi + echo + + allocator & + CHILD=$! + wait "$CHILD" || rc=$? + CHILD="" + trap - INT + + last=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 || true) + held=$(echo "$last" | awk '{print $1}') + rss=$(echo "$last" | awk '{print $2}') + swapat=$(awk '/^swapat/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + stop=$(awk '/^stop/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + + echo + if [ -z "$held" ]; then + echo " ! nothing was allocated. Even one ${STEP_MB} MB chunk failed —" + echo " try a smaller --step, or check ulimit -v in 'status'." + return 1 + fi + + echo " reached ${rss:-$held} MB resident" + [ -n "$swapat" ] && echo " swapping from ${swapat} MB" + + case "$stop" in + reached-the-cap) + echo " outcome stopped at the --to cap, not at a limit." + echo " The box held ${TO_MB} MB without complaint; there is more." ;; + floor) + echo " outcome stopped with a cushion intact, by choice." + echo " The real ceiling is higher — --to-oom finds it, at the" + echo " cost of an actual OOM kill." ;; + interrupted) + echo " outcome interrupted at ${rss:-$held} MB — where you stopped it," + echo " not where the box did." ;; + *) + # No stop line means the child did not decide to stop: it was ended. + if [ "$rc" -ge 128 ]; then + echo " outcome the child was killed (signal $((rc - 128))) at ${rss:-$held} MB." + elif [ "$rc" -ne 0 ]; then + echo " outcome the allocation failed at ${rss:-$held} MB (exit ${rc})." + echo " bash could not get the next chunk — an honest malloc" + echo " failure rather than a kill. That is the strict-overcommit" + echo " or ulimit path." + else + echo " outcome ended at ${rss:-$held} MB." + fi + local ev + ev=$(dmesg 2>/dev/null | tail -80 | grep -iE 'oom-kill|killed process' | tail -1 || true) + if [ -n "$ev" ]; then + echo " kernel ${ev#*] }" + else + echo " - dmesg is unreadable here (dmesg_restrict, or no privilege)," + echo " so the kill cannot be confirmed from this side. The number stands." + fi ;; + esac + + # The gap between the claim and the measurement is the finding — but only + # when the BOX chose where to stop. An empty $stop means the child was ended + # rather than deciding to end; anything else (--to, the floor) is a stop we + # asked for, and flagging those as short of the ceiling would put a warning + # on every deliberately small run. + local got="${rss:-$held}" + echo + if [ -z "$stop" ] && [ "$got" -lt $(( ceiling * 70 / 100 )) ]; then + echo " ! claimed ${ceiling} MB, gave up ${got} MB — under 70% of it." + echo " Something is taking the difference. 'status' names the candidates:" + echo " a cgroup cap, ulimit -v, or memory already resident." + fi + return 0 +} + +# ── all ──────────────────────────────────────────────────────────────────── + +all() { + status + echo + echo "────────────────────────────────────────────────────────────" + echo + push + + local got budget_mb ceiling + load_config + if [ -n "$BUDGET_GB" ]; then + budget_mb=$(( BUDGET_GB * 1024 )) + else + budget_mb=$(( NODES * NODE_MB )) + fi + ceiling=$(effective_ceiling_mb) + got=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 | awk '{print $2}' || true) + [ -n "$got" ] || got=0 + + echo + echo "verdict" + if [ -n "$BUDGET_GB" ]; then + echo " budget ${budget_mb} MB (--budget)" + else + # rig's own figure for this profile: nodes times what one node costs. + # Addons carry no memory figure in rig yet, so this is the cluster alone + # and whatever you deploy comes on top. --budget once you know that too. + echo " budget ${budget_mb} MB — profile ${PROFILE_NAME}: ${NODES} node(s) x ${NODE_MB} MB," + echo " the cluster alone; your workload comes on top (--budget GB)" + fi + echo " measured ${got} MB handed over" + + if [ "$got" -ge "$budget_mb" ]; then + echo " fits, with $(( got - budget_mb )) MB spare." + if [ "$got" -lt $(( budget_mb * 130 / 100 )) ]; then + echo " - under 30% spare is thin once a workload runs on top: memory use" + echo " is spiky, and the spikes are what get killed." + fi + else + echo " ! short by $(( budget_mb - got )) MB." + if [ "$ceiling" -ge "$budget_mb" ]; then + echo " The box CLAIMS enough (${ceiling} MB) but did not deliver it." + echo " Free something, or read the caps section again." + else + echo " The box does not have it to give. A bigger machine, or a profile" + echo " with fewer nodes." + fi + fi + return 0 +} + +# ── main ─────────────────────────────────────────────────────────────────── + +parse_flags() { + while [ $# -gt 0 ]; do + case "$1" in + --to) TO_MB=$(( ${2:?--to needs a value in GB} * 1024 )); shift 2 ;; + --to-mb) TO_MB="${2:?--to-mb needs a value in MB}"; shift 2 ;; + --step) STEP_MB="${2:?--step needs a value in MB}"; STEP_EXPLICIT=yes; shift 2 ;; + --to-oom) TO_OOM=yes; shift ;; + --budget) BUDGET_GB="${2:?--budget needs a value in GB}"; BUDGET_EXPLICIT=yes; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac + done + if [ "$TO_OOM" = yes ] && [ -n "$TO_MB" ]; then + echo "--to and --to-oom contradict each other: one stops early, the other" >&2 + echo "refuses to stop at all. Pick one." >&2 + exit 1 + fi + return 0 +} + +require_linux +find_cgroup + +cmd="${1:-status}" +[ $# -gt 0 ] && shift + +case "$cmd" in + status) parse_flags "$@"; status ;; + push) parse_flags "$@"; push ;; + all) parse_flags "$@"; all ;; + backup) backup ;; + restore) restore ;; + *) echo "usage: $0 [status|push|all|backup|restore]" >&2 + echo " push [--to GB] [--to-mb MB] [--step MB] [--to-oom]" >&2 + echo " all [--budget GB]" >&2 + exit 1 ;; +esac diff --git a/rig/standalone/data/Makefile b/rig/standalone/data/Makefile new file mode 100644 index 0000000..56ac921 --- /dev/null +++ b/rig/standalone/data/Makefile @@ -0,0 +1,23 @@ +# GENERATED by make standalone — do not edit +# +# Shorthand for the scripts beside it; they run without it. Every target +# calls a verb its script accepts — read from that script's own dispatch. + +HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +.PHONY: $(ARGS) +endif + +.DEFAULT_GOAL := help +.PHONY: help deps mem + +help: ## list targets + @grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +deps: ## rigdeps.sh [detect|list|verify|fetch|install] (default detect) + bash $(HERE)rigdeps.sh $(or $(ARGS),detect) + +mem: ## rigmini.sh [status|push|all|backup|restore] (default status) + bash $(HERE)rigmini.sh $(or $(ARGS),status) diff --git a/rig/standalone/data/rigdeps.sh b/rig/standalone/data/rigdeps.sh new file mode 100755 index 0000000..79b2578 --- /dev/null +++ b/rig/standalone/data/rigdeps.sh @@ -0,0 +1,963 @@ +#!/usr/bin/env bash +# GENERATED by make standalone — do not edit +# +# rigdeps.sh for profile 'data', flattened from: +# ctrl/deps.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'data' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="metallb postgres redis airflow" + declare -gx AIRFLOW_ADMIN_USER="admin" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="off" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DATA_NAMESPACE="data" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="1" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_DB="app" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx POSTGRES_STORAGE="2Gi" + declare -gx POSTGRES_USER="app" + declare -gx PROFILE_NAME="data" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="local" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/deps.sh ── +# 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 list # the pinned versions +# deps.sh verify [core|dev] # run what is installed and see if it works +# 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")" + +# Pins arrive through load_config like every other setting, not by sourcing +# versions.env here. That is what lets `make standalone` freeze them into a +# one-file installer: configuration has exactly one way in. +# (sourced library inlined above) +load_config + +# 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. +# A /proc/meminfo field in MB, 0 if the field is absent. MEMINFO exists so the +# tight and does-not-fit branches can be exercised against a real machine's +# numbers from somewhere else; in normal use it is always /proc/meminfo. +mb_of() { + awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } + END { if (!found) printf "0" }' "${MEMINFO:-/proc/meminfo}" +} + +host_file() { + local p="${1#/}" + if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then + echo "$HOST_ROOT/$p" + else + echo "/$p" + fi +} + +# ── the tools this script itself needs ───────────────────────────────────── + +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 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 +} + +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 ───────────────────────────────────────────────────────────────── + +# 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)" + echo " arch $(arch) ($(uname -m))" + + local osr; osr=$(host_file /etc/os-release) + [ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")" + + # In MB. Whole gigabytes lose nearly half a GB on exactly the machines where + # it matters: 1874 MB available used to print as "1 GB". Facts only — whether + # that is enough depends on the profile, which check.sh knows and this does not. + local total_mb avail_mb swap_total_mb swap_used_mb om + total_mb=$(mb_of MemTotal) + avail_mb=$(mb_of MemAvailable) + swap_total_mb=$(mb_of SwapTotal) + swap_used_mb=$(( swap_total_mb - $(mb_of SwapFree) )) + printf " memory %d MB total, %d MB available\n" "$total_mb" "$avail_mb" + if [ "$swap_total_mb" -gt 0 ]; then + printf " swap %d MB used of %d MB\n" "$swap_used_mb" "$swap_total_mb" + fi + + # How the kernel answers an allocation it cannot really satisfy. With 1 it + # always says yes and settles up later with the OOM killer, so a cluster that + # starts cleanly can still lose processes afterwards. + om=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess" ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit" ;; + 2) echo " overcommit 2 strict — an allocation fails honestly instead of killing later" ;; + esac + + echo " install to $OUT_BIN" + detect_libc + detect_prereqs + detect_wsl + detect_filesystem + detect_docker + detect_inotify + detect_toolchain +} + +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 +} + +# 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() { + # 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, 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.") + 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=$($SHA "$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" + download "$(resolve_url "$url")" "$tmp" + 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" + download "$(resolve_url "$url")" "$tmp" + 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. +# +# docker-compose is 'dev' for the same reason, and is here because the distro +# docker packages ship the daemon and CLI but frequently not the compose +# plugin — so `docker compose up` fails with "unknown command" on an otherwise +# working Docker, and nothing about that message names the missing piece. +DEV_TOOLS="kind tilt ctlptl docker-compose" + +# ── what is already on this machine ─────────────────────────────────────── +# +# A tool already on PATH at its pinned version is left where it is. Without +# this, install downloads a second copy into OUT_BIN and then reports the first +# one as shadowed — noise, and wrong, when both are the same version. That is +# the normal state of any machine someone set up by hand, whatever directory +# they happened to choose. + +pin_of() { + case "$1" in + kubectl) echo "$KUBECTL_VERSION" ;; + jq) echo "$JQ_VERSION" ;; + kind) echo "$KIND_VERSION" ;; + tilt) echo "$TILT_VERSION" ;; + ctlptl) echo "$CTLPTL_VERSION" ;; + docker-compose) echo "$COMPOSE_VERSION" ;; + esac +} + +# The version string a binary reports. Each tool spells the question +# differently, and kubectl has to be told --client or it goes looking for a +# server to ask. +reported_version() { + local tool="$1" path="$2" + case "$tool" in + kubectl) "$path" version --client 2>/dev/null ;; + jq) "$path" --version 2>/dev/null ;; + *) "$path" version 2>/dev/null ;; + esac +} + +# Does the binary at PATH report PIN? Matched as a whole version token, so +# 0.37.6 never matches 10.37.60, with the leading v optional either side: kind +# says v0.32.0, jq says jq-1.8.2, and tilt says v0.37.6 against a pin of 0.37.6. +# +# Bash's own regex rather than grep, deliberately. grep is not the same program +# on every machine — some builds reject patterns that others accept — and a +# failed grep inside a count reads exactly like a zero. +version_matches() { + local tool="$1" path="$2" pin="$3" out v re + out=$(reported_version "$tool" "$path") || return 1 + v="${pin#v}" + v="${v//./\\.}" + re="(^|[^0-9.])v?${v}([^0-9.]|\$)" + [[ $out =~ $re ]] +} + +# DEPS_ONLY narrows a fetch to the tools it names. Unset means the whole tier, +# which is what an explicit `deps.sh fetch` always gets: "download these into +# DIR" must not quietly skip something because this machine happens to have it. +# Only install() sets it, to what detect_toolchain found missing or mismatched. +want() { [ -z "${DEPS_ONLY:-}" ] || [[ " $DEPS_ONLY " == *" $1 "* ]]; } + +# Every tool in the tier with its state, probed once and reported once. What +# still needs fetching is left in TOOLCHAIN_NEED for install() to act on. +TOOLCHAIN_NEED="" +detect_toolchain() { + local tier="${TIER:-dev}" b pin path found + TOOLCHAIN_NEED="" + echo + echo "toolchain (pinned, tier '$tier')" + for b in $(tier_tools "$tier"); do + pin=$(pin_of "$b") + path=$(command -v "$b" 2>/dev/null || true) + # compose is the one tool that is normally NOT a binary on PATH. It is a + # docker CLI plugin, so a machine where `docker compose` works perfectly + # has no `docker-compose` to find — and probing only PATH would report it + # missing and re-download a copy that is already there. That is the exact + # noise the version-aware skip exists to prevent, so ask docker instead. + if [ "$b" = docker-compose ] && [ -z "$path" ]; then + if found=$(docker compose version --short 2>/dev/null) && [ -n "$found" ]; then + if [ "${found#v}" = "${pin#v}" ]; then + printf " %-8s %-9s %s\n" "$b" "$pin" "docker cli plugin" + else + printf " ! %-8s wants %s, the docker cli plugin reports '%s'\n" \ + "$b" "$pin" "$found" + TOOLCHAIN_NEED+="$b " + fi + continue + fi + fi + if [ -z "$path" ]; then + printf " - %-8s %-9s not found\n" "$b" "$pin" + TOOLCHAIN_NEED+="$b " + elif version_matches "$b" "$path" "$pin"; then + printf " %-8s %-9s %s\n" "$b" "$pin" "$path" + else + found=$(reported_version "$b" "$path" 2>/dev/null | head -1 || true) + printf " ! %-8s wants %s, %s reports '%s'\n" "$b" "$pin" "$path" "$found" + TOOLCHAIN_NEED+="$b " + fi + done + if [ -z "$TOOLCHAIN_NEED" ]; then + echo " every pinned tool is already on PATH — nothing to fetch" + else + echo " 'make deps' fetches only: ${TOOLCHAIN_NEED% }" + fi +} + +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 + + if [ -n "${DEPS_ONLY:-}" ]; then + echo "fetching ${DEPS_ONLY% } (source: $DEPS_SOURCE)" + else + echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)" + fi + if want kubectl; then fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"; fi + if want jq; then fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"; fi + if [ "$tier" = "dev" ]; then + if want kind; then fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"; fi + if want tilt; then fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0; fi + if want ctlptl; then fetch_tgz ctlptl "$CTLPTL_URL" "$CTLPTL_SHA256" "$dest" ctlptl 0; fi + if want docker-compose; then + fetch_bin docker-compose "$COMPOSE_URL" "$COMPOSE_SHA256" "$dest" + fi + 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. +# 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 +} + +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 +} + +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 + # The same version in both places is not a conflict: nothing changes for + # any other project whichever copy PATH happens to find first. + if version_matches "$b" "$existing" "$(pin_of "$b")"; then continue; fi + 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") +} + +# A copy in OUT_BIN only gives you `docker-compose`. That hyphenated form is the +# retired v1 spelling; every compose file written in the last few years assumes +# `docker compose`, which resolves plugins BY NAME out of a plugin directory. +# So the binary is fetched like any other and then linked, in your own home — +# no root, and nothing outside it. +install_compose_plugin() { + local src="$OUT_BIN/docker-compose" dir="$HOME/.docker/cli-plugins" + [ -x "$src" ] || return 0 + mkdir -p "$dir" + # Something else already owns that name — docker-desktop and some distro + # packages install a real file there. Overwriting it would take the plugin + # away from whatever put it there, so say so and let the user decide. + if [ -e "$dir/docker-compose" ] && [ ! -L "$dir/docker-compose" ]; then + MANUAL+=("Something already installs the compose plugin at + $dir/docker-compose + To use rig's pinned build instead: + ln -sf $src $dir/docker-compose") + return 0 + fi + ln -sfn "$src" "$dir/docker-compose" + echo " compose plugin -> $dir/docker-compose" + return 0 +} + +install() { + local tier="${1:-dev}" b + TIER="$tier" + detect + + # detect_toolchain has already probed PATH. Fetch only what it found missing + # or at the wrong version; a tool already present at its pin stays where it is. + if [ -n "$TOOLCHAIN_NEED" ]; then + echo + DEPS_ONLY="$TOOLCHAIN_NEED" fetch "$tier" + echo + echo "installed to $OUT_BIN ($tier):" + for b in $TOOLCHAIN_NEED; do + if [ -x "$OUT_BIN/$b" ]; then echo " $b"; fi + done + if [ "$tier" = "core" ]; then + echo " (no kind/tilt — 'make deps dev' adds them)" + fi + # Only when compose was one of the things fetched: linking a binary + # that is already satisfied elsewhere on PATH would point the plugin at + # a copy rig did not install. + case " $TOOLCHAIN_NEED " in + *" docker-compose "*) install_compose_plugin ;; + esac + + # Only worth saying when something actually landed in OUT_BIN. When every + # tool was satisfied elsewhere, OUT_BIN may reasonably be off PATH, and + # telling the user to add it would be advice to fix nothing. + case ":${PATH}:" in + *":$OUT_BIN:"*) ;; + *) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc: + export PATH=\"${OUT_BIN}:\$PATH\"") ;; + esac + fi + warn_shadowing "$tier" + + report_manual +} + +# ── 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 + +# Baked mode copies binaries already in the image, so it needs no downloader. +need_downloads() { + require_amd64 + if [ "$DEPS_SOURCE" != baked ]; then pick_downloader; fi + pick_sha +} + +case "$cmd" in + detect) detect; report_manual ;; + list) list ;; + verify) verify_tools "${1:-dev}" ;; + fetch) need_downloads; fetch "$@" ;; + install) need_downloads; install "${1:-dev}" ;; + *) echo "usage: $0 [detect|list|verify|fetch|install]" >&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 diff --git a/rig/standalone/data/rigmini.sh b/rig/standalone/data/rigmini.sh new file mode 100755 index 0000000..5bd197a --- /dev/null +++ b/rig/standalone/data/rigmini.sh @@ -0,0 +1,864 @@ +#!/usr/bin/env bash +# GENERATED by make standalone — do not edit +# +# rigmini.sh for profile 'data', flattened from: +# ctrl/mem.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'data' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="metallb postgres redis airflow" + declare -gx AIRFLOW_ADMIN_USER="admin" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="off" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DATA_NAMESPACE="data" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="1" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_DB="app" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx POSTGRES_STORAGE="2Gi" + declare -gx POSTGRES_USER="app" + declare -gx PROFILE_NAME="data" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="local" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/mem.sh ── +# How much memory this machine will actually give you before something dies — +# rig's memory tool, and (generated from this file) the standalone rigmini.sh. +# +# There are two numbers and they are rarely the same. `status` reports what the +# machine ADVERTISES and what is quietly capping it. `push` finds what it will +# SURVIVE, by allocating until it stops. `all` does both and weighs the result +# against what this profile's cluster needs. +# +# The gap between them is the whole reason this exists. Under WSL the cap lives +# in .wslconfig; in a container or a managed workspace it is a cgroup limit, and +# there /proc/meminfo reports the HOST's memory while the kernel kills you at a +# fraction of it. A script that only read MemTotal would confidently report 32 GB +# on a box that OOMs at 2. +# +# Runs on native Linux and under WSL. On WSL the memory you see is a VM +# allocation that can be raised, and the commonest failure is raising it without +# restarting — so status compares what .wslconfig says with what actually booted. +# +# Reports and instructs. It never raises a limit, frees anything or installs a +# package. The one write it can make is `backup`, which copies .wslconfig beside +# itself, so that `restore` has something to put back after a hand edit. +# +# Usage: +# mem.sh status what it has, what caps it +# mem.sh push [--to GB] [--to-oom] climb until it stops +# mem.sh all [--budget GB] both, then the verdict +# mem.sh backup | restore .wslconfig, WSL only +set -euo pipefail +cd "$(dirname "$0")" +# (sourced library inlined above) + +# ── defaults ─────────────────────────────────────────────────────────────── + +STEP_MB=0 # per allocation; 0 means scale it to the ceiling. See push(). +STEP_EXPLICIT=no # whether --step was given, which turns the scaling off. +TO_MB="" # --to: stop here regardless. Empty means no hard cap. +TO_OOM=no # --to-oom: opt in to running until the kernel intervenes. +BUDGET_GB="" # --budget; empty means what this profile's cluster needs, from rig. +BUDGET_EXPLICIT=no # whether --budget was given, which retires the guess below. + +# ── 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 ;; + esac + + # Everything below reads /proc. Without it there is nothing to measure, and + # failing here beats printing a page of empty fields. + if [ ! -r /proc/meminfo ]; then + echo "no readable /proc/meminfo — this needs a Linux kernel." >&2 + echo "On macOS or a BSD none of the numbers below exist." >&2 + exit 1 + fi +} + +is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; } + +is_container() { + [ -f /.dockerenv ] && return 0 + grep -qE '(docker|containerd|kubepods|lxc|podman)' /proc/1/cgroup 2>/dev/null +} + +platform() { + if is_wsl; then echo WSL + elif is_container; then echo container + else echo "native linux" + fi +} + +# ── reading memory ───────────────────────────────────────────────────────── + +mb() { echo $(( $(awk "/^$1:/{print \$2}" /proc/meminfo) / 1024 )); } + +# MemAvailable arrived in kernel 3.14. Older kernels — and they turn up on +# corporate images — need the estimate it replaced, which is worse but not wrong. +avail_meminfo_mb() { + if grep -q '^MemAvailable:' /proc/meminfo; then + mb MemAvailable + else + awk '/^(MemFree|Buffers|Cached):/{t+=$2} END{print int(t/1024)}' /proc/meminfo + fi +} + +# Where a cgroup records this cgroup's own limit and usage. Set once by +# find_cgroup, because every later reading needs both and hunting for the files +# on each call would be the slow part of the poll loop. +CG_MAX_FILE="" +CG_CUR_FILE="" +CG_VERSION="" + +find_cgroup() { + local rel + + # Inside a container the cgroup namespace makes the top of the tree BE the + # container's own cgroup, so the unqualified path is already the right one. + # On a host it is the root cgroup, which is never limited — hence the second + # attempt via /proc/self/cgroup, which names the slice this shell is in. + if [ -r /sys/fs/cgroup/memory.max ]; then + CG_VERSION=v2 + CG_MAX_FILE=/sys/fs/cgroup/memory.max + CG_CUR_FILE=/sys/fs/cgroup/memory.current + elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + CG_VERSION=v1 + CG_MAX_FILE=/sys/fs/cgroup/memory/memory.limit_in_bytes + CG_CUR_FILE=/sys/fs/cgroup/memory/memory.usage_in_bytes + fi + + rel=$(awk -F: '$1=="0"{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] && [ -r "/sys/fs/cgroup${rel}/memory.max" ]; then + CG_VERSION=v2 + CG_MAX_FILE="/sys/fs/cgroup${rel}/memory.max" + CG_CUR_FILE="/sys/fs/cgroup${rel}/memory.current" + return 0 + fi + + rel=$(awk -F: '$2 ~ /(^|,)memory(,|$)/{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] \ + && [ -r "/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" ]; then + CG_VERSION=v1 + CG_MAX_FILE="/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" + CG_CUR_FILE="/sys/fs/cgroup/memory${rel}/memory.usage_in_bytes" + fi + return 0 +} + +# The cap in MB, or "" when there is none worth reporting. v2 spells unlimited +# "max"; v1 spells it as a number near 2^63, which is why this compares against +# MemTotal rather than testing for a magic value — a "limit" above the machine's +# own memory is not a limit, however it is written. +cgroup_cap_mb() { + local raw cap + [ -n "$CG_MAX_FILE" ] && [ -r "$CG_MAX_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_MAX_FILE" 2>/dev/null || echo max) + [ "$raw" = "max" ] && { echo ""; return 0; } + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + cap=$((raw / 1024 / 1024)) + [ "$cap" -ge "$(mb MemTotal)" ] && { echo ""; return 0; } + echo "$cap" +} + +cgroup_used_mb() { + local raw + [ -n "$CG_CUR_FILE" ] && [ -r "$CG_CUR_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_CUR_FILE" 2>/dev/null || echo "") + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((raw / 1024 / 1024)) +} + +# ulimit -v is a per-process address-space cap. It stops YOU long before the box +# does, and because it is inherited from a login shell it is easy to hit without +# knowing it is set. +ulimit_v_mb() { + local v; v=$(ulimit -v 2>/dev/null || echo unlimited) + [ "$v" = "unlimited" ] && { echo ""; return 0; } + case "$v" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((v / 1024)) +} + +# The number everything else is about: the lowest of the things that can stop +# you. Printed at the end of `status` and used as the sanity bound in `push`. +effective_ceiling_mb() { + local c; c=$(mb MemTotal) + local cap; cap=$(cgroup_cap_mb) + local ul; ul=$(ulimit_v_mb) + [ -n "$cap" ] && [ "$cap" -lt "$c" ] && c="$cap" + [ -n "$ul" ] && [ "$ul" -lt "$c" ] && c="$ul" + echo "$c" +} + +# How much room is left RIGHT NOW, from whichever accounting actually governs. +# In a capped container /proc/meminfo describes the host and is worse than +# useless for this — it would report tens of gigabytes free on a box that is one +# allocation from being killed. +headroom_mb() { + local cap used + cap=$(cgroup_cap_mb) + used=$(cgroup_used_mb) + if [ -n "$cap" ] && [ -n "$used" ]; then + echo $(( cap - used )) + else + avail_meminfo_mb + fi +} + +# ── status ───────────────────────────────────────────────────────────────── + +# /mnt/c/Users can hold several real accounts — a renamed login leaves the old +# directory behind — so picking the first alphabetically is a coin toss. Ask +# Windows, then fall back to whichever profile actually owns a config. +wslconfig_path() { + local profile winpath found + profile=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d "\r\n" || true) + case "$profile" in + ""|*%*) ;; + *) winpath=$(wslpath -u "$profile" 2>/dev/null || true) + if [ -n "$winpath" ] && [ -d "$winpath" ]; then + echo "$winpath/.wslconfig"; return 0 + fi ;; + esac + found=$(ls -d /mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true) + [ -n "$found" ] && echo "$found" + return 0 +} + +hogs() { + echo " holding the most:" + ps -eo rss,comm --sort=-rss 2>/dev/null \ + | awk 'NR>1 && NR<=6 {printf " %6.0f MB %s\n", $1/1024, $2}' + return 0 +} + +status() { + local total avail swap_total swap_free cap ul cur + + echo "host" + echo " platform $(platform)" + echo " kernel $(uname -r)" + [ -r /etc/os-release ] && \ + echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' /etc/os-release)" + echo " cpu $(getconf _NPROCESSORS_ONLN 2>/dev/null || echo '?') online, load $(cut -d' ' -f1-3 /proc/loadavg)" + + # ── the caps first, because they decide what the totals below are worth ── + echo + echo "caps" + cap=$(cgroup_cap_mb) + if [ -n "$cap" ]; then + cur=$(cgroup_used_mb) + echo " cgroup ${cap} MB (${CG_VERSION}, ${CG_CUR_FILE##*/} says ${cur:-?} MB used)" + echo " ! /proc/meminfo below describes the HOST, not this cgroup." + echo " $(mb MemTotal) MB total is not yours; ${cap} MB is." + elif [ -n "$CG_VERSION" ]; then + echo " cgroup none (${CG_VERSION} present, no memory limit set)" + else + echo " cgroup no memory controller found" + fi + + ul=$(ulimit_v_mb) + if [ -n "$ul" ]; then + echo " ! ulimit -v ${ul} MB — a per-process cap, inherited from your shell" + echo " it stops this process long before the machine runs out" + else + echo " ulimit -v unlimited" + fi + + # overcommit_memory=0 is the default heuristic: a large allocation is + # granted on a guess, and the reckoning arrives later as an OOM kill rather + # than as a failed malloc. It is why `push` touches every page it asks for. + local om or_ + om=$(cat /proc/sys/vm/overcommit_memory 2>/dev/null || echo '?') + or_=$(cat /proc/sys/vm/overcommit_ratio 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess," ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit," ;; + 2) echo " overcommit 2 strict (ratio ${or_}%) — allocation fails honestly instead of killing later," ;; + *) echo " overcommit ${om}" ;; + esac + [ "$om" != "?" ] && echo " so RSS is the number to trust, not what a process asked for" + + # ── what it says it has ── + total=$(mb MemTotal); avail=$(avail_meminfo_mb) + swap_total=$(mb SwapTotal); swap_free=$(mb SwapFree) + echo + echo "memory" + echo " total ${total} MB" + echo " available ${avail} MB" + echo " swap ${swap_total} MB ($(( swap_total - swap_free )) MB used)" + if [ "$swap_total" -eq 0 ]; then + echo " - no swap: this box has no cushion. It goes from fine to OOM-killed" + echo " with nothing in between, which is the abrupt failure you get in a VM." + fi + + # postgres puts its shared buffers in /dev/shm. Docker's default is 64 MB, + # and the resulting failure names neither shm nor the size. + if [ -d /dev/shm ]; then + local shm; shm=$(df -Pm /dev/shm 2>/dev/null | awk 'NR==2{print $2}') + if [ -n "$shm" ]; then + if [ "$shm" -le 64 ]; then + echo " ! /dev/shm ${shm} MB — postgres puts shared memory here and 64 MB" + echo " is docker's default. Raise it with --shm-size when postgres fails." + else + echo " /dev/shm ${shm} MB" + fi + fi + fi + + echo + echo "disk" + local d + for d in / /tmp /var/lib/docker; do + [ -d "$d" ] || continue + df -Pm "$d" 2>/dev/null | awk -v p="$d" 'NR==2{printf " %-12s %s MB free of %s MB\n", p, $4, $2}' + done + + # kind and Tilt both watch large trees, and the failure mode is silent: + # they simply stop noticing file changes. Cheap to report while we are here. + 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 + echo "tooling" + echo " inotify watches=$w instances=$i" + if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then + echo " ! low — anything watching files will silently stop seeing changes" + fi + + if ! command -v docker >/dev/null 2>&1; then + if [ -S /var/run/docker.sock ]; then + echo " docker socket present, no cli" + else + echo " docker not installed" + fi + elif docker info >/dev/null 2>&1; then + local n + n=$(docker ps -q 2>/dev/null | wc -l) + echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null), ${n} container(s) running" + else + echo " ! docker cli present but the daemon is unreachable" + fi + + # WSL keeps its cap on the Windows side, in a file this shell can read but + # not usefully apply — the change costs a full VM restart. Report it, and + # report the commonest mistake, which is editing it and not restarting. + if is_wsl; then + local cfg conf conf_mb n + cfg=$(wslconfig_path) + echo + echo "wsl" + if [ -z "$cfg" ]; then + echo " ! cannot tell which Windows profile owns .wslconfig" + else + echo " config $cfg" + conf=$(configured_memory "$cfg") + if [ -n "$conf" ]; then + conf_mb=$(to_mb "$conf") + echo " configured $conf (${conf_mb} MB), booted ${total} MB" + # The VM reports a little less than allocated; 15% covers the + # kernel without calling every healthy machine a mismatch. + if [ -n "$conf_mb" ] && [ "$total" -lt $(( conf_mb * 85 / 100 )) ]; then + echo " ! configured ${conf_mb} MB but booted ${total} MB — not applied yet." + echo " From a WINDOWS terminal: wsl --shutdown then start the distro again." + fi + else + echo " configured no memory= set (WSL defaults to 50% of host RAM, or 8 GB," + echo " whichever is less). To raise it, add on the Windows side:" + echo " [wsl2]" + echo " memory=8GB" + echo " then from a WINDOWS terminal: wsl --shutdown" + fi + n=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$n" -gt 0 ]; then + echo " backups $n (newest: $(ls -t "$cfg".*.bak 2>/dev/null | head -1))" + fi + fi + else + echo + echo " - native linux: no VM allocation to raise. If memory is tight the levers" + echo " are freeing something or adding swap." + fi + + echo + echo "effective ceiling $(effective_ceiling_mb) MB" + echo " the lowest of MemTotal, the cgroup cap and ulimit -v. What the box" + echo " claims. 'push' measures what it will actually hand over." + + [ "$avail" -lt $(( total / 5 )) ] && { echo; hogs; } + return 0 +} + +# ── .wslconfig ───────────────────────────────────────────────────────────── + +require_wsl() { + if ! is_wsl; then + echo "$1 acts on .wslconfig, which only exists under WSL." >&2 + echo "This is native Linux — there is no VM allocation to save or roll back." >&2 + echo "Use 'status' to see what the machine actually has." >&2 + exit 1 + fi +} + +# backup and restore act on the file, so unlike status they must not guess. +wslconfig_required() { + local cfg; cfg=$(wslconfig_required) + if [ -z "$cfg" ]; then + echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2 + ls -d /mnt/c/Users/*/ 2>/dev/null \ + | grep -viE "/(All Users|Default|Default User|Public)/$" | sed "s/^/ /" >&2 + exit 1 + fi + echo "$cfg" +} + +configured_memory() { + [ -r "$1" ] || { echo ""; return; } + sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$1" | tail -1 | tr -d '[:space:]' +} + +# "9GB" / "8192MB" / "9G" -> MB, so it can be compared with /proc/meminfo. +to_mb() { + local v="${1^^}" n + n=$(echo "$v" | tr -dc '0-9') + [ -n "$n" ] || { echo ""; return; } + case "$v" in + *GB|*G) echo $(( n * 1024 )) ;; + *MB|*M) echo "$n" ;; + *) echo $(( n / 1024 / 1024 )) ;; + esac +} + +backup() { + require_wsl backup + local cfg dest + cfg=$(wslconfig_required) + [ -r "$cfg" ] || { echo "nothing to back up: $cfg does not exist" >&2; exit 1; } + # Timestamped and never overwritten: a backup that can destroy itself on a + # second run is not a backup. + dest="${cfg}.$(date +%Y%m%d-%H%M%S).bak" + cp "$cfg" "$dest" + echo "backed up $dest" + echo + echo "Edit $cfg by hand, then from a WINDOWS terminal: wsl --shutdown" +} + +restore() { + require_wsl restore + local cfg newest count + cfg=$(wslconfig_required) + newest=$(ls -t "$cfg".*.bak 2>/dev/null | head -1 || true) + [ -n "$newest" ] || { echo "no backups found beside $cfg" >&2; exit 1; } + + echo "restoring $newest" + echo " -> $cfg" + echo + + # Newest is the right default — undo the last edit — but if you backed up + # *after* editing, the state you want is older. Show the rest so a no-op + # restore is obviously a no-op rather than a mystery. + count=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$count" -gt 1 ]; then + echo "$count backups exist, newest first:" + ls -t "$cfg".*.bak | sed 's/^/ /' + echo " (restoring the newest; copy another by hand to pick an older one)" + echo + fi + + if [ -r "$cfg" ]; then + echo "what changes:" + if diff "$cfg" "$newest" > /tmp/mem.diff 2>&1 && [ ! -s /tmp/mem.diff ]; then + echo " nothing — that backup is identical to the current config" + else + sed 's/^/ /' /tmp/mem.diff + fi + rm -f /tmp/mem.diff + echo + fi + + printf "proceed? [y/N] " + read -r reply + case "$reply" in + y|Y|yes|Yes) ;; + *) echo "left alone"; return 0 ;; + esac + cp "$newest" "$cfg" + echo "restored. From a WINDOWS terminal: wsl --shutdown" +} + +# ── push ─────────────────────────────────────────────────────────────────── + +STATE="" +CHILD="" + +cleanup() { + if [ -n "$CHILD" ] && kill -0 "$CHILD" 2>/dev/null; then + kill -KILL "$CHILD" 2>/dev/null || true + wait "$CHILD" 2>/dev/null || true + fi + [ -n "$STATE" ] && rm -f "$STATE" + return 0 +} + +# The child allocates and stops itself; the parent only watches. That split is +# the point: under --to-oom the allocating process is expected to be killed, and +# something has to survive to say how far it got. +allocator() { + # Raise our own OOM score to the maximum so the kernel picks THIS process + # first. Raising needs no privilege (only lowering does). Without it, the + # kernel is free to choose your shell, your ssh session or dockerd — on a + # box you are still using, that is not an acceptable coin toss. + echo 1000 > "/proc/$BASHPID/oom_score_adj" 2>/dev/null || true + + local arr=() held=0 i=0 rss swapped avail first_swap=0 + local bytes=$((STEP_MB * 1024 * 1024)) + local swap_used_start + swap_used_start=$(( $(mb SwapTotal) - $(mb SwapFree) )) + + while :; do + # Written STRAIGHT INTO the array element. The obvious spelling — + # build one chunk and `arr+=("$chunk")` — costs three copies per step, + # not one: the template stays resident, expanding "$chunk" makes a + # temporary word, and the append makes the element. A 128 MB step then + # needs 384 MB transiently, and on a small box it is killed on the + # first append while reporting a third of the true ceiling. + # + # printf -v into a subscript also means every page is written, so it is + # resident rather than merely promised — the only kind of allocation + # that measures anything under heuristic overcommit. + printf -v "arr[$i]" '%*s' "$bytes" '' + i=$((i + 1)); held=$((held + STEP_MB)) + + rss=$(awk '/^VmRSS:/{print int($2/1024)}' "/proc/$BASHPID/status" 2>/dev/null || echo 0) + avail=$(headroom_mb) + swapped=$(( $(mb SwapTotal) - $(mb SwapFree) - swap_used_start )) + [ "$swapped" -lt 0 ] && swapped=0 + + printf '%8s MB held rss %7s MB headroom %7s MB swap +%s MB\n' \ + "$held" "$rss" "$avail" "$swapped" + printf '%s %s %s %s\n' "$held" "$rss" "$avail" "$swapped" >> "$STATE" + + # Worth calling out separately from the ceiling: this is where the box + # stops being fast and starts being unusable, which for a scheduler is + # a different and earlier problem than being killed. + if [ "$swapped" -gt 0 ] && [ "$first_swap" -eq 0 ]; then + first_swap=$held + echo " - first swap page at ${held} MB — past here it works but crawls" + echo "swapat $held" >> "$STATE" + fi + + if [ -n "$TO_MB" ] && [ "$held" -ge "$TO_MB" ]; then + echo "stop reached-the-cap" >> "$STATE"; return 0 + fi + if [ "$TO_OOM" = no ] && [ "$avail" -lt "$FLOOR_MB" ]; then + echo "stop floor" >> "$STATE"; return 0 + fi + done +} + +push() { + local total ceiling rc=0 last held rss swapat stop + total=$(mb MemTotal) + ceiling=$(effective_ceiling_mb) + + # A step is worth about a sixty-fourth of the ceiling: enough resolution to + # find the edge, few enough lines to read, and small enough that the + # transient cost of one allocation never dominates a small box. A fixed + # size cannot do all three — 128 MB is fine on 16 GB and absurd on 512 MB. + if [ "$STEP_EXPLICIT" = no ]; then + STEP_MB=$(( ceiling / 64 )) + [ "$STEP_MB" -lt 4 ] && STEP_MB=4 + [ "$STEP_MB" -gt 256 ] && STEP_MB=256 + fi + + # Stop with a cushion rather than riding it to the kill. How big a cushion + # depends on what it is protecting. Under a cgroup cap, running out kills + # only this container's own processes, so it need cover no more than the + # shell that prints the result — and a 512 MB cushion on a 1 GB box would + # halve the answer. On a host there is everything else to protect, and the + # OOM killer does not promise to pick the process that caused the problem. + if [ -n "$(cgroup_cap_mb)" ]; then FLOOR_MB=64; else FLOOR_MB=512; fi + [ $(( ceiling / 20 )) -gt "$FLOOR_MB" ] && FLOOR_MB=$(( ceiling / 20 )) + + STATE=$(mktemp "${TMPDIR:-/tmp}/rigmini.XXXXXX") + trap cleanup EXIT + # INT kills the child and lets the summary below print anyway, so an + # impatient Ctrl-C still tells you how far it got — and, more importantly, + # still gives the memory back. + trap 'echo; echo " interrupted"; echo "stop interrupted" >> "$STATE"; [ -n "$CHILD" ] && kill -KILL "$CHILD" 2>/dev/null || true' INT + + echo "push" + echo " step ${STEP_MB} MB per allocation, every page touched" + echo " ceiling ${ceiling} MB claimed" + if [ -n "$TO_MB" ]; then + echo " stopping at ${TO_MB} MB (--to)" + elif [ "$TO_OOM" = yes ]; then + echo " ! stopping only when the kernel stops it (--to-oom)" + echo " the allocating child is marked as the preferred OOM victim," + echo " but nothing about an OOM kill is entirely polite. Not on a box" + echo " running anything you mind losing." + else + echo " stopping when headroom drops below ${FLOOR_MB} MB" + fi + echo + + allocator & + CHILD=$! + wait "$CHILD" || rc=$? + CHILD="" + trap - INT + + last=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 || true) + held=$(echo "$last" | awk '{print $1}') + rss=$(echo "$last" | awk '{print $2}') + swapat=$(awk '/^swapat/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + stop=$(awk '/^stop/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + + echo + if [ -z "$held" ]; then + echo " ! nothing was allocated. Even one ${STEP_MB} MB chunk failed —" + echo " try a smaller --step, or check ulimit -v in 'status'." + return 1 + fi + + echo " reached ${rss:-$held} MB resident" + [ -n "$swapat" ] && echo " swapping from ${swapat} MB" + + case "$stop" in + reached-the-cap) + echo " outcome stopped at the --to cap, not at a limit." + echo " The box held ${TO_MB} MB without complaint; there is more." ;; + floor) + echo " outcome stopped with a cushion intact, by choice." + echo " The real ceiling is higher — --to-oom finds it, at the" + echo " cost of an actual OOM kill." ;; + interrupted) + echo " outcome interrupted at ${rss:-$held} MB — where you stopped it," + echo " not where the box did." ;; + *) + # No stop line means the child did not decide to stop: it was ended. + if [ "$rc" -ge 128 ]; then + echo " outcome the child was killed (signal $((rc - 128))) at ${rss:-$held} MB." + elif [ "$rc" -ne 0 ]; then + echo " outcome the allocation failed at ${rss:-$held} MB (exit ${rc})." + echo " bash could not get the next chunk — an honest malloc" + echo " failure rather than a kill. That is the strict-overcommit" + echo " or ulimit path." + else + echo " outcome ended at ${rss:-$held} MB." + fi + local ev + ev=$(dmesg 2>/dev/null | tail -80 | grep -iE 'oom-kill|killed process' | tail -1 || true) + if [ -n "$ev" ]; then + echo " kernel ${ev#*] }" + else + echo " - dmesg is unreadable here (dmesg_restrict, or no privilege)," + echo " so the kill cannot be confirmed from this side. The number stands." + fi ;; + esac + + # The gap between the claim and the measurement is the finding — but only + # when the BOX chose where to stop. An empty $stop means the child was ended + # rather than deciding to end; anything else (--to, the floor) is a stop we + # asked for, and flagging those as short of the ceiling would put a warning + # on every deliberately small run. + local got="${rss:-$held}" + echo + if [ -z "$stop" ] && [ "$got" -lt $(( ceiling * 70 / 100 )) ]; then + echo " ! claimed ${ceiling} MB, gave up ${got} MB — under 70% of it." + echo " Something is taking the difference. 'status' names the candidates:" + echo " a cgroup cap, ulimit -v, or memory already resident." + fi + return 0 +} + +# ── all ──────────────────────────────────────────────────────────────────── + +all() { + status + echo + echo "────────────────────────────────────────────────────────────" + echo + push + + local got budget_mb ceiling + load_config + if [ -n "$BUDGET_GB" ]; then + budget_mb=$(( BUDGET_GB * 1024 )) + else + budget_mb=$(( NODES * NODE_MB )) + fi + ceiling=$(effective_ceiling_mb) + got=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 | awk '{print $2}' || true) + [ -n "$got" ] || got=0 + + echo + echo "verdict" + if [ -n "$BUDGET_GB" ]; then + echo " budget ${budget_mb} MB (--budget)" + else + # rig's own figure for this profile: nodes times what one node costs. + # Addons carry no memory figure in rig yet, so this is the cluster alone + # and whatever you deploy comes on top. --budget once you know that too. + echo " budget ${budget_mb} MB — profile ${PROFILE_NAME}: ${NODES} node(s) x ${NODE_MB} MB," + echo " the cluster alone; your workload comes on top (--budget GB)" + fi + echo " measured ${got} MB handed over" + + if [ "$got" -ge "$budget_mb" ]; then + echo " fits, with $(( got - budget_mb )) MB spare." + if [ "$got" -lt $(( budget_mb * 130 / 100 )) ]; then + echo " - under 30% spare is thin once a workload runs on top: memory use" + echo " is spiky, and the spikes are what get killed." + fi + else + echo " ! short by $(( budget_mb - got )) MB." + if [ "$ceiling" -ge "$budget_mb" ]; then + echo " The box CLAIMS enough (${ceiling} MB) but did not deliver it." + echo " Free something, or read the caps section again." + else + echo " The box does not have it to give. A bigger machine, or a profile" + echo " with fewer nodes." + fi + fi + return 0 +} + +# ── main ─────────────────────────────────────────────────────────────────── + +parse_flags() { + while [ $# -gt 0 ]; do + case "$1" in + --to) TO_MB=$(( ${2:?--to needs a value in GB} * 1024 )); shift 2 ;; + --to-mb) TO_MB="${2:?--to-mb needs a value in MB}"; shift 2 ;; + --step) STEP_MB="${2:?--step needs a value in MB}"; STEP_EXPLICIT=yes; shift 2 ;; + --to-oom) TO_OOM=yes; shift ;; + --budget) BUDGET_GB="${2:?--budget needs a value in GB}"; BUDGET_EXPLICIT=yes; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac + done + if [ "$TO_OOM" = yes ] && [ -n "$TO_MB" ]; then + echo "--to and --to-oom contradict each other: one stops early, the other" >&2 + echo "refuses to stop at all. Pick one." >&2 + exit 1 + fi + return 0 +} + +require_linux +find_cgroup + +cmd="${1:-status}" +[ $# -gt 0 ] && shift + +case "$cmd" in + status) parse_flags "$@"; status ;; + push) parse_flags "$@"; push ;; + all) parse_flags "$@"; all ;; + backup) backup ;; + restore) restore ;; + *) echo "usage: $0 [status|push|all|backup|restore]" >&2 + echo " push [--to GB] [--to-mb MB] [--step MB] [--to-oom]" >&2 + echo " all [--budget GB]" >&2 + exit 1 ;; +esac diff --git a/rig/standalone/minimal/Makefile b/rig/standalone/minimal/Makefile new file mode 100644 index 0000000..56ac921 --- /dev/null +++ b/rig/standalone/minimal/Makefile @@ -0,0 +1,23 @@ +# GENERATED by make standalone — do not edit +# +# Shorthand for the scripts beside it; they run without it. Every target +# calls a verb its script accepts — read from that script's own dispatch. + +HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +.PHONY: $(ARGS) +endif + +.DEFAULT_GOAL := help +.PHONY: help deps mem + +help: ## list targets + @grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +deps: ## rigdeps.sh [detect|list|verify|fetch|install] (default detect) + bash $(HERE)rigdeps.sh $(or $(ARGS),detect) + +mem: ## rigmini.sh [status|push|all|backup|restore] (default status) + bash $(HERE)rigmini.sh $(or $(ARGS),status) diff --git a/rig/standalone/minimal/rigdeps.sh b/rig/standalone/minimal/rigdeps.sh new file mode 100755 index 0000000..ca001a9 --- /dev/null +++ b/rig/standalone/minimal/rigdeps.sh @@ -0,0 +1,958 @@ +#!/usr/bin/env bash +# GENERATED by make standalone — do not edit +# +# rigdeps.sh for profile 'minimal', flattened from: +# ctrl/deps.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'minimal' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="off" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="1" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx PROFILE_NAME="minimal" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="local" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/deps.sh ── +# 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 list # the pinned versions +# deps.sh verify [core|dev] # run what is installed and see if it works +# 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")" + +# Pins arrive through load_config like every other setting, not by sourcing +# versions.env here. That is what lets `make standalone` freeze them into a +# one-file installer: configuration has exactly one way in. +# (sourced library inlined above) +load_config + +# 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. +# A /proc/meminfo field in MB, 0 if the field is absent. MEMINFO exists so the +# tight and does-not-fit branches can be exercised against a real machine's +# numbers from somewhere else; in normal use it is always /proc/meminfo. +mb_of() { + awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } + END { if (!found) printf "0" }' "${MEMINFO:-/proc/meminfo}" +} + +host_file() { + local p="${1#/}" + if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then + echo "$HOST_ROOT/$p" + else + echo "/$p" + fi +} + +# ── the tools this script itself needs ───────────────────────────────────── + +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 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 +} + +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 ───────────────────────────────────────────────────────────────── + +# 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)" + echo " arch $(arch) ($(uname -m))" + + local osr; osr=$(host_file /etc/os-release) + [ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")" + + # In MB. Whole gigabytes lose nearly half a GB on exactly the machines where + # it matters: 1874 MB available used to print as "1 GB". Facts only — whether + # that is enough depends on the profile, which check.sh knows and this does not. + local total_mb avail_mb swap_total_mb swap_used_mb om + total_mb=$(mb_of MemTotal) + avail_mb=$(mb_of MemAvailable) + swap_total_mb=$(mb_of SwapTotal) + swap_used_mb=$(( swap_total_mb - $(mb_of SwapFree) )) + printf " memory %d MB total, %d MB available\n" "$total_mb" "$avail_mb" + if [ "$swap_total_mb" -gt 0 ]; then + printf " swap %d MB used of %d MB\n" "$swap_used_mb" "$swap_total_mb" + fi + + # How the kernel answers an allocation it cannot really satisfy. With 1 it + # always says yes and settles up later with the OOM killer, so a cluster that + # starts cleanly can still lose processes afterwards. + om=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess" ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit" ;; + 2) echo " overcommit 2 strict — an allocation fails honestly instead of killing later" ;; + esac + + echo " install to $OUT_BIN" + detect_libc + detect_prereqs + detect_wsl + detect_filesystem + detect_docker + detect_inotify + detect_toolchain +} + +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 +} + +# 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() { + # 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, 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.") + 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=$($SHA "$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" + download "$(resolve_url "$url")" "$tmp" + 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" + download "$(resolve_url "$url")" "$tmp" + 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. +# +# docker-compose is 'dev' for the same reason, and is here because the distro +# docker packages ship the daemon and CLI but frequently not the compose +# plugin — so `docker compose up` fails with "unknown command" on an otherwise +# working Docker, and nothing about that message names the missing piece. +DEV_TOOLS="kind tilt ctlptl docker-compose" + +# ── what is already on this machine ─────────────────────────────────────── +# +# A tool already on PATH at its pinned version is left where it is. Without +# this, install downloads a second copy into OUT_BIN and then reports the first +# one as shadowed — noise, and wrong, when both are the same version. That is +# the normal state of any machine someone set up by hand, whatever directory +# they happened to choose. + +pin_of() { + case "$1" in + kubectl) echo "$KUBECTL_VERSION" ;; + jq) echo "$JQ_VERSION" ;; + kind) echo "$KIND_VERSION" ;; + tilt) echo "$TILT_VERSION" ;; + ctlptl) echo "$CTLPTL_VERSION" ;; + docker-compose) echo "$COMPOSE_VERSION" ;; + esac +} + +# The version string a binary reports. Each tool spells the question +# differently, and kubectl has to be told --client or it goes looking for a +# server to ask. +reported_version() { + local tool="$1" path="$2" + case "$tool" in + kubectl) "$path" version --client 2>/dev/null ;; + jq) "$path" --version 2>/dev/null ;; + *) "$path" version 2>/dev/null ;; + esac +} + +# Does the binary at PATH report PIN? Matched as a whole version token, so +# 0.37.6 never matches 10.37.60, with the leading v optional either side: kind +# says v0.32.0, jq says jq-1.8.2, and tilt says v0.37.6 against a pin of 0.37.6. +# +# Bash's own regex rather than grep, deliberately. grep is not the same program +# on every machine — some builds reject patterns that others accept — and a +# failed grep inside a count reads exactly like a zero. +version_matches() { + local tool="$1" path="$2" pin="$3" out v re + out=$(reported_version "$tool" "$path") || return 1 + v="${pin#v}" + v="${v//./\\.}" + re="(^|[^0-9.])v?${v}([^0-9.]|\$)" + [[ $out =~ $re ]] +} + +# DEPS_ONLY narrows a fetch to the tools it names. Unset means the whole tier, +# which is what an explicit `deps.sh fetch` always gets: "download these into +# DIR" must not quietly skip something because this machine happens to have it. +# Only install() sets it, to what detect_toolchain found missing or mismatched. +want() { [ -z "${DEPS_ONLY:-}" ] || [[ " $DEPS_ONLY " == *" $1 "* ]]; } + +# Every tool in the tier with its state, probed once and reported once. What +# still needs fetching is left in TOOLCHAIN_NEED for install() to act on. +TOOLCHAIN_NEED="" +detect_toolchain() { + local tier="${TIER:-dev}" b pin path found + TOOLCHAIN_NEED="" + echo + echo "toolchain (pinned, tier '$tier')" + for b in $(tier_tools "$tier"); do + pin=$(pin_of "$b") + path=$(command -v "$b" 2>/dev/null || true) + # compose is the one tool that is normally NOT a binary on PATH. It is a + # docker CLI plugin, so a machine where `docker compose` works perfectly + # has no `docker-compose` to find — and probing only PATH would report it + # missing and re-download a copy that is already there. That is the exact + # noise the version-aware skip exists to prevent, so ask docker instead. + if [ "$b" = docker-compose ] && [ -z "$path" ]; then + if found=$(docker compose version --short 2>/dev/null) && [ -n "$found" ]; then + if [ "${found#v}" = "${pin#v}" ]; then + printf " %-8s %-9s %s\n" "$b" "$pin" "docker cli plugin" + else + printf " ! %-8s wants %s, the docker cli plugin reports '%s'\n" \ + "$b" "$pin" "$found" + TOOLCHAIN_NEED+="$b " + fi + continue + fi + fi + if [ -z "$path" ]; then + printf " - %-8s %-9s not found\n" "$b" "$pin" + TOOLCHAIN_NEED+="$b " + elif version_matches "$b" "$path" "$pin"; then + printf " %-8s %-9s %s\n" "$b" "$pin" "$path" + else + found=$(reported_version "$b" "$path" 2>/dev/null | head -1 || true) + printf " ! %-8s wants %s, %s reports '%s'\n" "$b" "$pin" "$path" "$found" + TOOLCHAIN_NEED+="$b " + fi + done + if [ -z "$TOOLCHAIN_NEED" ]; then + echo " every pinned tool is already on PATH — nothing to fetch" + else + echo " 'make deps' fetches only: ${TOOLCHAIN_NEED% }" + fi +} + +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 + + if [ -n "${DEPS_ONLY:-}" ]; then + echo "fetching ${DEPS_ONLY% } (source: $DEPS_SOURCE)" + else + echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)" + fi + if want kubectl; then fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"; fi + if want jq; then fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"; fi + if [ "$tier" = "dev" ]; then + if want kind; then fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"; fi + if want tilt; then fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0; fi + if want ctlptl; then fetch_tgz ctlptl "$CTLPTL_URL" "$CTLPTL_SHA256" "$dest" ctlptl 0; fi + if want docker-compose; then + fetch_bin docker-compose "$COMPOSE_URL" "$COMPOSE_SHA256" "$dest" + fi + 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. +# 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 +} + +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 +} + +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 + # The same version in both places is not a conflict: nothing changes for + # any other project whichever copy PATH happens to find first. + if version_matches "$b" "$existing" "$(pin_of "$b")"; then continue; fi + 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") +} + +# A copy in OUT_BIN only gives you `docker-compose`. That hyphenated form is the +# retired v1 spelling; every compose file written in the last few years assumes +# `docker compose`, which resolves plugins BY NAME out of a plugin directory. +# So the binary is fetched like any other and then linked, in your own home — +# no root, and nothing outside it. +install_compose_plugin() { + local src="$OUT_BIN/docker-compose" dir="$HOME/.docker/cli-plugins" + [ -x "$src" ] || return 0 + mkdir -p "$dir" + # Something else already owns that name — docker-desktop and some distro + # packages install a real file there. Overwriting it would take the plugin + # away from whatever put it there, so say so and let the user decide. + if [ -e "$dir/docker-compose" ] && [ ! -L "$dir/docker-compose" ]; then + MANUAL+=("Something already installs the compose plugin at + $dir/docker-compose + To use rig's pinned build instead: + ln -sf $src $dir/docker-compose") + return 0 + fi + ln -sfn "$src" "$dir/docker-compose" + echo " compose plugin -> $dir/docker-compose" + return 0 +} + +install() { + local tier="${1:-dev}" b + TIER="$tier" + detect + + # detect_toolchain has already probed PATH. Fetch only what it found missing + # or at the wrong version; a tool already present at its pin stays where it is. + if [ -n "$TOOLCHAIN_NEED" ]; then + echo + DEPS_ONLY="$TOOLCHAIN_NEED" fetch "$tier" + echo + echo "installed to $OUT_BIN ($tier):" + for b in $TOOLCHAIN_NEED; do + if [ -x "$OUT_BIN/$b" ]; then echo " $b"; fi + done + if [ "$tier" = "core" ]; then + echo " (no kind/tilt — 'make deps dev' adds them)" + fi + # Only when compose was one of the things fetched: linking a binary + # that is already satisfied elsewhere on PATH would point the plugin at + # a copy rig did not install. + case " $TOOLCHAIN_NEED " in + *" docker-compose "*) install_compose_plugin ;; + esac + + # Only worth saying when something actually landed in OUT_BIN. When every + # tool was satisfied elsewhere, OUT_BIN may reasonably be off PATH, and + # telling the user to add it would be advice to fix nothing. + case ":${PATH}:" in + *":$OUT_BIN:"*) ;; + *) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc: + export PATH=\"${OUT_BIN}:\$PATH\"") ;; + esac + fi + warn_shadowing "$tier" + + report_manual +} + +# ── 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 + +# Baked mode copies binaries already in the image, so it needs no downloader. +need_downloads() { + require_amd64 + if [ "$DEPS_SOURCE" != baked ]; then pick_downloader; fi + pick_sha +} + +case "$cmd" in + detect) detect; report_manual ;; + list) list ;; + verify) verify_tools "${1:-dev}" ;; + fetch) need_downloads; fetch "$@" ;; + install) need_downloads; install "${1:-dev}" ;; + *) echo "usage: $0 [detect|list|verify|fetch|install]" >&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 diff --git a/rig/standalone/rigmini.sh b/rig/standalone/minimal/rigmini.sh similarity index 68% rename from rig/standalone/rigmini.sh rename to rig/standalone/minimal/rigmini.sh index af4a6b7..93d04bf 100755 --- a/rig/standalone/rigmini.sh +++ b/rig/standalone/minimal/rigmini.sh @@ -1,14 +1,112 @@ #!/usr/bin/env bash -# How much memory this box will actually give you before something dies. +# GENERATED by make standalone — do not edit # -# rig answers this for a machine it is installed on. This is the single file -# version, for a machine rig is not going to: paste it onto a fresh AWS -# WorkSpace, an EC2 box or a container, run it, and get the same numbers in the -# same order so two machines can be read side by side. +# rigmini.sh for profile 'minimal', flattened from: +# ctrl/mem.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'minimal' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="off" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="1" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx PROFILE_NAME="minimal" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="local" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/mem.sh ── +# How much memory this machine will actually give you before something dies — +# rig's memory tool, and (generated from this file) the standalone rigmini.sh. # # There are two numbers and they are rarely the same. `status` reports what the # machine ADVERTISES and what is quietly capping it. `push` finds what it will -# SURVIVE, by allocating until it stops. +# SURVIVE, by allocating until it stops. `all` does both and weighs the result +# against what this profile's cluster needs. # # The gap between them is the whole reason this exists. Under WSL the cap lives # in .wslconfig; in a container or a managed workspace it is a cgroup limit, and @@ -16,15 +114,22 @@ # fraction of it. A script that only read MemTotal would confidently report 32 GB # on a box that OOMs at 2. # -# Reports and instructs. It never raises a limit, frees anything, writes a -# config or installs a package — on a machine you are still evaluating, a probe -# that changes what it is measuring is worse than no probe. +# Runs on native Linux and under WSL. On WSL the memory you see is a VM +# allocation that can be raised, and the commonest failure is raising it without +# restarting — so status compares what .wslconfig says with what actually booted. +# +# Reports and instructs. It never raises a limit, frees anything or installs a +# package. The one write it can make is `backup`, which copies .wslconfig beside +# itself, so that `restore` has something to put back after a hand edit. # # Usage: -# rigmini.sh status what it has, what caps it -# rigmini.sh push [--to GB] [--to-oom] climb until it stops -# rigmini.sh all [--budget GB] both, then the verdict +# mem.sh status what it has, what caps it +# mem.sh push [--to GB] [--to-oom] climb until it stops +# mem.sh all [--budget GB] both, then the verdict +# mem.sh backup | restore .wslconfig, WSL only set -euo pipefail +cd "$(dirname "$0")" +# (sourced library inlined above) # ── defaults ─────────────────────────────────────────────────────────────── @@ -32,7 +137,7 @@ STEP_MB=0 # per allocation; 0 means scale it to the ceiling. See pus STEP_EXPLICIT=no # whether --step was given, which turns the scaling off. TO_MB="" # --to: stop here regardless. Empty means no hard cap. TO_OOM=no # --to-oom: opt in to running until the kernel intervenes. -BUDGET_GB=6 # what the rig data profile is assumed to want; see all(). +BUDGET_GB="" # --budget; empty means what this profile's cluster needs, from rig. BUDGET_EXPLICIT=no # whether --budget was given, which retires the guess below. # ── platform ─────────────────────────────────────────────────────────────── @@ -289,7 +394,7 @@ status() { if [ -n "$shm" ]; then if [ "$shm" -le 64 ]; then echo " ! /dev/shm ${shm} MB — postgres puts shared memory here and 64 MB" - echo " is docker's default. Raise it with --shm-size when the cabinet fails." + echo " is docker's default. Raise it with --shm-size when postgres fails." else echo " /dev/shm ${shm} MB" fi @@ -334,7 +439,7 @@ status() { # not usefully apply — the change costs a full VM restart. Report it, and # report the commonest mistake, which is editing it and not restarting. if is_wsl; then - local cfg conf + local cfg conf conf_mb n cfg=$(wslconfig_path) echo echo "wsl" @@ -342,17 +447,32 @@ status() { echo " ! cannot tell which Windows profile owns .wslconfig" else echo " config $cfg" - conf=$(sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$cfg" 2>/dev/null \ - | tail -1 | tr -d '[:space:]') + conf=$(configured_memory "$cfg") if [ -n "$conf" ]; then - echo " configured $conf (booted ${total} MB)" - echo " - if those disagree the edit has not been applied." - echo " From a WINDOWS terminal: wsl --shutdown" + conf_mb=$(to_mb "$conf") + echo " configured $conf (${conf_mb} MB), booted ${total} MB" + # The VM reports a little less than allocated; 15% covers the + # kernel without calling every healthy machine a mismatch. + if [ -n "$conf_mb" ] && [ "$total" -lt $(( conf_mb * 85 / 100 )) ]; then + echo " ! configured ${conf_mb} MB but booted ${total} MB — not applied yet." + echo " From a WINDOWS terminal: wsl --shutdown then start the distro again." + fi else - echo " configured no memory= set (WSL defaults to half the host RAM, or 8 GB," - echo " whichever is less — which is where your Airflow ceiling comes from)" + echo " configured no memory= set (WSL defaults to 50% of host RAM, or 8 GB," + echo " whichever is less). To raise it, add on the Windows side:" + echo " [wsl2]" + echo " memory=8GB" + echo " then from a WINDOWS terminal: wsl --shutdown" + fi + n=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$n" -gt 0 ]; then + echo " backups $n (newest: $(ls -t "$cfg".*.bak 2>/dev/null | head -1))" fi fi + else + echo + echo " - native linux: no VM allocation to raise. If memory is tight the levers" + echo " are freeing something or adding swap." fi echo @@ -364,6 +484,103 @@ status() { return 0 } +# ── .wslconfig ───────────────────────────────────────────────────────────── + +require_wsl() { + if ! is_wsl; then + echo "$1 acts on .wslconfig, which only exists under WSL." >&2 + echo "This is native Linux — there is no VM allocation to save or roll back." >&2 + echo "Use 'status' to see what the machine actually has." >&2 + exit 1 + fi +} + +# backup and restore act on the file, so unlike status they must not guess. +wslconfig_required() { + local cfg; cfg=$(wslconfig_required) + if [ -z "$cfg" ]; then + echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2 + ls -d /mnt/c/Users/*/ 2>/dev/null \ + | grep -viE "/(All Users|Default|Default User|Public)/$" | sed "s/^/ /" >&2 + exit 1 + fi + echo "$cfg" +} + +configured_memory() { + [ -r "$1" ] || { echo ""; return; } + sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$1" | tail -1 | tr -d '[:space:]' +} + +# "9GB" / "8192MB" / "9G" -> MB, so it can be compared with /proc/meminfo. +to_mb() { + local v="${1^^}" n + n=$(echo "$v" | tr -dc '0-9') + [ -n "$n" ] || { echo ""; return; } + case "$v" in + *GB|*G) echo $(( n * 1024 )) ;; + *MB|*M) echo "$n" ;; + *) echo $(( n / 1024 / 1024 )) ;; + esac +} + +backup() { + require_wsl backup + local cfg dest + cfg=$(wslconfig_required) + [ -r "$cfg" ] || { echo "nothing to back up: $cfg does not exist" >&2; exit 1; } + # Timestamped and never overwritten: a backup that can destroy itself on a + # second run is not a backup. + dest="${cfg}.$(date +%Y%m%d-%H%M%S).bak" + cp "$cfg" "$dest" + echo "backed up $dest" + echo + echo "Edit $cfg by hand, then from a WINDOWS terminal: wsl --shutdown" +} + +restore() { + require_wsl restore + local cfg newest count + cfg=$(wslconfig_required) + newest=$(ls -t "$cfg".*.bak 2>/dev/null | head -1 || true) + [ -n "$newest" ] || { echo "no backups found beside $cfg" >&2; exit 1; } + + echo "restoring $newest" + echo " -> $cfg" + echo + + # Newest is the right default — undo the last edit — but if you backed up + # *after* editing, the state you want is older. Show the rest so a no-op + # restore is obviously a no-op rather than a mystery. + count=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$count" -gt 1 ]; then + echo "$count backups exist, newest first:" + ls -t "$cfg".*.bak | sed 's/^/ /' + echo " (restoring the newest; copy another by hand to pick an older one)" + echo + fi + + if [ -r "$cfg" ]; then + echo "what changes:" + if diff "$cfg" "$newest" > /tmp/mem.diff 2>&1 && [ ! -s /tmp/mem.diff ]; then + echo " nothing — that backup is identical to the current config" + else + sed 's/^/ /' /tmp/mem.diff + fi + rm -f /tmp/mem.diff + echo + fi + + printf "proceed? [y/N] " + read -r reply + case "$reply" in + y|Y|yes|Yes) ;; + *) echo "left alone"; return 0 ;; + esac + cp "$newest" "$cfg" + echo "restored. From a WINDOWS terminal: wsl --shutdown" +} + # ── push ─────────────────────────────────────────────────────────────────── STATE="" @@ -560,28 +777,33 @@ all() { push local got budget_mb ceiling - budget_mb=$(( BUDGET_GB * 1024 )) + load_config + if [ -n "$BUDGET_GB" ]; then + budget_mb=$(( BUDGET_GB * 1024 )) + else + budget_mb=$(( NODES * NODE_MB )) + fi ceiling=$(effective_ceiling_mb) got=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 | awk '{print $2}' || true) [ -n "$got" ] || got=0 echo echo "verdict" - echo " budget ${BUDGET_GB} GB for kind + postgres + redis + airflow" - # Only worth explaining while it is still a guess. Once --budget is given - # the number came from somewhere better than this reasoning, and repeating - # the derivation would describe a figure that is no longer in use. - if [ "$BUDGET_EXPLICIT" = no ]; then - echo " - that is 2 GB per kind node, which is rig's own figure, plus about" - echo " 4 GB for the three cabinets. THE 4 GB IS AN ESTIMATE, not something" - echo " measured. Re-run with --budget once you have watched the real thing." + if [ -n "$BUDGET_GB" ]; then + echo " budget ${budget_mb} MB (--budget)" + else + # rig's own figure for this profile: nodes times what one node costs. + # Addons carry no memory figure in rig yet, so this is the cluster alone + # and whatever you deploy comes on top. --budget once you know that too. + echo " budget ${budget_mb} MB — profile ${PROFILE_NAME}: ${NODES} node(s) x ${NODE_MB} MB," + echo " the cluster alone; your workload comes on top (--budget GB)" fi echo " measured ${got} MB handed over" if [ "$got" -ge "$budget_mb" ]; then echo " fits, with $(( got - budget_mb )) MB spare." if [ "$got" -lt $(( budget_mb * 130 / 100 )) ]; then - echo " - under 30% spare is thin for a scheduler. Airflow's memory use" + echo " - under 30% spare is thin once a workload runs on top: memory use" echo " is spiky, and the spikes are what get killed." fi else @@ -590,8 +812,8 @@ all() { echo " The box CLAIMS enough (${ceiling} MB) but did not deliver it." echo " Free something, or read the caps section again." else - echo " The box does not have it to give. A bigger bundle, or a smaller" - echo " profile: PROFILE=minimal drops the cabinets entirely." + echo " The box does not have it to give. A bigger machine, or a profile" + echo " with fewer nodes." fi fi return 0 @@ -628,7 +850,9 @@ case "$cmd" in status) parse_flags "$@"; status ;; push) parse_flags "$@"; push ;; all) parse_flags "$@"; all ;; - *) echo "usage: $0 [status|push|all]" >&2 + backup) backup ;; + restore) restore ;; + *) echo "usage: $0 [status|push|all|backup|restore]" >&2 echo " push [--to GB] [--to-mb MB] [--step MB] [--to-oom]" >&2 echo " all [--budget GB]" >&2 exit 1 ;; diff --git a/rig/standalone/offline/Makefile b/rig/standalone/offline/Makefile new file mode 100644 index 0000000..56ac921 --- /dev/null +++ b/rig/standalone/offline/Makefile @@ -0,0 +1,23 @@ +# GENERATED by make standalone — do not edit +# +# Shorthand for the scripts beside it; they run without it. Every target +# calls a verb its script accepts — read from that script's own dispatch. + +HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifneq ($(ARGS),) +$(eval $(ARGS):;@:) +.PHONY: $(ARGS) +endif + +.DEFAULT_GOAL := help +.PHONY: help deps mem + +help: ## list targets + @grep -hE '^[a-z][a-z-]*:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16 + +deps: ## rigdeps.sh [detect|list|verify|fetch|install] (default detect) + bash $(HERE)rigdeps.sh $(or $(ARGS),detect) + +mem: ## rigmini.sh [status|push|all|backup|restore] (default status) + bash $(HERE)rigmini.sh $(or $(ARGS),status) diff --git a/rig/standalone/offline/rigdeps.sh b/rig/standalone/offline/rigdeps.sh new file mode 100755 index 0000000..5a36598 --- /dev/null +++ b/rig/standalone/offline/rigdeps.sh @@ -0,0 +1,958 @@ +#!/usr/bin/env bash +# GENERATED by make standalone — do not edit +# +# rigdeps.sh for profile 'offline', flattened from: +# ctrl/deps.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'offline' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="metallb" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="on" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.audit.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.audit.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.audit.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="1" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx PROFILE_NAME="offline" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="local" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/deps.sh ── +# 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 list # the pinned versions +# deps.sh verify [core|dev] # run what is installed and see if it works +# 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")" + +# Pins arrive through load_config like every other setting, not by sourcing +# versions.env here. That is what lets `make standalone` freeze them into a +# one-file installer: configuration has exactly one way in. +# (sourced library inlined above) +load_config + +# 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. +# A /proc/meminfo field in MB, 0 if the field is absent. MEMINFO exists so the +# tight and does-not-fit branches can be exercised against a real machine's +# numbers from somewhere else; in normal use it is always /proc/meminfo. +mb_of() { + awk -v k="$1:" '$1 == k { printf "%d", $2 / 1024; found = 1 } + END { if (!found) printf "0" }' "${MEMINFO:-/proc/meminfo}" +} + +host_file() { + local p="${1#/}" + if [ "$HOST_ROOT" != "/" ] && [ -e "$HOST_ROOT/$p" ]; then + echo "$HOST_ROOT/$p" + else + echo "/$p" + fi +} + +# ── the tools this script itself needs ───────────────────────────────────── + +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 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 +} + +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 ───────────────────────────────────────────────────────────────── + +# 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)" + echo " arch $(arch) ($(uname -m))" + + local osr; osr=$(host_file /etc/os-release) + [ -r "$osr" ] && echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' "$osr")" + + # In MB. Whole gigabytes lose nearly half a GB on exactly the machines where + # it matters: 1874 MB available used to print as "1 GB". Facts only — whether + # that is enough depends on the profile, which check.sh knows and this does not. + local total_mb avail_mb swap_total_mb swap_used_mb om + total_mb=$(mb_of MemTotal) + avail_mb=$(mb_of MemAvailable) + swap_total_mb=$(mb_of SwapTotal) + swap_used_mb=$(( swap_total_mb - $(mb_of SwapFree) )) + printf " memory %d MB total, %d MB available\n" "$total_mb" "$avail_mb" + if [ "$swap_total_mb" -gt 0 ]; then + printf " swap %d MB used of %d MB\n" "$swap_used_mb" "$swap_total_mb" + fi + + # How the kernel answers an allocation it cannot really satisfy. With 1 it + # always says yes and settles up later with the OOM killer, so a cluster that + # starts cleanly can still lose processes afterwards. + om=$(cat "${OVERCOMMIT_FILE:-/proc/sys/vm/overcommit_memory}" 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess" ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit" ;; + 2) echo " overcommit 2 strict — an allocation fails honestly instead of killing later" ;; + esac + + echo " install to $OUT_BIN" + detect_libc + detect_prereqs + detect_wsl + detect_filesystem + detect_docker + detect_inotify + detect_toolchain +} + +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 +} + +# 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() { + # 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, 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.") + 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=$($SHA "$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" + download "$(resolve_url "$url")" "$tmp" + 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" + download "$(resolve_url "$url")" "$tmp" + 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. +# +# docker-compose is 'dev' for the same reason, and is here because the distro +# docker packages ship the daemon and CLI but frequently not the compose +# plugin — so `docker compose up` fails with "unknown command" on an otherwise +# working Docker, and nothing about that message names the missing piece. +DEV_TOOLS="kind tilt ctlptl docker-compose" + +# ── what is already on this machine ─────────────────────────────────────── +# +# A tool already on PATH at its pinned version is left where it is. Without +# this, install downloads a second copy into OUT_BIN and then reports the first +# one as shadowed — noise, and wrong, when both are the same version. That is +# the normal state of any machine someone set up by hand, whatever directory +# they happened to choose. + +pin_of() { + case "$1" in + kubectl) echo "$KUBECTL_VERSION" ;; + jq) echo "$JQ_VERSION" ;; + kind) echo "$KIND_VERSION" ;; + tilt) echo "$TILT_VERSION" ;; + ctlptl) echo "$CTLPTL_VERSION" ;; + docker-compose) echo "$COMPOSE_VERSION" ;; + esac +} + +# The version string a binary reports. Each tool spells the question +# differently, and kubectl has to be told --client or it goes looking for a +# server to ask. +reported_version() { + local tool="$1" path="$2" + case "$tool" in + kubectl) "$path" version --client 2>/dev/null ;; + jq) "$path" --version 2>/dev/null ;; + *) "$path" version 2>/dev/null ;; + esac +} + +# Does the binary at PATH report PIN? Matched as a whole version token, so +# 0.37.6 never matches 10.37.60, with the leading v optional either side: kind +# says v0.32.0, jq says jq-1.8.2, and tilt says v0.37.6 against a pin of 0.37.6. +# +# Bash's own regex rather than grep, deliberately. grep is not the same program +# on every machine — some builds reject patterns that others accept — and a +# failed grep inside a count reads exactly like a zero. +version_matches() { + local tool="$1" path="$2" pin="$3" out v re + out=$(reported_version "$tool" "$path") || return 1 + v="${pin#v}" + v="${v//./\\.}" + re="(^|[^0-9.])v?${v}([^0-9.]|\$)" + [[ $out =~ $re ]] +} + +# DEPS_ONLY narrows a fetch to the tools it names. Unset means the whole tier, +# which is what an explicit `deps.sh fetch` always gets: "download these into +# DIR" must not quietly skip something because this machine happens to have it. +# Only install() sets it, to what detect_toolchain found missing or mismatched. +want() { [ -z "${DEPS_ONLY:-}" ] || [[ " $DEPS_ONLY " == *" $1 "* ]]; } + +# Every tool in the tier with its state, probed once and reported once. What +# still needs fetching is left in TOOLCHAIN_NEED for install() to act on. +TOOLCHAIN_NEED="" +detect_toolchain() { + local tier="${TIER:-dev}" b pin path found + TOOLCHAIN_NEED="" + echo + echo "toolchain (pinned, tier '$tier')" + for b in $(tier_tools "$tier"); do + pin=$(pin_of "$b") + path=$(command -v "$b" 2>/dev/null || true) + # compose is the one tool that is normally NOT a binary on PATH. It is a + # docker CLI plugin, so a machine where `docker compose` works perfectly + # has no `docker-compose` to find — and probing only PATH would report it + # missing and re-download a copy that is already there. That is the exact + # noise the version-aware skip exists to prevent, so ask docker instead. + if [ "$b" = docker-compose ] && [ -z "$path" ]; then + if found=$(docker compose version --short 2>/dev/null) && [ -n "$found" ]; then + if [ "${found#v}" = "${pin#v}" ]; then + printf " %-8s %-9s %s\n" "$b" "$pin" "docker cli plugin" + else + printf " ! %-8s wants %s, the docker cli plugin reports '%s'\n" \ + "$b" "$pin" "$found" + TOOLCHAIN_NEED+="$b " + fi + continue + fi + fi + if [ -z "$path" ]; then + printf " - %-8s %-9s not found\n" "$b" "$pin" + TOOLCHAIN_NEED+="$b " + elif version_matches "$b" "$path" "$pin"; then + printf " %-8s %-9s %s\n" "$b" "$pin" "$path" + else + found=$(reported_version "$b" "$path" 2>/dev/null | head -1 || true) + printf " ! %-8s wants %s, %s reports '%s'\n" "$b" "$pin" "$path" "$found" + TOOLCHAIN_NEED+="$b " + fi + done + if [ -z "$TOOLCHAIN_NEED" ]; then + echo " every pinned tool is already on PATH — nothing to fetch" + else + echo " 'make deps' fetches only: ${TOOLCHAIN_NEED% }" + fi +} + +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 + + if [ -n "${DEPS_ONLY:-}" ]; then + echo "fetching ${DEPS_ONLY% } (source: $DEPS_SOURCE)" + else + echo "fetching '$tier' toolchain (source: $DEPS_SOURCE)" + fi + if want kubectl; then fetch_bin kubectl "$KUBECTL_URL" "$KUBECTL_SHA256" "$dest"; fi + if want jq; then fetch_bin jq "$JQ_URL" "$JQ_SHA256" "$dest"; fi + if [ "$tier" = "dev" ]; then + if want kind; then fetch_bin kind "$KIND_URL" "$KIND_SHA256" "$dest"; fi + if want tilt; then fetch_tgz tilt "$TILT_URL" "$TILT_SHA256" "$dest" tilt 0; fi + if want ctlptl; then fetch_tgz ctlptl "$CTLPTL_URL" "$CTLPTL_SHA256" "$dest" ctlptl 0; fi + if want docker-compose; then + fetch_bin docker-compose "$COMPOSE_URL" "$COMPOSE_SHA256" "$dest" + fi + 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. +# 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 +} + +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 +} + +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 + # The same version in both places is not a conflict: nothing changes for + # any other project whichever copy PATH happens to find first. + if version_matches "$b" "$existing" "$(pin_of "$b")"; then continue; fi + 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") +} + +# A copy in OUT_BIN only gives you `docker-compose`. That hyphenated form is the +# retired v1 spelling; every compose file written in the last few years assumes +# `docker compose`, which resolves plugins BY NAME out of a plugin directory. +# So the binary is fetched like any other and then linked, in your own home — +# no root, and nothing outside it. +install_compose_plugin() { + local src="$OUT_BIN/docker-compose" dir="$HOME/.docker/cli-plugins" + [ -x "$src" ] || return 0 + mkdir -p "$dir" + # Something else already owns that name — docker-desktop and some distro + # packages install a real file there. Overwriting it would take the plugin + # away from whatever put it there, so say so and let the user decide. + if [ -e "$dir/docker-compose" ] && [ ! -L "$dir/docker-compose" ]; then + MANUAL+=("Something already installs the compose plugin at + $dir/docker-compose + To use rig's pinned build instead: + ln -sf $src $dir/docker-compose") + return 0 + fi + ln -sfn "$src" "$dir/docker-compose" + echo " compose plugin -> $dir/docker-compose" + return 0 +} + +install() { + local tier="${1:-dev}" b + TIER="$tier" + detect + + # detect_toolchain has already probed PATH. Fetch only what it found missing + # or at the wrong version; a tool already present at its pin stays where it is. + if [ -n "$TOOLCHAIN_NEED" ]; then + echo + DEPS_ONLY="$TOOLCHAIN_NEED" fetch "$tier" + echo + echo "installed to $OUT_BIN ($tier):" + for b in $TOOLCHAIN_NEED; do + if [ -x "$OUT_BIN/$b" ]; then echo " $b"; fi + done + if [ "$tier" = "core" ]; then + echo " (no kind/tilt — 'make deps dev' adds them)" + fi + # Only when compose was one of the things fetched: linking a binary + # that is already satisfied elsewhere on PATH would point the plugin at + # a copy rig did not install. + case " $TOOLCHAIN_NEED " in + *" docker-compose "*) install_compose_plugin ;; + esac + + # Only worth saying when something actually landed in OUT_BIN. When every + # tool was satisfied elsewhere, OUT_BIN may reasonably be off PATH, and + # telling the user to add it would be advice to fix nothing. + case ":${PATH}:" in + *":$OUT_BIN:"*) ;; + *) MANUAL+=("Put the toolchain on your PATH — add to ~/.bashrc: + export PATH=\"${OUT_BIN}:\$PATH\"") ;; + esac + fi + warn_shadowing "$tier" + + report_manual +} + +# ── 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 + +# Baked mode copies binaries already in the image, so it needs no downloader. +need_downloads() { + require_amd64 + if [ "$DEPS_SOURCE" != baked ]; then pick_downloader; fi + pick_sha +} + +case "$cmd" in + detect) detect; report_manual ;; + list) list ;; + verify) verify_tools "${1:-dev}" ;; + fetch) need_downloads; fetch "$@" ;; + install) need_downloads; install "${1:-dev}" ;; + *) echo "usage: $0 [detect|list|verify|fetch|install]" >&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 diff --git a/rig/standalone/offline/rigmini.sh b/rig/standalone/offline/rigmini.sh new file mode 100755 index 0000000..df60ce7 --- /dev/null +++ b/rig/standalone/offline/rigmini.sh @@ -0,0 +1,859 @@ +#!/usr/bin/env bash +# GENERATED by make standalone — do not edit +# +# rigmini.sh for profile 'offline', flattened from: +# ctrl/mem.sh +# ctrl/lib/config.sh +# Edit those and run `make standalone`. Changes made here are lost, and +# `make selftest` fails while this file differs from what rig generates. + +# ── from the libraries ── +declare -- CONFIG_OVERRIDABLE=$'PROFILE CLUSTER K8S_VERSION KIND_CONFIG ADDONS\n REGISTRY_MODE INGRESS_MODE DNS_MODE TILT_PORT\n SOURCE ARCH DEPS_SOURCE HTTP_PORT HTTPS_PORT\n REGISTRY_PORT MANIFESTS_DIR' +_config_restore () +{ + local line; + while IFS= read -r line; do + if [ -n "$line" ]; then + eval "export $line"; + fi; + done <<< "$1"; + return 0 +} +default_cluster_name () +{ + local n; + n=$(basename "$(cd .. && pwd)"); + n=$(echo "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-'); + n=$(echo "$n" | sed 's/^-*//; s/-*$//'); + echo "${n:-rig}" +} +derive_port_base () +{ + local h; + h=$(printf '%s' "$1" | cksum | awk '{print $1}'); + echo $((20000 + (h % 200) * 10)) +} +render_kind_config () +{ + local host_workdir="${HOST_WORKDIR:-$(cd .. && pwd)}"; + sed -e "s|\${CLUSTER}|${CLUSTER}|g" -e "s|\${NODE_IMAGE}|${NODE_IMAGE}|g" -e "s|\${HTTP_PORT}|${HTTP_PORT}|g" -e "s|\${HOST_WORKDIR}|${host_workdir}|g" "$KIND_CONFIG_PATH" +} + +# ── configuration, frozen for profile 'offline' ── +load_config() { + local k saved="" + for k in $CONFIG_OVERRIDABLE; do + if [ -n "${!k+x}" ]; then saved+="$k=$(printf '%q' "${!k}")"$'\n'; fi + done + declare -gx ADDONS="metallb" + declare -gx AIRFLOW_IMAGE="apache/airflow:2.10.4" + declare -g AUDIT="on" + declare -gx CERT_MANAGER_VERSION="v1.21.1" + declare -g CLUSTER="rig" + declare -gx COMPOSE_SHA256="db1889184726840f75c4f9c001048430d4f25b3be3cb084d3ddd762bc0aed576" + declare -gx COMPOSE_URL="https://github.com/docker/compose/releases/download/v5.5.1/docker-compose-linux-x86_64" + declare -gx COMPOSE_VERSION="5.5.1" + declare -gx CTLPTL_SHA256="c63a1ec28e60bc3faf6becb76f53355c5cf5e0143dafdd27ad85db5584fa6b1e" + declare -gx CTLPTL_URL="https://github.com/tilt-dev/ctlptl/releases/download/v0.9.4/ctlptl.0.9.4.linux.x86_64.tar.gz" + declare -gx CTLPTL_VERSION="0.9.4" + declare -gx DNS_MODE="hosts" + declare -g HTTPS_PORT="20311" + declare -g HTTP_PORT="20310" + declare -gx INGRESS_MODE="hostport" + declare -gx JQ_SHA256="b1c22172dd303f3be49e935aa56aa48a8b7a46e0bc838b4997d3bb451495870f" + declare -gx JQ_URL="https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-linux-amd64" + declare -gx JQ_VERSION="1.8.2" + declare -gx K8S_VERSION="v1_36" + declare -gx KIND_CONFIG="kind-config.audit.yaml.tpl" + declare -g KIND_CONFIG_PATH="./k8s/kind-config.audit.yaml.tpl" + declare -g KIND_CONFIG_SHOWN="ctrl/k8s/kind-config.audit.yaml.tpl" + declare -gx KIND_SHA256="50030de23cf40a18505f20426f6a8506bedf13c6e509244bd1fa9463721b0f54" + declare -gx KIND_URL="https://github.com/kubernetes-sigs/kind/releases/download/v0.32.0/kind-linux-amd64" + declare -gx KIND_VERSION="v0.32.0" + declare -g KUBECONTEXT="kind-rig" + declare -gx KUBECTL_SHA256="ebbd080e7c2e275093b55915722043257eb24004363e20acb3c4d71919f88336" + declare -gx KUBECTL_URL="https://dl.k8s.io/release/v1.36.3/bin/linux/amd64/kubectl" + declare -gx KUBECTL_VERSION="v1.36.3" + declare -g MANIFESTS_DIR="ctrl/k8s/overlays/dev" + declare -gx METALLB_VERSION="v0.16.0" + declare -gx METRICS_SERVER_VERSION="v0.9.0" + declare -g NODES="1" + declare -g NODE_IMAGE="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -gx NODE_IMAGE_v1_33="kindest/node:v1.33.12@sha256:3f5c8443c620245e4d355cfe09e96a91ead32ceaa569d3f1ca9edf0cb2fe2ff4" + declare -gx NODE_IMAGE_v1_34="kindest/node:v1.34.8@sha256:02722c2dedddcfc00febf5d27fbeb9b7b2c14294c82109ff4a85d89ac9ba3256" + declare -gx NODE_IMAGE_v1_35="kindest/node:v1.35.5@sha256:ce977ae6d65918d0b58a5f8b5e940429c2ce42fa3a5619ec2bbc60b949c0ac95" + declare -gx NODE_IMAGE_v1_36="kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + declare -g NODE_MB="800" + declare -gx POSTGRES_IMAGE="postgres:16-alpine" + declare -gx PROFILE_NAME="offline" + declare -gx REDIS_IMAGE="redis:7-alpine" + declare -gx REGISTRY_IMAGE="registry:2" + declare -gx REGISTRY_MODE="local" + declare -g REGISTRY_PORT="20313" + declare -gx STUB_IMAGE="python:3.12-slim" + declare -g TILT_PORT="20312" + declare -gx TILT_SHA256="e9672b8a18d43501f35dcfe98465969a7db0e436b36cf0c50c7e6f8d40de5fe6" + declare -gx TILT_URL="https://github.com/tilt-dev/tilt/releases/download/v0.37.6/tilt.0.37.6.linux.x86_64.tar.gz" + declare -gx TILT_VERSION="0.37.6" + _config_restore "$saved" +} +# ── end of frozen configuration ── + +# ── ctrl/mem.sh ── +# How much memory this machine will actually give you before something dies — +# rig's memory tool, and (generated from this file) the standalone rigmini.sh. +# +# There are two numbers and they are rarely the same. `status` reports what the +# machine ADVERTISES and what is quietly capping it. `push` finds what it will +# SURVIVE, by allocating until it stops. `all` does both and weighs the result +# against what this profile's cluster needs. +# +# The gap between them is the whole reason this exists. Under WSL the cap lives +# in .wslconfig; in a container or a managed workspace it is a cgroup limit, and +# there /proc/meminfo reports the HOST's memory while the kernel kills you at a +# fraction of it. A script that only read MemTotal would confidently report 32 GB +# on a box that OOMs at 2. +# +# Runs on native Linux and under WSL. On WSL the memory you see is a VM +# allocation that can be raised, and the commonest failure is raising it without +# restarting — so status compares what .wslconfig says with what actually booted. +# +# Reports and instructs. It never raises a limit, frees anything or installs a +# package. The one write it can make is `backup`, which copies .wslconfig beside +# itself, so that `restore` has something to put back after a hand edit. +# +# Usage: +# mem.sh status what it has, what caps it +# mem.sh push [--to GB] [--to-oom] climb until it stops +# mem.sh all [--budget GB] both, then the verdict +# mem.sh backup | restore .wslconfig, WSL only +set -euo pipefail +cd "$(dirname "$0")" +# (sourced library inlined above) + +# ── defaults ─────────────────────────────────────────────────────────────── + +STEP_MB=0 # per allocation; 0 means scale it to the ceiling. See push(). +STEP_EXPLICIT=no # whether --step was given, which turns the scaling off. +TO_MB="" # --to: stop here regardless. Empty means no hard cap. +TO_OOM=no # --to-oom: opt in to running until the kernel intervenes. +BUDGET_GB="" # --budget; empty means what this profile's cluster needs, from rig. +BUDGET_EXPLICIT=no # whether --budget was given, which retires the guess below. + +# ── 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 ;; + esac + + # Everything below reads /proc. Without it there is nothing to measure, and + # failing here beats printing a page of empty fields. + if [ ! -r /proc/meminfo ]; then + echo "no readable /proc/meminfo — this needs a Linux kernel." >&2 + echo "On macOS or a BSD none of the numbers below exist." >&2 + exit 1 + fi +} + +is_wsl() { grep -qi microsoft /proc/version 2>/dev/null; } + +is_container() { + [ -f /.dockerenv ] && return 0 + grep -qE '(docker|containerd|kubepods|lxc|podman)' /proc/1/cgroup 2>/dev/null +} + +platform() { + if is_wsl; then echo WSL + elif is_container; then echo container + else echo "native linux" + fi +} + +# ── reading memory ───────────────────────────────────────────────────────── + +mb() { echo $(( $(awk "/^$1:/{print \$2}" /proc/meminfo) / 1024 )); } + +# MemAvailable arrived in kernel 3.14. Older kernels — and they turn up on +# corporate images — need the estimate it replaced, which is worse but not wrong. +avail_meminfo_mb() { + if grep -q '^MemAvailable:' /proc/meminfo; then + mb MemAvailable + else + awk '/^(MemFree|Buffers|Cached):/{t+=$2} END{print int(t/1024)}' /proc/meminfo + fi +} + +# Where a cgroup records this cgroup's own limit and usage. Set once by +# find_cgroup, because every later reading needs both and hunting for the files +# on each call would be the slow part of the poll loop. +CG_MAX_FILE="" +CG_CUR_FILE="" +CG_VERSION="" + +find_cgroup() { + local rel + + # Inside a container the cgroup namespace makes the top of the tree BE the + # container's own cgroup, so the unqualified path is already the right one. + # On a host it is the root cgroup, which is never limited — hence the second + # attempt via /proc/self/cgroup, which names the slice this shell is in. + if [ -r /sys/fs/cgroup/memory.max ]; then + CG_VERSION=v2 + CG_MAX_FILE=/sys/fs/cgroup/memory.max + CG_CUR_FILE=/sys/fs/cgroup/memory.current + elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + CG_VERSION=v1 + CG_MAX_FILE=/sys/fs/cgroup/memory/memory.limit_in_bytes + CG_CUR_FILE=/sys/fs/cgroup/memory/memory.usage_in_bytes + fi + + rel=$(awk -F: '$1=="0"{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] && [ -r "/sys/fs/cgroup${rel}/memory.max" ]; then + CG_VERSION=v2 + CG_MAX_FILE="/sys/fs/cgroup${rel}/memory.max" + CG_CUR_FILE="/sys/fs/cgroup${rel}/memory.current" + return 0 + fi + + rel=$(awk -F: '$2 ~ /(^|,)memory(,|$)/{print $3; exit}' /proc/self/cgroup 2>/dev/null || true) + if [ -n "$rel" ] && [ "$rel" != "/" ] \ + && [ -r "/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" ]; then + CG_VERSION=v1 + CG_MAX_FILE="/sys/fs/cgroup/memory${rel}/memory.limit_in_bytes" + CG_CUR_FILE="/sys/fs/cgroup/memory${rel}/memory.usage_in_bytes" + fi + return 0 +} + +# The cap in MB, or "" when there is none worth reporting. v2 spells unlimited +# "max"; v1 spells it as a number near 2^63, which is why this compares against +# MemTotal rather than testing for a magic value — a "limit" above the machine's +# own memory is not a limit, however it is written. +cgroup_cap_mb() { + local raw cap + [ -n "$CG_MAX_FILE" ] && [ -r "$CG_MAX_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_MAX_FILE" 2>/dev/null || echo max) + [ "$raw" = "max" ] && { echo ""; return 0; } + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + cap=$((raw / 1024 / 1024)) + [ "$cap" -ge "$(mb MemTotal)" ] && { echo ""; return 0; } + echo "$cap" +} + +cgroup_used_mb() { + local raw + [ -n "$CG_CUR_FILE" ] && [ -r "$CG_CUR_FILE" ] || { echo ""; return 0; } + raw=$(cat "$CG_CUR_FILE" 2>/dev/null || echo "") + case "$raw" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((raw / 1024 / 1024)) +} + +# ulimit -v is a per-process address-space cap. It stops YOU long before the box +# does, and because it is inherited from a login shell it is easy to hit without +# knowing it is set. +ulimit_v_mb() { + local v; v=$(ulimit -v 2>/dev/null || echo unlimited) + [ "$v" = "unlimited" ] && { echo ""; return 0; } + case "$v" in ''|*[!0-9]*) echo ""; return 0 ;; esac + echo $((v / 1024)) +} + +# The number everything else is about: the lowest of the things that can stop +# you. Printed at the end of `status` and used as the sanity bound in `push`. +effective_ceiling_mb() { + local c; c=$(mb MemTotal) + local cap; cap=$(cgroup_cap_mb) + local ul; ul=$(ulimit_v_mb) + [ -n "$cap" ] && [ "$cap" -lt "$c" ] && c="$cap" + [ -n "$ul" ] && [ "$ul" -lt "$c" ] && c="$ul" + echo "$c" +} + +# How much room is left RIGHT NOW, from whichever accounting actually governs. +# In a capped container /proc/meminfo describes the host and is worse than +# useless for this — it would report tens of gigabytes free on a box that is one +# allocation from being killed. +headroom_mb() { + local cap used + cap=$(cgroup_cap_mb) + used=$(cgroup_used_mb) + if [ -n "$cap" ] && [ -n "$used" ]; then + echo $(( cap - used )) + else + avail_meminfo_mb + fi +} + +# ── status ───────────────────────────────────────────────────────────────── + +# /mnt/c/Users can hold several real accounts — a renamed login leaves the old +# directory behind — so picking the first alphabetically is a coin toss. Ask +# Windows, then fall back to whichever profile actually owns a config. +wslconfig_path() { + local profile winpath found + profile=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d "\r\n" || true) + case "$profile" in + ""|*%*) ;; + *) winpath=$(wslpath -u "$profile" 2>/dev/null || true) + if [ -n "$winpath" ] && [ -d "$winpath" ]; then + echo "$winpath/.wslconfig"; return 0 + fi ;; + esac + found=$(ls -d /mnt/c/Users/*/.wslconfig 2>/dev/null | head -1 || true) + [ -n "$found" ] && echo "$found" + return 0 +} + +hogs() { + echo " holding the most:" + ps -eo rss,comm --sort=-rss 2>/dev/null \ + | awk 'NR>1 && NR<=6 {printf " %6.0f MB %s\n", $1/1024, $2}' + return 0 +} + +status() { + local total avail swap_total swap_free cap ul cur + + echo "host" + echo " platform $(platform)" + echo " kernel $(uname -r)" + [ -r /etc/os-release ] && \ + echo " distro $(sed -n 's/^PRETTY_NAME="\(.*\)"/\1/p' /etc/os-release)" + echo " cpu $(getconf _NPROCESSORS_ONLN 2>/dev/null || echo '?') online, load $(cut -d' ' -f1-3 /proc/loadavg)" + + # ── the caps first, because they decide what the totals below are worth ── + echo + echo "caps" + cap=$(cgroup_cap_mb) + if [ -n "$cap" ]; then + cur=$(cgroup_used_mb) + echo " cgroup ${cap} MB (${CG_VERSION}, ${CG_CUR_FILE##*/} says ${cur:-?} MB used)" + echo " ! /proc/meminfo below describes the HOST, not this cgroup." + echo " $(mb MemTotal) MB total is not yours; ${cap} MB is." + elif [ -n "$CG_VERSION" ]; then + echo " cgroup none (${CG_VERSION} present, no memory limit set)" + else + echo " cgroup no memory controller found" + fi + + ul=$(ulimit_v_mb) + if [ -n "$ul" ]; then + echo " ! ulimit -v ${ul} MB — a per-process cap, inherited from your shell" + echo " it stops this process long before the machine runs out" + else + echo " ulimit -v unlimited" + fi + + # overcommit_memory=0 is the default heuristic: a large allocation is + # granted on a guess, and the reckoning arrives later as an OOM kill rather + # than as a failed malloc. It is why `push` touches every page it asks for. + local om or_ + om=$(cat /proc/sys/vm/overcommit_memory 2>/dev/null || echo '?') + or_=$(cat /proc/sys/vm/overcommit_ratio 2>/dev/null || echo '?') + case "$om" in + 0) echo " overcommit 0 heuristic — allocations are granted on a guess," ;; + 1) echo " overcommit 1 always — every allocation succeeds; the OOM killer is the only limit," ;; + 2) echo " overcommit 2 strict (ratio ${or_}%) — allocation fails honestly instead of killing later," ;; + *) echo " overcommit ${om}" ;; + esac + [ "$om" != "?" ] && echo " so RSS is the number to trust, not what a process asked for" + + # ── what it says it has ── + total=$(mb MemTotal); avail=$(avail_meminfo_mb) + swap_total=$(mb SwapTotal); swap_free=$(mb SwapFree) + echo + echo "memory" + echo " total ${total} MB" + echo " available ${avail} MB" + echo " swap ${swap_total} MB ($(( swap_total - swap_free )) MB used)" + if [ "$swap_total" -eq 0 ]; then + echo " - no swap: this box has no cushion. It goes from fine to OOM-killed" + echo " with nothing in between, which is the abrupt failure you get in a VM." + fi + + # postgres puts its shared buffers in /dev/shm. Docker's default is 64 MB, + # and the resulting failure names neither shm nor the size. + if [ -d /dev/shm ]; then + local shm; shm=$(df -Pm /dev/shm 2>/dev/null | awk 'NR==2{print $2}') + if [ -n "$shm" ]; then + if [ "$shm" -le 64 ]; then + echo " ! /dev/shm ${shm} MB — postgres puts shared memory here and 64 MB" + echo " is docker's default. Raise it with --shm-size when postgres fails." + else + echo " /dev/shm ${shm} MB" + fi + fi + fi + + echo + echo "disk" + local d + for d in / /tmp /var/lib/docker; do + [ -d "$d" ] || continue + df -Pm "$d" 2>/dev/null | awk -v p="$d" 'NR==2{printf " %-12s %s MB free of %s MB\n", p, $4, $2}' + done + + # kind and Tilt both watch large trees, and the failure mode is silent: + # they simply stop noticing file changes. Cheap to report while we are here. + 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 + echo "tooling" + echo " inotify watches=$w instances=$i" + if [ "$w" -lt 524288 ] || [ "$i" -lt 512 ]; then + echo " ! low — anything watching files will silently stop seeing changes" + fi + + if ! command -v docker >/dev/null 2>&1; then + if [ -S /var/run/docker.sock ]; then + echo " docker socket present, no cli" + else + echo " docker not installed" + fi + elif docker info >/dev/null 2>&1; then + local n + n=$(docker ps -q 2>/dev/null | wc -l) + echo " docker $(docker version --format '{{.Server.Version}}' 2>/dev/null), ${n} container(s) running" + else + echo " ! docker cli present but the daemon is unreachable" + fi + + # WSL keeps its cap on the Windows side, in a file this shell can read but + # not usefully apply — the change costs a full VM restart. Report it, and + # report the commonest mistake, which is editing it and not restarting. + if is_wsl; then + local cfg conf conf_mb n + cfg=$(wslconfig_path) + echo + echo "wsl" + if [ -z "$cfg" ]; then + echo " ! cannot tell which Windows profile owns .wslconfig" + else + echo " config $cfg" + conf=$(configured_memory "$cfg") + if [ -n "$conf" ]; then + conf_mb=$(to_mb "$conf") + echo " configured $conf (${conf_mb} MB), booted ${total} MB" + # The VM reports a little less than allocated; 15% covers the + # kernel without calling every healthy machine a mismatch. + if [ -n "$conf_mb" ] && [ "$total" -lt $(( conf_mb * 85 / 100 )) ]; then + echo " ! configured ${conf_mb} MB but booted ${total} MB — not applied yet." + echo " From a WINDOWS terminal: wsl --shutdown then start the distro again." + fi + else + echo " configured no memory= set (WSL defaults to 50% of host RAM, or 8 GB," + echo " whichever is less). To raise it, add on the Windows side:" + echo " [wsl2]" + echo " memory=8GB" + echo " then from a WINDOWS terminal: wsl --shutdown" + fi + n=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$n" -gt 0 ]; then + echo " backups $n (newest: $(ls -t "$cfg".*.bak 2>/dev/null | head -1))" + fi + fi + else + echo + echo " - native linux: no VM allocation to raise. If memory is tight the levers" + echo " are freeing something or adding swap." + fi + + echo + echo "effective ceiling $(effective_ceiling_mb) MB" + echo " the lowest of MemTotal, the cgroup cap and ulimit -v. What the box" + echo " claims. 'push' measures what it will actually hand over." + + [ "$avail" -lt $(( total / 5 )) ] && { echo; hogs; } + return 0 +} + +# ── .wslconfig ───────────────────────────────────────────────────────────── + +require_wsl() { + if ! is_wsl; then + echo "$1 acts on .wslconfig, which only exists under WSL." >&2 + echo "This is native Linux — there is no VM allocation to save or roll back." >&2 + echo "Use 'status' to see what the machine actually has." >&2 + exit 1 + fi +} + +# backup and restore act on the file, so unlike status they must not guess. +wslconfig_required() { + local cfg; cfg=$(wslconfig_required) + if [ -z "$cfg" ]; then + echo "cannot tell which Windows profile owns .wslconfig. Candidates:" >&2 + ls -d /mnt/c/Users/*/ 2>/dev/null \ + | grep -viE "/(All Users|Default|Default User|Public)/$" | sed "s/^/ /" >&2 + exit 1 + fi + echo "$cfg" +} + +configured_memory() { + [ -r "$1" ] || { echo ""; return; } + sed -n 's/^[[:space:]]*memory[[:space:]]*=[[:space:]]*//p' "$1" | tail -1 | tr -d '[:space:]' +} + +# "9GB" / "8192MB" / "9G" -> MB, so it can be compared with /proc/meminfo. +to_mb() { + local v="${1^^}" n + n=$(echo "$v" | tr -dc '0-9') + [ -n "$n" ] || { echo ""; return; } + case "$v" in + *GB|*G) echo $(( n * 1024 )) ;; + *MB|*M) echo "$n" ;; + *) echo $(( n / 1024 / 1024 )) ;; + esac +} + +backup() { + require_wsl backup + local cfg dest + cfg=$(wslconfig_required) + [ -r "$cfg" ] || { echo "nothing to back up: $cfg does not exist" >&2; exit 1; } + # Timestamped and never overwritten: a backup that can destroy itself on a + # second run is not a backup. + dest="${cfg}.$(date +%Y%m%d-%H%M%S).bak" + cp "$cfg" "$dest" + echo "backed up $dest" + echo + echo "Edit $cfg by hand, then from a WINDOWS terminal: wsl --shutdown" +} + +restore() { + require_wsl restore + local cfg newest count + cfg=$(wslconfig_required) + newest=$(ls -t "$cfg".*.bak 2>/dev/null | head -1 || true) + [ -n "$newest" ] || { echo "no backups found beside $cfg" >&2; exit 1; } + + echo "restoring $newest" + echo " -> $cfg" + echo + + # Newest is the right default — undo the last edit — but if you backed up + # *after* editing, the state you want is older. Show the rest so a no-op + # restore is obviously a no-op rather than a mystery. + count=$(ls "$cfg".*.bak 2>/dev/null | wc -l) + if [ "$count" -gt 1 ]; then + echo "$count backups exist, newest first:" + ls -t "$cfg".*.bak | sed 's/^/ /' + echo " (restoring the newest; copy another by hand to pick an older one)" + echo + fi + + if [ -r "$cfg" ]; then + echo "what changes:" + if diff "$cfg" "$newest" > /tmp/mem.diff 2>&1 && [ ! -s /tmp/mem.diff ]; then + echo " nothing — that backup is identical to the current config" + else + sed 's/^/ /' /tmp/mem.diff + fi + rm -f /tmp/mem.diff + echo + fi + + printf "proceed? [y/N] " + read -r reply + case "$reply" in + y|Y|yes|Yes) ;; + *) echo "left alone"; return 0 ;; + esac + cp "$newest" "$cfg" + echo "restored. From a WINDOWS terminal: wsl --shutdown" +} + +# ── push ─────────────────────────────────────────────────────────────────── + +STATE="" +CHILD="" + +cleanup() { + if [ -n "$CHILD" ] && kill -0 "$CHILD" 2>/dev/null; then + kill -KILL "$CHILD" 2>/dev/null || true + wait "$CHILD" 2>/dev/null || true + fi + [ -n "$STATE" ] && rm -f "$STATE" + return 0 +} + +# The child allocates and stops itself; the parent only watches. That split is +# the point: under --to-oom the allocating process is expected to be killed, and +# something has to survive to say how far it got. +allocator() { + # Raise our own OOM score to the maximum so the kernel picks THIS process + # first. Raising needs no privilege (only lowering does). Without it, the + # kernel is free to choose your shell, your ssh session or dockerd — on a + # box you are still using, that is not an acceptable coin toss. + echo 1000 > "/proc/$BASHPID/oom_score_adj" 2>/dev/null || true + + local arr=() held=0 i=0 rss swapped avail first_swap=0 + local bytes=$((STEP_MB * 1024 * 1024)) + local swap_used_start + swap_used_start=$(( $(mb SwapTotal) - $(mb SwapFree) )) + + while :; do + # Written STRAIGHT INTO the array element. The obvious spelling — + # build one chunk and `arr+=("$chunk")` — costs three copies per step, + # not one: the template stays resident, expanding "$chunk" makes a + # temporary word, and the append makes the element. A 128 MB step then + # needs 384 MB transiently, and on a small box it is killed on the + # first append while reporting a third of the true ceiling. + # + # printf -v into a subscript also means every page is written, so it is + # resident rather than merely promised — the only kind of allocation + # that measures anything under heuristic overcommit. + printf -v "arr[$i]" '%*s' "$bytes" '' + i=$((i + 1)); held=$((held + STEP_MB)) + + rss=$(awk '/^VmRSS:/{print int($2/1024)}' "/proc/$BASHPID/status" 2>/dev/null || echo 0) + avail=$(headroom_mb) + swapped=$(( $(mb SwapTotal) - $(mb SwapFree) - swap_used_start )) + [ "$swapped" -lt 0 ] && swapped=0 + + printf '%8s MB held rss %7s MB headroom %7s MB swap +%s MB\n' \ + "$held" "$rss" "$avail" "$swapped" + printf '%s %s %s %s\n' "$held" "$rss" "$avail" "$swapped" >> "$STATE" + + # Worth calling out separately from the ceiling: this is where the box + # stops being fast and starts being unusable, which for a scheduler is + # a different and earlier problem than being killed. + if [ "$swapped" -gt 0 ] && [ "$first_swap" -eq 0 ]; then + first_swap=$held + echo " - first swap page at ${held} MB — past here it works but crawls" + echo "swapat $held" >> "$STATE" + fi + + if [ -n "$TO_MB" ] && [ "$held" -ge "$TO_MB" ]; then + echo "stop reached-the-cap" >> "$STATE"; return 0 + fi + if [ "$TO_OOM" = no ] && [ "$avail" -lt "$FLOOR_MB" ]; then + echo "stop floor" >> "$STATE"; return 0 + fi + done +} + +push() { + local total ceiling rc=0 last held rss swapat stop + total=$(mb MemTotal) + ceiling=$(effective_ceiling_mb) + + # A step is worth about a sixty-fourth of the ceiling: enough resolution to + # find the edge, few enough lines to read, and small enough that the + # transient cost of one allocation never dominates a small box. A fixed + # size cannot do all three — 128 MB is fine on 16 GB and absurd on 512 MB. + if [ "$STEP_EXPLICIT" = no ]; then + STEP_MB=$(( ceiling / 64 )) + [ "$STEP_MB" -lt 4 ] && STEP_MB=4 + [ "$STEP_MB" -gt 256 ] && STEP_MB=256 + fi + + # Stop with a cushion rather than riding it to the kill. How big a cushion + # depends on what it is protecting. Under a cgroup cap, running out kills + # only this container's own processes, so it need cover no more than the + # shell that prints the result — and a 512 MB cushion on a 1 GB box would + # halve the answer. On a host there is everything else to protect, and the + # OOM killer does not promise to pick the process that caused the problem. + if [ -n "$(cgroup_cap_mb)" ]; then FLOOR_MB=64; else FLOOR_MB=512; fi + [ $(( ceiling / 20 )) -gt "$FLOOR_MB" ] && FLOOR_MB=$(( ceiling / 20 )) + + STATE=$(mktemp "${TMPDIR:-/tmp}/rigmini.XXXXXX") + trap cleanup EXIT + # INT kills the child and lets the summary below print anyway, so an + # impatient Ctrl-C still tells you how far it got — and, more importantly, + # still gives the memory back. + trap 'echo; echo " interrupted"; echo "stop interrupted" >> "$STATE"; [ -n "$CHILD" ] && kill -KILL "$CHILD" 2>/dev/null || true' INT + + echo "push" + echo " step ${STEP_MB} MB per allocation, every page touched" + echo " ceiling ${ceiling} MB claimed" + if [ -n "$TO_MB" ]; then + echo " stopping at ${TO_MB} MB (--to)" + elif [ "$TO_OOM" = yes ]; then + echo " ! stopping only when the kernel stops it (--to-oom)" + echo " the allocating child is marked as the preferred OOM victim," + echo " but nothing about an OOM kill is entirely polite. Not on a box" + echo " running anything you mind losing." + else + echo " stopping when headroom drops below ${FLOOR_MB} MB" + fi + echo + + allocator & + CHILD=$! + wait "$CHILD" || rc=$? + CHILD="" + trap - INT + + last=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 || true) + held=$(echo "$last" | awk '{print $1}') + rss=$(echo "$last" | awk '{print $2}') + swapat=$(awk '/^swapat/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + stop=$(awk '/^stop/{print $2}' "$STATE" 2>/dev/null | head -1 || true) + + echo + if [ -z "$held" ]; then + echo " ! nothing was allocated. Even one ${STEP_MB} MB chunk failed —" + echo " try a smaller --step, or check ulimit -v in 'status'." + return 1 + fi + + echo " reached ${rss:-$held} MB resident" + [ -n "$swapat" ] && echo " swapping from ${swapat} MB" + + case "$stop" in + reached-the-cap) + echo " outcome stopped at the --to cap, not at a limit." + echo " The box held ${TO_MB} MB without complaint; there is more." ;; + floor) + echo " outcome stopped with a cushion intact, by choice." + echo " The real ceiling is higher — --to-oom finds it, at the" + echo " cost of an actual OOM kill." ;; + interrupted) + echo " outcome interrupted at ${rss:-$held} MB — where you stopped it," + echo " not where the box did." ;; + *) + # No stop line means the child did not decide to stop: it was ended. + if [ "$rc" -ge 128 ]; then + echo " outcome the child was killed (signal $((rc - 128))) at ${rss:-$held} MB." + elif [ "$rc" -ne 0 ]; then + echo " outcome the allocation failed at ${rss:-$held} MB (exit ${rc})." + echo " bash could not get the next chunk — an honest malloc" + echo " failure rather than a kill. That is the strict-overcommit" + echo " or ulimit path." + else + echo " outcome ended at ${rss:-$held} MB." + fi + local ev + ev=$(dmesg 2>/dev/null | tail -80 | grep -iE 'oom-kill|killed process' | tail -1 || true) + if [ -n "$ev" ]; then + echo " kernel ${ev#*] }" + else + echo " - dmesg is unreadable here (dmesg_restrict, or no privilege)," + echo " so the kill cannot be confirmed from this side. The number stands." + fi ;; + esac + + # The gap between the claim and the measurement is the finding — but only + # when the BOX chose where to stop. An empty $stop means the child was ended + # rather than deciding to end; anything else (--to, the floor) is a stop we + # asked for, and flagging those as short of the ceiling would put a warning + # on every deliberately small run. + local got="${rss:-$held}" + echo + if [ -z "$stop" ] && [ "$got" -lt $(( ceiling * 70 / 100 )) ]; then + echo " ! claimed ${ceiling} MB, gave up ${got} MB — under 70% of it." + echo " Something is taking the difference. 'status' names the candidates:" + echo " a cgroup cap, ulimit -v, or memory already resident." + fi + return 0 +} + +# ── all ──────────────────────────────────────────────────────────────────── + +all() { + status + echo + echo "────────────────────────────────────────────────────────────" + echo + push + + local got budget_mb ceiling + load_config + if [ -n "$BUDGET_GB" ]; then + budget_mb=$(( BUDGET_GB * 1024 )) + else + budget_mb=$(( NODES * NODE_MB )) + fi + ceiling=$(effective_ceiling_mb) + got=$(grep -E '^[0-9]' "$STATE" 2>/dev/null | tail -1 | awk '{print $2}' || true) + [ -n "$got" ] || got=0 + + echo + echo "verdict" + if [ -n "$BUDGET_GB" ]; then + echo " budget ${budget_mb} MB (--budget)" + else + # rig's own figure for this profile: nodes times what one node costs. + # Addons carry no memory figure in rig yet, so this is the cluster alone + # and whatever you deploy comes on top. --budget once you know that too. + echo " budget ${budget_mb} MB — profile ${PROFILE_NAME}: ${NODES} node(s) x ${NODE_MB} MB," + echo " the cluster alone; your workload comes on top (--budget GB)" + fi + echo " measured ${got} MB handed over" + + if [ "$got" -ge "$budget_mb" ]; then + echo " fits, with $(( got - budget_mb )) MB spare." + if [ "$got" -lt $(( budget_mb * 130 / 100 )) ]; then + echo " - under 30% spare is thin once a workload runs on top: memory use" + echo " is spiky, and the spikes are what get killed." + fi + else + echo " ! short by $(( budget_mb - got )) MB." + if [ "$ceiling" -ge "$budget_mb" ]; then + echo " The box CLAIMS enough (${ceiling} MB) but did not deliver it." + echo " Free something, or read the caps section again." + else + echo " The box does not have it to give. A bigger machine, or a profile" + echo " with fewer nodes." + fi + fi + return 0 +} + +# ── main ─────────────────────────────────────────────────────────────────── + +parse_flags() { + while [ $# -gt 0 ]; do + case "$1" in + --to) TO_MB=$(( ${2:?--to needs a value in GB} * 1024 )); shift 2 ;; + --to-mb) TO_MB="${2:?--to-mb needs a value in MB}"; shift 2 ;; + --step) STEP_MB="${2:?--step needs a value in MB}"; STEP_EXPLICIT=yes; shift 2 ;; + --to-oom) TO_OOM=yes; shift ;; + --budget) BUDGET_GB="${2:?--budget needs a value in GB}"; BUDGET_EXPLICIT=yes; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 1 ;; + esac + done + if [ "$TO_OOM" = yes ] && [ -n "$TO_MB" ]; then + echo "--to and --to-oom contradict each other: one stops early, the other" >&2 + echo "refuses to stop at all. Pick one." >&2 + exit 1 + fi + return 0 +} + +require_linux +find_cgroup + +cmd="${1:-status}" +[ $# -gt 0 ] && shift + +case "$cmd" in + status) parse_flags "$@"; status ;; + push) parse_flags "$@"; push ;; + all) parse_flags "$@"; all ;; + backup) backup ;; + restore) restore ;; + *) echo "usage: $0 [status|push|all|backup|restore]" >&2 + echo " push [--to GB] [--to-mb MB] [--step MB] [--to-oom]" >&2 + echo " all [--budget GB]" >&2 + exit 1 ;; +esac diff --git a/rig/standalone/rigdeps.sh b/rig/standalone/rigdeps.sh deleted file mode 100755 index ee352c2..0000000 --- a/rig/standalone/rigdeps.sh +++ /dev/null @@ -1,637 +0,0 @@ -#!/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