feat(install): versioned installs and a real uninstall — box#79's layout, ported (#35) #36

Merged
dan-claude-bot merged 1 commit from feat/versioned-installs into main 2026-07-18 20:18:05 +00:00
5 changed files with 765 additions and 29 deletions

View file

@ -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/<version>/ one full tree per installed version
current -> versions/<v> 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 <version> # 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=<dir-or-tarball>` 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 <version> # 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

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.0-dev

268
bin/rig
View file

@ -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 <command> [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 <root>/versions/<v>, 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 <version>
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 [<version>|--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 <root>/versions/<v> and becomes the default.
EOF
}
# --- the versioned install (box#79's layout, ported — #35) -------------------
# install.sh lands each version at <install-root>/versions/<v>, 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/<v> — 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 <version>"
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 <version>' to repoint the default first (refusing to remove versions while it is broken)"
[ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'rig use <other>' 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

View file

@ -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/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default version
# $BINDIR/rig -> $DEST/current/bin/rig the PATH entry
#
# Versions install side by side: `rig versions` lists them, `rig use <v>`
# 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=<dir-or-tarball> 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/<v> 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/<its-VERSION> 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 <repo>-<ref> — 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-<ref>/
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/<version> -----------------------------------
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 '<unknown source>')"
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/<v> 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"

View file

@ -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 <rig_home> <rig_bin> [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 ...] <cmd...> — 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/<v>" 0 "" test -x "$H1/versions/$VER/bin/rig"
check "install: 'current' points at versions/<v>" 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 <cmd...> — 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"
check "uninstall --all: warns on a bootstrapped host (never refuses)" 0 "this host is bootstrapped" \
irig RIG_ROLE_MARKER="$MARK" "$B1/rig" uninstall --all --force
check "uninstall --all: removed the whole install" 0 "" bash -c "
[ ! -e '$H1' ] && [ ! -L '$H1' ] &&
[ ! -e '$B1/rig' ] && [ ! -L '$B1/rig' ]"
# ...and RIG_YES=1 is the installer-family consent (no --force, no tty).
check "uninstall --all: RIG_YES=1 is consent without a terminal" 0 "uninstalled" \
irig bash -c "RIG_YES=1 '$B2/rig' uninstall --all </dev/null"
check "uninstall --all: ZERO residue — root and symlinks" 0 "" bash -c "
[ ! -e '$H2' ] && [ ! -L '$H2' ] &&
[ ! -e '$B2/rig' ] && [ ! -L '$B2/rig' ]"
# The last word is a re-check: a survivor must turn into a loud INCOMPLETE,
# never a cheerful "uninstalled". (Root ignores file modes, so this drill is
# meaningful — and runnable — for a non-root runner only.)
if [ "$(id -u)" -ne 0 ]; then
H7="$WORK/h7"; B7="$WORK/b7"
inst "$H7" "$B7" >/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 ]