#!/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 FOUR KNOBS plus the installed pin snapshot, precedence # _DIR > _REF > snapshot > pin fetch: # 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) # RIG_TEMPLATES_HOST which FORGE that repo lives on (default # https://github.com) — see templates_archive_urls # and, absent both overrides, the snapshot installed beside this file when it # matches the PIN below, then a live fetch of that pin as the fallback. # 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 forge the registry lives on. GitHub by default, so every existing caller # is byte-unchanged; overridable because a self-hosted Forgejo is a different # origin AND a different URL grammar (#109). RIG_TEMPLATES_HOST_DEFAULT="https://github.com" # templates_archive_urls — the source-tarball candidates # for , in order, one per line. A PURE function (no network, no globals): # test/cli.sh lifts it and drives it against both forges, the resolve_latest_tag # precedent in install.sh. # # The two forges are not URL-compatible, and the difference is not cosmetic: # # GitHub three forms, refs/tags FIRST so a tag always outranks a branch # sharing its name (a pin must win), refs/heads as the fallback # that keeps a branch ref working, then the bare form a commit SHA # downloads through. # Forgejo ONE form. /archive/.tar.gz resolves tags, branches and SHAs # alike, and the refs/{tags,heads}/ paths are not served at all — # emitting them would mean two guaranteed 404s ahead of every fetch # and a failure message listing URLs that never could have worked. # # Measured against forgejo.heavyduty.builders, not inferred from the docs. templates_archive_urls() { local host="${1%/}" repo="$2" ref="$3" case "$host" in https://github.com|http://github.com|*//github.com) printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref" printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref" printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; *) printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; esac } # 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) MACHINE_KEYS_REQUIRED=(ROOT_DOOR HOST JOIN) # 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. # The host rides every non-snapshot description: a registry served from the # wrong FORGE fails exactly like a misspelled repo, and naming only the repo # would send the reader hunting for a typo that is not there (#109). templates_source_desc() { local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}" if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR" elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then printf '%s@%s (snapshot)' \ "${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \ "$RIG_TEMPLATES_PIN" else printf '%s/%s@%s%s' \ "${host%/}" \ "${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 } # The snapshot path is derived from this library's installed tree. Its # pin-bearing directory name is the staleness guard: an older snapshot is # invisible after a pin bump. A usable registry has at least one definition; # an empty directory means an interrupted extraction and falls through to the # same live fetch as an absent snapshot. templates_snapshot_dir() { local lib_dir lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" printf '%s/templates@%s' "$(cd "$lib_dir/../.." && pwd)" "$RIG_TEMPLATES_PIN" } templates_snapshot_usable() { local snapshot role_env snapshot="$(templates_snapshot_dir)" [ -d "$snapshot" ] || return 1 role_env="$(find "$snapshot" -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null)" [ -n "$role_env" ] } # templates_resolve — resolve the 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 come from # templates_archive_urls, which is forge-aware — see there for why the two # forges cannot share one list. # # 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. # # On a SELF-HOSTED forge there is a third way to fail that reads exactly like # the other two, so the refusal names it: an instance with # REQUIRE_SIGNIN_VIEW=true serves 404 for public repos to anonymous callers — # the same status a wrong ref gets. A mint holds no credentials and never # will, so such an instance cannot host a registry until it serves public # repos anonymously (#109). TEMPLATES_TMP="" # shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script REGISTRY_DIR="" templates_resolve() { local repo ref host 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 if [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then REGISTRY_DIR="$(templates_snapshot_dir)" return 0 fi repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}" 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)" while IFS= read -r url; do if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi done < <(templates_archive_urls "$host" "$repo" "$ref") if [ -z "$got" ]; then printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2 templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&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 case "${host%/}" in https://github.com|http://github.com) ;; *) printf 'on a self-hosted forge, check the instance serves PUBLIC repos to anonymous callers too: Forgejo with REQUIRE_SIGNIN_VIEW=true answers 404 for a public repo, which is indistinguishable from a wrong ref above (set FORGEJO__service__REQUIRE_SIGNIN_VIEW=false).\n' >&2 ;; esac 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 source archive holds exactly one top-level directory — assert that # SHAPE, never the name, because the name is the forge's choice and the two # disagree: GitHub writes - (slashes flattened), Forgejo writes # bare . Globbing for the shape is what makes this line survive a # forge swap untouched. 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_family — directory names are the registry's family tag. # workstation is the one intentional suffix-less machine role (#152 / epic D5). template_family() { case "$1" in *-box) printf 'tenant\n' ;; *-server|workstation) printf 'machine\n' ;; *) return 1 ;; esac } # templates_machine_roles — only machine definitions, for the # machine bootstrap's unknown-role refusal. templates_machine_roles() { local role while IFS= read -r role; do [ "$(template_family "$role" 2>/dev/null || true)" = "machine" ] || continue printf '%s\n' "$role" done < <(templates_roles "$1") } # 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 } # machine_template_parse_env — the fleet-machine traits schema. # The globals match bootstrap's table columns so a definition becomes a table # row without changing any downstream trait behavior. # shellcheck disable=SC2034 machine_template_parse_env() { local file="$1" line key val n=0 seen=" " k ok TPL_ROOT_DOOR="" TPL_HOST="" TPL_JOIN="" [ -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 "${MACHINE_KEYS_REQUIRED[@]}"; do [ "$key" = "$k" ] && ok=1 done [ -n "$ok" ] || { printf 'template.env:%d: unknown key: %s (allowed: %s)\n' \ "$n" "$key" "${MACHINE_KEYS_REQUIRED[*]}" >&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 ROOT_DOOR) TPL_ROOT_DOOR="$val" ;; HOST) TPL_HOST="$val" ;; JOIN) TPL_JOIN="$val" ;; esac done < "$file" for k in "${MACHINE_KEYS_REQUIRED[@]}"; do case "$seen" in *" $k "*) ;; *) printf 'template.env: missing required key: %s\n' "$k" >&2 return 1 ;; esac done case "$TPL_ROOT_DOOR" in open|closed) ;; *) printf 'template.env: ROOT_DOOR: want open or closed, got: %s\n' "$TPL_ROOT_DOOR" >&2; return 1 ;; esac case "$TPL_HOST" in yes|no) ;; *) printf 'template.env: HOST: want yes or no, got: %s\n' "$TPL_HOST" >&2; return 1 ;; esac case "$TPL_JOIN" in authkey|login) ;; *) printf 'template.env: JOIN: want authkey or login, got: %s\n' "$TPL_JOIN" >&2; return 1 ;; esac } # 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 family role="$(basename "$dir")" [ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; } family="$(template_family "$role" 2>/dev/null || true)" [ -n "$family" ] || { printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76; workstation is #152 machine carve-out)\n' "$role" >&2 return 1 } if [ "$family" = "tenant" ]; then 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; } else machine_template_parse_env "$dir/template.env" || return 1 [ ! -e "$dir/creds.md" ] \ || { printf '%s: creds.md is not allowed for machine roles (machines render no tenant context)\n' "$role" >&2; return 1; } if [ -e "$dir/install.sh" ]; then [ -s "$dir/install.sh" ] \ || { printf '%s: install.sh is 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; } fi fi return 0 }