Merge pull request #156 from codex-bot-andresmgsl/build/152-machine-role-template

feat: add registry-backed machine roles
This commit is contained in:
Daniel Marin 2026-07-25 14:31:40 +01:00 committed by GitHub
commit 2af3b24fd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 234 additions and 20 deletions

View file

@ -401,6 +401,34 @@ 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.
### Machine-role templates
Machine presets can also live in the
[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates)
registry. A `*-server` directory is a fleet-machine definition; the exact
name `workstation` is the deliberate suffix-less exception. Its
`template.env` contains exactly the three traits bootstrap's built-in table
uses:
```dotenv
ROOT_DOOR="open" # open|closed
HOST="no" # yes|no
JOIN="authkey" # authkey|login
```
An `install.sh` is optional. When present, bootstrap runs it as root,
non-interactively, from the definition directory with `RIG_ROLE` set, after
the tailnet join, host setup, role marker prerequisites, and operator
convergence. A nonzero exit fails bootstrap and names the role and registry
source. The definition owns idempotence, just as bootstrap does.
Built-in roles and `custom` take precedence over registry names. Any other
non-tenant role is looked up in the resolved registry; the same three source
knobs below apply, including `RIG_TEMPLATES_DIR` for an offline local
definition. Pin reviewed registry content into rig's tree before using it on
fleet machines: an optional machine `install.sh` executes as root on metal,
and an override is the operator explicitly choosing a different trust root.
### `rig bootstrap <role>-box` — the box tenants
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted

3
changelog.d/152.md Normal file
View file

@ -0,0 +1,3 @@
### Added
- Machine-role templates can declare bootstrap traits and an optional final root install hook (#152)

View file

@ -12,6 +12,8 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
. "$HERE/lib/users-config.sh" # parse_users_file — the --users PRE-FLIGHT only
# shellcheck source=SCRIPTDIR/lib/manifest.sh
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
# shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/templates.sh" # registry-backed machine-role definitions
# The users lib is sourced for validation, never for convergence: `users apply`
# stays the single owner of what a users file DOES to a box (#51). Bootstrap
# borrows the parser so a typo'd users file is caught in the same breath as a
@ -119,6 +121,7 @@ EOF
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
MACHINE_TEMPLATE_DIR=""
case "$ROLE" in
--undo)
shift
@ -136,7 +139,20 @@ case "$ROLE" in
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 '-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 ;;
*)
shift
templates_resolve || exit 2
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
MACHINE_TEMPLATE_DIR="$REGISTRY_DIR/$ROLE"
if [[ ! "$ROLE" =~ ^[a-z][a-z0-9-]*-server$ ]] \
|| [ "$(template_family "$ROLE" 2>/dev/null || true)" != "machine" ] \
|| [ ! -f "$MACHINE_TEMPLATE_DIR/template.env" ]; then
MACHINE_ROLES="$(templates_machine_roles "$REGISTRY_DIR" | paste -sd'|' -)"
[ -n "$MACHINE_ROLES" ] || MACHINE_ROLES="none"
die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom; machine roles from $(templates_source_desc): $MACHINE_ROLES; or a '-box' tenant role)" 2
fi
machine_template_parse_env "$MACHINE_TEMPLATE_DIR/template.env" \
|| die "invalid machine role $ROLE from $(templates_source_desc)" 2 ;;
esac
# Role→traits map — the single place a role's shape is declared (issue #26).
@ -156,6 +172,7 @@ case "$ROLE" in
dev-server) ROOT_DOOR=closed HOST=yes JOIN=authkey ;;
workstation) ROOT_DOOR=closed HOST=yes JOIN=login ;;
custom) ;;
*) ROOT_DOOR="$TPL_ROOT_DOOR" HOST="$TPL_HOST" JOIN="$TPL_JOIN" ;;
esac
# custom has no hostname default: a made-up name on a made-up shape helps nobody.
@ -784,6 +801,17 @@ if [ -n "$USERS_FILE" ]; then
"$HERE/users-apply.sh" --file "$USERS_FILE"
fi
# A registry machine's optional install is the final convergence phase: after
# join, host setup, the marker prerequisites, and operators. It inherits the
# caller environment, adds only the selected role, and runs from its definition
# directory. Definitions own idempotence, like bootstrap itself.
if [ -n "$MACHINE_TEMPLATE_DIR" ] && [ -e "$MACHINE_TEMPLATE_DIR/install.sh" ]; then
log "running install hook for ${ROLE} from $(templates_source_desc)"
if ! (cd "$MACHINE_TEMPLATE_DIR" && RIG_ROLE="$ROLE" bash ./install.sh); then
die "install hook failed for role $ROLE from $(templates_source_desc)"
fi
fi
log "done — role ${ROLE}, hostname ${TS_HOSTNAME}"
if [ "$ROLE" = "control-plane-server" ]; then
log "next: rig coolify install --version <pin>"

View file

@ -43,6 +43,7 @@ RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0
# 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
@ -128,6 +129,26 @@ templates_roles() {
done
}
# template_family <role> — 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 <registry-dir> — 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 <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
@ -206,6 +227,62 @@ template_parse_env() {
done
}
# machine_template_parse_env <template.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 <role> <creds.md> — the agent-context file's
# content, on stdout: the one file every agent reads before touching
# anything. The skeleton is MECHANISM and lives here once — the box#80 guard
@ -248,13 +325,15 @@ EOF
# 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
local dir="${1%/}" role family
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
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; }
@ -262,5 +341,16 @@ template_lint() {
|| { 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
}

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# rig template-lint <role-dir>... — is this a valid tenant-role definition?
# rig template-lint <role-dir>... — is this a valid 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
@ -23,12 +23,15 @@ usage() {
cat <<'EOF'
usage: rig template-lint <role-dir>...
Validate tenant-role definitions (the heavy-duty/rig-templates shape):
each <role-dir> 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.
Validate role definitions (the heavy-duty/rig-templates shape).
Tenant roles use a *-box directory, tenant template.env schema, a shebang
install.sh, and non-blank creds.md. Machine roles use a *-server directory
(or exact name workstation), the ROOT_DOOR/HOST/JOIN schema, no creds.md,
and an optional install.sh which must be non-empty and carry a shebang.
template.env is parsed as KEY="value" data and never sourced. Every refusal
names the failing key or file. Exits non-zero if any definition fails;
nothing is written.
EOF
}

View file

@ -613,6 +613,22 @@ 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"
mkdir -p "$TPL_FIX/scratch-server"
printf 'ROOT_DOOR="closed"\nHOST="no"\nJOIN="login"\n' > "$TPL_FIX/scratch-server/template.env"
mkdir -p "$TPL_FIX/workstation"
printf 'ROOT_DOOR="closed"\nHOST="yes"\nJOIN="authkey"\n' > "$TPL_FIX/workstation/template.env"
mkdir -p "$TPL_FIX/baddoor-server"
printf 'ROOT_DOOR="ajar"\nHOST="no"\nJOIN="authkey"\n' > "$TPL_FIX/baddoor-server/template.env"
mkdir -p "$TPL_FIX/tenantkeys-server"
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/tenantkeys-server/template.env"
mkdir -p "$TPL_FIX/machinekeys-box"
cp "$TPL_FIX/scratch-server/template.env" "$TPL_FIX/machinekeys-box/template.env"
mkdir -p "$TPL_FIX/creds-server"
cp "$TPL_FIX/scratch-server/template.env" "$TPL_FIX/creds-server/template.env"
printf 'not used\n' > "$TPL_FIX/creds-server/creds.md"
mkdir -p "$TPL_FIX/noshebang-server"
cp "$TPL_FIX/scratch-server/template.env" "$TPL_FIX/noshebang-server/template.env"
printf 'exit 0\n' > "$TPL_FIX/noshebang-server/install.sh"
# 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
@ -653,6 +669,28 @@ check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "Box TENANT role
check "bootstrap: an unheard-of '-box' role still dispatches (zero code changes)" 0 "Box TENANT roles" \
"$ROOT/commands/bootstrap.sh" scratch-box --help
# Machine roles use the same resolved registry but remain table-compatible:
# loading happens before flag parsing, so an explicit flag overrides the
# definition exactly as it overrides a built-in row.
check "machine template: traits load from the local registry" 2 "join=login" \
env RIG_TEMPLATES_DIR="$TPL_FIX" TS_AUTHKEY=x \
"$ROOT/commands/bootstrap.sh" scratch-server --no-users
if [ "$(id -u)" -ne 0 ]; then
check "machine template: a flag overrides the loaded trait" 1 "must run as root" \
env RIG_TEMPLATES_DIR="$TPL_FIX" TS_AUTHKEY=x \
"$ROOT/commands/bootstrap.sh" scratch-server --no-users --join authkey
fi
check "machine template: invalid ROOT_DOOR is refused by key" 2 "ROOT_DOOR" \
env RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap.sh" baddoor-server --no-users
check "machine template: unknown role lists machine definitions" 2 "scratch-server" \
env RIG_TEMPLATES_DIR="$TPL_FIX" "$ROOT/commands/bootstrap.sh" absent-server
check "machine template: unknown role names the resolved source" 2 "RIG_TEMPLATES_DIR" \
env RIG_TEMPLATES_DIR="$TPL_FIX" "$ROOT/commands/bootstrap.sh" absent-server
check "machine template: a registry role cannot shadow a built-in" 2 "unset TS_AUTHKEY" \
env RIG_TEMPLATES_DIR="$TPL_FIX" TS_AUTHKEY=x \
"$ROOT/commands/bootstrap.sh" workstation --no-users
# 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,
@ -844,6 +882,30 @@ cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX
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"
check "template-lint: a traits-only machine definition passes" 0 "OK: " \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-server"
check "template-lint: workstation is the machine-family carve-out" 0 "OK: " \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/workstation"
check "template-lint: machine roles refuse tenant keys" 1 "unknown key: USER" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/tenantkeys-server"
check "template-lint: tenant roles refuse machine keys" 1 "unknown key: ROOT_DOOR" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/machinekeys-box"
check "template-lint: machine roles refuse creds.md" 1 "creds.md is not allowed" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/creds-server"
check "template-lint: machine install.sh requires a shebang" 1 "no shebang" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/noshebang-server"
# The install is deliberately after the users phase and its wrapper names both
# role and source. Dynamic execution belongs to the root integration path; the
# non-root offline harness pins the safety ordering and failure contract.
machine_hook_at="$(grep -n 'running install hook for' "$ROOT/commands/bootstrap.sh" | head -n1 | cut -d: -f1)"
check "machine template: install hook is bootstrap's last convergence phase" 0 "" \
test "${users_apply_at:-999999}" -lt "${machine_hook_at:-0}"
# shellcheck disable=SC2016
check "machine template: install failure names role and source" 0 "" \
grep -qF 'install hook failed for role $ROLE from $(templates_source_desc)' "$ROOT/commands/bootstrap.sh"
# shellcheck disable=SC2016
check "machine template: install runs from its definition with RIG_ROLE" 0 "" \
grep -qF 'cd "$MACHINE_TEMPLATE_DIR" && RIG_ROLE="$ROLE" bash ./install.sh' "$ROOT/commands/bootstrap.sh"
rm -rf "$TPL_FIX" "$TPL_WORK"
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal