#!/usr/bin/env bash # rig bootstrap — the box TENANT 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'), so every path here is non-interactive and nothing joins # or admits — no tailnet, no keys, no prompts. staging's tailnet join stays # operator-run ('rig bootstrap workload' 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 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 [--user ] 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|codex|grok 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 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` with a tagged pre-auth key. --user the tenant user the box seed created (default: the role's name; staging defaults to `ops`) Tenant roles are creds-free and non-interactive by contract — box auto-runs them at mint (`box exec … rig bootstrap claude`). 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|codex|grok|staging) shift ;; -h|--help) usage; exit 0 ;; "") usage >&2; die "tenant role required (claude|codex|grok|staging)" 2 ;; *) die "unknown tenant role: $ROLE (want claude|codex|grok|staging)" 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 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|workload|runner|dev|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload'." 2 ;; --ts-tag) [ $# -ge 2 ] && shift die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload', 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 rename out loud — before #31, `staging` was the VM-host # PRESET; that shape is now spelled through the traits. # - class= (agent tenants) → refuse: an agent box is never a tailnet machine. # - class= with host=no (staging only) → PROCEED, and leave the marker alone: # that is the staging guest AFTER its operator-run workload join, and # re-converging docker+hardening on it is exactly what convergence is for. 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. Note: before rig#31, 'staging' was the VM-host preset; that shape is now 'rig bootstrap custom --class server --host yes --join authkey' (or 'dev --class server')." ;; *class=*) if [ "$ROLE" != "staging" ]; 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 ;; 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 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 — 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) # The claude 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|grok) apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;; staging) # 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" ]; 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's guests 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" 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, codex) ---------------------------------------------------- # Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified # upstream when the template was written); the claude 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" ] || [ "$ROLE" = "codex" ]; 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 -- …' 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 template's scar). CLI="" CLI_SRC="" case "$ROLE" in claude) 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) 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) # 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) ;; # no agent lives on the staging 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" runuser -l "$TENANT_USER" -c "$CLI --version" >/dev/null 2>&1 \ || 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 ($(runuser -l "$TENANT_USER" -c "$CLI --version" 2>/dev/null | head -n1))" # 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) append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;; codex) append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;; grok) 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 shell niceties --------------------------------------------------- # The claude 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" ]; 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 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 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" ]; 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 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" ]; then log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname 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