From 587a44617baab30a97c58f33bd71d4a51db5649f Mon Sep 17 00:00:00 2001 From: codex-bot-andresmgsl <244098813+codex-bot-andresmgsl@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:57:52 +0000 Subject: [PATCH] feat: add registry-backed machine roles --- commands/bootstrap.sh | 29 +++++++++- commands/lib/templates.sh | 114 ++++++++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 04f63ac..2b040cb 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -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,19 @@ 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 [ "$(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 +171,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 +800,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" ./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 " diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh index 688019c..18acbd3 100644 --- a/commands/lib/templates.sh +++ b/commands/lib/templates.sh @@ -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 — 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 @@ -206,6 +227,62 @@ template_parse_env() { 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 @@ -248,19 +325,32 @@ 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 - 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; } + 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 }