Merge pull request #146 from claude-bot-andresmgsl/build/110-rig-templates
feat: role templates move to heavy-duty/rig-templates — pinned by default, overridable per mint
This commit is contained in:
commit
e250f7f7da
12 changed files with 836 additions and 365 deletions
50
README.md
50
README.md
|
|
@ -398,7 +398,7 @@ 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
|
end of all the SSH connections: `root-door=closed`, `join=login`, entering the
|
||||||
tailnet as *your* device rather than the fleet's.
|
tailnet as *your* device rather than the fleet's.
|
||||||
|
|
||||||
### `rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box>` — the box tenants
|
### `rig bootstrap <role>-box` — the box tenants
|
||||||
|
|
||||||
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
|
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
|
||||||
guest. Convergent — safe to re-run; a second run changes nothing.
|
guest. Convergent — safe to re-run; a second run changes nothing.
|
||||||
|
|
@ -419,16 +419,41 @@ same harness as everything else — and re-runnable on an *existing* box to
|
||||||
converge it to a new spec instead of re-minting it. One convergence engine;
|
converge it to a new spec instead of re-minting it. One convergence engine;
|
||||||
the guests were the hole.
|
the guests were the hole.
|
||||||
|
|
||||||
It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh`
|
It is **one mechanism, parameterized per DEFINITION** (#110), not four
|
||||||
holds the whole per-tenant table), not four hand-maintained scripts:
|
hand-maintained scripts. The agent-tenant definitions live in the
|
||||||
|
**[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates)
|
||||||
|
registry** — one directory per role (`template.env`, the allowlisted data
|
||||||
|
table rig parses and never sources; `install.sh`, the CLI install; `creds.md`,
|
||||||
|
the per-vendor context paragraph) — so adding an agent tenant is a data PR
|
||||||
|
there, never a mechanism edit here (#109 was the evidence: adding kimi, pure
|
||||||
|
data, meant editing six files in this repo). Which `-box` roles exist is the
|
||||||
|
registry's fact; `rig bootstrap <anything>-box` dispatches on the family
|
||||||
|
suffix and refuses an unknown role by listing what the resolved registry
|
||||||
|
actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacent
|
||||||
|
(sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user
|
||||||
|
`ops`, box#69's server posture with `root-door=open` acceptance.
|
||||||
|
|
||||||
| tenant role | user | what lands |
|
**Where the registry comes from — three knobs, precedence high to low:**
|
||||||
|---------------|----------|------------|
|
|
||||||
| `claude-box` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` |
|
| knob | meaning |
|
||||||
| `codex-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` |
|
|------|---------|
|
||||||
| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
|
| `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" |
|
||||||
| `kimi-box` | `kimi` | the toolbelt, docker, the kimi CLI (uv-managed) on the system PATH, and `~/.kimi/AGENTS.md` |
|
| `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time |
|
||||||
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) |
|
| *(neither set)* | **the in-tree pin** — `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh`, the `BOX_RELEASE` discipline: bumped by ordinary reviewed rig PR, so a rig release freezes the mechanism+registry pair, and a newer rig matches newer templates by default (the #110 ruling) |
|
||||||
|
|
||||||
|
**The security trade — in bold, not a footnote.** **A main-tracked
|
||||||
|
rig-templates repo means every merged PR there executes as root inside every
|
||||||
|
future mint.** This is acceptable — and an improvement — only because of
|
||||||
|
three facts together: (1) it *narrows* today's surface, where all of rig is
|
||||||
|
main-tracked-as-root; (2) the repo is small, single-purpose, and
|
||||||
|
ceremony-governed with a **human merge** as the gate and the review panel
|
||||||
|
ahead of it; (3) drills pin the SHA they proved. If any of those three
|
||||||
|
weakens, the default flips to a pinned `RIG_TEMPLATES_REF`. install.sh diffs
|
||||||
|
in that repo are the highest-trust review surface in the org — the reviewer
|
||||||
|
doctrine should say so. *(2026-07-24: the flip this paragraph reserves was
|
||||||
|
taken, before the migration and by the decider — the default IS the pin
|
||||||
|
above, so a merged template reaches mints only through a reviewed pin bump
|
||||||
|
here, or an explicit per-mint `RIG_TEMPLATES_REF`.)*
|
||||||
|
|
||||||
**The role carries the suffix; the user does not.** A tenant user is the
|
**The role carries the suffix; the user does not.** A tenant user is the
|
||||||
account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
|
account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
|
||||||
|
|
@ -462,8 +487,9 @@ disposability facts, and the guard note — **never run `box setup-host`,
|
||||||
`box teardown-host`, or the drill inside a box; the box you are in is not a
|
`box teardown-host`, or the drill inside a box; the box you are in is not a
|
||||||
host you own**. A nested box stack claims the guest's own uplink subnet and
|
host you own**. A nested box stack claims the guest's own uplink subnet and
|
||||||
silently breaks its networking (box#80). The note lives in
|
silently breaks its networking (box#80). The note lives in
|
||||||
`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was
|
`lib/templates.sh`'s renderer exactly once — mechanism, not template data —
|
||||||
the point of moving it here.
|
never copy-pasted per definition; that was the point of moving it here. Only
|
||||||
|
the creds paragraph is the definition's (`creds.md`).
|
||||||
|
|
||||||
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
|
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
|
||||||
host=no` — no `root-door=`, because a guest has no root-door policy of its own
|
host=no` — no `root-door=`, because a guest has no root-door policy of its own
|
||||||
|
|
|
||||||
28
bin/rig
28
bin/rig
|
|
@ -37,15 +37,19 @@ commands:
|
||||||
Leave the tailnet only when /etc/rig/role proves rig performed the
|
Leave the tailnet only when /etc/rig/role proves rig performed the
|
||||||
join, then remove that marker. Refuses for pre-existing or old unknown
|
join, then remove that marker. Refuses for pre-existing or old unknown
|
||||||
joins, and while a GitHub runner is installed. Run as root.
|
joins, and while a GitHub runner is installed. Run as root.
|
||||||
bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>]
|
bootstrap <role>-box [--user <name>]
|
||||||
The box TENANT roles: converge a box-minted guest. The '-box' suffix
|
The box TENANT roles: converge a box-minted guest. The '-box' suffix
|
||||||
names the family (a guest, vs the '-server' machine roles above).
|
names the family (a guest, vs the '-server' machine roles above).
|
||||||
claude-box|codex-box|grok-box|kimi-box land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on
|
The agent tenants (claude-box, codex-box, grok-box, kimi-box, …) are
|
||||||
the system PATH, and the agent-context file (with the box#80 guard:
|
DEFINED in the heavy-duty/rig-templates registry — resolved via
|
||||||
never run box setup-host inside a box). staging-box lands box#69's
|
RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin — and land
|
||||||
server posture — docker + sshd hardening; its tailnet join stays
|
the agent toolbelt (git, gh, tmux, docker), the agent's CLI on the
|
||||||
operator-run via 'rig bootstrap workload-server'. Creds-free and non-interactive: box
|
system PATH, and the agent-context file (with the box#80 guard:
|
||||||
auto-runs these at mint. Run as root, inside the box.
|
never run box setup-host inside a box). staging-box, in rig's own
|
||||||
|
tree, lands box#69's server posture — docker + sshd hardening; its
|
||||||
|
tailnet join stays operator-run via 'rig bootstrap workload-server'.
|
||||||
|
Creds-free and non-interactive: box auto-runs these at mint. Run as
|
||||||
|
root, inside the box.
|
||||||
coolify install --version <pin>
|
coolify install --version <pin>
|
||||||
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
|
||||||
coolify backup install [options]
|
coolify backup install [options]
|
||||||
|
|
@ -95,6 +99,12 @@ commands:
|
||||||
Shut root SSH on a class=human box once an admin key works. Refuses
|
Shut root SSH on a class=human box once an admin key works. Refuses
|
||||||
on class=server — root there is the control plane's automation door —
|
on class=server — root there is the control plane's automation door —
|
||||||
and while no admin holds a key. Run as root.
|
and while no admin holds a key. Run as root.
|
||||||
|
template-lint <role-dir>...
|
||||||
|
Validate tenant-role definitions (the heavy-duty/rig-templates
|
||||||
|
shape): template.env against the allowlist (data, never sourced),
|
||||||
|
install.sh present with a shebang, creds.md non-blank. Every refusal
|
||||||
|
names the failing key. The registry repo's CI runs this on every PR;
|
||||||
|
no root, no network, no writes.
|
||||||
manifest [<key>]
|
manifest [<key>]
|
||||||
Print /etc/rig/manifest — which rig converged this machine and when
|
Print /etc/rig/manifest — which rig converged this machine and when
|
||||||
(bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at
|
(bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at
|
||||||
|
|
@ -455,6 +465,10 @@ case "$cmd" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
|
template-lint)
|
||||||
|
shift
|
||||||
|
exec "$ROOT/commands/template-lint.sh" "$@"
|
||||||
|
;;
|
||||||
manifest)
|
manifest)
|
||||||
shift
|
shift
|
||||||
exec "$ROOT/commands/manifest.sh" "$@"
|
exec "$ROOT/commands/manifest.sh" "$@"
|
||||||
|
|
|
||||||
8
changelog.d/110.md
Normal file
8
changelog.d/110.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Agent-tenant definitions live in heavy-duty/rig-templates, pinned in-tree and overridable per mint (`RIG_TEMPLATES_DIR`/`_REF`/`_REPO`); the in-tree case arms are gone, `staging-box` stays (#110)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `rig template-lint` validates role definitions; rig-templates CI runs it on every PR (#110)
|
||||||
|
- Drill records cite the rig-templates SHA the converge read (#110)
|
||||||
|
|
@ -1,24 +1,34 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> — the box TENANT
|
# rig bootstrap <role>-box — the box TENANT roles ('-box' names the family: a
|
||||||
# roles ('-box' names the family: a guest, vs the '-server' machine roles): what a
|
# guest, vs the '-server' machine roles): what a box-minted guest becomes
|
||||||
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed
|
# (issue #31). box mints the thin, creds-free seed (base image, user, rig
|
||||||
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the
|
# preinstalled — heavy-duty/box#81); rig converges the tenant content that
|
||||||
# tenant content that used to live in the templates' cloud-init, idempotent and
|
# used to live in the templates' cloud-init, idempotent and effective-state
|
||||||
# effective-state asserted, so an EXISTING box can be re-run to a new spec
|
# asserted, so an EXISTING box can be re-run to a new spec instead of
|
||||||
# instead of re-minted. One mechanism, parameterized per tenant through
|
# re-minted.
|
||||||
# lib/tenant-config.sh — never four hand-maintained copies.
|
#
|
||||||
|
# One MECHANISM, parameterized per tenant by a fetched DEFINITION (#110): the
|
||||||
|
# agent-tenant registry lives in heavy-duty/rig-templates — one directory per
|
||||||
|
# role (template.env, install.sh, creds.md), resolved through lib/templates.sh
|
||||||
|
# (RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin) — so adding a
|
||||||
|
# tenant is a data PR there, never an edit here (#109 is the scar: adding
|
||||||
|
# kimi, pure data, meant editing six files in this repo). staging-box is the
|
||||||
|
# one in-tree tenant: it is mechanism-adjacent (sshd hardening, docker — no
|
||||||
|
# agent, no CLI, no context file), so it converges from rig's own tree.
|
||||||
#
|
#
|
||||||
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
|
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
|
||||||
# bootstrap claude-box'), so every path here is non-interactive and nothing joins
|
# bootstrap claude-box'), so every path here is non-interactive and nothing joins
|
||||||
# or admits — no tailnet, no keys, no prompts. staging-box's tailnet join stays
|
# or admits — no tailnet, no keys, no prompts. That is also why the registry
|
||||||
# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the
|
# fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
|
||||||
# creds split box#69 designed.
|
# staging-box's tailnet join stays operator-run ('rig bootstrap
|
||||||
|
# workload-server' through 'box shell'), exactly the creds split box#69
|
||||||
|
# designed.
|
||||||
# Convergent: safe to re-run; a second run changes nothing.
|
# Convergent: safe to re-run; a second run changes nothing.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
|
# shellcheck source=SCRIPTDIR/lib/templates.sh
|
||||||
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
|
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
|
||||||
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
# shellcheck source=SCRIPTDIR/lib/users-config.sh
|
||||||
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
|
||||||
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
# shellcheck source=SCRIPTDIR/lib/sshd.sh
|
||||||
|
|
@ -32,26 +42,35 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
usage: rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>]
|
usage: rig bootstrap <role>-box [--user <name>]
|
||||||
|
|
||||||
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
|
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
|
||||||
creds-free seed (base image, user, rig preinstalled); this converges the
|
creds-free seed (base image, user, rig preinstalled); this converges the
|
||||||
tenant on top, and re-runs converge an existing box to a new spec.
|
tenant on top, and re-runs converge an existing box to a new spec.
|
||||||
|
|
||||||
claude-box|codex-box|grok-box|kimi-box
|
<role>-box an agent tenant DEFINED IN THE REGISTRY
|
||||||
the agent tenants: base tooling (git, gh, tmux, …),
|
(heavy-duty/rig-templates — claude-box, codex-box,
|
||||||
|
grok-box, kimi-box, …): base tooling (git, gh, tmux, …),
|
||||||
docker, the agent's CLI on the system PATH, and the
|
docker, the agent's CLI on the system PATH, and the
|
||||||
agent-context file — including the box#80 guard: never
|
agent-context file — including the box#80 guard: never
|
||||||
run `box setup-host` or the drill inside a box.
|
run `box setup-host` or the drill inside a box.
|
||||||
staging-box the server tenant (box#69's posture): docker + sshd
|
staging-box the server tenant (box#69's posture), in rig's own
|
||||||
hardening. The tailnet workload join is deliberately
|
tree: docker + sshd hardening. The tailnet workload
|
||||||
NOT here — it holds a credential, so it stays
|
join is deliberately NOT here — it holds a credential,
|
||||||
operator-run: `box shell` → `sudo rig bootstrap
|
so it stays operator-run: `box shell` → `sudo rig
|
||||||
workload-server` with a tagged pre-auth key.
|
bootstrap workload-server` with a tagged pre-auth key.
|
||||||
|
|
||||||
--user <name> the tenant user the box seed created (default: the
|
--user <name> the tenant user the box seed created (default: the
|
||||||
role's name minus the suffix; staging-box defaults to
|
definition's USER; staging-box defaults to `ops`)
|
||||||
`ops`)
|
|
||||||
|
The registry source is three knobs, precedence high to low:
|
||||||
|
RIG_TEMPLATES_DIR a local folder (no fetch — the offline/test path, and
|
||||||
|
"try a template before it exists anywhere")
|
||||||
|
RIG_TEMPLATES_REF a ref of RIG_TEMPLATES_REPO (default
|
||||||
|
heavy-duty/rig-templates), fetched as a tarball
|
||||||
|
(neither set) the ref pinned in rig's tree (lib/templates.sh
|
||||||
|
RIG_TEMPLATES_PIN — bumped by ordinary rig PR, so a
|
||||||
|
rig release freezes the mechanism+registry pair)
|
||||||
|
|
||||||
Tenant roles are creds-free and non-interactive by contract — box auto-runs
|
Tenant roles are creds-free and non-interactive by contract — box auto-runs
|
||||||
them at mint (`box exec … rig bootstrap claude-box`). They take none of the
|
them at mint (`box exec … rig bootstrap claude-box`). They take none of the
|
||||||
|
|
@ -63,19 +82,35 @@ EOF
|
||||||
# --- args (validated before the root check, so errors are testable) ---------
|
# --- args (validated before the root check, so errors are testable) ---------
|
||||||
ROLE="${1:-}"
|
ROLE="${1:-}"
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
claude-box|codex-box|grok-box|kimi-box|staging-box) shift ;;
|
staging-box) shift ;;
|
||||||
|
*-box)
|
||||||
|
# The family suffix is the whole gate here — WHICH '-box' roles exist is
|
||||||
|
# the resolved registry's fact, checked below, so a template added to the
|
||||||
|
# registry is mintable with zero code changes in rig (#110).
|
||||||
|
shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
"") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
|
"") usage >&2; die "tenant role required (a '-box' role from the template registry, or staging-box)" 2 ;;
|
||||||
*) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
|
*) die "unknown tenant role: $ROLE — tenant roles carry the '-box' family suffix (#76); the machine roles are control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom" 2 ;;
|
||||||
esac
|
esac
|
||||||
|
# The suffix rule above admits ANY '-box' name, so the charset is pinned
|
||||||
|
# before the name is ever used as a path component: a crafted role dies HERE,
|
||||||
|
# never in a registry lookup (the valid_version discipline, bin/rig).
|
||||||
|
[[ "$ROLE" =~ ^[a-z][a-z0-9-]*-box$ ]] \
|
||||||
|
|| die "invalid tenant role name: '$ROLE' — must match ^[a-z][a-z0-9-]*-box\$" 2
|
||||||
|
|
||||||
TENANT_USER="$(tenant_user "$ROLE")"
|
TENANT_USER_OVERRIDE=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
--user)
|
--user)
|
||||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||||
TENANT_USER="$2"; shift 2 ;;
|
TENANT_USER_OVERRIDE="$2"; shift 2
|
||||||
|
# Same charset the users file enforces, for the same reasons (a leading
|
||||||
|
# '-' reads as a usermod flag; '|', ':' corrupt things downstream).
|
||||||
|
# Checked HERE, at parse — the definition's USER is checked by the
|
||||||
|
# parser — so the refusal needs no registry and no network.
|
||||||
|
[[ "$TENANT_USER_OVERRIDE" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|
||||||
|
|| die "invalid user: '$TENANT_USER_OVERRIDE' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2 ;;
|
||||||
--hostname|--root-door|--host|--join)
|
--hostname|--root-door|--host|--join)
|
||||||
# The machine-role traits, refused with a story rather than "unknown
|
# The machine-role traits, refused with a story rather than "unknown
|
||||||
# flag": a tenant is a guest, not a tailnet machine — its shape comes
|
# flag": a tenant is a guest, not a tailnet machine — its shape comes
|
||||||
|
|
@ -89,10 +124,6 @@ while [ $# -gt 0 ]; do
|
||||||
*) die "unknown flag: $1" 2 ;;
|
*) die "unknown flag: $1" 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
# Same charset the users file enforces, for the same reasons (a leading '-'
|
|
||||||
# reads as a usermod flag; '|', ':' corrupt things downstream).
|
|
||||||
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|
|
||||||
|| die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2
|
|
||||||
|
|
||||||
# --- guards ------------------------------------------------------------------
|
# --- guards ------------------------------------------------------------------
|
||||||
# A tenant role converges a box GUEST. A box already carrying a machine-role
|
# A tenant role converges a box GUEST. A box already carrying a machine-role
|
||||||
|
|
@ -140,6 +171,34 @@ if [ -n "$EXISTING_ROOT_DOOR" ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- the definition ----------------------------------------------------------
|
||||||
|
# Resolved and parsed BEFORE the root check (but after the marker guards,
|
||||||
|
# which need no definition and must stay refusable with no registry in
|
||||||
|
# reach), so the two refusals a definition can earn — unknown role (listing
|
||||||
|
# what the resolved source actually contains) and malformed data (naming the
|
||||||
|
# failing key) — are testable non-root, offline, via RIG_TEMPLATES_DIR
|
||||||
|
# fixtures. The parse is the mint's
|
||||||
|
# own guard, deliberately duplicating the registry CI's lint: CI protects the
|
||||||
|
# registry, this protects a mint served through RIG_TEMPLATES_REPO/_DIR that
|
||||||
|
# CI never saw. template.env is parsed, NEVER sourced — a definition cannot
|
||||||
|
# execute arbitrary shell through its data file; install.sh is the one
|
||||||
|
# deliberately executable part, and it runs only after the root check below.
|
||||||
|
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
|
||||||
|
TPL_DIR=""
|
||||||
|
if [ "$ROLE" = "staging-box" ]; then
|
||||||
|
TENANT_USER="${TENANT_USER_OVERRIDE:-ops}" # box#69's ops
|
||||||
|
else
|
||||||
|
templates_resolve \
|
||||||
|
|| die "cannot resolve the template registry ($(templates_source_desc)) — see above" 2
|
||||||
|
TPL_DIR="$REGISTRY_DIR/$ROLE"
|
||||||
|
if [ ! -f "$TPL_DIR/template.env" ]; then
|
||||||
|
die "unknown tenant role: $ROLE — the resolved registry ($(templates_source_desc)) defines: $(templates_roles "$REGISTRY_DIR" | tr '\n' ' ')— and staging-box is in rig's own tree. A misconfigured RIG_TEMPLATES_REPO/_REF/_DIR looks exactly like this; check the source before the spelling." 2
|
||||||
|
fi
|
||||||
|
template_parse_env "$TPL_DIR/template.env" \
|
||||||
|
|| die "invalid definition for $ROLE in $(templates_source_desc) — the failing key is named above. The registry's CI lints every PR ('rig template-lint'); a malformed definition reaching a mint means the source above was never linted." 2
|
||||||
|
TENANT_USER="${TENANT_USER_OVERRIDE:-$TPL_USER}"
|
||||||
|
fi
|
||||||
|
|
||||||
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
||||||
if [ -r /etc/os-release ]; then
|
if [ -r /etc/os-release ]; then
|
||||||
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
|
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
|
||||||
|
|
@ -178,19 +237,18 @@ append_line_once() {
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
log "installing base packages (tenant ${ROLE})"
|
log "installing base packages (tenant ${ROLE})"
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
case "$ROLE" in
|
if [ "$ROLE" = "staging-box" ]; then
|
||||||
claude-box)
|
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
|
||||||
# The claude-box tenant keeps zsh (its shell UX ships with the box); the
|
# which only exists once the package is installed — pristine container/VM
|
||||||
# remaining list is the shared agent toolbelt the templates carried.
|
# images (and thin seeds) do not ship it.
|
||||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;;
|
apt-get install -y -qq curl ca-certificates tmux openssh-server
|
||||||
codex-box|grok-box|kimi-box)
|
else
|
||||||
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
|
# The shared agent toolbelt the templates carried, plus the definition's
|
||||||
staging-box)
|
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
|
||||||
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
|
# word list, every word already vetted by the parser's package-name gate.
|
||||||
# which only exists once the package is installed — pristine container/VM
|
# shellcheck disable=SC2086
|
||||||
# images (and thin seeds) do not ship it.
|
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS
|
||||||
apt-get install -y -qq curl ca-certificates tmux openssh-server ;;
|
fi
|
||||||
esac
|
|
||||||
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
|
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
|
||||||
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
|
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
|
||||||
# the operator's git credential lands.
|
# the operator's git credential lands.
|
||||||
|
|
@ -232,17 +290,19 @@ else
|
||||||
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
|
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- node (claude-box, codex-box) ----------------------------------------------------
|
# --- node (definitions carrying NEEDS_NODE="yes") ----------------------------
|
||||||
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified
|
# An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
|
||||||
# upstream when the template was written); the claude-box tenant ships node as part
|
# verified upstream when the template was written); claude ships node as part
|
||||||
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node.
|
# of its toolbelt, same pin. Whether a tenant needs it is the DEFINITION's
|
||||||
|
# fact (NEEDS_NODE), never a role list here — grok's CLI is a self-contained
|
||||||
|
# binary and kimi's is uv-managed Python, so both say no.
|
||||||
node_ok() {
|
node_ok() {
|
||||||
command -v node >/dev/null 2>&1 || return 1
|
command -v node >/dev/null 2>&1 || return 1
|
||||||
local major
|
local major
|
||||||
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
|
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
|
||||||
[ "${major:-0}" -ge 22 ] 2>/dev/null
|
[ "${major:-0}" -ge 22 ] 2>/dev/null
|
||||||
}
|
}
|
||||||
if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
|
if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
|
||||||
if node_ok; then
|
if node_ok; then
|
||||||
log "node $(node --version) already present"
|
log "node $(node --version) already present"
|
||||||
else
|
else
|
||||||
|
|
@ -254,60 +314,53 @@ if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- the agent CLI -----------------------------------------------------------
|
# --- the agent CLI -----------------------------------------------------------
|
||||||
# Per-agent install, shared discipline: install only when the CLI is absent
|
# Per-definition install, shared discipline: install only when the CLI is
|
||||||
# (upgrades are the CLI's own business), then put it on the SYSTEM path —
|
# absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
|
||||||
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc
|
# definition names one, `command -v` when it does not (an npm global's path
|
||||||
# files, so a PATH export alone is invisible to it (the #15 lesson) — and
|
# is the prefix's fact, not the data file's) — then put it on the SYSTEM
|
||||||
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is
|
# path: 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads
|
||||||
# what cost the last drill (the grok-box template's scar).
|
# no rc files, so a PATH export alone is invisible to it (the #15 lesson).
|
||||||
|
# And assert it ANSWERS as the tenant user: a CLI that exists but cannot run
|
||||||
|
# is what cost the last drill (the grok-box template's scar).
|
||||||
|
#
|
||||||
|
# install.sh — the definition's one executable part — runs AS ROOT with the
|
||||||
|
# tenant named in its environment (TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE);
|
||||||
|
# each definition drops to the tenant user itself (runuser -l) where the
|
||||||
|
# vendor's layout demands it, because some installs are inherently root's
|
||||||
|
# (codex's npm -g writes the global prefix). This is the trade #110 states in
|
||||||
|
# bold — a registry definition executes as root inside every future mint —
|
||||||
|
# and it is why install.sh diffs there are the highest-trust review surface
|
||||||
|
# in the org, why the default ref is a reviewed in-tree pin, and why the data
|
||||||
|
# file beside it is parsed rather than sourced.
|
||||||
CLI="" CLI_SRC=""
|
CLI="" CLI_SRC=""
|
||||||
case "$ROLE" in
|
if [ "$ROLE" != "staging-box" ]; then
|
||||||
claude-box)
|
CLI="$TPL_CLI_NAME"
|
||||||
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude"
|
# '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
|
||||||
if [ ! -e "$CLI_SRC" ]; then
|
# substitution, never by the shell (hence the literal quoted tilde, SC2088).
|
||||||
log "installing the Claude Code CLI as ${TENANT_USER}"
|
# shellcheck disable=SC2088
|
||||||
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash'
|
case "$TPL_CLI_SRC" in
|
||||||
else
|
'~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
|
||||||
log "claude CLI already installed"
|
*) CLI_SRC="$TPL_CLI_SRC" ;;
|
||||||
fi ;;
|
esac
|
||||||
codex-box)
|
installed=""
|
||||||
CLI=codex
|
if [ -n "$CLI_SRC" ]; then
|
||||||
if ! command -v codex >/dev/null 2>&1; then
|
[ -e "$CLI_SRC" ] && installed=1
|
||||||
log "installing the Codex CLI (npm global)"
|
elif command -v "$CLI" >/dev/null 2>&1; then
|
||||||
npm install -g @openai/codex
|
installed=1
|
||||||
else
|
fi
|
||||||
log "codex CLI already installed"
|
if [ -z "$installed" ]; then
|
||||||
fi
|
log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
|
||||||
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
|
TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
|
||||||
grok-box)
|
TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
|
||||||
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`,
|
bash "$TPL_DIR/install.sh" \
|
||||||
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir.
|
|| die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
|
||||||
# Run it AS the tenant user, never root: a symlink into root's 0700 home
|
else
|
||||||
# would be a CLI that exists and cannot run.
|
log "${CLI} CLI already installed"
|
||||||
CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok"
|
fi
|
||||||
if [ ! -e "$CLI_SRC" ]; then
|
if [ -z "$CLI_SRC" ]; then
|
||||||
log "installing the Grok CLI as ${TENANT_USER}"
|
CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
|
||||||
runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash'
|
[ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?"
|
||||||
else
|
fi
|
||||||
log "grok CLI already installed"
|
|
||||||
fi ;;
|
|
||||||
kimi-box)
|
|
||||||
# The OFFICIAL installer (code.kimi.com/install.sh): a uv-managed Python
|
|
||||||
# tool (kimi-cli), landing `kimi` in ~/.local/bin — uv's tool bin — with
|
|
||||||
# uv bringing its own managed CPython, so no apt python pin here (the
|
|
||||||
# node section above stays claude/codex-only for the same reason). Run AS
|
|
||||||
# the tenant user, never root: grok's lesson — a root-owned install under
|
|
||||||
# a 0700 home is a CLI that exists and cannot run.
|
|
||||||
CLI=kimi CLI_SRC="$TENANT_HOME/.local/bin/kimi"
|
|
||||||
if [ ! -e "$CLI_SRC" ]; then
|
|
||||||
log "installing the Kimi CLI as ${TENANT_USER}"
|
|
||||||
runuser -l "$TENANT_USER" -c 'curl -LsSf https://code.kimi.com/install.sh | bash'
|
|
||||||
else
|
|
||||||
log "kimi CLI already installed"
|
|
||||||
fi ;;
|
|
||||||
staging-box) ;; # no agent lives on the staging-box tenant
|
|
||||||
esac
|
|
||||||
if [ -n "$CLI" ]; then
|
|
||||||
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
|
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
|
||||||
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
|
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
|
||||||
# One capture serves both the assert and the log line; emptiness IS the
|
# One capture serves both the assert and the log line; emptiness IS the
|
||||||
|
|
@ -316,29 +369,23 @@ if [ -n "$CLI" ]; then
|
||||||
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
|
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
|
||||||
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
|
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
|
||||||
|
|
||||||
# The interactive-shell PATH exports the templates carried, converged as
|
# The interactive-shell PATH export the templates carried, converged as a
|
||||||
# literal rc lines (written once, never duplicated). Single quotes are the
|
# literal rc line (written once, never duplicated). The definition's
|
||||||
# point: the line must expand in the USER's shell, not here.
|
# PATH_LINE is DATA, appended verbatim: it must expand in the USER's
|
||||||
# shellcheck disable=SC2016
|
# shell, not here.
|
||||||
case "$ROLE" in
|
append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
|
||||||
claude-box)
|
|
||||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
|
|
||||||
codex-box)
|
|
||||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
|
|
||||||
grok-box)
|
|
||||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
|
|
||||||
kimi-box)
|
|
||||||
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
|
|
||||||
esac
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- the agent-context file --------------------------------------------------
|
# --- the agent-context file --------------------------------------------------
|
||||||
# The one file every agent reads before touching anything. Rendered from
|
# The one file every agent reads before touching anything. The skeleton —
|
||||||
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or
|
# including the box#80 guard note ("never run box setup-host or the drill
|
||||||
# the drill inside a box; the box you are in is not a host you own") lives
|
# inside a box; the box you are in is not a host you own") — is MECHANISM,
|
||||||
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded
|
# rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
|
||||||
# like every file rig converges.
|
# template; only the creds paragraph is the definition's (creds.md).
|
||||||
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
# cmp-guarded like every file rig converges. staging-box has no agent and no
|
||||||
|
# context file.
|
||||||
|
if [ "$ROLE" != "staging-box" ]; then
|
||||||
|
CTX_PATH="$TENANT_HOME/$TPL_CONTEXT_PATH"
|
||||||
CTX_DIR="$(dirname "$CTX_PATH")"
|
CTX_DIR="$(dirname "$CTX_PATH")"
|
||||||
if [ ! -d "$CTX_DIR" ]; then
|
if [ ! -d "$CTX_DIR" ]; then
|
||||||
mkdir -p "$CTX_DIR"
|
mkdir -p "$CTX_DIR"
|
||||||
|
|
@ -348,7 +395,7 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
||||||
# its ownership is converged on every run, not only on creation.
|
# its ownership is converged on every run, not only on creation.
|
||||||
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
|
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
|
||||||
CTX_TMP="$(mktemp)"
|
CTX_TMP="$(mktemp)"
|
||||||
render_tenant_context "$ROLE" > "$CTX_TMP"
|
render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$CTX_TMP"
|
||||||
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
|
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
|
||||||
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
|
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
|
||||||
log "agent-context file written: ${CTX_PATH}"
|
log "agent-context file written: ${CTX_PATH}"
|
||||||
|
|
@ -358,32 +405,6 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
|
||||||
rm -f "$CTX_TMP"
|
rm -f "$CTX_TMP"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- claude-box shell niceties ---------------------------------------------------
|
|
||||||
# The claude-box template shipped zsh + oh-my-zsh + tmux mouse mode; they move with
|
|
||||||
# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a
|
|
||||||
# bootstrap whose real work (CLI, context, docker) already converged.
|
|
||||||
if [ "$ROLE" = "claude-box" ]; then
|
|
||||||
if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then
|
|
||||||
chsh -s /usr/bin/zsh "$TENANT_USER"
|
|
||||||
log "login shell set to zsh for ${TENANT_USER}"
|
|
||||||
else
|
|
||||||
log "login shell already zsh for ${TENANT_USER}"
|
|
||||||
fi
|
|
||||||
if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then
|
|
||||||
log "installing oh-my-zsh for ${TENANT_USER}"
|
|
||||||
# Single quotes on purpose: the $(...) must run in the USER's shell.
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \
|
|
||||||
|| warn "oh-my-zsh install failed — cosmetic only; continuing"
|
|
||||||
else
|
|
||||||
log "oh-my-zsh already installed"
|
|
||||||
fi
|
|
||||||
# After oh-my-zsh (it rewrites .zshrc on first install).
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"'
|
|
||||||
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- staging-box server posture --------------------------------------------------
|
# --- staging-box server posture --------------------------------------------------
|
||||||
# box#69's posture, minus the join: docker (above) + sshd hardening, through
|
# box#69's posture, minus the join: docker (above) + sshd hardening, through
|
||||||
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a
|
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ usage: rig bootstrap <control-plane-server|workload-server|runner-server|
|
||||||
(--users <path> | --no-users)
|
(--users <path> | --no-users)
|
||||||
[--hostname <name>] [--root-door <closed|open>]
|
[--hostname <name>] [--root-door <closed|open>]
|
||||||
[--host <yes|no>] [--join <authkey|login>]
|
[--host <yes|no>] [--join <authkey|login>]
|
||||||
rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>]
|
rig bootstrap <role>-box [--user <name>]
|
||||||
(the box TENANT roles — see their own --help; they take
|
(the box TENANT roles — the agent tenants come from the
|
||||||
no --users, see below)
|
heavy-duty/rig-templates registry, staging-box from
|
||||||
|
rig's own tree; see their own --help — they take no
|
||||||
|
--users, see below)
|
||||||
rig bootstrap --undo
|
rig bootstrap --undo
|
||||||
leave the tailnet only when the role marker proves rig
|
leave the tailnet only when the role marker proves rig
|
||||||
performed the join, then remove the role marker
|
performed the join, then remove the role marker
|
||||||
|
|
@ -59,7 +61,8 @@ and per-human accounts keep attribution intact for the times someone does go
|
||||||
in. So the complete path is the default path and skipping it is a deliberate
|
in. So the complete path is the default path and skipping it is a deliberate
|
||||||
--no-users, not an omission.
|
--no-users, not an omission.
|
||||||
|
|
||||||
--users does NOT reach the box TENANT roles (claude-box|codex-box|grok-box|kimi-box|staging-box). A
|
--users does NOT reach the box TENANT roles (any '-box' name, e.g.
|
||||||
|
claude-box, staging-box). A
|
||||||
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
|
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
|
||||||
non-interactively, with no file to hand it; the guest never joins the tailnet
|
non-interactively, with no file to hand it; the guest never joins the tailnet
|
||||||
and has no SSH door of its own — entry is `box shell`, gated by the HOST's
|
and has no SSH door of its own — entry is `box shell`, gated by the HOST's
|
||||||
|
|
@ -122,15 +125,18 @@ case "$ROLE" in
|
||||||
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
|
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
|
||||||
exec "$HERE/bootstrap-undo.sh" ;;
|
exec "$HERE/bootstrap-undo.sh" ;;
|
||||||
control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;;
|
control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;;
|
||||||
claude-box|codex-box|grok-box|kimi-box|staging-box)
|
*-box)
|
||||||
# The box TENANT roles (#31) are a different family — guests a box mints,
|
# The box TENANT roles (#31) are a different family — guests a box mints,
|
||||||
# never tailnet machines — and live in their own mechanism, one script
|
# never tailnet machines — and live in their own mechanism, one script
|
||||||
# parameterized per tenant. Dispatched here so `rig bootstrap <role>`
|
# parameterized per DEFINITION fetched from the template registry (#110;
|
||||||
# stays the single entrypoint for both families.
|
# staging-box stays in-tree). Dispatched on the FAMILY SUFFIX (#76), not
|
||||||
|
# an enumerated list: which '-box' roles exist is the registry's fact, so
|
||||||
|
# a template added there is mintable with zero code changes here.
|
||||||
|
# `rig bootstrap <role>` stays the single entrypoint for both families.
|
||||||
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-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 tenant role: claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
|
"") 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 tenant role: claude-box|codex-box|grok-box|kimi-box|staging-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 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Role→traits map — the single place a role's shape is declared (issue #26).
|
# Role→traits map — the single place a role's shape is declared (issue #26).
|
||||||
|
|
|
||||||
266
commands/lib/templates.sh
Normal file
266
commands/lib/templates.sh
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
#!/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:
|
||||||
|
#
|
||||||
|
# <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.
|
||||||
|
#
|
||||||
|
# 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 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, 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 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=""
|
||||||
|
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
||||||
|
REGISTRY_DIR=""
|
||||||
|
templates_resolve() {
|
||||||
|
local repo ref 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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
# shellcheck disable=SC2034 # the function's output global, read by the sourcing script
|
||||||
|
REGISTRY_DIR="${1%/}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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; }
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# 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 <<EOF
|
||||||
|
# You are running inside a box (tenant: ${role})
|
||||||
|
|
||||||
|
A box is a trust-less, network-isolated, ephemeral VM created by the
|
||||||
|
\`box\` CLI. Keep this context in mind:
|
||||||
|
|
||||||
|
$(cat "$creds_file")
|
||||||
|
- **Isolated.** The box reaches the public internet but nothing on the host or
|
||||||
|
local network. There is no inbound path.
|
||||||
|
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
||||||
|
removed; the operator persists work via git push and via \`box snapshot\`.
|
||||||
|
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
|
||||||
|
or the drill inside a box. The box you are in is not a host you own: a
|
||||||
|
nested box stack claims the guest's own uplink subnet and gateway, and
|
||||||
|
silently breaks this box's networking with intermittent egress blackouts
|
||||||
|
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
|
||||||
|
and testing never needs the host stack; host setup belongs to the operator's
|
||||||
|
machine, never this one.
|
||||||
|
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||||
|
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
|
||||||
|
runbook — how to install dependencies, start services, template environment
|
||||||
|
files, seed data, and smoke-test — and follow it. It is documentation for
|
||||||
|
you, not a script the host runs.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Shared parameters for the box TENANT roles (claude-box, codex-box, grok-box,
|
|
||||||
# kimi-box, staging-box) — the '-box' suffix names the FAMILY (a guest a box mints, vs the
|
|
||||||
# '-server' machine roles rig converges); see #76.
|
|
||||||
# sourced by bootstrap-tenant.sh and by the test harness. Pure text→text, no
|
|
||||||
# side effects: the per-tenant differences live HERE, in one table, so the
|
|
||||||
# mechanism stays one script parameterized per tenant instead of four
|
|
||||||
# hand-maintained copies (repo precedent: parse_users_file, runner-config).
|
|
||||||
|
|
||||||
# tenant_user <role> — the user the box seed creates (box.env BOX_USER). The
|
|
||||||
# agent tenants are named after their agent (minus the suffix — the USER is not
|
|
||||||
# the role); staging-box keeps box#69's `ops`.
|
|
||||||
tenant_user() {
|
|
||||||
case "$1" in
|
|
||||||
claude-box) printf 'claude' ;;
|
|
||||||
codex-box) printf 'codex' ;;
|
|
||||||
grok-box) printf 'grok' ;;
|
|
||||||
kimi-box) printf 'kimi' ;;
|
|
||||||
staging-box) printf 'ops' ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# tenant_context_path <role> <home> — where the agent-context file lands. Each
|
|
||||||
# agent CLI reads its own instructions file from its own dotdir (named for the
|
|
||||||
# agent, not the role — the dotdir is the CLI's, and the suffix is rig's);
|
|
||||||
# staging-box has no agent and no context file (return 1).
|
|
||||||
tenant_context_path() {
|
|
||||||
case "$1" in
|
|
||||||
claude-box) printf '%s/.claude/CLAUDE.md' "$2" ;;
|
|
||||||
codex-box) printf '%s/.codex/AGENTS.md' "$2" ;;
|
|
||||||
grok-box) printf '%s/.grok/AGENTS.md' "$2" ;;
|
|
||||||
# kimi documents only PROJECT-level AGENTS.md today (no global file); its
|
|
||||||
# dotdir is ~/.kimi (config.toml, sessions/, credentials/). The context
|
|
||||||
# file lands at the <dotdir>/AGENTS.md convention the other CLIs converged
|
|
||||||
# on, so it is where an operator (or a future global-read) will look — an
|
|
||||||
# honest placement, not a claim that the CLI auto-loads it.
|
|
||||||
kimi-box) printf '%s/.kimi/AGENTS.md' "$2" ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# render_tenant_context <role> — the agent-context file's content, on stdout.
|
|
||||||
# One renderer for all four agents: only the creds paragraph is per-vendor,
|
|
||||||
# and the box#80 guard note lives HERE once — never copy-pasted per template.
|
|
||||||
# staging-box renders nothing (return 1): no agent lives there.
|
|
||||||
render_tenant_context() {
|
|
||||||
local role="$1" creds
|
|
||||||
# The single-quoted markdown below carries literal `$`-free backtick prose;
|
|
||||||
# single quotes are deliberate — nothing in it may expand here.
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
case "$role" in
|
|
||||||
claude-box)
|
|
||||||
creds='- **Creds-free by default.** The box starts with no Claude and no git
|
|
||||||
credentials. If you need to authenticate Claude, the operator runs `/login`
|
|
||||||
interactively. For git, the operator adds their own credentials (a PAT or
|
|
||||||
`gh auth login`). Never assume credentials are present; never ask for or
|
|
||||||
store secrets on disk beyond what the operator sets up.' ;;
|
|
||||||
codex-box)
|
|
||||||
creds='- **Creds-free by default.** The box starts with no OpenAI and no git
|
|
||||||
credentials. If you need to authenticate Codex, the operator runs the
|
|
||||||
login flow (`codex`) interactively. For git, the operator adds their own
|
|
||||||
credentials (a PAT or `gh auth login`). Never assume credentials are
|
|
||||||
present; never ask for or store secrets on disk beyond what the operator
|
|
||||||
sets up.' ;;
|
|
||||||
grok-box)
|
|
||||||
creds='- **Creds-free by default.** The box starts with no xAI and no git
|
|
||||||
credentials. If you need to authenticate, the operator runs
|
|
||||||
`grok login` interactively (SuperGrok / X Premium+). For git, the
|
|
||||||
operator adds their own credentials (a PAT or `gh auth login`). Never
|
|
||||||
assume credentials are present; never ask for or store secrets on disk
|
|
||||||
beyond what the operator sets up.' ;;
|
|
||||||
kimi-box)
|
|
||||||
creds='- **Creds-free by default.** The box starts with no Moonshot and no git
|
|
||||||
credentials. If you need to authenticate, the operator runs `kimi` and
|
|
||||||
its `/login` flow interactively (Kimi Code OAuth, or an API key). For
|
|
||||||
git, the operator adds their own credentials (a PAT or `gh auth login`).
|
|
||||||
Never assume credentials are present; never ask for or store secrets on
|
|
||||||
disk beyond what the operator sets up.' ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
cat <<EOF
|
|
||||||
# You are running inside a box (tenant: ${role})
|
|
||||||
|
|
||||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
|
||||||
\`box\` CLI. Keep this context in mind:
|
|
||||||
|
|
||||||
${creds}
|
|
||||||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
|
||||||
local network. There is no inbound path.
|
|
||||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
|
||||||
removed; the operator persists work via git push and via \`box snapshot\`.
|
|
||||||
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
|
|
||||||
or the drill inside a box. The box you are in is not a host you own: a
|
|
||||||
nested box stack claims the guest's own uplink subnet and gateway, and
|
|
||||||
silently breaks this box's networking with intermittent egress blackouts
|
|
||||||
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
|
|
||||||
and testing never needs the host stack; host setup belongs to the operator's
|
|
||||||
machine, never this one.
|
|
||||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
|
||||||
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
|
|
||||||
runbook — how to install dependencies, start services, template environment
|
|
||||||
files, seed data, and smoke-test — and follow it. It is documentation for
|
|
||||||
you, not a script the host runs.
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
52
commands/template-lint.sh
Executable file
52
commands/template-lint.sh
Executable file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# rig template-lint <role-dir>... — is this a valid tenant-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
|
||||||
|
# heavy-duty/rig-templates repo's CI runs this on every definition on every
|
||||||
|
# PR, so a broken definition is refused before it can ever reach a mint
|
||||||
|
# (#110). The two gates are deliberate: CI protects the registry, the
|
||||||
|
# mint-time parse protects a mint served through RIG_TEMPLATES_REPO/_DIR
|
||||||
|
# that CI never saw.
|
||||||
|
#
|
||||||
|
# Pure read: no root, no network, no writes — lintable anywhere, including
|
||||||
|
# the registry repo's checkout, where rig's tree is only a fetched tool.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
|
||||||
|
# shellcheck source=SCRIPTDIR/lib/templates.sh
|
||||||
|
. "$HERE/lib/templates.sh" # template_lint (and the schema it enforces)
|
||||||
|
|
||||||
|
die() { printf 'rig-template-lint: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
||||||
|
|
||||||
|
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.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
"") usage >&2; die "at least one role directory required" 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
for dir in "$@"; do
|
||||||
|
case "$dir" in
|
||||||
|
-*) usage >&2; die "unknown flag: $dir" 2 ;;
|
||||||
|
esac
|
||||||
|
if template_lint "$dir"; then
|
||||||
|
printf 'rig-template-lint: OK: %s\n' "$dir"
|
||||||
|
else
|
||||||
|
printf 'rig-template-lint: FAIL: %s\n' "$dir" >&2
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit "$fail"
|
||||||
|
|
@ -51,6 +51,14 @@ REPO="${RIG_REPO:-heavy-duty/rig}"
|
||||||
REF="${RIG_REF:-}"
|
REF="${RIG_REF:-}"
|
||||||
BOXREPO="${BOX_REPO:-heavy-duty/box}"
|
BOXREPO="${BOX_REPO:-heavy-duty/box}"
|
||||||
BOXREF="${BOX_REF:-}"
|
BOXREF="${BOX_REF:-}"
|
||||||
|
# The template registry the converge will read (#110). No explicitness
|
||||||
|
# demand here, unlike the two refs above: the DEFAULT is already a pin — the
|
||||||
|
# candidate tree's RIG_TEMPLATES_PIN, read after install from what actually
|
||||||
|
# landed — so an unset override means "the ref the release will really use",
|
||||||
|
# not "whatever main was that afternoon".
|
||||||
|
TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||||
|
TPLREF="${RIG_TEMPLATES_REF:-}"
|
||||||
|
TPL_SHA=""
|
||||||
ROLE=staging-server
|
ROLE=staging-server
|
||||||
USERS_FILE="${DRILL_USERS_FILE:-}"
|
USERS_FILE="${DRILL_USERS_FILE:-}"
|
||||||
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
|
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
|
||||||
|
|
@ -266,6 +274,8 @@ emit_record() {
|
||||||
printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt"
|
printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt"
|
||||||
printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \
|
printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \
|
||||||
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
|
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
|
||||||
|
printf 'Template registry: %s@%s (ref %s) — the rig-templates the converge read (#110).\n' \
|
||||||
|
"${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" "${TPLREF:-unresolved}"
|
||||||
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
|
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
|
||||||
printf '| Leg | Result |\n'
|
printf '| Leg | Result |\n'
|
||||||
printf '| --- | --- |\n'
|
printf '| --- | --- |\n'
|
||||||
|
|
@ -378,6 +388,21 @@ RIG_TREE="$(tree_of "$(command -v rig)")"
|
||||||
assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1
|
assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1
|
||||||
DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)"
|
DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)"
|
||||||
ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)"
|
ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)"
|
||||||
|
|
||||||
|
# The rig-templates ref this candidate converges (#110), for the record: the
|
||||||
|
# env override when the drill was pointed somewhere, else the pin read from
|
||||||
|
# the INSTALLED tree — what actually landed, never this checkout's copy. A
|
||||||
|
# 40-hex ref IS its own SHA (the pin's normal shape); anything else resolves
|
||||||
|
# through ref_sha like the two candidates above.
|
||||||
|
if [ -z "$TPLREF" ]; then
|
||||||
|
TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)"
|
||||||
|
fi
|
||||||
|
if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
|
TPL_SHA="${TPLREF:0:7}"
|
||||||
|
elif [ -n "$TPLREF" ]; then
|
||||||
|
TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")"
|
||||||
|
fi
|
||||||
|
inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved})"
|
||||||
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
|
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,11 @@ schedule, in separate sittings. What makes that safe is that every drill
|
||||||
**pins the same fixed set of candidate refs**: rig's drill runs `--host yes`
|
**pins the same fixed set of candidate refs**: rig's drill runs `--host yes`
|
||||||
with `BOX_REF=release/<box-version>`, so it exercises the box that will
|
with `BOX_REF=release/<box-version>`, so it exercises the box that will
|
||||||
actually ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it
|
actually ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it
|
||||||
exercises the rig that will actually ship. Both measure the same pair.
|
exercises the rig that will actually ship. Both measure the same pair. The
|
||||||
|
record also cites the **rig-templates SHA** the converge read (#110) — the
|
||||||
|
candidate tree's `RIG_TEMPLATES_PIN` unless the drill was pointed elsewhere
|
||||||
|
via `RIG_TEMPLATES_REF` — so the mechanism+registry pair a release freezes
|
||||||
|
is the pair the drill proved.
|
||||||
|
|
||||||
That — not sequencing — is what dissolves the box↔rig recursion. The refs are
|
That — not sequencing — is what dissolves the box↔rig recursion. The refs are
|
||||||
static identifiers that exist as soon as the release branches do, long before
|
static identifiers that exist as soon as the release branches do, long before
|
||||||
|
|
|
||||||
313
test/cli.sh
313
test/cli.sh
|
|
@ -572,19 +572,53 @@ else
|
||||||
echo "skip: bootstrap non-root refusals (running as root)"
|
echo "skip: bootstrap non-root refusals (running as root)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- box tenant roles (#31/#76): claude-box|codex-box|grok-box|kimi-box|staging-box ---
|
# --- box tenant roles (#31/#76/#110): <role>-box from the registry + staging-box ---
|
||||||
# What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh),
|
# What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh),
|
||||||
# parameterized per tenant through lib/tenant-config.sh, dispatched from
|
# parameterized per DEFINITION fetched from the template registry
|
||||||
# bootstrap.sh so `rig bootstrap <role>` stays the single entrypoint. The real
|
# (heavy-duty/rig-templates; lib/templates.sh resolves RIG_TEMPLATES_DIR >
|
||||||
# converge needs root, a tenant user, and the network — the container
|
# RIG_TEMPLATES_REF > the in-tree pin), dispatched from bootstrap.sh on the
|
||||||
# rehearsal's job — so the harness proves what it can non-root: the whole
|
# '-box' FAMILY SUFFIX so `rig bootstrap <role>` stays the single entrypoint
|
||||||
# arg/refusal surface, the pure parameter table, the rendered agent-context
|
# and a template added to the registry is mintable with zero code changes
|
||||||
# file (guard note included), and grep-pins on the shipped script.
|
# here. The real converge needs root, a tenant user, and the network — the
|
||||||
|
# container rehearsal's job — so the harness proves what it can non-root and
|
||||||
|
# OFFLINE: the whole refusal surface, the resolution precedence, the parser
|
||||||
|
# and the renderer, against fixture definitions via RIG_TEMPLATES_DIR.
|
||||||
|
|
||||||
|
# The fixture registry: one valid scratch definition, plus broken ones the
|
||||||
|
# parser must refuse BY NAME. Synthetic on purpose — the real definitions
|
||||||
|
# live in heavy-duty/rig-templates, and this suite must hold whatever those
|
||||||
|
# say (offline is the point: no fetch, no network, no coupling).
|
||||||
|
TPL_FIX="$(mktemp -d)"
|
||||||
|
mkdir -p "$TPL_FIX/scratch-box"
|
||||||
|
cat > "$TPL_FIX/scratch-box/template.env" <<'TPLEOF'
|
||||||
|
# comments and blank lines are the only non-KEY="value" grammar
|
||||||
|
|
||||||
|
USER="scratch"
|
||||||
|
CONTEXT_PATH=".scratch/AGENTS.md"
|
||||||
|
CLI_NAME="scratch"
|
||||||
|
CLI_SRC="~/.local/bin/scratch"
|
||||||
|
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
|
||||||
|
NEEDS_NODE="no"
|
||||||
|
APT_EXTRAS="zsh"
|
||||||
|
TPLEOF
|
||||||
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$TPL_FIX/scratch-box/install.sh"
|
||||||
|
printf -- '- **Creds-free by default.** The scratch vendor paragraph.\n' > "$TPL_FIX/scratch-box/creds.md"
|
||||||
|
mkdir -p "$TPL_FIX/badkey-box"
|
||||||
|
printf 'USER="x"\nCOLOR="red"\n' > "$TPL_FIX/badkey-box/template.env"
|
||||||
|
mkdir -p "$TPL_FIX/missing-box"
|
||||||
|
printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nPATH_LINE="p"\n' > "$TPL_FIX/missing-box/template.env"
|
||||||
|
mkdir -p "$TPL_FIX/garbled-box"
|
||||||
|
printf 'USER=unquoted\n' > "$TPL_FIX/garbled-box/template.env"
|
||||||
|
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"
|
||||||
|
|
||||||
# THE HARD CUT, tenant half (#76). The pre-rename names are gone and must fail
|
# 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
|
# as UNKNOWN — asserted per name, because an alias left in for one tenant is the
|
||||||
# shape that survives review: the taxonomy reads complete while one old name
|
# shape that survives review. And the #110 cut on top: the mechanism no longer
|
||||||
# still quietly converges. Checked at BOTH entrypoints, since bootstrap.sh has
|
# KNOWS any agent tenant by name — which '-box' roles exist is the registry's
|
||||||
# its own dispatch list and a name could survive in one and not the other.
|
# fact, so the old names die on the family-suffix rule, not an enumerated list.
|
||||||
for r in claude codex grok staging; do
|
for r in claude codex grok staging; do
|
||||||
check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \
|
check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \
|
||||||
"$ROOT/commands/bootstrap-tenant.sh" "$r"
|
"$ROOT/commands/bootstrap-tenant.sh" "$r"
|
||||||
|
|
@ -593,39 +627,46 @@ for r in claude codex grok staging; do
|
||||||
done
|
done
|
||||||
check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help
|
check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help
|
||||||
check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh"
|
check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh"
|
||||||
check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato
|
check "tenant: a suffix-less role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato
|
||||||
check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude-box --nope
|
check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude-box --nope
|
||||||
check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user
|
check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user
|
||||||
check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user 'fo|o'
|
check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user 'fo|o'
|
||||||
# The docker converge asserts the DAEMON answers, not just the client binary —
|
# The suffix rule admits ANY '-box' name, so the charset gate must catch a
|
||||||
# a dead dockerd passing `docker --version` is the "linked but cannot run"
|
# crafted one BEFORE it is used as a path component (the valid_version
|
||||||
# scar in daemon form. Grep-pinned so the assert cannot ship deleted.
|
# discipline): uppercase, dots, a leading '-' all die at the name, never in a
|
||||||
|
# registry lookup.
|
||||||
|
check "tenant: a crafted role name dies at the charset gate" 2 "invalid tenant role name" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" 'UPPER-box'
|
||||||
check "tenant: dockerd effective-state assert is present" 0 "" \
|
check "tenant: dockerd effective-state assert is present" 0 "" \
|
||||||
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
|
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
|
||||||
# The machine-role traits die with the tenant story, never "unknown flag" — an
|
# The machine-role traits die with the tenant story, never "unknown flag" — an
|
||||||
# operator reaching for --hostname must learn where the trait family went.
|
# operator coming from the machine families needs the boundary, not a shrug.
|
||||||
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
|
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
|
||||||
"$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed
|
"$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed
|
||||||
check "tenant: --hostname dies the same way" 2 "have no traits" \
|
check "tenant: --hostname dies the same way" 2 "have no traits" \
|
||||||
"$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest
|
"$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest
|
||||||
# Dispatch: the machine-role entrypoint hands tenant roles to the tenant
|
# Dispatch: the machine-role entrypoint hands ANY '-box' role to the tenant
|
||||||
# mechanism with args intact (--help reaching the TENANT usage proves both).
|
# mechanism on the family suffix — enumerating them would re-chain template
|
||||||
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|codex-box|grok-box|kimi-box|staging-box" \
|
# velocity to rig edits, the exact coupling #110 removes.
|
||||||
|
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "Box TENANT roles" \
|
||||||
"$ROOT/commands/bootstrap.sh" claude-box --help
|
"$ROOT/commands/bootstrap.sh" claude-box --help
|
||||||
# The marker guard fires BEFORE the root check (repo precedent: the coolify
|
check "bootstrap: an unheard-of '-box' role still dispatches (zero code changes)" 0 "Box TENANT roles" \
|
||||||
# marker warning), so the refusals are provable here off fixture markers. A
|
"$ROOT/commands/bootstrap.sh" scratch-box --help
|
||||||
|
|
||||||
|
# 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,
|
# VM host (host=yes) refuses for every tenant — and names the staging PAIR,
|
||||||
# because whoever lands here has the two halves confused and wants the metal
|
# because whoever hits it has the halves confused: the guest (staging-box), the metal
|
||||||
# (staging-server). An agent tenant refuses ANY machine-role box; staging-box
|
# (staging-server). An agent tenant refuses ANY machine-role box; staging-box
|
||||||
# tolerates ONLY root-door=open with host=no — that is the guest after its
|
# tolerates exactly the workload-joined guest (root-door=open host=no) and refuses the
|
||||||
# operator-run workload join, and re-converging it is what convergence is for.
|
# rest. These need no registry: the guards run before the resolution, so a
|
||||||
# A closed-door machine (root-door=closed via custom) is NOT that guest, and
|
# poisoning converge is refused even when the registry is unreachable.
|
||||||
# open-door hardening would die at it with root-door=open-specific messaging —
|
|
||||||
# refuse instead.
|
|
||||||
TEN_FIX="$(mktemp -d)"
|
TEN_FIX="$(mktemp -d)"
|
||||||
printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$TEN_FIX/host"
|
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine"
|
||||||
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine"
|
printf 'role=custom root-door=closed host=no join=authkey\n' > "$TEN_FIX/closed"
|
||||||
printf 'role=custom root-door=closed host=no join=login\n' > "$TEN_FIX/closed"
|
printf 'role=staging-server root-door=open host=yes join=authkey\n' > "$TEN_FIX/host"
|
||||||
|
printf 'role=workload class=server\n' > "$TEN_FIX/pre77-machine"
|
||||||
|
printf 'role=dev class=human\n' > "$TEN_FIX/pre77-human"
|
||||||
printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant"
|
printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant"
|
||||||
check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \
|
check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||||
|
|
@ -635,30 +676,69 @@ check "tenant: the host refusal sends you to the metal half of the pair" 1 "stag
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||||
check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
|
check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||||
|
|
||||||
# The tenant guard's compat read (#77). This guard asks "does this marker name
|
# The tenant guard's compat read (#77). This guard asks "does this marker name
|
||||||
# a root-door policy?" as its proxy for "is this a real fleet machine?", and it
|
# a root-door policy?" through the resolver, so the pre-#77 spelling counts —
|
||||||
# must ask it in BOTH vocabularies. Kept deliberately at the retired spelling,
|
# pattern-matching one spelling would fail OPEN here: the marker stops looking
|
||||||
# same reason as the close-root fixtures below: a pre-#77 box that stops
|
# like a machine's, the refusal never fires, and a tenant converge clobbers a
|
||||||
# looking like a machine here is the fail-OPEN direction of this rename — the
|
# live fleet box's marker.
|
||||||
# agent-tenant refusal never fires, and `rig bootstrap claude-box` converges a
|
|
||||||
# tenant straight over a live fleet box, clobbering the marker that holds its
|
|
||||||
# root-door policy. Do not modernize these two fixtures.
|
|
||||||
printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/pre77-machine"
|
|
||||||
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/pre77-human"
|
|
||||||
check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \
|
check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||||
check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \
|
check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||||
|
# ...and the guard needs no registry: an unreachable RIG_TEMPLATES_DIR must
|
||||||
|
# not stop a refusal that protects a live fleet box.
|
||||||
|
check "tenant: the marker guard fires even with the registry unreachable" 1 "never tailnet machines" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/machine" RIG_TEMPLATES_DIR=/nonexistent/registry \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" claude-box
|
||||||
|
|
||||||
|
# The definition surface (#110), offline via RIG_TEMPLATES_DIR. An unknown
|
||||||
|
# role's refusal LISTS what the resolved source actually contains and names
|
||||||
|
# the source — a misconfigured RIG_TEMPLATES_REPO/_REF/_DIR must be visible
|
||||||
|
# in the error rather than looking like a typo.
|
||||||
|
check "tenant: unknown role lists the resolved registry" 2 "scratch-box" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
|
||||||
|
check "tenant: the unknown-role refusal names the source" 2 "RIG_TEMPLATES_DIR" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
|
||||||
|
check "tenant: an unreadable RIG_TEMPLATES_DIR refuses loudly" 2 "not a directory" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
|
||||||
|
# _DIR outranks _REF: with both set, resolution must not touch the network —
|
||||||
|
# provable offline exactly because a fetch attempt would fail here.
|
||||||
|
check "tenant: RIG_TEMPLATES_DIR outranks RIG_TEMPLATES_REF" 2 "scratch-box" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" RIG_TEMPLATES_REF=some-branch \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
|
||||||
|
# A malformed definition is refused at bootstrap BY KEY (the box.env
|
||||||
|
# discipline: parsed, never sourced — so a template cannot execute arbitrary
|
||||||
|
# shell through the data file). The registry CI's lint is the other gate;
|
||||||
|
# this one protects a mint served through a source CI never saw.
|
||||||
|
check "tenant: an unknown key is refused by name" 2 "unknown key: COLOR" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" badkey-box
|
||||||
|
check "tenant: a missing required key is refused by name" 2 "missing required key: CLI_NAME" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" missing-box
|
||||||
|
check "tenant: a non-KEY=\"value\" line is refused with its line number" 2 'not KEY="value"' \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" garbled-box
|
||||||
|
check "tenant: a bad NEEDS_NODE value is refused by key" 2 "NEEDS_NODE" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" badnode-box
|
||||||
|
check "tenant: an option riding APT_EXTRAS is refused by key" 2 "APT_EXTRAS" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" badapt-box
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before
|
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before
|
||||||
# the root check, and the harness machine may carry a real /etc/rig/role.
|
# the root check, and the harness machine may carry a real /etc/rig/role.
|
||||||
check "tenant: claude-box parses, refuses non-root" 1 "must run as root" \
|
# Reaching the root check proves the whole pre-root surface passed: the
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
# name, the flags, the guard, the resolution AND the parse.
|
||||||
check "tenant: codex-box parses, refuses non-root" 1 "must run as root" \
|
check "tenant: a valid definition parses, refuses non-root" 1 "must run as root" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex-box
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
check "tenant: grok-box parses, refuses non-root" 1 "must run as root" \
|
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box
|
check "tenant: staging-box needs no registry at all" 1 "must run as root" \
|
||||||
|
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||||
check "tenant: staging-box tolerates a workload-joined guest's marker" 1 "must run as root" \
|
check "tenant: staging-box tolerates a workload-joined guest's marker" 1 "must run as root" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||||
# ...and the same guest joined before #77: reaching the root check (rather
|
# ...and the same guest joined before #77: reaching the root check (rather
|
||||||
|
|
@ -666,53 +746,127 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||||
check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \
|
check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
|
||||||
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
|
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
|
||||||
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box
|
env RIG_ROLE_MARKER="$TEN_FIX/tenant" RIG_TEMPLATES_DIR="$TPL_FIX" \
|
||||||
|
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
|
||||||
else
|
else
|
||||||
echo "skip: tenant non-root refusals (running as root)"
|
echo "skip: tenant non-root refusals (running as root)"
|
||||||
fi
|
fi
|
||||||
rm -rf "$TEN_FIX"
|
rm -rf "$TEN_FIX"
|
||||||
|
|
||||||
# The per-tenant parameter table and the agent-context renderer are pure lib
|
# The same definition served from a REF (tarball fetch, curl stubbed — the
|
||||||
# functions on purpose (repo precedent: parse_users_file, json_string_array):
|
# release.sh discipline) and from a local DIR must resolve to identical
|
||||||
# the CLI path to them sits behind root + a real tenant user, so the harness
|
# converge inputs: the parsed TPL_* table and the rendered context file are
|
||||||
# proves them here, sourced, non-root and network-free.
|
# everything the mechanism consumes, so identical inputs ARE the identical
|
||||||
tuser() { bash -c 'set -euo pipefail
|
# converge (#110's acceptance criterion, provable offline).
|
||||||
. "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; }
|
TPL_WORK="$(mktemp -d)"
|
||||||
tpath() { bash -c 'set -euo pipefail
|
mkdir -p "$TPL_WORK/bin" "$TPL_WORK/stage/rig-templates-testref"
|
||||||
. "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; }
|
cp -r "$TPL_FIX"/. "$TPL_WORK/stage/rig-templates-testref/"
|
||||||
tctx() { bash -c 'set -euo pipefail
|
tar -czf "$TPL_WORK/reg.tar.gz" -C "$TPL_WORK/stage" rig-templates-testref
|
||||||
. "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; }
|
cat > "$TPL_WORK/bin/curl" <<'CURLEOF'
|
||||||
check "tenant params: agent users are named after their agent" 0 "claude" tuser claude-box
|
#!/usr/bin/env bash
|
||||||
check "tenant params: kimi's user drops the suffix too" 0 "kimi" tuser kimi-box
|
# stub: invoked as `curl -fsSL <url> -o <out>` by templates_resolve
|
||||||
check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging-box
|
echo "$2" >> "${CURL_LOG:?}"
|
||||||
check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude-box /home/claude
|
cp "${CURL_TARBALL:?}" "$4"
|
||||||
check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex-box /home/codex
|
CURLEOF
|
||||||
check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok-box /home/grok
|
chmod +x "$TPL_WORK/bin/curl"
|
||||||
check "tenant params: kimi context lands in ~/.kimi/AGENTS.md" 0 "/home/kimi/.kimi/AGENTS.md" tpath kimi-box /home/kimi
|
# Single quotes deliberate throughout (SC2016): the $-expressions expand in
|
||||||
check "tenant params: staging has no context file" 1 "" tpath staging-box /home/ops
|
# the INNER bash, against the sourced lib's state, never in the harness.
|
||||||
# The box#80 guard note lives ONCE, in the renderer, and every agent's file
|
# shellcheck disable=SC2016
|
||||||
# carries it — the layering decision's whole point: never per-template again.
|
tpl_inputs_script='set -euo pipefail
|
||||||
check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude-box
|
. "$1/commands/lib/templates.sh"
|
||||||
check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex-box
|
templates_resolve
|
||||||
check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok-box
|
template_parse_env "$REGISTRY_DIR/scratch-box/template.env"
|
||||||
check "tenant context: kimi carries the box#80 guard" 0 "box setup-host" tctx kimi-box
|
printf "USER=%s|CTX=%s|CLI=%s|SRC=%s|PATH=%s|NODE=%s|APT=%s\n" \
|
||||||
check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude-box
|
"$TPL_USER" "$TPL_CONTEXT_PATH" "$TPL_CLI_NAME" "$TPL_CLI_SRC" \
|
||||||
check "tenant context: the guard cites box#80" 0 "box#80" tctx claude-box
|
"$TPL_PATH_LINE" "$TPL_NEEDS_NODE" "$TPL_APT_EXTRAS"
|
||||||
check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude-box
|
render_tenant_context scratch-box "$REGISTRY_DIR/scratch-box/creds.md"'
|
||||||
check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude-box
|
tpl_from_dir() { # tpl_from_dir <outfile> — the local-folder path
|
||||||
check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex-box
|
env RIG_TEMPLATES_DIR="$TPL_FIX" bash -c "$tpl_inputs_script" _ "$ROOT" > "$1"
|
||||||
check "tenant context: grok names its login flow" 0 "grok login" tctx grok-box
|
}
|
||||||
check "tenant context: kimi names its login flow" 0 "Kimi Code OAuth" tctx kimi-box
|
tpl_from_ref() { # tpl_from_ref <outfile> — the tarball path, curl stubbed
|
||||||
check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging-box
|
env PATH="$TPL_WORK/bin:$PATH" CURL_LOG="$TPL_WORK/curl.log" \
|
||||||
|
CURL_TARBALL="$TPL_WORK/reg.tar.gz" RIG_TEMPLATES_REF=testref \
|
||||||
|
bash -c "$tpl_inputs_script" _ "$ROOT" > "$1"
|
||||||
|
}
|
||||||
|
check "templates: a local DIR resolves and parses" 0 "" tpl_from_dir "$TPL_WORK/from-dir"
|
||||||
|
check "templates: a REF resolves through the tarball fetch (stubbed curl)" 0 "" \
|
||||||
|
tpl_from_ref "$TPL_WORK/from-ref"
|
||||||
|
check "templates: DIR and REF yield byte-identical converge inputs" 0 "" \
|
||||||
|
diff "$TPL_WORK/from-dir" "$TPL_WORK/from-ref"
|
||||||
|
# The fetch's first candidate is refs/tags — a tag must outrank a branch that
|
||||||
|
# happens to share its name (install.sh's own precedence, the pin must win).
|
||||||
|
check "templates: the fetch asks refs/tags first" 0 "/archive/refs/tags/testref.tar.gz" \
|
||||||
|
head -n1 "$TPL_WORK/curl.log"
|
||||||
|
check "templates: the rendered context carries the box#80 guard" 0 "box setup-host" \
|
||||||
|
cat "$TPL_WORK/from-dir"
|
||||||
|
check "templates: the guard says whose host this is not" 0 "not a host you own" \
|
||||||
|
cat "$TPL_WORK/from-dir"
|
||||||
|
check "templates: the guard cites box#80" 0 "box#80" cat "$TPL_WORK/from-dir"
|
||||||
|
check "templates: the definition's creds paragraph is spliced in" 0 "The scratch vendor paragraph" \
|
||||||
|
cat "$TPL_WORK/from-dir"
|
||||||
|
check "templates: the bootstrap runbook note survives the split" 0 "Bootstrap runbook" \
|
||||||
|
cat "$TPL_WORK/from-dir"
|
||||||
|
# The default ref is the IN-TREE PIN (the BOX_RELEASE discipline, ruled on
|
||||||
|
# #110: pinned, not main-tracked): exactly one greppable assignment, so a pin
|
||||||
|
# bump is a one-line PR and the drill can read the pin from an installed tree.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "templates: the pin is one greppable line" 0 "1" \
|
||||||
|
bash -c 'grep -c "^RIG_TEMPLATES_PIN=" "$1/commands/lib/templates.sh"' _ "$ROOT"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "templates: unset knobs fall back to the pin" 0 "the in-tree pin" \
|
||||||
|
bash -c '. "$1/commands/lib/templates.sh" && templates_source_desc' _ "$ROOT"
|
||||||
|
|
||||||
|
# rig template-lint — the registry repo's CI gate, same schema as the mint's
|
||||||
|
# parser (rig defines validity; rig-templates CI enforces it on every PR).
|
||||||
|
check "template-lint: --help exits 0" 0 "usage:" "$ROOT/commands/template-lint.sh" --help
|
||||||
|
check "template-lint: a directory is required" 2 "role directory required" "$ROOT/commands/template-lint.sh"
|
||||||
|
check "template-lint: dispatched from bin/rig" 0 "usage:" "$ROOT/bin/rig" template-lint --help
|
||||||
|
check "template-lint: a valid definition passes" 0 "OK: " "$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box"
|
||||||
|
check "template-lint: an unknown key fails by name" 1 "unknown key: COLOR" \
|
||||||
|
"$ROOT/commands/template-lint.sh" "$TPL_FIX/badkey-box"
|
||||||
|
check "template-lint: one bad definition fails the whole run" 1 "FAIL: " \
|
||||||
|
"$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box" "$TPL_FIX/badkey-box"
|
||||||
|
mkdir -p "$TPL_FIX/plain"
|
||||||
|
cp "$TPL_FIX/scratch-box"/* "$TPL_FIX/plain/"
|
||||||
|
check "template-lint: a suffix-less role directory is refused (#76)" 1 "family suffix" \
|
||||||
|
"$ROOT/commands/template-lint.sh" "$TPL_FIX/plain"
|
||||||
|
mkdir -p "$TPL_FIX/noinstall-box"
|
||||||
|
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noinstall-box/"
|
||||||
|
check "template-lint: a missing install.sh is refused by name" 1 "install.sh missing" \
|
||||||
|
"$ROOT/commands/template-lint.sh" "$TPL_FIX/noinstall-box"
|
||||||
|
mkdir -p "$TPL_FIX/blankcreds-box"
|
||||||
|
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/install.sh" "$TPL_FIX/blankcreds-box/"
|
||||||
|
printf ' \n\t\n' > "$TPL_FIX/blankcreds-box/creds.md"
|
||||||
|
check "template-lint: a blank creds.md is refused by name" 1 "creds.md missing or blank" \
|
||||||
|
"$ROOT/commands/template-lint.sh" "$TPL_FIX/blankcreds-box"
|
||||||
|
mkdir -p "$TPL_FIX/noshebang-box"
|
||||||
|
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noshebang-box/"
|
||||||
|
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"
|
||||||
|
rm -rf "$TPL_FIX" "$TPL_WORK"
|
||||||
|
|
||||||
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal
|
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal
|
||||||
# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or
|
# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or
|
||||||
# apt-installs incus. A grep that finds nothing (exit 1) is the pass.
|
# apt-installs incus. A grep that finds nothing (exit 1) is the pass. The
|
||||||
|
# same absences hold for the templates lib — it fetches DATA, unauthenticated
|
||||||
|
# by contract, and must never grow a credential to do it.
|
||||||
check "tenant: never touches the tailnet" 1 "" \
|
check "tenant: never touches the tailnet" 1 "" \
|
||||||
grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh"
|
grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh"
|
||||||
check "tenant: non-interactive — nothing prompts" 1 "" \
|
check "tenant: non-interactive — nothing prompts" 1 "" \
|
||||||
grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh"
|
grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh"
|
||||||
check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
|
check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
|
||||||
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh"
|
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh"
|
||||||
|
check "templates lib: the fetch carries no credential" 1 "" \
|
||||||
|
grep -nE 'Authorization|gh api|GITHUB_TOKEN' "$ROOT/commands/lib/templates.sh"
|
||||||
|
# The data file is PARSED, never executed: the parse loop reads lines, and
|
||||||
|
# no source statement may ever reach template.env. Grep-pinned because the
|
||||||
|
# failure is silent and total — a sourced template.env is arbitrary shell
|
||||||
|
# running as root at every mint.
|
||||||
|
check "templates lib: the parser READS template.env line by line" 0 "" \
|
||||||
|
grep -qF 'while IFS= read -r line' "$ROOT/commands/lib/templates.sh"
|
||||||
|
check "templates lib: template.env is never sourced" 1 "" \
|
||||||
|
grep -nE '(source|^[[:space:]]*\.)[[:space:]]+[^#]*template\.env' "$ROOT/commands/lib/templates.sh" "$ROOT/commands/bootstrap-tenant.sh"
|
||||||
# staging-box's posture rides the SAME hardening code as the machine roles — the
|
# staging-box's posture rides the SAME hardening code as the machine roles — the
|
||||||
# shared lib call is the anti-drift property, so pin the call, not the words.
|
# shared lib call is the anti-drift property, so pin the call, not the words.
|
||||||
check "tenant: staging-box hardens through the shared sshd lib" 0 "" \
|
check "tenant: staging-box hardens through the shared sshd lib" 0 "" \
|
||||||
|
|
@ -746,7 +900,6 @@ check "tenant: the marker write follows the context-file converge" \
|
||||||
# shellcheck disable=SC2016
|
# shellcheck disable=SC2016
|
||||||
check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \
|
check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \
|
||||||
grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh"
|
grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh"
|
||||||
|
|
||||||
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
|
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
|
||||||
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
|
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
|
||||||
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version
|
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "pa
|
||||||
emit() { # emit <outfile> — emit_record with the harness globals staged
|
emit() { # emit <outfile> — emit_record with the harness globals staged
|
||||||
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
|
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
|
||||||
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
|
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
|
||||||
|
TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" \
|
||||||
bash -c '
|
bash -c '
|
||||||
. "$1"
|
. "$1"
|
||||||
pass=12 fail=1 skipped=1
|
pass=12 fail=1 skipped=1
|
||||||
|
|
@ -173,6 +174,7 @@ check "record: the version-and-date heading" 0 "# Release drill — 9.9.9 — "
|
||||||
check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md"
|
check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md"
|
||||||
check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md"
|
check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md"
|
||||||
check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md"
|
check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md"
|
||||||
|
check "record: the template registry SHA rides alongside the pair (#110)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432)" cat "$WORK/record.md"
|
||||||
check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md"
|
check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md"
|
||||||
check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md"
|
check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md"
|
||||||
check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"
|
check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue