forked from heavy-duty/rig
feat(templates): the registry lib — three-knob resolution, the allowlist parser, the definition lint (#110)
The role-template registry moves out of rig's tree (heavy-duty/rig-templates, ruled 2026-07-24: pinned to the rig version by default, overridable per mint). This is the schema-and-resolution half rig keeps: RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree RIG_TEMPLATES_PIN (the BOX_RELEASE discipline), template.env parsed against an allowlist and never sourced, every refusal naming the failing key, and the lint the registry repo's CI will run on every definition. The pin currently names the registry's pre-seed head; it bumps to the seeded tree in this PR's course (the seed PR is the other half of the build task).
This commit is contained in:
parent
5e70c0bb66
commit
29563a1246
1 changed files with 215 additions and 0 deletions
215
commands/lib/templates.sh
Normal file
215
commands/lib/templates.sh
Normal file
|
|
@ -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:
|
||||||
|
#
|
||||||
|
# <role>/template.env KEY="value" data, parsed against the allowlist
|
||||||
|
# below and NEVER sourced — a definition cannot
|
||||||
|
# execute shell through its data file
|
||||||
|
# <role>/install.sh the CLI install (the one inherently executable part)
|
||||||
|
# <role>/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/<ref> form, which is how a commit-SHA pin (the
|
||||||
|
# default) downloads. Failure lists every URL tried: the fetch is
|
||||||
|
# unauthenticated by contract (box auto-runs bootstrap at mint, holding
|
||||||
|
# nothing), so "is the repo public and the ref real" is the whole diagnosis.
|
||||||
|
TEMPLATES_TMP=""
|
||||||
|
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=<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 (<repo>-<ref>,
|
||||||
|
# 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 <registry-dir> — 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 <template.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 <role-dir> — 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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue