diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh new file mode 100644 index 0000000..8f1505a --- /dev/null +++ b/commands/lib/templates.sh @@ -0,0 +1,215 @@ +#!/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: tenant-config, +# runner-config). +# +# 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. +RIG_TEMPLATES_PIN=30f4fa4dcb4e9f104058ad9dd5b7c42bafa98e73 + +# 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, 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/ 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="" +templates_resolve() { + local repo ref url got="" d + 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" + 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 + } + d="${1%/}" + printf '%s\n' "$d" +} + +# 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; } +} + +# template_lint — 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 +}