The other half of #76. claude -> claude-box, codex -> codex-box, grok -> grok-box, staging -> staging-box, so a role name always says which family it belongs to: -server builds a fleet machine, -box converges a guest a box minted. With both halves in, the two families can no longer collide on a word the way `staging` did. The role carries the suffix; nothing inside the guest does. A tenant user is the account the box SEED created (BOX_USER) and each agent CLI reads its own dotdir, so claude-box still converges the `claude` user and still writes ~/.claude/CLAUDE.md. Every rename here is a $ROLE comparison or a case arm -- no CLI binary name, no dotdir path, and no account moved. README's tenant table now shows role and user in adjacent columns, because that distinction stopped being cosmetic the moment they differed. Hard cut, no aliases. The old names are refused as unknown at BOTH entrypoints -- `rig bootstrap <name>` and bootstrap-tenant.sh directly -- and the suite asserts each of the four at each, because bootstrap.sh keeps its own dispatch list and a name could survive in one and not the other. An alias left in for a single tenant is the shape that survives review: the taxonomy reads complete while one old name still quietly converges. The consequence is cross-repo. A seed carrying BOX_BOOTSTRAP_ROLE="claude" now fails its own mint-time bootstrap, so heavy-duty/box#123 updates the seeds and must land after this. Closes #76 (tenant half) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
458 lines
18 KiB
Bash
Executable file
458 lines
18 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
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]
|
|
|
|
commands:
|
|
bootstrap <control-plane-server|workload-server|runner-server|staging-server|
|
|
dev-server|workstation|custom>
|
|
(--users <path> | --no-users)
|
|
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
|
|
[--join <authkey|login>]
|
|
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
|
|
tailscale join, then this box's operators. --users is REQUIRED on
|
|
every role — it converges the users file as bootstrap's last phase
|
|
(identical to 'rig users apply --file <path>', marker written first)
|
|
so one command leaves a box with its people on it; --no-users is the
|
|
deliberate opt-out and leaves root as the only door. The tenant roles
|
|
below take neither. Roles are presets over the three traits; any flag
|
|
overrides its trait, and custom states all of them. Prompts for a
|
|
single-use TAGGED tailnet pre-auth key (TS_AUTHKEY env overrides the
|
|
prompt); the key's tags are the tailnet tag, verified after join —
|
|
only control-plane-server and workload-server may carry tag:server.
|
|
The '-server' suffix names the FAMILY (a fleet machine, vs the '-box'
|
|
tenant roles); custom and workstation take none. join=login
|
|
(workstation) needs no key: interactive login, node must come up
|
|
untagged. Run as root.
|
|
bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
|
|
The box TENANT roles: converge a box-minted guest. The '-box' suffix
|
|
names the family (a guest, vs the '-server' machine roles above).
|
|
claude-box|codex-box|grok-box land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on
|
|
the system PATH, and the agent-context file (with the box#80 guard:
|
|
never run box setup-host inside a box). staging-box lands box#69's
|
|
server posture — docker + sshd hardening; its tailnet join stays
|
|
operator-run via 'rig bootstrap workload-server'. Creds-free and non-interactive: box
|
|
auto-runs these at mint. Run as root, inside the box.
|
|
coolify install --version <pin>
|
|
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
|
coolify backup install [options]
|
|
Nightly age-encrypted dump of the control-plane database, as a
|
|
systemd timer. rig installs the machinery and templates an empty
|
|
0600 bindings file; you fill in the age recipient and S3 details.
|
|
Control-plane box only. Run as root.
|
|
db <dump|restore> ...
|
|
Ad-hoc PostgreSQL dump/restore for a container on this box. `dump`
|
|
writes a gzipped SQL artifact (--no-owner --no-acl, so it restores
|
|
onto a different instance); `restore` loads one back, connecting as
|
|
the container's own superuser, behind a confirm gate. Run as root.
|
|
runner install --repo <owner/repo> [options]
|
|
GitHub Actions runner as a systemd service under an unprivileged
|
|
user — outbound-only, no Docker. Prompts for the short-lived
|
|
registration token (RUNNER_TOKEN env overrides). Run as root.
|
|
runner status [--user <name>]
|
|
What this box's runner is registered to: repo, name, labels, unit.
|
|
Reads the box only — no token, no network call. Run as root.
|
|
runner remove [--local] [--user <name>]
|
|
Take the service down and deregister the runner. Prompts for the
|
|
short-lived removal token (RUNNER_REMOVE_TOKEN env overrides).
|
|
Run as root.
|
|
runner repoint --repo <owner/repo> [options]
|
|
Move an installed runner to another repository — deregister, then
|
|
re-register, reusing the binary already on the box. Needs a removal
|
|
token for the old repo and a registration token for the new one.
|
|
Run as root.
|
|
users apply --file <path>
|
|
Converge named operator accounts from a declarative users file, on
|
|
every class: groups by role (admin/rig/box), passwords locked always,
|
|
authorized_keys made exact, visudo-gated sudoers rules. Users dropped
|
|
from the file are locked, never deleted. '-' reads stdin. Run as root.
|
|
users status
|
|
Roles (derived from actual group membership), key counts and lock
|
|
state for the rig-managed users. Reads the box only. Run as root.
|
|
users close-root
|
|
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
|
|
Installs the latest RELEASE (RIG_REF=<tag> pins one, RIG_REF=main
|
|
tracks the development tree). 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)
|
|
shift
|
|
exec "$ROOT/commands/bootstrap.sh" "$@"
|
|
;;
|
|
coolify)
|
|
shift
|
|
sub="${1:-}"
|
|
case "$sub" in
|
|
install)
|
|
shift
|
|
exec "$ROOT/commands/coolify-install.sh" "$@"
|
|
;;
|
|
backup)
|
|
shift
|
|
if [ "${1:-}" != "install" ]; then
|
|
usage >&2
|
|
exit 2
|
|
fi
|
|
shift
|
|
exec "$ROOT/commands/coolify-backup-install.sh" "$@"
|
|
;;
|
|
*)
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
;;
|
|
db)
|
|
shift
|
|
case "${1:-}" in
|
|
dump|restore|-h|--help)
|
|
exec "$ROOT/commands/db.sh" "$@"
|
|
;;
|
|
*)
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
;;
|
|
runner)
|
|
shift
|
|
sub="${1:-}"
|
|
case "$sub" in
|
|
install)
|
|
shift
|
|
exec "$ROOT/commands/runner-install.sh" "$@"
|
|
;;
|
|
status)
|
|
shift
|
|
exec "$ROOT/commands/runner-status.sh" "$@"
|
|
;;
|
|
remove)
|
|
shift
|
|
exec "$ROOT/commands/runner-remove.sh" "$@"
|
|
;;
|
|
repoint)
|
|
shift
|
|
exec "$ROOT/commands/runner-repoint.sh" "$@"
|
|
;;
|
|
*)
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
;;
|
|
users)
|
|
shift
|
|
sub="${1:-}"
|
|
case "$sub" in
|
|
apply)
|
|
shift
|
|
exec "$ROOT/commands/users-apply.sh" "$@"
|
|
;;
|
|
status)
|
|
shift
|
|
exec "$ROOT/commands/users-status.sh" "$@"
|
|
;;
|
|
close-root)
|
|
shift
|
|
exec "$ROOT/commands/users-close-root.sh" "$@"
|
|
;;
|
|
*)
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
;;
|
|
versions)
|
|
shift
|
|
cmd_versions "$@"
|
|
;;
|
|
use)
|
|
shift
|
|
cmd_use "$@"
|
|
;;
|
|
uninstall)
|
|
shift
|
|
cmd_uninstall "$@"
|
|
;;
|
|
-V|--version)
|
|
version
|
|
exit 0
|
|
;;
|
|
-h|--help|help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
"")
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
*)
|
|
printf 'rig: unknown command: %s\n' "$cmd" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|