rig/commands/bootstrap-tenant.sh
dan-claude-bot 94d9628766 feat(bootstrap)!: box tenant roles carry a -box suffix
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>
2026-07-20 00:36:36 +00:00

393 lines
20 KiB
Bash
Executable file

#!/usr/bin/env bash
# rig bootstrap <claude-box|codex-box|grok-box|staging-box> — the box TENANT
# roles ('-box' names the family: a guest, vs the '-server' machine roles): what a
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the
# tenant content that used to live in the templates' cloud-init, idempotent and
# effective-state asserted, so an EXISTING box can be re-run to a new spec
# instead of re-minted. One mechanism, parameterized per tenant through
# lib/tenant-config.sh — never four hand-maintained copies.
#
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
# bootstrap claude-box'), so every path here is non-interactive and nothing joins
# or admits — no tailnet, no keys, no prompts. staging-box's tailnet join stays
# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the
# creds split box#69 designed.
# Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
creds-free seed (base image, user, rig preinstalled); this converges the
tenant on top, and re-runs converge an existing box to a new spec.
claude-box|codex-box|grok-box
the agent tenants: base tooling (git, gh, tmux, …),
docker, the agent's CLI on the system PATH, and the
agent-context file — including the box#80 guard: never
run `box setup-host` or the drill inside a box.
staging-box the server tenant (box#69's posture): docker + sshd
hardening. The tailnet workload join is deliberately
NOT here — it holds a credential, so it stays
operator-run: `box shell` → `sudo rig bootstrap
workload-server` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the
role's name minus the suffix; staging-box defaults to
`ops`)
Tenant roles are creds-free and non-interactive by contract — box auto-runs
them at mint (`box exec … rig bootstrap claude-box`). They take none of the
machine-role traits (--hostname/--class/--host/--join): a tenant is a guest,
not a tailnet machine. Run as root, inside the box.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
case "$ROLE" in
claude-box|codex-box|grok-box|staging-box) shift ;;
-h|--help) usage; exit 0 ;;
"") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|staging-box)" 2 ;;
*) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|staging-box)" 2 ;;
esac
TENANT_USER="$(tenant_user "$ROLE")"
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 2
TENANT_USER="$2"; shift 2 ;;
--hostname|--class|--host|--join)
# The machine-role traits, refused with a story rather than "unknown
# flag": a tenant is a guest, not a tailnet machine — its shape comes
# from the box seed, and the one trait-shaped thing a staging-box guest
# eventually does (join the tailnet as a workload) is deliberately not
# here: it holds a credential, so it stays operator-run.
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging-box's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 2 ;;
--ts-tag)
[ $# -ge 2 ] && shift
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging-box's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# Same charset the users file enforces, for the same reasons (a leading '-'
# reads as a usermod flag; '|', ':' corrupt things downstream).
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2
# --- guards ------------------------------------------------------------------
# A tenant role converges a box GUEST. A box already carrying a machine-role
# marker is a tailnet machine rig built on purpose, and quietly turning it into
# a tenant (or clobbering its marker) is how a fleet box gets poisoned. Checked
# BEFORE the root check so the refusals are testable non-root, off fixture
# markers (repo precedent: the coolify marker warning). Two refusals, one
# tolerance:
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
# Names the staging PAIR out loud, because whoever lands here has the two
# halves confused: the metal is `staging-server`, the guest `staging-box`.
# - class= (agent tenants) → refuse: an agent box is never a tailnet machine.
# - class=server with host=no (staging-box only) → PROCEED, and leave the
# marker alone: that is the guest AFTER its operator-run workload join, and
# re-converging docker+hardening on it is exactly what convergence is for.
# ONLY that shape — any other class (say class=human, via `custom`) is a
# machine rig built on purpose, and staging-box hardening it with server rules
# would die with server-specific messaging on a box that was never one.
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
case "$EXISTING_MARKER" in
*host=yes*)
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;;
*class=*)
if [ "$ROLE" != "staging-box" ]; then
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
fi
case "$EXISTING_MARKER" in
*class=server*) ;;
*)
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (class=server host=no). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run." ;;
esac ;;
esac
[ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars.
# shellcheck source=/dev/null
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
*debian*) ;;
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
# The tenant user is the SEED's to create (box.env BOX_USER + cloud-init), not
# rig's to conjure: a missing user means the seed and the role disagree, and
# inventing an account here would paper over exactly that mismatch.
id -u "$TENANT_USER" >/dev/null 2>&1 \
|| die "user '$TENANT_USER' does not exist — the box seed creates it (BOX_USER); pass --user <name> if this box's user differs"
TENANT_HOME="$(getent passwd "$TENANT_USER" | cut -d: -f6)"
TENANT_GROUP="$(id -gn "$TENANT_USER")"
[ -d "$TENANT_HOME" ] || die "user '$TENANT_USER' has no home directory ($TENANT_HOME)"
# append_line_once <file> <line> — converge a literal rc line: present exactly
# once, appended only when missing, ownership converged to the tenant user.
append_line_once() {
local file="$1" line="$2"
if [ ! -e "$file" ] || ! grep -qxF "$line" "$file"; then
printf '%s\n' "$line" >> "$file"
log "appended to ${file}: ${line}"
fi
chown "$TENANT_USER:$TENANT_GROUP" "$file"
}
# --- packages ----------------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive
log "installing base packages (tenant ${ROLE})"
apt-get update -qq
case "$ROLE" in
claude-box)
# The claude-box tenant keeps zsh (its shell UX ships with the box); the
# remaining list is the shared agent toolbelt the templates carried.
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;;
codex-box|grok-box)
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
staging-box)
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
# which only exists once the package is installed — pristine container/VM
# images (and thin seeds) do not ship it.
apt-get install -y -qq curl ca-certificates tmux openssh-server ;;
esac
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
# the operator's git credential lands.
command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it"
if [ "$ROLE" != "staging-box" ]; then
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
command -v git >/dev/null 2>&1 || die "git missing after package install"
fi
# --- docker ------------------------------------------------------------------
# Every tenant gets docker (the templates all carried it; staging-box's workloads run
# their workloads in it). Docker's own installer, convergence-guarded — its
# script is not a no-op when docker exists, so rig supplies the guard.
if ! command -v docker >/dev/null 2>&1; then
log "installing docker (get.docker.com)"
curl -fsSL https://get.docker.com | sh
else
log "docker already installed"
fi
docker --version >/dev/null 2>&1 || die "docker installed but 'docker --version' does not answer"
# The client answering is not the effective state — a dead dockerd would still
# pass it. Ask the daemon, with a bounded settle for the freshly-installed case
# (get.docker.com starts it, but not instantaneously on a slow guest).
docker_up=""
for _ in 1 2 3 4 5 6; do
if docker info >/dev/null 2>&1; then docker_up=1; break; fi
sleep 5
done
[ -n "$docker_up" ] || die "dockerd does not answer 'docker info' after 30s — the daemon is not running; check 'systemctl status docker' (or the container's init) before re-running"
log "dockerd answering"
if getent group docker >/dev/null 2>&1; then
if id -nG "$TENANT_USER" | tr ' ' '\n' | grep -qx docker; then
log "${TENANT_USER} already in the docker group"
else
usermod -aG docker "$TENANT_USER"
log "added ${TENANT_USER} to the docker group"
fi
else
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
fi
# --- node (claude-box, codex-box) ----------------------------------------------------
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified
# upstream when the template was written); the claude-box tenant ships node as part
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node.
node_ok() {
command -v node >/dev/null 2>&1 || return 1
local major
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
[ "${major:-0}" -ge 22 ] 2>/dev/null
}
if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
if node_ok; then
log "node $(node --version) already present"
else
log "installing node 22 (nodesource)"
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq nodejs
fi
node_ok || die "node >= 22 still missing after install — check the nodesource setup"
fi
# --- the agent CLI -----------------------------------------------------------
# Per-agent install, shared discipline: install only when the CLI is absent
# (upgrades are the CLI's own business), then put it on the SYSTEM path —
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc
# files, so a PATH export alone is invisible to it (the #15 lesson) — and
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is
# what cost the last drill (the grok-box template's scar).
CLI="" CLI_SRC=""
case "$ROLE" in
claude-box)
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Claude Code CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash'
else
log "claude CLI already installed"
fi ;;
codex-box)
CLI=codex
if ! command -v codex >/dev/null 2>&1; then
log "installing the Codex CLI (npm global)"
npm install -g @openai/codex
else
log "codex CLI already installed"
fi
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
grok-box)
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`,
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir.
# Run it AS the tenant user, never root: a symlink into root's 0700 home
# would be a CLI that exists and cannot run.
CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Grok CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash'
else
log "grok CLI already installed"
fi ;;
staging-box) ;; # no agent lives on the staging-box tenant
esac
if [ -n "$CLI" ]; then
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
# One capture serves both the assert and the log line; emptiness IS the
# failure signal (head exits 0 regardless, so a pipeline status can't be).
CLI_VER="$(runuser -l "$TENANT_USER" -c "$CLI --version" 2>/dev/null | head -n1)"
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
# The interactive-shell PATH exports the templates carried, converged as
# literal rc lines (written once, never duplicated). Single quotes are the
# point: the line must expand in the USER's shell, not here.
# shellcheck disable=SC2016
case "$ROLE" in
claude-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
codex-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
grok-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
esac
fi
# --- the agent-context file --------------------------------------------------
# The one file every agent reads before touching anything. Rendered from
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or
# the drill inside a box; the box you are in is not a host you own") lives
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded
# like every file rig converges.
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
CTX_DIR="$(dirname "$CTX_PATH")"
if [ ! -d "$CTX_DIR" ]; then
mkdir -p "$CTX_DIR"
log "created ${CTX_DIR}"
fi
# The dotdir is the AGENT's (it writes state next to its instructions), so
# its ownership is converged on every run, not only on creation.
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
CTX_TMP="$(mktemp)"
render_tenant_context "$ROLE" > "$CTX_TMP"
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
log "agent-context file written: ${CTX_PATH}"
else
log "agent-context file already current"
fi
rm -f "$CTX_TMP"
fi
# --- claude-box shell niceties ---------------------------------------------------
# The claude-box template shipped zsh + oh-my-zsh + tmux mouse mode; they move with
# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a
# bootstrap whose real work (CLI, context, docker) already converged.
if [ "$ROLE" = "claude-box" ]; then
if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then
chsh -s /usr/bin/zsh "$TENANT_USER"
log "login shell set to zsh for ${TENANT_USER}"
else
log "login shell already zsh for ${TENANT_USER}"
fi
if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then
log "installing oh-my-zsh for ${TENANT_USER}"
# Single quotes on purpose: the $(...) must run in the USER's shell.
# shellcheck disable=SC2016
runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \
|| warn "oh-my-zsh install failed — cosmetic only; continuing"
else
log "oh-my-zsh already installed"
fi
# After oh-my-zsh (it rewrites .zshrc on first install).
# shellcheck disable=SC2016
append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"'
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
fi
# --- staging-box server posture --------------------------------------------------
# box#69's posture, minus the join: docker (above) + sshd hardening, through
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a
# workload server in waiting, and its door must never be password-open even
# before the operator joins it. class=server: root SSH stays the control
# plane's future automation door.
if [ "$ROLE" = "staging-box" ]; then
harden_sshd server
fi
# --- role marker --------------------------------------------------------------
# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant
# has no root-door policy of its own — close-root fails closed on it), and
# host=no so `rig users apply` box-role gating keeps working. staging-box SKIPS the
# write when a machine marker is already present: after the operator-run
# workload join, the workload marker is the truer statement and rig never
# clobbers state a joined box earned.
if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then
MARKER_TMP="$(mktemp)"
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
mkdir -p "$(dirname "$MARKER_PATH")"
install -m 0644 "$MARKER_TMP" "$MARKER_PATH"
log "role marker written: role=$ROLE tenant=yes host=no"
else
log "role marker already current"
fi
rm -f "$MARKER_TMP"
else
log "machine role marker present (${EXISTING_MARKER}); leaving it alone"
fi
log "done — tenant ${ROLE}, user ${TENANT_USER}"
if [ "$ROLE" = "staging-box" ]; then
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --hostname <name> with a tagged pre-auth key"
else
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
fi