diff --git a/README.md b/README.md index e358237..2247c6a 100644 --- a/README.md +++ b/README.md @@ -398,7 +398,7 @@ unattended VM-host appliance) — and `workstation` is the machine at the keyboa end of all the SSH connections: `root-door=closed`, `join=login`, entering the tailnet as *your* device rather than the fleet's. -### `rig bootstrap ` — the box tenants +### `rig bootstrap -box` — the box tenants Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted guest. Convergent — safe to re-run; a second run changes nothing. @@ -419,16 +419,41 @@ same harness as everything else — and re-runnable on an *existing* box to converge it to a new spec instead of re-minting it. One convergence engine; the guests were the hole. -It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh` -holds the whole per-tenant table), not four hand-maintained scripts: +It is **one mechanism, parameterized per DEFINITION** (#110), not four +hand-maintained scripts. The agent-tenant definitions live in the +**[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates) +registry** — one directory per role (`template.env`, the allowlisted data +table rig parses and never sources; `install.sh`, the CLI install; `creds.md`, +the per-vendor context paragraph) — so adding an agent tenant is a data PR +there, never a mechanism edit here (#109 was the evidence: adding kimi, pure +data, meant editing six files in this repo). Which `-box` roles exist is the +registry's fact; `rig bootstrap -box` dispatches on the family +suffix and refuses an unknown role by listing what the resolved registry +actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacent +(sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user +`ops`, box#69's server posture with `root-door=open` acceptance. -| tenant role | user | what lands | -|---------------|----------|------------| -| `claude-box` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` | -| `codex-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` | -| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` | -| `kimi-box` | `kimi` | the toolbelt, docker, the kimi CLI (uv-managed) on the system PATH, and `~/.kimi/AGENTS.md` | -| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) | +**Where the registry comes from — three knobs, precedence high to low:** + +| knob | meaning | +|------|---------| +| `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" | +| `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time | +| *(neither set)* | **the in-tree pin** — `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh`, the `BOX_RELEASE` discipline: bumped by ordinary reviewed rig PR, so a rig release freezes the mechanism+registry pair, and a newer rig matches newer templates by default (the #110 ruling) | + +**The security trade — in bold, not a footnote.** **A main-tracked +rig-templates repo means every merged PR there executes as root inside every +future mint.** This is acceptable — and an improvement — only because of +three facts together: (1) it *narrows* today's surface, where all of rig is +main-tracked-as-root; (2) the repo is small, single-purpose, and +ceremony-governed with a **human merge** as the gate and the review panel +ahead of it; (3) drills pin the SHA they proved. If any of those three +weakens, the default flips to a pinned `RIG_TEMPLATES_REF`. install.sh diffs +in that repo are the highest-trust review surface in the org — the reviewer +doctrine should say so. *(2026-07-24: the flip this paragraph reserves was +taken, before the migration and by the decider — the default IS the pin +above, so a merged template reaches mints only through a reviewed pin bump +here, or an explicit per-mint `RIG_TEMPLATES_REF`.)* **The role carries the suffix; the user does not.** A tenant user is the account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir @@ -462,8 +487,9 @@ disposability facts, and the guard note — **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 silently breaks its networking (box#80). The note lives in -`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was -the point of moving it here. +`lib/templates.sh`'s renderer exactly once — mechanism, not template data — +never copy-pasted per definition; that was the point of moving it here. Only +the creds paragraph is the definition's (`creds.md`). **Tenants and the role marker.** A tenant run writes `role= tenant=yes host=no` — no `root-door=`, because a guest has no root-door policy of its own diff --git a/bin/rig b/bin/rig index 690432e..4ec2cb3 100755 --- a/bin/rig +++ b/bin/rig @@ -37,15 +37,19 @@ commands: Leave the tailnet only when /etc/rig/role proves rig performed the join, then remove that marker. Refuses for pre-existing or old unknown joins, and while a GitHub runner is installed. Run as root. - bootstrap [--user ] + bootstrap -box [--user ] 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|kimi-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. + The agent tenants (claude-box, codex-box, grok-box, kimi-box, …) are + DEFINED in the heavy-duty/rig-templates registry — resolved via + RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin — and 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, in rig's own + tree, 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 Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] @@ -95,6 +99,12 @@ commands: Shut root SSH on a class=human box once an admin key works. Refuses on class=server — root there is the control plane's automation door — and while no admin holds a key. Run as root. + template-lint ... + Validate tenant-role definitions (the heavy-duty/rig-templates + shape): template.env against the allowlist (data, never sourced), + install.sh present with a shebang, creds.md non-blank. Every refusal + names the failing key. The registry repo's CI runs this on every PR; + no root, no network, no writes. manifest [] Print /etc/rig/manifest — which rig converged this machine and when (bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at @@ -455,6 +465,10 @@ case "$cmd" in ;; esac ;; + template-lint) + shift + exec "$ROOT/commands/template-lint.sh" "$@" + ;; manifest) shift exec "$ROOT/commands/manifest.sh" "$@" diff --git a/changelog.d/110.md b/changelog.d/110.md new file mode 100644 index 0000000..bc59e68 --- /dev/null +++ b/changelog.d/110.md @@ -0,0 +1,8 @@ +### Changed + +- Agent-tenant definitions live in heavy-duty/rig-templates, pinned in-tree and overridable per mint (`RIG_TEMPLATES_DIR`/`_REF`/`_REPO`); the in-tree case arms are gone, `staging-box` stays (#110) + +### Added + +- `rig template-lint` validates role definitions; rig-templates CI runs it on every PR (#110) +- Drill records cite the rig-templates SHA the converge read (#110) diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index 17a6ccb..1eb9d12 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -1,24 +1,34 @@ #!/usr/bin/env bash -# rig bootstrap — 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 -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 [--user ] +usage: rig bootstrap -box [--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-box|codex-box|grok-box|kimi-box - the agent tenants: base tooling (git, gh, tmux, …), + -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 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,35 @@ 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 + # Same charset the users file enforces, for the same reasons (a leading + # '-' reads as a usermod flag; '|', ':' corrupt things downstream). + # Checked HERE, at parse — the definition's USER is checked by the + # parser — so the refusal needs no registry and no network. + [[ "$TENANT_USER_OVERRIDE" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ + || die "invalid user: '$TENANT_USER_OVERRIDE' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 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,10 +124,6 @@ while [ $# -gt 0 ]; do *) 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 @@ -140,6 +171,34 @@ if [ -n "$EXISTING_ROOT_DOOR" ]; then fi fi +# --- the definition ---------------------------------------------------------- +# Resolved and parsed BEFORE the root check (but after the marker guards, +# which need no definition and must stay refusable with no registry in +# reach), 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 + [ "$(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. — @@ -178,19 +237,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) - # 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 +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 +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 +290,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 +314,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 -- …' 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 -- …' 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 -esac -if [ -n "$CLI" ]; then +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 + 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 +369,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 +395,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 +405,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 diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 81ace2c..04f63ac 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -29,9 +29,11 @@ usage: rig bootstrap | --no-users) [--hostname ] [--root-door ] [--host ] [--join ] - rig bootstrap [--user ] - (the box TENANT roles — see their own --help; they take - no --users, see below) + rig bootstrap -box [--user ] + (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 ` - # 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 ` 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). diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh new file mode 100644 index 0000000..688019c --- /dev/null +++ b/commands/lib/templates.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# The tenant-template REGISTRY (#110): resolve where role definitions come +# from, parse a definition's template.env against an allowlist, and lint a +# whole definition. Sourced by bootstrap-tenant.sh (the mint-time consumer) +# and template-lint.sh (the registry repo's CI gate) — pure functions plus +# one pin, no side effects at source time (repo precedent: runner-config, +# and the tenant-config table this lib replaces). +# +# The registry moved out of rig's tree so mechanism and data can move at +# different cadences (#109 is the evidence: adding kimi — pure data — meant +# editing six files here). rig keeps the mechanism and this schema; the +# definitions live in heavy-duty/rig-templates, one directory per role: +# +# /template.env KEY="value" data, parsed against the allowlist +# below and NEVER sourced — a definition cannot +# execute shell through its data file +# /install.sh the CLI install (the one inherently executable part) +# /creds.md the per-vendor creds-free paragraph the context +# renderer splices in +# +# THE SOURCE IS THREE KNOBS, precedence _DIR > _REF > pin: +# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the +# offline-test path, and "try a template before it +# exists anywhere") +# RIG_TEMPLATES_REF a ref in the registry repo, fetched as a tarball at +# bootstrap time (the same shape as the rig preinstall) +# RIG_TEMPLATES_REPO which repo that ref lives in (default +# heavy-duty/rig-templates) +# and, absent both overrides, the PIN below. + +# The default registry ref a mint converges — the BOX_RELEASE discipline +# (#103): one line, bumped deliberately by ordinary rig PR after review, so a +# rig release freezes the mechanism+registry pair and a newer rig matches +# newer templates by default (ruled 2026-07-24 on #110: pinned, not +# main-tracked). RIG_TEMPLATES_REF overrides it per mint. +# +# Currently the seed tree (rig-templates#1's head — fetchable from the +# upstream archive already, an ancestor of its main once merged): the four +# agent tenants ported byte-equivalent from the case arms this PR cut. +RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0 + +# The template.env schema. Grammar: blank lines, '#' comments, and +# KEY="value" — nothing else. Parsed by regex, never sourced. +TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE) +TEMPLATE_KEYS_OPTIONAL=(CLI_SRC NEEDS_NODE APT_EXTRAS) + +# templates_source_desc — where the resolved registry came from, for error +# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in +# the unknown-role refusal rather than looking like a typo. +templates_source_desc() { + if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then + printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR" + else + printf '%s@%s%s' \ + "${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \ + "${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \ + "$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')" + fi +} + +# templates_resolve — resolve the three knobs to a LOCAL directory holding +# 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/ 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="" + 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 + } + REGISTRY_DIR="$RIG_TEMPLATES_DIR" + return 0 + fi + repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" + ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" + command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; } + command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; } + TEMPLATES_TMP="$(mktemp -d)" + for url in \ + "https://github.com/$repo/archive/refs/tags/$ref.tar.gz" \ + "https://github.com/$repo/archive/refs/heads/$ref.tar.gz" \ + "https://github.com/$repo/archive/$ref.tar.gz"; do + if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi + done + if [ -z "$got" ]; then + printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2 + printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2 + printf ' https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$repo" "$ref" >&2 + printf ' https://github.com/%s/archive/%s.tar.gz\n' "$repo" "$ref" >&2 + printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR= bypasses the fetch.\n' >&2 + return 1 + fi + tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || { + printf 'cannot extract the registry tarball from %s\n' "$got" >&2 + return 1 + } + # A GitHub archive holds exactly one top-level directory (-, + # slashes flattened) — assert that shape instead of assuming the name. + set -- "$TEMPLATES_TMP"/*/ + { [ $# -eq 1 ] && [ -d "$1" ]; } || { + printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2 + return 1 + } + # shellcheck disable=SC2034 # the function's output global, read by the sourcing script + REGISTRY_DIR="${1%/}" +} + +# templates_roles — the roles a registry defines: its +# immediate subdirectories that carry a template.env. This list IS the +# unknown-role refusal's body, so it reflects what the resolved source +# actually contains — never a hardcoded set. +templates_roles() { + local d + for d in "$1"/*/; do + [ -f "$d/template.env" ] || continue + basename "$d" + done +} + +# template_parse_env — parse against the allowlist. Sets +# TPL_USER, TPL_CONTEXT_PATH, TPL_CLI_NAME, TPL_CLI_SRC, TPL_PATH_LINE, +# TPL_NEEDS_NODE (default no), TPL_APT_EXTRAS. Every refusal names the +# failing key (or line): the box.env discipline — a definition is data, and +# bad data is refused loudly, never executed to find out. +# shellcheck disable=SC2034 # the TPL_* globals are this function's OUTPUT, read by the sourcing script +template_parse_env() { + local file="$1" line key val n=0 seen=" " k ok + TPL_USER="" TPL_CONTEXT_PATH="" TPL_CLI_NAME="" TPL_CLI_SRC="" + TPL_PATH_LINE="" TPL_NEEDS_NODE="no" TPL_APT_EXTRAS="" + [ -f "$file" ] || { printf 'template.env missing: %s\n' "$file" >&2; return 1; } + while IFS= read -r line || [ -n "$line" ]; do + n=$((n+1)) + case "$line" in ''|'#'*) continue ;; esac + if [[ ! "$line" =~ ^([A-Z_]+)=\"(.*)\"$ ]]; then + printf 'template.env:%d: not KEY="value": %s\n' "$n" "$line" >&2 + return 1 + fi + key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}" + ok="" + for k in "${TEMPLATE_KEYS_REQUIRED[@]}" "${TEMPLATE_KEYS_OPTIONAL[@]}"; do + [ "$key" = "$k" ] && ok=1 + done + [ -n "$ok" ] || { printf 'template.env:%d: unknown key: %s (allowed: %s %s)\n' \ + "$n" "$key" "${TEMPLATE_KEYS_REQUIRED[*]}" "${TEMPLATE_KEYS_OPTIONAL[*]}" >&2; return 1; } + case "$seen" in *" $key "*) + printf 'template.env:%d: duplicate key: %s\n' "$n" "$key" >&2; return 1 ;; + esac + seen="$seen$key " + case "$key" in + USER) TPL_USER="$val" ;; + CONTEXT_PATH) TPL_CONTEXT_PATH="$val" ;; + CLI_NAME) TPL_CLI_NAME="$val" ;; + CLI_SRC) TPL_CLI_SRC="$val" ;; + PATH_LINE) TPL_PATH_LINE="$val" ;; + NEEDS_NODE) TPL_NEEDS_NODE="$val" ;; + APT_EXTRAS) TPL_APT_EXTRAS="$val" ;; + esac + done < "$file" + for k in "${TEMPLATE_KEYS_REQUIRED[@]}"; do + case "$seen" in *" $k "*) ;; *) + printf 'template.env: missing required key: %s\n' "$k" >&2; return 1 ;; + esac + done + # Value shapes — each refusal names its key. USER shares the charset the + # users file enforces (a leading '-' reads as a usermod flag; '|', ':' + # corrupt things downstream). + [[ "$TPL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ + || { printf 'template.env: USER: invalid user name: %s (want ^[a-z_][a-z0-9_-]{0,31}$)\n' "$TPL_USER" >&2; return 1; } + case "$TPL_CONTEXT_PATH" in + /*|*..*|'') printf 'template.env: CONTEXT_PATH: must be relative to the tenant home, without "..": %s\n' "$TPL_CONTEXT_PATH" >&2; return 1 ;; + esac + [[ "$TPL_CLI_NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] \ + || { printf 'template.env: CLI_NAME: not a sane command name: %s\n' "$TPL_CLI_NAME" >&2; return 1; } + # A literal '~/' on purpose (SC2088): the value is DATA — the mechanism + # expands it to the tenant home by string substitution, never the shell. + # shellcheck disable=SC2088 + case "$TPL_CLI_SRC" in + *..*) printf 'template.env: CLI_SRC: must not contain "..": %s\n' "$TPL_CLI_SRC" >&2; return 1 ;; + ''|'~/'*|/*) ;; + *) printf 'template.env: CLI_SRC: must be absolute or ~/-relative: %s\n' "$TPL_CLI_SRC" >&2; return 1 ;; + esac + case "$TPL_NEEDS_NODE" in + yes|no) ;; + *) printf 'template.env: NEEDS_NODE: want yes or no, got: %s\n' "$TPL_NEEDS_NODE" >&2; return 1 ;; + 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 — 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 < — the whole-definition check the registry repo's +# CI runs on every PR (rig defines what a valid template is; rig-templates +# CI enforces it, so a broken definition is refused before it can reach a +# mint). Same parser the mint runs — the two gates are not redundant: CI +# protects the registry, the mint-time parse protects a mint served through +# RIG_TEMPLATES_REPO/_DIR that CI never saw. +template_lint() { + local dir="${1%/}" role + role="$(basename "$dir")" + [ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; } + case "$role" in + *-box|*-server) ;; + *) printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76)\n' "$role" >&2; return 1 ;; + esac + template_parse_env "$dir/template.env" || return 1 + [ -s "$dir/install.sh" ] \ + || { printf '%s: install.sh missing or empty\n' "$role" >&2; return 1; } + head -n1 "$dir/install.sh" | grep -q '^#!' \ + || { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; } + grep -q '[^[:space:]]' "$dir/creds.md" 2>/dev/null \ + || { printf '%s: creds.md missing or blank (the context renderer splices it in — a blank paragraph would ship a context file with a hole)\n' "$role" >&2; return 1; } + return 0 +} diff --git a/commands/lib/tenant-config.sh b/commands/lib/tenant-config.sh deleted file mode 100644 index 2b3760b..0000000 --- a/commands/lib/tenant-config.sh +++ /dev/null @@ -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 — 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 — 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 /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 — 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 <... — is this a valid tenant-role definition? +# +# rig defines what a valid template is (the schema lives in +# lib/templates.sh, beside the mint-time parser that enforces it); the +# heavy-duty/rig-templates repo's CI runs this on every definition on every +# PR, so a broken definition is refused before it can ever reach a mint +# (#110). The two gates are deliberate: CI protects the registry, the +# mint-time parse protects a mint served through RIG_TEMPLATES_REPO/_DIR +# that CI never saw. +# +# Pure read: no root, no network, no writes — lintable anywhere, including +# the registry repo's checkout, where rig's tree is only a fetched tool. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/templates.sh +. "$HERE/lib/templates.sh" # template_lint (and the schema it enforces) + +die() { printf 'rig-template-lint: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig template-lint ... + +Validate tenant-role definitions (the heavy-duty/rig-templates shape): +each must carry a family-suffixed name (rig#76), a template.env +that parses against the allowlist (KEY="value" only — the file is data, +never sourced), an install.sh with a shebang, and a non-blank creds.md. +Every refusal names the failing key or file. Exits non-zero if any +definition fails; nothing is written. +EOF +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; + "") usage >&2; die "at least one role directory required" 2 ;; +esac + +fail=0 +for dir in "$@"; do + case "$dir" in + -*) usage >&2; die "unknown flag: $dir" 2 ;; + esac + if template_lint "$dir"; then + printf 'rig-template-lint: OK: %s\n' "$dir" + else + printf 'rig-template-lint: FAIL: %s\n' "$dir" >&2 + fail=1 + fi +done +exit "$fail" diff --git a/drill/drill.sh b/drill/drill.sh index 50d2832..7671d53 100644 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -51,6 +51,14 @@ REPO="${RIG_REPO:-heavy-duty/rig}" REF="${RIG_REF:-}" BOXREPO="${BOX_REPO:-heavy-duty/box}" BOXREF="${BOX_REF:-}" +# The template registry the converge will read (#110). No explicitness +# demand here, unlike the two refs above: the DEFAULT is already a pin — the +# candidate tree's RIG_TEMPLATES_PIN, read after install from what actually +# landed — so an unset override means "the ref the release will really use", +# not "whatever main was that afternoon". +TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" +TPLREF="${RIG_TEMPLATES_REF:-}" +TPL_SHA="" ROLE=staging-server USERS_FILE="${DRILL_USERS_FILE:-}" RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}" @@ -266,6 +274,8 @@ emit_record() { printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt" printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \ "${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF" + printf 'Template registry: %s@%s (ref %s) — the rig-templates the converge read (#110).\n' \ + "${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" "${TPLREF:-unresolved}" printf 'Instrument: drill/drill.sh, legs in execution order.\n\n' printf '| Leg | Result |\n' printf '| --- | --- |\n' @@ -378,6 +388,21 @@ RIG_TREE="$(tree_of "$(command -v rig)")" assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1 DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)" ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)" + +# The rig-templates ref this candidate converges (#110), for the record: the +# env override when the drill was pointed somewhere, else the pin read from +# the INSTALLED tree — what actually landed, never this checkout's copy. A +# 40-hex ref IS its own SHA (the pin's normal shape); anything else resolves +# through ref_sha like the two candidates above. +if [ -z "$TPLREF" ]; then + TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)" +fi +if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then + TPL_SHA="${TPLREF:0:7}" +elif [ -n "$TPLREF" ]; then + TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")" +fi +inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved})" [ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md" # ============================================================================= diff --git a/drills/README.md b/drills/README.md index 3928494..5b2927a 100644 --- a/drills/README.md +++ b/drills/README.md @@ -63,7 +63,11 @@ schedule, in separate sittings. What makes that safe is that every drill **pins the same fixed set of candidate refs**: rig's drill runs `--host yes` with `BOX_REF=release/`, so it exercises the box that will actually ship; box's drill mints with `RIG_REF=release/`, so it -exercises the rig that will actually ship. Both measure the same pair. +exercises the rig that will actually ship. Both measure the same pair. The +record also cites the **rig-templates SHA** the converge read (#110) — the +candidate tree's `RIG_TEMPLATES_PIN` unless the drill was pointed elsewhere +via `RIG_TEMPLATES_REF` — so the mechanism+registry pair a release freezes +is the pair the drill proved. That — not sequencing — is what dissolves the box↔rig recursion. The refs are static identifiers that exist as soon as the release branches do, long before diff --git a/test/cli.sh b/test/cli.sh index 54acc30..450f064 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -572,19 +572,53 @@ else echo "skip: bootstrap non-root refusals (running as root)" fi -# --- box tenant roles (#31/#76): claude-box|codex-box|grok-box|kimi-box|staging-box --- +# --- box tenant roles (#31/#76/#110): -box from the registry + staging-box --- # What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh), -# parameterized per tenant through lib/tenant-config.sh, dispatched from -# bootstrap.sh so `rig bootstrap ` stays the single entrypoint. The real -# converge needs root, a tenant user, and the network — the container -# rehearsal's job — so the harness proves what it can non-root: the whole -# arg/refusal surface, the pure parameter table, the rendered agent-context -# file (guard note included), and grep-pins on the shipped script. +# parameterized per DEFINITION fetched from the template registry +# (heavy-duty/rig-templates; lib/templates.sh resolves RIG_TEMPLATES_DIR > +# RIG_TEMPLATES_REF > the in-tree pin), dispatched from bootstrap.sh on the +# '-box' FAMILY SUFFIX so `rig bootstrap ` stays the single entrypoint +# and a template added to the registry is mintable with zero code changes +# here. The real converge needs root, a tenant user, and the network — the +# container rehearsal's job — so the harness proves what it can non-root and +# OFFLINE: the whole refusal surface, the resolution precedence, the parser +# and the renderer, against fixture definitions via RIG_TEMPLATES_DIR. + +# The fixture registry: one valid scratch definition, plus broken ones the +# parser must refuse BY NAME. Synthetic on purpose — the real definitions +# live in heavy-duty/rig-templates, and this suite must hold whatever those +# say (offline is the point: no fetch, no network, no coupling). +TPL_FIX="$(mktemp -d)" +mkdir -p "$TPL_FIX/scratch-box" +cat > "$TPL_FIX/scratch-box/template.env" <<'TPLEOF' +# comments and blank lines are the only non-KEY="value" grammar + +USER="scratch" +CONTEXT_PATH=".scratch/AGENTS.md" +CLI_NAME="scratch" +CLI_SRC="~/.local/bin/scratch" +PATH_LINE="export PATH="$HOME/.local/bin:$PATH"" +NEEDS_NODE="no" +APT_EXTRAS="zsh" +TPLEOF +printf '#!/usr/bin/env bash\nexit 0\n' > "$TPL_FIX/scratch-box/install.sh" +printf -- '- **Creds-free by default.** The scratch vendor paragraph.\n' > "$TPL_FIX/scratch-box/creds.md" +mkdir -p "$TPL_FIX/badkey-box" +printf 'USER="x"\nCOLOR="red"\n' > "$TPL_FIX/badkey-box/template.env" +mkdir -p "$TPL_FIX/missing-box" +printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nPATH_LINE="p"\n' > "$TPL_FIX/missing-box/template.env" +mkdir -p "$TPL_FIX/garbled-box" +printf 'USER=unquoted\n' > "$TPL_FIX/garbled-box/template.env" +mkdir -p "$TPL_FIX/badnode-box" +printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nCLI_NAME="x"\nPATH_LINE="p"\nNEEDS_NODE="maybe"\n' > "$TPL_FIX/badnode-box/template.env" +mkdir -p "$TPL_FIX/badapt-box" +printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nCLI_NAME="x"\nPATH_LINE="p"\nAPT_EXTRAS="zsh -o"\n' > "$TPL_FIX/badapt-box/template.env" + # THE HARD CUT, tenant half (#76). The pre-rename names are gone and must fail # as UNKNOWN — asserted per name, because an alias left in for one tenant is the -# shape that survives review: the taxonomy reads complete while one old name -# still quietly converges. Checked at BOTH entrypoints, since bootstrap.sh has -# its own dispatch list and a name could survive in one and not the other. +# shape that survives review. And the #110 cut on top: the mechanism no longer +# KNOWS any agent tenant by name — which '-box' roles exist is the registry's +# fact, so the old names die on the family-suffix rule, not an enumerated list. for r in claude codex grok staging; do check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \ "$ROOT/commands/bootstrap-tenant.sh" "$r" @@ -593,39 +627,46 @@ for r in claude codex grok staging; do done check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh" -check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato +check "tenant: a suffix-less role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude-box --nope check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user 'fo|o' -# The docker converge asserts the DAEMON answers, not just the client binary — -# a dead dockerd passing `docker --version` is the "linked but cannot run" -# scar in daemon form. Grep-pinned so the assert cannot ship deleted. +# The suffix rule admits ANY '-box' name, so the charset gate must catch a +# crafted one BEFORE it is used as a path component (the valid_version +# discipline): uppercase, dots, a leading '-' all die at the name, never in a +# registry lookup. +check "tenant: a crafted role name dies at the charset gate" 2 "invalid tenant role name" \ + "$ROOT/commands/bootstrap-tenant.sh" 'UPPER-box' check "tenant: dockerd effective-state assert is present" 0 "" \ grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh" # The machine-role traits die with the tenant story, never "unknown flag" — an -# operator reaching for --hostname must learn where the trait family went. +# operator coming from the machine families needs the boundary, not a shrug. check "tenant: trait flags die with the tenant story" 2 "have no traits" \ "$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed check "tenant: --hostname dies the same way" 2 "have no traits" \ "$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest -# Dispatch: the machine-role entrypoint hands tenant roles to the tenant -# mechanism with args intact (--help reaching the TENANT usage proves both). -check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|codex-box|grok-box|kimi-box|staging-box" \ +# Dispatch: the machine-role entrypoint hands ANY '-box' role to the tenant +# mechanism on the family suffix — enumerating them would re-chain template +# velocity to rig edits, the exact coupling #110 removes. +check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "Box TENANT roles" \ "$ROOT/commands/bootstrap.sh" claude-box --help -# The marker guard fires BEFORE the root check (repo precedent: the coolify -# marker warning), so the refusals are provable here off fixture markers. A +check "bootstrap: an unheard-of '-box' role still dispatches (zero code changes)" 0 "Box TENANT roles" \ + "$ROOT/commands/bootstrap.sh" scratch-box --help + +# The tenant marker guard (#83), against marker FIXTURES (never the harness +# machine's real /etc/rig/role): converging a tenant onto a machine-role box or a # VM host (host=yes) refuses for every tenant — and names the staging PAIR, -# because whoever lands here has the two halves confused and wants the metal +# because whoever hits it has the halves confused: the guest (staging-box), the metal # (staging-server). An agent tenant refuses ANY machine-role box; staging-box -# tolerates ONLY root-door=open with host=no — that is the guest after its -# operator-run workload join, and re-converging it is what convergence is for. -# A closed-door machine (root-door=closed via custom) is NOT that guest, and -# open-door hardening would die at it with root-door=open-specific messaging — -# refuse instead. +# tolerates exactly the workload-joined guest (root-door=open host=no) and refuses the +# rest. These need no registry: the guards run before the resolution, so a +# poisoning converge is refused even when the registry is unreachable. TEN_FIX="$(mktemp -d)" -printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$TEN_FIX/host" -printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine" -printf 'role=custom root-door=closed host=no join=login\n' > "$TEN_FIX/closed" +printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine" +printf 'role=custom root-door=closed host=no join=authkey\n' > "$TEN_FIX/closed" +printf 'role=staging-server root-door=open host=yes join=authkey\n' > "$TEN_FIX/host" +printf 'role=workload class=server\n' > "$TEN_FIX/pre77-machine" +printf 'role=dev class=human\n' > "$TEN_FIX/pre77-human" printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant" check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \ env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box @@ -635,30 +676,69 @@ check "tenant: the host refusal sends you to the metal half of the pair" 1 "stag env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \ env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box - # The tenant guard's compat read (#77). This guard asks "does this marker name -# a root-door policy?" as its proxy for "is this a real fleet machine?", and it -# must ask it in BOTH vocabularies. Kept deliberately at the retired spelling, -# same reason as the close-root fixtures below: a pre-#77 box that stops -# looking like a machine here is the fail-OPEN direction of this rename — the -# agent-tenant refusal never fires, and `rig bootstrap claude-box` converges a -# tenant straight over a live fleet box, clobbering the marker that holds its -# root-door policy. Do not modernize these two fixtures. -printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/pre77-machine" -printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/pre77-human" +# a root-door policy?" through the resolver, so the pre-#77 spelling counts — +# pattern-matching one spelling would fail OPEN here: the marker stops looking +# like a machine's, the refusal never fires, and a tenant converge clobbers a +# live fleet box's marker. check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \ env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \ env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box +# ...and the guard needs no registry: an unreachable RIG_TEMPLATES_DIR must +# not stop a refusal that protects a live fleet box. +check "tenant: the marker guard fires even with the registry unreachable" 1 "never tailnet machines" \ + env RIG_ROLE_MARKER="$TEN_FIX/machine" RIG_TEMPLATES_DIR=/nonexistent/registry \ + "$ROOT/commands/bootstrap-tenant.sh" claude-box + +# The definition surface (#110), offline via RIG_TEMPLATES_DIR. An unknown +# role's refusal LISTS what the resolved source actually contains and names +# the source — a misconfigured RIG_TEMPLATES_REPO/_REF/_DIR must be visible +# in the error rather than looking like a typo. +check "tenant: unknown role lists the resolved registry" 2 "scratch-box" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" nosuch-box +check "tenant: the unknown-role refusal names the source" 2 "RIG_TEMPLATES_DIR" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" nosuch-box +check "tenant: an unreadable RIG_TEMPLATES_DIR refuses loudly" 2 "not a directory" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \ + "$ROOT/commands/bootstrap-tenant.sh" scratch-box +# _DIR outranks _REF: with both set, resolution must not touch the network — +# provable offline exactly because a fetch attempt would fail here. +check "tenant: RIG_TEMPLATES_DIR outranks RIG_TEMPLATES_REF" 2 "scratch-box" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" RIG_TEMPLATES_REF=some-branch \ + "$ROOT/commands/bootstrap-tenant.sh" nosuch-box +# A malformed definition is refused at bootstrap BY KEY (the box.env +# discipline: parsed, never sourced — so a template cannot execute arbitrary +# shell through the data file). The registry CI's lint is the other gate; +# this one protects a mint served through a source CI never saw. +check "tenant: an unknown key is refused by name" 2 "unknown key: COLOR" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" badkey-box +check "tenant: a missing required key is refused by name" 2 "missing required key: CLI_NAME" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" missing-box +check "tenant: a non-KEY=\"value\" line is refused with its line number" 2 'not KEY="value"' \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" garbled-box +check "tenant: a bad NEEDS_NODE value is refused by key" 2 "NEEDS_NODE" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" badnode-box +check "tenant: an option riding APT_EXTRAS is refused by key" 2 "APT_EXTRAS" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" badapt-box if [ "$(id -u)" -ne 0 ]; then # RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before # the root check, and the harness machine may carry a real /etc/rig/role. - check "tenant: claude-box parses, refuses non-root" 1 "must run as root" \ - env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude-box - check "tenant: codex-box parses, refuses non-root" 1 "must run as root" \ - env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex-box - check "tenant: grok-box parses, refuses non-root" 1 "must run as root" \ - env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box + # Reaching the root check proves the whole pre-root surface passed: the + # name, the flags, the guard, the resolution AND the parse. + check "tenant: a valid definition parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" scratch-box + check "tenant: staging-box needs no registry at all" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \ + "$ROOT/commands/bootstrap-tenant.sh" staging-box check "tenant: staging-box tolerates a workload-joined guest's marker" 1 "must run as root" \ env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box # ...and the same guest joined before #77: reaching the root check (rather @@ -666,53 +746,127 @@ if [ "$(id -u)" -ne 0 ]; then check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \ env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \ - env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box + env RIG_ROLE_MARKER="$TEN_FIX/tenant" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" scratch-box else echo "skip: tenant non-root refusals (running as root)" fi rm -rf "$TEN_FIX" -# The per-tenant parameter table and the agent-context renderer are pure lib -# functions on purpose (repo precedent: parse_users_file, json_string_array): -# the CLI path to them sits behind root + a real tenant user, so the harness -# proves them here, sourced, non-root and network-free. -tuser() { bash -c 'set -euo pipefail - . "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; } -tpath() { bash -c 'set -euo pipefail - . "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; } -tctx() { bash -c 'set -euo pipefail - . "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; } -check "tenant params: agent users are named after their agent" 0 "claude" tuser claude-box -check "tenant params: kimi's user drops the suffix too" 0 "kimi" tuser kimi-box -check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging-box -check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude-box /home/claude -check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex-box /home/codex -check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok-box /home/grok -check "tenant params: kimi context lands in ~/.kimi/AGENTS.md" 0 "/home/kimi/.kimi/AGENTS.md" tpath kimi-box /home/kimi -check "tenant params: staging has no context file" 1 "" tpath staging-box /home/ops -# The box#80 guard note lives ONCE, in the renderer, and every agent's file -# carries it — the layering decision's whole point: never per-template again. -check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude-box -check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex-box -check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok-box -check "tenant context: kimi carries the box#80 guard" 0 "box setup-host" tctx kimi-box -check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude-box -check "tenant context: the guard cites box#80" 0 "box#80" tctx claude-box -check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude-box -check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude-box -check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex-box -check "tenant context: grok names its login flow" 0 "grok login" tctx grok-box -check "tenant context: kimi names its login flow" 0 "Kimi Code OAuth" tctx kimi-box -check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging-box +# The same definition served from a REF (tarball fetch, curl stubbed — the +# release.sh discipline) and from a local DIR must resolve to identical +# converge inputs: the parsed TPL_* table and the rendered context file are +# everything the mechanism consumes, so identical inputs ARE the identical +# converge (#110's acceptance criterion, provable offline). +TPL_WORK="$(mktemp -d)" +mkdir -p "$TPL_WORK/bin" "$TPL_WORK/stage/rig-templates-testref" +cp -r "$TPL_FIX"/. "$TPL_WORK/stage/rig-templates-testref/" +tar -czf "$TPL_WORK/reg.tar.gz" -C "$TPL_WORK/stage" rig-templates-testref +cat > "$TPL_WORK/bin/curl" <<'CURLEOF' +#!/usr/bin/env bash +# stub: invoked as `curl -fsSL -o ` by templates_resolve +echo "$2" >> "${CURL_LOG:?}" +cp "${CURL_TARBALL:?}" "$4" +CURLEOF +chmod +x "$TPL_WORK/bin/curl" +# Single quotes deliberate throughout (SC2016): the $-expressions expand in +# the INNER bash, against the sourced lib's state, never in the harness. +# shellcheck disable=SC2016 +tpl_inputs_script='set -euo pipefail + . "$1/commands/lib/templates.sh" + templates_resolve + template_parse_env "$REGISTRY_DIR/scratch-box/template.env" + printf "USER=%s|CTX=%s|CLI=%s|SRC=%s|PATH=%s|NODE=%s|APT=%s\n" \ + "$TPL_USER" "$TPL_CONTEXT_PATH" "$TPL_CLI_NAME" "$TPL_CLI_SRC" \ + "$TPL_PATH_LINE" "$TPL_NEEDS_NODE" "$TPL_APT_EXTRAS" + render_tenant_context scratch-box "$REGISTRY_DIR/scratch-box/creds.md"' +tpl_from_dir() { # tpl_from_dir — the local-folder path + env RIG_TEMPLATES_DIR="$TPL_FIX" bash -c "$tpl_inputs_script" _ "$ROOT" > "$1" +} +tpl_from_ref() { # tpl_from_ref — the tarball path, curl stubbed + env PATH="$TPL_WORK/bin:$PATH" CURL_LOG="$TPL_WORK/curl.log" \ + CURL_TARBALL="$TPL_WORK/reg.tar.gz" RIG_TEMPLATES_REF=testref \ + bash -c "$tpl_inputs_script" _ "$ROOT" > "$1" +} +check "templates: a local DIR resolves and parses" 0 "" tpl_from_dir "$TPL_WORK/from-dir" +check "templates: a REF resolves through the tarball fetch (stubbed curl)" 0 "" \ + tpl_from_ref "$TPL_WORK/from-ref" +check "templates: DIR and REF yield byte-identical converge inputs" 0 "" \ + diff "$TPL_WORK/from-dir" "$TPL_WORK/from-ref" +# The fetch's first candidate is refs/tags — a tag must outrank a branch that +# happens to share its name (install.sh's own precedence, the pin must win). +check "templates: the fetch asks refs/tags first" 0 "/archive/refs/tags/testref.tar.gz" \ + head -n1 "$TPL_WORK/curl.log" +check "templates: the rendered context carries the box#80 guard" 0 "box setup-host" \ + cat "$TPL_WORK/from-dir" +check "templates: the guard says whose host this is not" 0 "not a host you own" \ + cat "$TPL_WORK/from-dir" +check "templates: the guard cites box#80" 0 "box#80" cat "$TPL_WORK/from-dir" +check "templates: the definition's creds paragraph is spliced in" 0 "The scratch vendor paragraph" \ + cat "$TPL_WORK/from-dir" +check "templates: the bootstrap runbook note survives the split" 0 "Bootstrap runbook" \ + cat "$TPL_WORK/from-dir" +# The default ref is the IN-TREE PIN (the BOX_RELEASE discipline, ruled on +# #110: pinned, not main-tracked): exactly one greppable assignment, so a pin +# bump is a one-line PR and the drill can read the pin from an installed tree. +# shellcheck disable=SC2016 +check "templates: the pin is one greppable line" 0 "1" \ + bash -c 'grep -c "^RIG_TEMPLATES_PIN=" "$1/commands/lib/templates.sh"' _ "$ROOT" +# shellcheck disable=SC2016 +check "templates: unset knobs fall back to the pin" 0 "the in-tree pin" \ + bash -c '. "$1/commands/lib/templates.sh" && templates_source_desc' _ "$ROOT" + +# rig template-lint — the registry repo's CI gate, same schema as the mint's +# parser (rig defines validity; rig-templates CI enforces it on every PR). +check "template-lint: --help exits 0" 0 "usage:" "$ROOT/commands/template-lint.sh" --help +check "template-lint: a directory is required" 2 "role directory required" "$ROOT/commands/template-lint.sh" +check "template-lint: dispatched from bin/rig" 0 "usage:" "$ROOT/bin/rig" template-lint --help +check "template-lint: a valid definition passes" 0 "OK: " "$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box" +check "template-lint: an unknown key fails by name" 1 "unknown key: COLOR" \ + "$ROOT/commands/template-lint.sh" "$TPL_FIX/badkey-box" +check "template-lint: one bad definition fails the whole run" 1 "FAIL: " \ + "$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box" "$TPL_FIX/badkey-box" +mkdir -p "$TPL_FIX/plain" +cp "$TPL_FIX/scratch-box"/* "$TPL_FIX/plain/" +check "template-lint: a suffix-less role directory is refused (#76)" 1 "family suffix" \ + "$ROOT/commands/template-lint.sh" "$TPL_FIX/plain" +mkdir -p "$TPL_FIX/noinstall-box" +cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noinstall-box/" +check "template-lint: a missing install.sh is refused by name" 1 "install.sh missing" \ + "$ROOT/commands/template-lint.sh" "$TPL_FIX/noinstall-box" +mkdir -p "$TPL_FIX/blankcreds-box" +cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/install.sh" "$TPL_FIX/blankcreds-box/" +printf ' \n\t\n' > "$TPL_FIX/blankcreds-box/creds.md" +check "template-lint: a blank creds.md is refused by name" 1 "creds.md missing or blank" \ + "$ROOT/commands/template-lint.sh" "$TPL_FIX/blankcreds-box" +mkdir -p "$TPL_FIX/noshebang-box" +cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noshebang-box/" +printf 'exit 0\n' > "$TPL_FIX/noshebang-box/install.sh" +check "template-lint: an install.sh without a shebang is refused" 1 "no shebang" \ + "$ROOT/commands/template-lint.sh" "$TPL_FIX/noshebang-box" +rm -rf "$TPL_FIX" "$TPL_WORK" + # Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal # idiom): nothing in the tenant mechanism touches the tailnet, prompts, or -# apt-installs incus. A grep that finds nothing (exit 1) is the pass. +# apt-installs incus. A grep that finds nothing (exit 1) is the pass. The +# same absences hold for the templates lib — it fetches DATA, unauthenticated +# by contract, and must never grow a credential to do it. check "tenant: never touches the tailnet" 1 "" \ grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh" check "tenant: non-interactive — nothing prompts" 1 "" \ grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh" check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \ grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh" +check "templates lib: the fetch carries no credential" 1 "" \ + grep -nE 'Authorization|gh api|GITHUB_TOKEN' "$ROOT/commands/lib/templates.sh" +# The data file is PARSED, never executed: the parse loop reads lines, and +# no source statement may ever reach template.env. Grep-pinned because the +# failure is silent and total — a sourced template.env is arbitrary shell +# running as root at every mint. +check "templates lib: the parser READS template.env line by line" 0 "" \ + grep -qF 'while IFS= read -r line' "$ROOT/commands/lib/templates.sh" +check "templates lib: template.env is never sourced" 1 "" \ + grep -nE '(source|^[[:space:]]*\.)[[:space:]]+[^#]*template\.env' "$ROOT/commands/lib/templates.sh" "$ROOT/commands/bootstrap-tenant.sh" # staging-box's posture rides the SAME hardening code as the machine roles — the # shared lib call is the anti-drift property, so pin the call, not the words. check "tenant: staging-box hardens through the shared sshd lib" 0 "" \ @@ -746,7 +900,6 @@ check "tenant: the marker write follows the context-file converge" \ # shellcheck disable=SC2016 check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \ grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh" - check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh" check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version diff --git a/test/drill.sh b/test/drill.sh index dbb6477..b189931 100644 --- a/test/drill.sh +++ b/test/drill.sh @@ -159,6 +159,7 @@ check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "pa emit() { # emit — emit_record with the harness globals staged DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \ REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \ + TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" \ bash -c ' . "$1" pass=12 fail=1 skipped=1 @@ -173,6 +174,7 @@ check "record: the version-and-date heading" 0 "# Release drill — 9.9.9 — " check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md" check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md" check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md" +check "record: the template registry SHA rides alongside the pair (#110)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432)" cat "$WORK/record.md" check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md" check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md" check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"