diff --git a/README.md b/README.md index 3a69ad1..8b80dd1 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,56 @@ takes arguments, does its work, and stores no credential, ever. curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash ``` -Installs the tree to `~/.local/share/rig` and links `rig` onto your -PATH (`/usr/local/bin` when root). Re-run any time to upgrade. +The layout, under the install root (`~/.local/share/rig`): + +``` +versions// one full tree per installed version +current -> versions/ the tracked default +$BINDIR/rig -> current/bin/rig the PATH entry, riding the chain +``` + +`rig` lands on your PATH via `~/.local/bin` (`/usr/local/bin` when root). + +**Re-running is a safe converge.** Installing a version you already have +changes nothing and says so (`RIG_REINSTALL=1` replaces that version's +tree); a **new** version installs side by side and becomes the default — so +"re-run any time to upgrade" stays true, and every version you had stays +installed as the way back: + +```sh +rig versions # what is installed, which is current, which is running +rig use # flip the default (atomic; asserts the flip took) +``` + +On a **bootstrapped host** (one where `/etc/rig/role` exists) switching the +default — by upgrade or by `rig use` — prints a WARNING, because a +different rig under a converged host changes what a re-converge +(`rig bootstrap`, `rig users apply`) would do. It warns rather than +refuses: unlike box (which protects live boxes), rig holds no user state a +flip can strand, and upgrading a bootstrapped host is the normal case. + +A pre-versioning flat install is migrated into `versions/` automatically on +the next installer run — the tree is moved, not re-downloaded, and +preserved bit for bit. For scripting: `RIG_HOME`/`RIG_BIN` override the +install root and bin dir, `RIG_INSTALL_SOURCE=` installs +from a local tree instead of downloading (how the test suite proves the +installer under review), and `RIG_YES=1` answers `rig uninstall`'s prompt +in automation. + +### Uninstall + +`rig uninstall` is the real uninstall — no more "rm -rf two paths" prose — +and it **ends with an absence assert**: every path it removed is +re-checked, and any survivor makes it exit 1 naming the leftovers instead +of reporting a clean uninstall that wasn't. + +```sh +rig uninstall # one non-current version (side-by-side cleanup) +rig uninstall --all # everything: every version, current, the PATH symlinks +``` + +Asks before removing; `--force` or `RIG_YES=1` skips the prompt. The host +itself is untouched — what bootstrap converged stays converged. ## Commands @@ -717,6 +765,12 @@ to do" and exits 0. ## Testing `bash test/cli.sh` (dependency-free assertions) + shellcheck run in CI. The +versioned install is proven by REAL installer runs: `RIG_INSTALL_SOURCE` +points install.sh at the tree under review and the harness drives it against +throwaway `RIG_HOME`/`RIG_BIN` roots — fresh install, converge, reinstall, +side-by-side upgrade, `use`/rollback, flat-tree migration, symlink healing, +the bootstrapped-host warning (via `RIG_ROLE_MARKER` fixtures), and both +uninstalls with their absence asserts. The `rig users` family is covered the same way: the harness drives its refusal matrix — users-file parsing, the marker gates, the lexical drop-in-name assertion, the validate-then-apply ordering — through the sourced lib diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0d4d124 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0-dev diff --git a/bin/rig b/bin/rig index 56ffc5a..6f577a7 100755 --- a/bin/rig +++ b/bin/rig @@ -3,6 +3,11 @@ set -euo pipefail ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" +log() { printf 'rig: %s\n' "$*"; } +warn() { printf 'rig: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig: ERROR: %s\n' "$*" >&2; exit 1; } +version() { echo "rig $(cat "$ROOT/VERSION" 2>/dev/null || echo unknown) ($ROOT)"; } + usage() { cat <<'EOF' usage: rig [args] @@ -59,12 +64,259 @@ commands: Shut root SSH on a class=human box once an admin key works. Refuses on class=server — root there is the control plane's automation door — and while no admin holds a key. Run as root. + versions + List the installed rig versions — install.sh lands each one side by + side at /versions/, a 'current' symlink tracks the default + (what the rig on your PATH runs). The default is marked (current); + the tree answering this command is marked (running). + use + Switch the default rig version — repoint 'current' (and the PATH + symlink riding it) at an installed version, atomically, then assert + the flip took. WARNS on a bootstrapped host (/etc/rig/role exists): + switching the rig under a converged host changes what a re-converge + would do. + uninstall [|--all] [--force] + Remove one NON-current installed version, or --all: every version, + 'current', and the PATH symlinks. Asks first (--force or RIG_YES=1 + skips the prompt) and ENDS with an absence assert — every removed + path is re-checked, and any survivor makes it exit 1 naming the + leftovers instead of reporting a clean uninstall that wasn't. + --version + Print the running rig's version (its tree's own VERSION file) and + where it runs from. install/upgrade: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash + Re-run any time: an installed version converges (no-op), a new one + installs side by side at /versions/ and becomes the default. EOF } +# --- the versioned install (box#79's layout, ported — #35) ------------------- +# install.sh lands each version at /versions/, with a +# 'current' symlink naming the default and $BINDIR/rig pointing through it. +# $ROOT (readlink -f, line 4) already resolved the whole chain, so a versioned +# install always runs from .../versions/ — and a git checkout does not, +# which is how these verbs know to refuse instead of uninstalling somebody's +# working copy. +install_root() { + local vdir; vdir="$(dirname "$ROOT")" + [ "$(basename "$vdir")" = versions ] || return 1 + dirname "$vdir" +} + +# A version is a DIRECTORY NAME under versions/ — nothing else. One strict +# gate for every caller that builds a path from one (the installer's new_ver, +# migration's flat_ver, and bin/rig's 'use'/single-version uninstall): only +# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, +# spaces and option-lookalikes by construction — a crafted version dies HERE, +# never in an rm -rf or an ln. install.sh carries a byte-identical copy; +# test/cli.sh diffs the two so the gates cannot drift. +valid_version() { + case "$1" in + ''|.*|-*) return 1 ;; + *[!A-Za-z0-9._+-]*) return 1 ;; + esac + return 0 +} + +# The flip gate, rig's shape (#35): box refuses version flips under existing +# boxes; rig's stake is the converged HOST — /etc/rig/role marks a box that +# bootstrap has made into something. Switching the default rig under it +# changes what a re-converge would do, which is worth a warning, not a +# refusal: there is no user state a flip can strand, and flipping versions on +# a bootstrapped host is the normal upgrade. RIG_ROLE_MARKER overrides the +# path so tests point it at fixtures (repo precedent: the coolify marker +# gate). install.sh carries a byte-identical copy; test/cli.sh diffs the two. +warn_bootstrapped() { # $1 = what is about to happen + local marker="${RIG_ROLE_MARKER:-/etc/rig/role}" + [ -e "$marker" ] || return 0 + warn "this host is bootstrapped ($(head -n1 "$marker" 2>/dev/null || echo "role marker at $marker"))" + warn "$1 changes what a re-converge (rig bootstrap, users apply) would do — proceeding." +} + +# The PATH symlinks that could ride this install: the one this invocation came +# in on, RIG_BIN's, and the tier default's. Candidates only — every consumer +# checks where a link actually points before touching it, so a symlink that is +# somebody else's (another install root, a hand-rolled wrapper) is never moved. +bin_links() { + local c=() + [ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}") + [ -n "${RIG_BIN:-}" ] && c+=("$RIG_BIN/rig") + if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/rig); else c+=("$HOME/.local/bin/rig"); fi + printf '%s\n' "${c[@]}" | awk '!seen[$0]++' +} + +converge_bin_links() { # $1 = install root: point our PATH symlinks through current + local ir="$1" p t + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in + "$ir"/*) ln -sfn "$ir/current/bin/rig" "$p" ;; + esac + done < <(bin_links) +} + +cmd_versions() { + local ir cur d v mark + ir="$(install_root)" || die "this rig runs from a working tree ($ROOT), not a versioned install — nothing to list" + cur="$(readlink -f "$ir/current" 2>/dev/null || true)" + echo "VERSIONS ($ir)" + for d in "$ir/versions"/*/; do + [ -d "$d" ] || continue + v="$(basename "$d")" + mark="" + [ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)" + [ "$(readlink -f "$d")" = "$ROOT" ] && mark="$mark (running)" + printf ' %s%s\n' "$v" "$mark" + done + echo + echo "switch the default: rig use " + echo "install another: re-run install.sh (versions land side by side)" +} + +cmd_use() { + local v="${1:-}" ir eff expect out + if [ -z "$v" ]; then + printf 'rig: use needs a version (see: rig versions)\n' >&2 + usage >&2 + exit 2 + fi + ir="$(install_root)" || die "this rig runs from a working tree ($ROOT), not a versioned install — nothing to switch" + valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'rig versions')" + [ -d "$ir/versions/$v" ] || die "no such version: $v (see 'rig versions')" + warn_bootstrapped "switching the default rig version to $v" + # An atomic flip, not unlink+create: ln -sfn leaves a window where current + # is missing; a rename over it does not. + ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current" + converge_bin_links "$ir" + # Assert the EFFECTIVE result, not the intent: current must resolve to the + # version asked for, and the chain's own binary must answer that version — + # a flip that "worked" while the operator's rig still runs the old tree is + # exactly the flakiness this verb exists to end. + eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + [ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v" + expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)" + if [ -n "$expect" ]; then + out="$("$ir/current/bin/rig" --version 2>&1 || true)" + case "$out" in + *"$expect"*) : ;; + *) die "current/bin/rig answers '$out', not version $expect — the symlink chain is broken" ;; + esac + fi + log "switched to $v (current -> versions/$v)" +} + +# The uninstall's own confirmation: --force, or RIG_YES=1, or a TTY. RIG_YES +# is the installer-family consent contract — how automation says yes without +# a terminal; without any of the three we refuse rather than assume consent. +uninstall_confirm() { # $1 = question + [ "$force" -eq 1 ] && return 0 + [ -n "${RIG_YES:-}" ] && return 0 + if [ ! -t 0 ]; then + printf 'rig: refusing to %s without --force (no terminal to confirm on; RIG_YES=1 also means yes)\n' "$1" >&2 + exit 2 + fi + local reply + printf 'rig: %s? [y/N] ' "$1" + read -r reply + case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac +} + +# 'rig uninstall' — the real uninstall, replacing the undocumented rm -rf +# prose. Trees and symlinks, and it ENDS by PROVING the absence — the last +# word is a re-check, not a hope. +cmd_uninstall() { + local ir a ver="" all=0 force=0 cur p t leftover="" + local targets=() + for a in "$@"; do + case "$a" in + --all) all=1 ;; + --force) force=1 ;; + -*) + printf 'rig: unknown option: %s\n' "$a" >&2 + usage >&2 + exit 2 + ;; + *) + if [ -n "$ver" ]; then + printf 'rig: uninstall takes one version, or --all\n' >&2 + usage >&2 + exit 2 + fi + ver="$a" + ;; + esac + done + ir="$(install_root)" || die "this rig runs from a working tree ($ROOT), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)" + [ -w "$ir" ] || die "cannot write $ir — uninstall as the user that installed it (or root: sudo rig uninstall)" + + # -- one version ----------------------------------------------------------- + if [ -n "$ver" ] && [ "$all" -eq 0 ]; then + valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'rig versions')" + [ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'rig versions')" + cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")" + # A broken current makes the CURRENT guard below unfireable (cur empty + # when the link is missing; cur naming a non-directory when it dangles — + # readlink -f resolves a link whose last component does not exist). Heal + # first, then decide; never delete around a broken default. + { [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \ + || die "current is dangling — 'rig use ' to repoint the default first (refusing to remove versions while it is broken)" + [ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'rig use ' first, or 'rig uninstall --all' for everything" + uninstall_confirm "remove rig version $ver from $ir" + # rm's exit code is not the verdict — the absence re-check below is (a + # half-removed tree must be reported as INCOMPLETE, not as a crash). + rm -rf "${ir:?}/versions/$ver" || true + if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then + echo "rig: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2 + exit 1 + fi + log "removed version $ver (the default stays $cur)" + return 0 + fi + if [ -n "$ver" ]; then + printf 'rig: a version and --all together is ambiguous\n' >&2 + usage >&2 + exit 2 + fi + + # -- everything (bare 'rig uninstall' and '--all' both mean all of it) ----- + warn_bootstrapped "removing rig entirely" + uninstall_confirm "remove the ENTIRE rig install at $ir (every version)" + + # The removal set, gathered BEFORE anything is deleted, so the absence + # assert below re-checks exactly what was promised gone. PATH symlinks are + # removed only when they resolve into (or dangle at) THIS install root. + targets+=("$ir") + while IFS= read -r p; do + [ -L "$p" ] || continue + t="$(readlink -f "$p" 2>/dev/null || true)" + [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)" + case "$t" in "$ir"/*) targets+=("$p") ;; esac + done < <(bin_links) + mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++') + + # rm's exit code is not the verdict — the absence assert below is (a + # half-removed tree must be reported as INCOMPLETE by name, not as a crash). + for p in "${targets[@]}"; do rm -rf "$p" || true; done + + # END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink. + # A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims + # get verified. + for p in "${targets[@]}"; do + if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi + done + if [ -n "$leftover" ]; then + echo "rig: uninstall INCOMPLETE — still present:$leftover" >&2 + echo "rig: remove them by hand, and re-check each path is really gone." >&2 + exit 1 + fi + echo "rig: uninstalled — removed:" + for p in "${targets[@]}"; do echo "rig: · $p"; done +} + cmd="${1:-}" case "$cmd" in bootstrap) @@ -154,6 +406,22 @@ case "$cmd" in ;; esac ;; + versions) + shift + cmd_versions "$@" + ;; + use) + shift + cmd_use "$@" + ;; + uninstall) + shift + cmd_uninstall "$@" + ;; + -V|--version) + version + exit 0 + ;; -h|--help|help) usage exit 0 diff --git a/install.sh b/install.sh index e10f9db..ad00104 100644 --- a/install.sh +++ b/install.sh @@ -3,9 +3,26 @@ set -euo pipefail # rig installer — intended for: curl -fsSL .../install.sh | bash # -# Downloads the rig repo tarball, installs the whole tree under $DEST, -# and puts a `rig` symlink on PATH via $BINDIR. Re-run any time to -# upgrade. +# Downloads the rig repo tarball and installs it into the VERSIONED layout +# under $DEST (box#79's layout, ported — heavy-duty/rig#35): +# +# $DEST/versions// one full tree per installed version +# $DEST/current -> versions/ the default version +# $BINDIR/rig -> $DEST/current/bin/rig the PATH entry +# +# Versions install side by side: `rig versions` lists them, `rig use ` +# switches the default, `rig uninstall` removes them. Re-running with an +# already-installed version is a converging no-op (RIG_REINSTALL=1 replaces +# that version's tree); a NEW version installs beside the old one and becomes +# the default — with a WARNING when this host is bootstrapped (/etc/rig/role +# exists), because switching the rig under a converged host changes what a +# re-converge would do. rig warns where box refuses: rig has no boxes to +# protect, and the operator flipping versions on purpose is the common case. +# A pre-versioning flat tree is migrated in place, so upgrading is seamless. +# +# RIG_INSTALL_SOURCE= installs from a local tree instead of +# downloading — for CI and the test suite, so what lands is the code under +# review. REPO="${RIG_REPO:-heavy-duty/rig}" REF="${RIG_REF:-main}" @@ -20,45 +37,211 @@ log() { printf 'rig-install: %s\n' "$*"; } warn() { printf 'rig-install: WARNING: %s\n' "$*" >&2; } die() { printf 'rig-install: ERROR: %s\n' "$*" >&2; exit 1; } +# A version is a DIRECTORY NAME under versions/ — nothing else. One strict +# gate for every caller that builds a path from one (the installer's new_ver, +# migration's flat_ver, and bin/rig's 'use'/single-version uninstall): only +# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes, +# spaces and option-lookalikes by construction — a crafted version dies HERE, +# never in an rm -rf or an ln. bin/rig carries a byte-identical copy; +# test/cli.sh diffs the two so the gates cannot drift. +valid_version() { + case "$1" in + ''|.*|-*) return 1 ;; + *[!A-Za-z0-9._+-]*) return 1 ;; + esac + return 0 +} + +# The flip gate, rig's shape (#35): box refuses version flips under existing +# boxes; rig's stake is the converged HOST — /etc/rig/role marks a box that +# bootstrap has made into something. Switching the default rig under it +# changes what a re-converge would do, which is worth a warning, not a +# refusal: there is no user state a flip can strand, and flipping versions on +# a bootstrapped host is the normal upgrade. RIG_ROLE_MARKER overrides the +# path so tests point it at fixtures (repo precedent: the coolify marker +# gate). bin/rig carries a byte-identical copy; test/cli.sh diffs the two. +warn_bootstrapped() { # $1 = what is about to happen + local marker="${RIG_ROLE_MARKER:-/etc/rig/role}" + [ -e "$marker" ] || return 0 + warn "this host is bootstrapped ($(head -n1 "$marker" 2>/dev/null || echo "role marker at $marker"))" + warn "$1 changes what a re-converge (rig bootstrap, users apply) would do — proceeding." +} + # --- prerequisites ----------------------------------------------------------- -command -v curl >/dev/null 2>&1 || die "curl is required but was not found." +# curl only when something must be downloaded — a local RIG_INSTALL_SOURCE +# needs none, which is what lets test/cli.sh drive REAL installs offline. +if [ -z "${RIG_INSTALL_SOURCE:-}" ]; then + command -v curl >/dev/null 2>&1 || die "curl is required but was not found." +fi command -v tar >/dev/null 2>&1 || die "tar is required but was not found." +if [ -n "${RIG_INSTALL_SOURCE:-}" ]; then + SRCDESC="local source $RIG_INSTALL_SOURCE" +else + SRCDESC="$REPO@$REF" +fi + +# Flip $DEST/current to versions/ atomically: build the new link beside it, +# rename over. Plain ln -sfn is unlink+create — a window where current names +# nothing and a concurrent 'rig' invocation dies mid-chain. bin/rig's cmd_use +# flips with the same pattern. +flip_current() { + ln -sfn "versions/$1" "$DEST/current.new.$$" + mv -Tf "$DEST/current.new.$$" "$DEST/current" +} + +# --- migrate a pre-versioning flat install ----------------------------------- +# The old installer put the tree FLAT at $DEST (bin/rig directly under it). +# Move such a tree to versions/ BEFORE anything else, so the +# upgrade is seamless and the version comparison below sees the truth. The +# move is two renames inside one parent directory — no copying, no window with +# no install — and the operator's tree is preserved bit for bit. Pre-VERSION +# trees (every flat rig install, rig#32) migrate as 0.0.0-unknown. +if [ -e "$DEST/bin/rig" ] && [ ! -d "$DEST/versions" ]; then + flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)" + # The flat tree's VERSION is data from disk, not from this installer — the + # same trust boundary as the new_ver check, so the same gate: a corrupted + # (or hostile) VERSION must not steer the mv/ln below out of versions/. + valid_version "$flat_ver" || die "the flat install's VERSION is not a sane directory name: '$flat_ver' — fix $DEST/VERSION (one line, e.g. 0.1.0), then re-run" + log "found a pre-versioning flat install at $DEST (version $flat_ver) — migrating it into the versioned layout" + staging="$DEST.migrating.$$" + mv "$DEST" "$staging" + mkdir -p "$DEST/versions" + mv "$staging" "$DEST/versions/$flat_ver" + flip_current "$flat_ver" + mkdir -p "$BINDIR" + ln -sfn "$DEST/current/bin/rig" "$BINDIR/rig" + log "migrated: it now lives at $DEST/versions/$flat_ver (still current)" +fi + # --- temp workspace ---------------------------------------------------------- TMPDIR="$(mktemp -d)" cleanup() { rm -rf "$TMPDIR"; } trap cleanup EXIT -URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" +# --- acquire the tree -------------------------------------------------------- +if [ -n "${RIG_INSTALL_SOURCE:-}" ]; then + SRC="$RIG_INSTALL_SOURCE" + INSTALLED_FROM="local:$SRC" + if [ -d "$SRC" ]; then + log "copying local tree $SRC" + mkdir -p "$TMPDIR/tree" + # tar, not cp -a: --exclude=.git, so a working checkout never carries its + # VCS state (or its size) into the install tree. + tar -C "$SRC" --exclude=.git -cf - . | tar -xf - -C "$TMPDIR/tree" + EXTRACTED="$TMPDIR/tree" + elif [ -f "$SRC" ]; then + log "extracting local tarball $SRC" + tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC" + EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" + else + die "RIG_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC" + fi +else + INSTALLED_FROM="$REPO@$REF" + URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" + log "installing rig ($REPO@$REF)" + log "downloading $URL" + curl -fsSL "$URL" -o "$TMPDIR/rig.tar.gz" \ + || die "failed to download $URL" -log "installing rig ($REPO@$REF)" -log "downloading $URL" -curl -fsSL "$URL" -o "$TMPDIR/rig.tar.gz" \ - || die "failed to download $URL" + log "extracting archive" + tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \ + || die "failed to extract archive" -log "extracting archive" -tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \ - || die "failed to extract archive" + # GitHub names the archive's top dir - — deriving that name is + # guesswork (it broke for real at box's repo rename). The tarball has + # exactly ONE top-level directory: take the directory, whatever it is + # called, and let the bin/rig check below judge whether it is the right tree. + EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)" +fi +[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC" +[ -f "$EXTRACTED/bin/rig" ] || die "source does not contain bin/rig — is $SRCDESC correct?" -# GitHub archives extract to a single top-level dir like rig-/ -EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'rig-*' | head -n1)" -[ -n "$EXTRACTED" ] || die "could not find extracted rig-* directory in archive" -[ -f "$EXTRACTED/bin/rig" ] || die "archive does not contain bin/rig — is $REPO@$REF correct?" +# The tree's own VERSION file names the directory it lands in — the version IS +# the identity of what is being installed, and 'rig versions' lists these names. +new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || true)" +[ -n "$new_ver" ] || die "source has no VERSION file — cannot install it as a version" +valid_version "$new_ver" || die "the source's VERSION is not a sane directory name: '$new_ver'" -# --- atomically replace $DEST -------------------------------------------------- -log "installing into $DEST" -rm -rf "$DEST" -mkdir -p "$(dirname "$DEST")" -mv "$EXTRACTED" "$DEST" +set_exec() { # $1 = a rig tree: the executable bits install.sh owns + chmod +x "$1/bin/rig" + if [ -d "$1/commands" ]; then + find "$1/commands" -name '*.sh' -exec chmod +x {} + + fi +} -chmod +x "$DEST/bin/rig" "$DEST"/commands/*.sh +# --- install into $DEST/versions/ ----------------------------------- +VDIR="$DEST/versions/$new_ver" +newly_installed=0 +if [ -d "$VDIR" ]; then + if [ -n "${RIG_REINSTALL:-}" ]; then + # Replace THIS version's tree, as atomically as two renames allow — never + # a partial overlay of new files onto an old tree. + log "RIG_REINSTALL=1 — replacing the installed $new_ver tree" + stage="$VDIR.new.$$"; old="$VDIR.old.$$" + rm -rf "$stage" "$old" + set_exec "$EXTRACTED" + mv "$EXTRACTED" "$stage" + # Swap by renames, delete LAST: rm-then-move leaves a hole the whole + # length of the delete where current -> this version resolves to nothing. + mv "$VDIR" "$old" + mv "$stage" "$VDIR" + rm -rf "$old" + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" + log "reinstalled $new_ver" + else + cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '')" + log "rig $new_ver is already installed ($cur_from) — nothing to do." + log "(RIG_REINSTALL=1 replaces this version's tree; 'rig versions' lists what is installed.)" + fi +else + log "installing $new_ver into $VDIR" + mkdir -p "$DEST/versions" + set_exec "$EXTRACTED" + mv "$EXTRACTED" "$VDIR" + newly_installed=1 + # Record WHAT was installed, so a caller can assert it got what it asked + # for — an installer invoked with stale env vars silently falls back to the + # defaults, and INSTALLED_FROM is how that lie gets caught. + printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM" +fi -# --- put rig on PATH ------------------------------------------------------ +# --- which version is the default? ------------------------------------------- +# 'current' is the tracked default; flipping it is the ONLY step that changes +# what an operator's `rig` runs. A fresh host (or a dangling current) is +# claimed outright; an upgrade flips — with the bootstrapped-host warning — +# because a re-run that silently left you on the old version would make +# "re-run any time to upgrade" a lie. Judged from versions/ itself +# (readlink -f), never from what a wedged current claims. +cur="$(readlink -f "$DEST/current" 2>/dev/null || true)" +want="$(readlink -f "$VDIR")" +if [ -z "$cur" ] || [ ! -d "$cur" ]; then + flip_current "$new_ver" + log "default version: $new_ver" +elif [ "$cur" = "$want" ]; then + : # already the default — nothing to flip +elif [ "$newly_installed" -eq 0 ]; then + # A converge/no-op (or RIG_REINSTALL) of a version that is NOT the default + # never moves the default — a re-run must change nothing; switching is + # 'rig use', a deliberate act. + log "the default stays $(basename "$cur") — 'rig use $new_ver' switches." +else + old_ver="$(basename "$cur")" + warn_bootstrapped "switching the default rig version ($old_ver -> $new_ver)" + flip_current "$new_ver" + log "default version switched: $old_ver -> $new_ver ('rig use $old_ver' switches back)" +fi + +# --- put rig on PATH --------------------------------------------------------- +# ln -sfn converges, and that includes HEALING: a stale or dangling +# $BINDIR/rig (say, its tree half-removed by hand) must never block or wedge +# an install — it gets repointed at the current chain, whatever it said before. mkdir -p "$BINDIR" -ln -sf "$DEST/bin/rig" "$BINDIR/rig" -log "linked $BINDIR/rig -> $DEST/bin/rig" +ln -sfn "$DEST/current/bin/rig" "$BINDIR/rig" +log "linked $BINDIR/rig -> $DEST/current/bin/rig" -# --- PATH check ---------------------------------------------------------------- +# --- PATH check -------------------------------------------------------------- case ":$PATH:" in *":$BINDIR:"*) : ;; *) @@ -67,4 +250,4 @@ case ":$PATH:" in ;; esac -log "done — try: rig --help" +log "done ($SRCDESC, version $new_ver) — try: rig --help" diff --git a/test/cli.sh b/test/cli.sh index 5cbc0e4..369df61 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -774,6 +774,236 @@ rm -f "$DUMP_TMP" check "no main-shell os-release sourcing" 1 "" \ grep -rnE '^[[:space:]]*\.[[:space:]]+/etc/os-release' "$ROOT/commands" +# --------------------------------------------------------------------------- +# The versioned install (box#79's layout, ported — #35). RIG_INSTALL_SOURCE +# bypasses the network, so these are REAL runs of install.sh against throwaway +# RIG_HOME/RIG_BIN roots — layout, symlink chain, flat-tree migration, symlink +# healing, use and uninstall are all DRIVEN, not grepped. The bootstrapped-host +# flip gate (rig's analog of box's #66 refusal: WARN, never refuse) is driven +# too, through RIG_ROLE_MARKER fixtures — no root, no network, no real marker. +# --------------------------------------------------------------------------- +check "install.sh is valid bash" 0 "" bash -n "$ROOT/install.sh" +VER="$(cat "$ROOT/VERSION")" +check "--version answers the tree's own VERSION" 0 "rig $VER" "$ROOT/bin/rig" --version +check "-V is --version" 0 "rig $VER" "$ROOT/bin/rig" -V +check "help lists the versioned verbs" 0 "uninstall" "$ROOT/bin/rig" --help + +WORK="$(mktemp -d)" +FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" + +# A fabricated "newer release": the same CLI, a different VERSION — what an +# upgrade actually is, from the installer's point of view. +SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin" +cp "$ROOT/bin/rig" "$SRC9/bin/rig"; chmod +x "$SRC9/bin/rig" +echo "9.9.9-drill" > "$SRC9/VERSION" +SRC8="$WORK/src-8.8.8"; mkdir -p "$SRC8/bin" +cp "$ROOT/bin/rig" "$SRC8/bin/rig"; chmod +x "$SRC8/bin/rig" +echo "8.8.8-drill" > "$SRC8/VERSION" + +inst() { # inst [VAR=val ...] — run install.sh for real + local h="$1" b="$2"; shift 2 + env HOME="$FAKEHOME" RIG_ROLE_MARKER="$WORK/no-marker" \ + RIG_HOME="$h" RIG_BIN="$b" \ + RIG_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.sh" +} +irig() { # irig [VAR=val ...] — run an installed rig, marker-free + env HOME="$FAKEHOME" RIG_ROLE_MARKER="$WORK/no-marker" "$@" +} + +# --- fresh install: the layout and the chain -------------------------------- +H1="$WORK/h1"; B1="$WORK/b1" +check "install: a fresh install runs clean" 0 "done" inst "$H1" "$B1" +check "install: the tree lands in versions/" 0 "" test -x "$H1/versions/$VER/bin/rig" +check "install: 'current' points at versions/" 0 "versions/$VER" readlink "$H1/current" +check "install: the PATH symlink rides the chain" 0 "$H1/current/bin/rig" readlink "$B1/rig" +check "install: rig --version answers through the whole chain" 0 "rig $VER" irig "$B1/rig" --version +check "install: INSTALLED_FROM records the local source" 0 "local:" cat "$H1/versions/$VER/INSTALLED_FROM" + +# --- converge, don't clobber ------------------------------------------------ +touch "$H1/versions/$VER/CANARY" +check "install: a same-version re-run is a no-op that says so" 0 "already installed" inst "$H1" "$B1" +check "install: the no-op left the tree untouched" 0 "" test -e "$H1/versions/$VER/CANARY" +check "install: RIG_REINSTALL=1 replaces that version's tree" 0 "reinstalled" inst "$H1" "$B1" RIG_REINSTALL=1 +check "install: the reinstall really replaced it (canary gone)" 1 "" test -e "$H1/versions/$VER/CANARY" + +# --- a second version: side-by-side, and the flip --------------------------- +check "install: a second version installs side-by-side" 0 "" inst "$H1" "$B1" RIG_INSTALL_SOURCE="$SRC9" +check "install: ...into its own versions dir" 0 "" test -x "$H1/versions/9.9.9-drill/bin/rig" +check "install: ...and the old version stays" 0 "" test -d "$H1/versions/$VER" +check "install: the default flips to the new version" 0 "rig 9.9.9-drill" irig "$B1/rig" --version + +# --- rig versions ----------------------------------------------------------- +check "versions: lists the installed versions" 0 "$VER" irig "$B1/rig" versions +check "versions: marks the current default" 0 "(current)" irig "$B1/rig" versions +check "versions: marks the running one" 0 "(running)" irig "$B1/rig" versions + +# --- rig use ---------------------------------------------------------------- +check "use: no argument is a usage error" 2 "use needs a version" irig "$B1/rig" use +check "use: an unknown version is refused by name" 1 "no such version" irig "$B1/rig" use 1.2.3 +# A version is a directory NAME — a crafted one must die at the gate, never +# reach the ln (current pointing outside the root) or an rm -rf. +check "use: a path-traversal version dies at the gate" 1 "not a sane version name" \ + irig "$B1/rig" use '../../tmp/evil' +check "use: flips the default" 0 "switched to $VER" irig "$B1/rig" use "$VER" +check "use: the flip is effective through the PATH chain" 0 "rig $VER" irig "$B1/rig" --version +check "install: an installed-but-not-current version is a no-op too" 0 "already installed" \ + inst "$H1" "$B1" RIG_INSTALL_SOURCE="$SRC9" +check "install: ...and does not move the default" 0 "rig $VER" irig "$B1/rig" --version + +# --- the flip gate: a bootstrapped host WARNS, never refuses (#35) ---------- +# box refuses version flips under existing boxes; rig's stake is the converged +# host itself — /etc/rig/role. The deliberate decision: warn and proceed. +# Driven against a fixture marker; counting fires proves silence too. +MARK="$WORK/role-marker" +printf 'role=workload class=server host=no join=authkey\n' > "$MARK" +H2="$WORK/h2"; B2="$WORK/b2" +check "flip gate: baseline install" 0 "done" inst "$H2" "$B2" +check "flip gate: an upgrade on a bootstrapped host WARNS" 0 "this host is bootstrapped" \ + inst "$H2" "$B2" RIG_INSTALL_SOURCE="$SRC9" RIG_ROLE_MARKER="$MARK" +check "flip gate: ...and still flips (warn, not refuse)" 0 "rig 9.9.9-drill" irig "$B2/rig" --version +check "flip gate: 'rig use' on a bootstrapped host WARNS" 0 "this host is bootstrapped" \ + irig RIG_ROLE_MARKER="$MARK" "$B2/rig" use "$VER" +check "flip gate: ...and still flips" 0 "rig $VER" irig "$B2/rig" --version +# Silence when no marker: warning every un-bootstrapped host would train +# operators to ignore it. +flip_warns() { # flip_warns — how many bootstrapped warnings fired + "$@" 2>&1 | grep -c "this host is bootstrapped" || true +} +check "flip gate: no marker, no warning (installer)" 0 "0" \ + flip_warns inst "$H2" "$B2" RIG_INSTALL_SOURCE="$SRC9" +check "flip gate: no marker, no warning (rig use)" 0 "0" \ + flip_warns irig "$B2/rig" use "$VER" +check "flip gate: a fresh install never warns (nothing changes under the host)" 0 "0" \ + flip_warns inst "$WORK/h2f" "$WORK/b2f" RIG_ROLE_MARKER="$MARK" + +# --- migration: a flat pre-versioning tree becomes a versioned one ---------- +H3="$WORK/h3"; B3="$WORK/b3"; mkdir -p "$H3/bin" "$B3" +cp "$ROOT/bin/rig" "$H3/bin/rig"; chmod +x "$H3/bin/rig" +cp "$ROOT/VERSION" "$H3/VERSION" +echo "test@flat" > "$H3/INSTALLED_FROM" +ln -s "$H3/bin/rig" "$B3/rig" +check "migrate: a flat tree is moved into versions/" 0 "migrating" inst "$H3" "$B3" +check "migrate: the OPERATOR'S tree moved (not a fresh copy)" 0 "test@flat" \ + cat "$H3/versions/$VER/INSTALLED_FROM" +check "migrate: nothing flat remains at the root" 1 "" test -e "$H3/bin" +check "migrate: current points at the migrated version" 0 "versions/$VER" readlink "$H3/current" +check "migrate: the PATH symlink was re-pointed through current" 0 "$H3/current/bin/rig" readlink "$B3/rig" +check "migrate: the migrated install answers --version" 0 "rig $VER" irig "$B3/rig" --version + +# ...and the seamless upgrade every REAL flat rig install takes: no VERSION +# file at all (pre-rig#32), so it migrates as 0.0.0-unknown and the new +# version lands beside it and becomes the default. +H4="$WORK/h4"; B4="$WORK/b4"; mkdir -p "$H4/bin" "$B4" +cp "$ROOT/bin/rig" "$H4/bin/rig"; chmod +x "$H4/bin/rig" +ln -s "$H4/bin/rig" "$B4/rig" +check "migrate: a VERSION-less flat tree migrates as 0.0.0-unknown" 0 "0.0.0-unknown" \ + inst "$H4" "$B4" RIG_INSTALL_SOURCE="$SRC9" +check "migrate+upgrade: both versions present" 0 "" \ + bash -c "[ -d '$H4/versions/0.0.0-unknown' ] && [ -d '$H4/versions/9.9.9-drill' ]" +check "migrate+upgrade: the new version is the default" 0 "rig 9.9.9-drill" \ + irig "$B4/rig" --version + +# A broken current must halt the single-version uninstall BEFORE any decision: +# the CURRENT guard keys off what current resolves to, and a dangling link +# makes that answer a lie. Drive the version tree's own binary — the current +# chain is exactly what is broken. Heal current afterwards. +ln -sfn "versions/gone" "$H4/current" +check "uninstall: refuses while current is dangling (heal before delete)" 1 "dangling" \ + irig "$H4/versions/9.9.9-drill/bin/rig" uninstall 0.0.0-unknown --force +check "uninstall: ...and both version trees survived the refusal" 0 "" \ + bash -c "[ -d '$H4/versions/0.0.0-unknown' ] && [ -d '$H4/versions/9.9.9-drill' ]" +ln -sfn "versions/9.9.9-drill" "$H4/current" + +# The migration reads VERSION off the old tree — disk data, not installer +# data. A hostile value must refuse BEFORE the tree moves anywhere. +H9="$WORK/h9"; B9="$WORK/b9"; mkdir -p "$H9/bin" "$B9" +cp "$ROOT/bin/rig" "$H9/bin/rig"; chmod +x "$H9/bin/rig" +printf '%s\n' '../pwn' > "$H9/VERSION" +check "migrate: a hostile flat VERSION refuses to migrate" 1 "not a sane directory name" \ + inst "$H9" "$B9" +check "migrate: ...with the flat tree untouched where it was" 0 "" test -x "$H9/bin/rig" + +# --- healing: a wedged $BINDIR/rig must never block an install -------------- +H5="$WORK/h5"; B5="$WORK/b5"; mkdir -p "$B5" +ln -s "$WORK/nowhere/rig" "$B5/rig" # dangling +check "heal: a DANGLING \$BINDIR/rig does not wedge the install" 0 "done" inst "$H5" "$B5" +check "heal: ...and got repointed" 0 "rig $VER" irig "$B5/rig" --version +H6="$WORK/h6"; B6="$WORK/b6"; mkdir -p "$B6" +ln -s /bin/true "$B6/rig" # stale, but resolvable +check "heal: a STALE \$BINDIR/rig with no tree does not fake 'installed'" 0 "installing $VER" \ + inst "$H6" "$B6" +check "heal: ...the install is real and answers" 0 "rig $VER" irig "$B6/rig" --version + +# --- rig uninstall: one version --------------------------------------------- +check "uninstall: refuses to remove the CURRENT version" 1 "CURRENT" \ + irig "$B1/rig" uninstall "$VER" --force +check "uninstall: an unknown version is refused by name" 1 "no such version" \ + irig "$B1/rig" uninstall 5.5.5 --force +check "uninstall: a path-traversal version dies at the gate (never an rm -rf)" 1 "not a sane version name" \ + irig "$B1/rig" uninstall '../../../../etc' --force +check "uninstall: a version plus --all is ambiguous (usage error)" 2 "ambiguous" \ + irig "$B1/rig" uninstall 9.9.9-drill --all --force +check "uninstall: an unknown flag is refused" 2 "unknown option" \ + irig "$B1/rig" uninstall --nope +check "uninstall: removes a non-current version" 0 "removed version" \ + irig "$B1/rig" uninstall 9.9.9-drill --force +check "uninstall: that version dir is gone" 1 "" test -e "$H1/versions/9.9.9-drill" +check "uninstall: the current version still answers" 0 "rig $VER" irig "$B1/rig" --version + +# --- rig uninstall: everything ---------------------------------------------- +check "uninstall: refuses without --force when no terminal" 2 "refusing" \ + irig bash -c "'$B1/rig' uninstall --all /dev/null 2>&1 + mkdir -p "$H7/versions/$VER/stuck"; touch "$H7/versions/$VER/stuck/pin" + chmod 555 "$H7/versions/$VER/stuck" + check "uninstall: a survivor makes it scream INCOMPLETE (exit 1)" 1 "INCOMPLETE" \ + irig "$B7/rig" uninstall --all --force + chmod -R u+w "$H7" 2>/dev/null +else + echo "skip: uninstall INCOMPLETE drill (root ignores file modes)" +fi + +# --- the versioned verbs from a working tree: refuse, don't guess ----------- +check "uninstall: refuses from a working tree" 1 "not a versioned install" "$ROOT/bin/rig" uninstall --all --force +check "versions: refuses from a working tree" 1 "not a versioned install" "$ROOT/bin/rig" versions +check "use: refuses from a working tree" 1 "not a versioned install" "$ROOT/bin/rig" use 1.0.0 + +# The version-name gate must be ONE decision: install.sh and bin/rig carry +# byte-identical copies (the installer runs before any tree exists), and a +# drifted copy is two gates pretending to be one — a version install.sh would +# refuse must not be one 'rig use' accepts. +VVBIN="$(mktemp)"; VVINST="$(mktemp)" +awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/bin/rig" > "$VVBIN" +awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/install.sh" > "$VVINST" +check "valid_version: extracted from bin/rig (guards the awk)" 0 "A-Za-z0-9" cat "$VVBIN" +check "valid_version: bin/rig and install.sh copies are byte-identical" 0 "" diff "$VVBIN" "$VVINST" +rm -f "$VVBIN" "$VVINST" +# Same discipline for the flip gate: one bootstrapped-host stance, two copies. +WBBIN="$(mktemp)"; WBINST="$(mktemp)" +awk '/^warn_bootstrapped\(\) \{/,/^\}/' "$ROOT/bin/rig" > "$WBBIN" +awk '/^warn_bootstrapped\(\) \{/,/^\}/' "$ROOT/install.sh" > "$WBINST" +check "warn_bootstrapped: extracted from bin/rig (guards the awk)" 0 "RIG_ROLE_MARKER" cat "$WBBIN" +check "warn_bootstrapped: bin/rig and install.sh copies are byte-identical" 0 "" diff "$WBBIN" "$WBINST" +rm -f "$WBBIN" "$WBINST" + +rm -rf "$WORK" + echo "---" echo "$PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]