forked from heavy-duty/rig
feat(bootstrap): the tenant mechanism converges from fetched definitions — the agent case arms are cut (#110)
bootstrap-tenant.sh is one mechanism parameterized by a registry definition (template.env parsed against the allowlist, install.sh run as root with the tenant in its environment, creds.md spliced into the shared context skeleton). staging-box stays in-tree — mechanism-adjacent, no agent. The dispatch in bootstrap.sh moves to the '-box' family-suffix rule so a template added to the registry is mintable with zero code changes here; an unknown role's refusal lists what the resolved source actually contains, naming the source. lib/tenant-config.sh — the per-tenant case-arm table — is deleted: the hard cut, #76's discipline.
This commit is contained in:
parent
c9c8ad9ba9
commit
b99d08ea3d
4 changed files with 237 additions and 273 deletions
|
|
@ -1,24 +1,34 @@
|
|||
#!/usr/bin/env bash
|
||||
# rig bootstrap <claude-box|codex-box|grok-box|kimi-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.
|
||||
# rig bootstrap <role>-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 by a fetched DEFINITION (#110): the
|
||||
# agent-tenant registry lives in heavy-duty/rig-templates — one directory per
|
||||
# role (template.env, install.sh, creds.md), resolved through lib/templates.sh
|
||||
# (RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin) — so adding a
|
||||
# tenant is a data PR there, never an edit here (#109 is the scar: adding
|
||||
# kimi, pure data, meant editing six files in this repo). staging-box is the
|
||||
# one in-tree tenant: it is mechanism-adjacent (sshd hardening, docker — no
|
||||
# agent, no CLI, no context file), so it converges from rig's own tree.
|
||||
#
|
||||
# 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.
|
||||
# or admits — no tailnet, no keys, no prompts. That is also why the registry
|
||||
# fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
|
||||
# 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/templates.sh
|
||||
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
|
||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
||||
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||
|
|
@ -32,26 +42,35 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
usage: rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>]
|
||||
usage: rig bootstrap <role>-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|kimi-box
|
||||
the agent tenants: base tooling (git, gh, tmux, …),
|
||||
<role>-box an agent tenant DEFINED IN THE REGISTRY
|
||||
(heavy-duty/rig-templates — claude-box, codex-box,
|
||||
grok-box, kimi-box, …): 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.
|
||||
staging-box the server tenant (box#69's posture), in rig's own
|
||||
tree: 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`)
|
||||
definition's USER; staging-box defaults to `ops`)
|
||||
|
||||
The registry source is three knobs, precedence high to low:
|
||||
RIG_TEMPLATES_DIR a local folder (no fetch — the offline/test path, and
|
||||
"try a template before it exists anywhere")
|
||||
RIG_TEMPLATES_REF a ref of RIG_TEMPLATES_REPO (default
|
||||
heavy-duty/rig-templates), fetched as a tarball
|
||||
(neither set) the ref pinned in rig's tree (lib/templates.sh
|
||||
RIG_TEMPLATES_PIN — bumped by ordinary rig PR, so a
|
||||
rig release freezes the mechanism+registry pair)
|
||||
|
||||
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
|
||||
|
|
@ -63,19 +82,29 @@ EOF
|
|||
# --- args (validated before the root check, so errors are testable) ---------
|
||||
ROLE="${1:-}"
|
||||
case "$ROLE" in
|
||||
claude-box|codex-box|grok-box|kimi-box|staging-box) shift ;;
|
||||
staging-box) shift ;;
|
||||
*-box)
|
||||
# The family suffix is the whole gate here — WHICH '-box' roles exist is
|
||||
# the resolved registry's fact, checked below, so a template added to the
|
||||
# registry is mintable with zero code changes in rig (#110).
|
||||
shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
"") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
|
||||
*) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
|
||||
"") usage >&2; die "tenant role required (a '-box' role from the template registry, or staging-box)" 2 ;;
|
||||
*) die "unknown tenant role: $ROLE — tenant roles carry the '-box' family suffix (#76); the machine roles are control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom" 2 ;;
|
||||
esac
|
||||
# The suffix rule above admits ANY '-box' name, so the charset is pinned
|
||||
# before the name is ever used as a path component: a crafted role dies HERE,
|
||||
# never in a registry lookup (the valid_version discipline, bin/rig).
|
||||
[[ "$ROLE" =~ ^[a-z][a-z0-9-]*-box$ ]] \
|
||||
|| die "invalid tenant role name: '$ROLE' — must match ^[a-z][a-z0-9-]*-box\$" 2
|
||||
|
||||
TENANT_USER="$(tenant_user "$ROLE")"
|
||||
TENANT_USER_OVERRIDE=""
|
||||
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 ;;
|
||||
TENANT_USER_OVERRIDE="$2"; shift 2 ;;
|
||||
--hostname|--root-door|--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
|
||||
|
|
@ -89,6 +118,32 @@ while [ $# -gt 0 ]; do
|
|||
*) die "unknown flag: $1" 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- the definition ----------------------------------------------------------
|
||||
# Resolved and parsed BEFORE the root check, so the two refusals a definition
|
||||
# can earn — unknown role (listing what the resolved source actually
|
||||
# contains) and malformed data (naming the failing key) — are testable
|
||||
# non-root, offline, via RIG_TEMPLATES_DIR fixtures. The parse is the mint's
|
||||
# own guard, deliberately duplicating the registry CI's lint: CI protects the
|
||||
# registry, this protects a mint served through RIG_TEMPLATES_REPO/_DIR that
|
||||
# CI never saw. template.env is parsed, NEVER sourced — a definition cannot
|
||||
# execute arbitrary shell through its data file; install.sh is the one
|
||||
# deliberately executable part, and it runs only after the root check below.
|
||||
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
|
||||
TPL_DIR=""
|
||||
if [ "$ROLE" = "staging-box" ]; then
|
||||
TENANT_USER="${TENANT_USER_OVERRIDE:-ops}" # box#69's ops
|
||||
else
|
||||
templates_resolve \
|
||||
|| die "cannot resolve the template registry ($(templates_source_desc)) — see above" 2
|
||||
TPL_DIR="$REGISTRY_DIR/$ROLE"
|
||||
if [ ! -f "$TPL_DIR/template.env" ]; then
|
||||
die "unknown tenant role: $ROLE — the resolved registry ($(templates_source_desc)) defines: $(templates_roles "$REGISTRY_DIR" | tr '\n' ' ')— and staging-box is in rig's own tree. A misconfigured RIG_TEMPLATES_REPO/_REF/_DIR looks exactly like this; check the source before the spelling." 2
|
||||
fi
|
||||
template_parse_env "$TPL_DIR/template.env" \
|
||||
|| die "invalid definition for $ROLE in $(templates_source_desc) — the failing key is named above. The registry's CI lints every PR ('rig template-lint'); a malformed definition reaching a mint means the source above was never linted." 2
|
||||
TENANT_USER="${TENANT_USER_OVERRIDE:-$TPL_USER}"
|
||||
fi
|
||||
# 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}$ ]] \
|
||||
|
|
@ -178,19 +233,18 @@ append_line_once() {
|
|||
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|kimi-box)
|
||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
|
||||
staging-box)
|
||||
if [ "$ROLE" = "staging-box" ]; then
|
||||
# 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
|
||||
apt-get install -y -qq curl ca-certificates tmux openssh-server
|
||||
else
|
||||
# The shared agent toolbelt the templates carried, plus the definition's
|
||||
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
|
||||
# word list, every word already vetted by the parser's package-name gate.
|
||||
# shellcheck disable=SC2086
|
||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS
|
||||
fi
|
||||
# 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.
|
||||
|
|
@ -232,17 +286,19 @@ 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 (definitions carrying NEEDS_NODE="yes") ----------------------------
|
||||
# An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
|
||||
# verified upstream when the template was written); claude ships node as part
|
||||
# of its toolbelt, same pin. Whether a tenant needs it is the DEFINITION's
|
||||
# fact (NEEDS_NODE), never a role list here — grok's CLI is a self-contained
|
||||
# binary and kimi's is uv-managed Python, so both say no.
|
||||
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 [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
|
||||
if node_ok; then
|
||||
log "node $(node --version) already present"
|
||||
else
|
||||
|
|
@ -254,60 +310,53 @@ if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
|
|||
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).
|
||||
# Per-definition install, shared discipline: install only when the CLI is
|
||||
# absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
|
||||
# definition names one, `command -v` when it does not (an npm global's path
|
||||
# is the prefix's fact, not the data file's) — 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).
|
||||
#
|
||||
# install.sh — the definition's one executable part — runs AS ROOT with the
|
||||
# tenant named in its environment (TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE);
|
||||
# each definition drops to the tenant user itself (runuser -l) where the
|
||||
# vendor's layout demands it, because some installs are inherently root's
|
||||
# (codex's npm -g writes the global prefix). This is the trade #110 states in
|
||||
# bold — a registry definition executes as root inside every future mint —
|
||||
# and it is why install.sh diffs there are the highest-trust review surface
|
||||
# in the org, why the default ref is a reviewed in-tree pin, and why the data
|
||||
# file beside it is parsed rather than sourced.
|
||||
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 ;;
|
||||
kimi-box)
|
||||
# The OFFICIAL installer (code.kimi.com/install.sh): a uv-managed Python
|
||||
# tool (kimi-cli), landing `kimi` in ~/.local/bin — uv's tool bin — with
|
||||
# uv bringing its own managed CPython, so no apt python pin here (the
|
||||
# node section above stays claude/codex-only for the same reason). Run AS
|
||||
# the tenant user, never root: grok's lesson — a root-owned install under
|
||||
# a 0700 home is a CLI that exists and cannot run.
|
||||
CLI=kimi CLI_SRC="$TENANT_HOME/.local/bin/kimi"
|
||||
if [ ! -e "$CLI_SRC" ]; then
|
||||
log "installing the Kimi CLI as ${TENANT_USER}"
|
||||
runuser -l "$TENANT_USER" -c 'curl -LsSf https://code.kimi.com/install.sh | bash'
|
||||
else
|
||||
log "kimi CLI already installed"
|
||||
fi ;;
|
||||
staging-box) ;; # no agent lives on the staging-box tenant
|
||||
if [ "$ROLE" != "staging-box" ]; then
|
||||
CLI="$TPL_CLI_NAME"
|
||||
# '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
|
||||
# substitution, never by the shell (hence the literal quoted tilde, SC2088).
|
||||
# shellcheck disable=SC2088
|
||||
case "$TPL_CLI_SRC" in
|
||||
'~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
|
||||
*) CLI_SRC="$TPL_CLI_SRC" ;;
|
||||
esac
|
||||
if [ -n "$CLI" ]; then
|
||||
installed=""
|
||||
if [ -n "$CLI_SRC" ]; then
|
||||
[ -e "$CLI_SRC" ] && installed=1
|
||||
elif command -v "$CLI" >/dev/null 2>&1; then
|
||||
installed=1
|
||||
fi
|
||||
if [ -z "$installed" ]; then
|
||||
log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
|
||||
TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
|
||||
TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
|
||||
bash "$TPL_DIR/install.sh" \
|
||||
|| die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
|
||||
else
|
||||
log "${CLI} CLI already installed"
|
||||
fi
|
||||
if [ -z "$CLI_SRC" ]; then
|
||||
CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
|
||||
[ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?"
|
||||
fi
|
||||
[ -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
|
||||
|
|
@ -316,29 +365,23 @@ if [ -n "$CLI" ]; then
|
|||
[ -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"' ;;
|
||||
kimi-box)
|
||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
|
||||
esac
|
||||
# The interactive-shell PATH export the templates carried, converged as a
|
||||
# literal rc line (written once, never duplicated). The definition's
|
||||
# PATH_LINE is DATA, appended verbatim: it must expand in the USER's
|
||||
# shell, not here.
|
||||
append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
|
||||
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
|
||||
# The one file every agent reads before touching anything. The skeleton —
|
||||
# including 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") — is MECHANISM,
|
||||
# rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
|
||||
# template; only the creds paragraph is the definition's (creds.md).
|
||||
# cmp-guarded like every file rig converges. staging-box has no agent and no
|
||||
# context file.
|
||||
if [ "$ROLE" != "staging-box" ]; then
|
||||
CTX_PATH="$TENANT_HOME/$TPL_CONTEXT_PATH"
|
||||
CTX_DIR="$(dirname "$CTX_PATH")"
|
||||
if [ ! -d "$CTX_DIR" ]; then
|
||||
mkdir -p "$CTX_DIR"
|
||||
|
|
@ -348,7 +391,7 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
|||
# 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"
|
||||
render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$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}"
|
||||
|
|
@ -358,32 +401,6 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
|||
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
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ usage: rig bootstrap <control-plane-server|workload-server|runner-server|
|
|||
(--users <path> | --no-users)
|
||||
[--hostname <name>] [--root-door <closed|open>]
|
||||
[--host <yes|no>] [--join <authkey|login>]
|
||||
rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>]
|
||||
(the box TENANT roles — see their own --help; they take
|
||||
no --users, see below)
|
||||
rig bootstrap <role>-box [--user <name>]
|
||||
(the box TENANT roles — the agent tenants come from the
|
||||
heavy-duty/rig-templates registry, staging-box from
|
||||
rig's own tree; see their own --help — they take no
|
||||
--users, see below)
|
||||
rig bootstrap --undo
|
||||
leave the tailnet only when the role marker proves rig
|
||||
performed the join, then remove the role marker
|
||||
|
|
@ -59,7 +61,8 @@ and per-human accounts keep attribution intact for the times someone does go
|
|||
in. So the complete path is the default path and skipping it is a deliberate
|
||||
--no-users, not an omission.
|
||||
|
||||
--users does NOT reach the box TENANT roles (claude-box|codex-box|grok-box|kimi-box|staging-box). A
|
||||
--users does NOT reach the box TENANT roles (any '-box' name, e.g.
|
||||
claude-box, staging-box). A
|
||||
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
|
||||
non-interactively, with no file to hand it; the guest never joins the tailnet
|
||||
and has no SSH door of its own — entry is `box shell`, gated by the HOST's
|
||||
|
|
@ -122,15 +125,18 @@ case "$ROLE" in
|
|||
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
|
||||
exec "$HERE/bootstrap-undo.sh" ;;
|
||||
control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;;
|
||||
claude-box|codex-box|grok-box|kimi-box|staging-box)
|
||||
*-box)
|
||||
# The box TENANT roles (#31) are a different family — guests a box mints,
|
||||
# never tailnet machines — and live in their own mechanism, one script
|
||||
# parameterized per tenant. Dispatched here so `rig bootstrap <role>`
|
||||
# stays the single entrypoint for both families.
|
||||
# parameterized per DEFINITION fetched from the template registry (#110;
|
||||
# staging-box stays in-tree). Dispatched on the FAMILY SUFFIX (#76), not
|
||||
# an enumerated list: which '-box' roles exist is the registry's fact, so
|
||||
# a template added there is mintable with zero code changes here.
|
||||
# `rig bootstrap <role>` stays the single entrypoint for both families.
|
||||
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
|
||||
*) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
|
||||
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
|
||||
*) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
|
||||
esac
|
||||
|
||||
# Role→traits map — the single place a role's shape is declared (issue #26).
|
||||
|
|
|
|||
|
|
@ -55,23 +55,27 @@ templates_source_desc() {
|
|||
}
|
||||
|
||||
# templates_resolve — resolve the three knobs to a LOCAL directory holding
|
||||
# the registry, printed on stdout. RIG_TEMPLATES_DIR wins and is used as-is;
|
||||
# otherwise the repo@ref tarball is fetched and extracted under a temp dir,
|
||||
# recorded in TEMPLATES_TMP for the caller to rm. Candidate URLs follow
|
||||
# install.sh's precedence — a tag outranks a branch that shares its name —
|
||||
# plus the bare archive/<ref> form, which is how a commit-SHA pin (the
|
||||
# default) downloads. Failure lists every URL tried: the fetch is
|
||||
# unauthenticated by contract (box auto-runs bootstrap at mint, holding
|
||||
# nothing), so "is the repo public and the ref real" is the whole diagnosis.
|
||||
# the registry, left in the REGISTRY_DIR global (a global, not stdout: a
|
||||
# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP,
|
||||
# the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is
|
||||
# used as-is; otherwise the repo@ref tarball is fetched and extracted under
|
||||
# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's
|
||||
# precedence — a tag outranks a branch that shares its name — plus the bare
|
||||
# archive/<ref> form, which is how a commit-SHA pin (the default) downloads.
|
||||
# Failure lists every URL tried: the fetch is unauthenticated by contract
|
||||
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
|
||||
# and the ref real" is the whole diagnosis.
|
||||
TEMPLATES_TMP=""
|
||||
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
||||
REGISTRY_DIR=""
|
||||
templates_resolve() {
|
||||
local repo ref url got="" d
|
||||
local repo ref url got=""
|
||||
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
||||
[ -d "$RIG_TEMPLATES_DIR" ] || {
|
||||
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$RIG_TEMPLATES_DIR"
|
||||
REGISTRY_DIR="$RIG_TEMPLATES_DIR"
|
||||
return 0
|
||||
fi
|
||||
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||
|
|
@ -104,8 +108,8 @@ templates_resolve() {
|
|||
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
|
||||
return 1
|
||||
}
|
||||
d="${1%/}"
|
||||
printf '%s\n' "$d"
|
||||
# shellcheck disable=SC2034 # the function's output global, read by the sourcing script
|
||||
REGISTRY_DIR="${1%/}"
|
||||
}
|
||||
|
||||
# templates_roles <registry-dir> — the roles a registry defines: its
|
||||
|
|
@ -188,6 +192,49 @@ template_parse_env() {
|
|||
esac
|
||||
[ -n "$TPL_PATH_LINE" ] \
|
||||
|| { printf 'template.env: PATH_LINE: must not be empty\n' >&2; return 1; }
|
||||
# Every word must be a sane package name — the list is handed to apt-get
|
||||
# unquoted by design, and this is what keeps an option ('-o …') or a path
|
||||
# from riding in through the data file.
|
||||
local pkg
|
||||
for pkg in $TPL_APT_EXTRAS; do
|
||||
[[ "$pkg" =~ ^[a-z0-9][a-z0-9.+-]*$ ]] \
|
||||
|| { printf 'template.env: APT_EXTRAS: not a sane package name: %s\n' "$pkg" >&2; return 1; }
|
||||
done
|
||||
}
|
||||
|
||||
# render_tenant_context <role> <creds.md> — the agent-context file's
|
||||
# content, on stdout: the one file every agent reads before touching
|
||||
# anything. The skeleton is MECHANISM and lives here once — 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") must never be copy-pasted per template again —
|
||||
# and only the creds paragraph is per-vendor DATA, spliced in from the
|
||||
# definition's creds.md.
|
||||
render_tenant_context() {
|
||||
local role="$1" creds_file="$2"
|
||||
cat <<EOF
|
||||
# You are running inside a box (tenant: ${role})
|
||||
|
||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
||||
\`box\` CLI. Keep this context in mind:
|
||||
|
||||
$(cat "$creds_file")
|
||||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
||||
local network. There is no inbound path.
|
||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
||||
removed; the operator persists work via git push and via \`box snapshot\`.
|
||||
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
|
||||
or the drill inside a box. The box you are in is not a host you own: a
|
||||
nested box stack claims the guest's own uplink subnet and gateway, and
|
||||
silently breaks this box's networking with intermittent egress blackouts
|
||||
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
|
||||
and testing never needs the host stack; host setup belongs to the operator's
|
||||
machine, never this one.
|
||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
|
||||
runbook — how to install dependencies, start services, template environment
|
||||
files, seed data, and smoke-test — and follow it. It is documentation for
|
||||
you, not a script the host runs.
|
||||
EOF
|
||||
}
|
||||
|
||||
# template_lint <role-dir> — the whole-definition check the registry repo's
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Shared parameters for the box TENANT roles (claude-box, codex-box, grok-box,
|
||||
# kimi-box, staging-box) — the '-box' suffix names the FAMILY (a guest a box mints, vs the
|
||||
# '-server' machine roles rig converges); see #76.
|
||||
# sourced by bootstrap-tenant.sh and by the test harness. Pure text→text, no
|
||||
# side effects: the per-tenant differences live HERE, in one table, so the
|
||||
# mechanism stays one script parameterized per tenant instead of four
|
||||
# hand-maintained copies (repo precedent: parse_users_file, runner-config).
|
||||
|
||||
# tenant_user <role> — the user the box seed creates (box.env BOX_USER). The
|
||||
# agent tenants are named after their agent (minus the suffix — the USER is not
|
||||
# the role); staging-box keeps box#69's `ops`.
|
||||
tenant_user() {
|
||||
case "$1" in
|
||||
claude-box) printf 'claude' ;;
|
||||
codex-box) printf 'codex' ;;
|
||||
grok-box) printf 'grok' ;;
|
||||
kimi-box) printf 'kimi' ;;
|
||||
staging-box) printf 'ops' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# tenant_context_path <role> <home> — where the agent-context file lands. Each
|
||||
# agent CLI reads its own instructions file from its own dotdir (named for the
|
||||
# agent, not the role — the dotdir is the CLI's, and the suffix is rig's);
|
||||
# staging-box has no agent and no context file (return 1).
|
||||
tenant_context_path() {
|
||||
case "$1" in
|
||||
claude-box) printf '%s/.claude/CLAUDE.md' "$2" ;;
|
||||
codex-box) printf '%s/.codex/AGENTS.md' "$2" ;;
|
||||
grok-box) printf '%s/.grok/AGENTS.md' "$2" ;;
|
||||
# kimi documents only PROJECT-level AGENTS.md today (no global file); its
|
||||
# dotdir is ~/.kimi (config.toml, sessions/, credentials/). The context
|
||||
# file lands at the <dotdir>/AGENTS.md convention the other CLIs converged
|
||||
# on, so it is where an operator (or a future global-read) will look — an
|
||||
# honest placement, not a claim that the CLI auto-loads it.
|
||||
kimi-box) printf '%s/.kimi/AGENTS.md' "$2" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# render_tenant_context <role> — the agent-context file's content, on stdout.
|
||||
# One renderer for all four agents: only the creds paragraph is per-vendor,
|
||||
# and the box#80 guard note lives HERE once — never copy-pasted per template.
|
||||
# staging-box renders nothing (return 1): no agent lives there.
|
||||
render_tenant_context() {
|
||||
local role="$1" creds
|
||||
# The single-quoted markdown below carries literal `$`-free backtick prose;
|
||||
# single quotes are deliberate — nothing in it may expand here.
|
||||
# shellcheck disable=SC2016
|
||||
case "$role" in
|
||||
claude-box)
|
||||
creds='- **Creds-free by default.** The box starts with no Claude and no git
|
||||
credentials. If you need to authenticate Claude, the operator runs `/login`
|
||||
interactively. For git, the operator adds their own credentials (a PAT or
|
||||
`gh auth login`). Never assume credentials are present; never ask for or
|
||||
store secrets on disk beyond what the operator sets up.' ;;
|
||||
codex-box)
|
||||
creds='- **Creds-free by default.** The box starts with no OpenAI and no git
|
||||
credentials. If you need to authenticate Codex, the operator runs the
|
||||
login flow (`codex`) interactively. For git, the operator adds their own
|
||||
credentials (a PAT or `gh auth login`). Never assume credentials are
|
||||
present; never ask for or store secrets on disk beyond what the operator
|
||||
sets up.' ;;
|
||||
grok-box)
|
||||
creds='- **Creds-free by default.** The box starts with no xAI and no git
|
||||
credentials. If you need to authenticate, the operator runs
|
||||
`grok login` interactively (SuperGrok / X Premium+). For git, the
|
||||
operator adds their own credentials (a PAT or `gh auth login`). Never
|
||||
assume credentials are present; never ask for or store secrets on disk
|
||||
beyond what the operator sets up.' ;;
|
||||
kimi-box)
|
||||
creds='- **Creds-free by default.** The box starts with no Moonshot and no git
|
||||
credentials. If you need to authenticate, the operator runs `kimi` and
|
||||
its `/login` flow interactively (Kimi Code OAuth, or an API key). For
|
||||
git, the operator adds their own credentials (a PAT or `gh auth login`).
|
||||
Never assume credentials are present; never ask for or store secrets on
|
||||
disk beyond what the operator sets up.' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
cat <<EOF
|
||||
# You are running inside a box (tenant: ${role})
|
||||
|
||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
||||
\`box\` CLI. Keep this context in mind:
|
||||
|
||||
${creds}
|
||||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
||||
local network. There is no inbound path.
|
||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
||||
removed; the operator persists work via git push and via \`box snapshot\`.
|
||||
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
|
||||
or the drill inside a box. The box you are in is not a host you own: a
|
||||
nested box stack claims the guest's own uplink subnet and gateway, and
|
||||
silently breaks this box's networking with intermittent egress blackouts
|
||||
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
|
||||
and testing never needs the host stack; host setup belongs to the operator's
|
||||
machine, never this one.
|
||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
|
||||
runbook — how to install dependencies, start services, template environment
|
||||
files, seed data, and smoke-test — and follow it. It is documentation for
|
||||
you, not a script the host runs.
|
||||
EOF
|
||||
}
|
||||
Loading…
Reference in a new issue