rig/commands/bootstrap-tenant.sh
claude-bot-andresmgsl 559a5b23ba test: drive cron converge against stubbed systemctl
The service converge+assert block becomes converge_cron(), behavior-
identical, so test/cli.sh can lift it verbatim (the drop_incus precedent)
and drive it: the already-enabled/active re-run is a no-op, a disabled
stopped unit converges, and an unrecoverably masked or start-dead unit
dies nonzero naming cron and #162 — proven by execution, not only by
structural pins. The converge logs now state the probe fact instead of
claiming an unverified success (panel nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:11:13 +00:00

504 lines
27 KiB
Bash
Executable file

#!/usr/bin/env bash
# rig bootstrap <role>-box — the box TENANT roles ('-box' names the family: a
# guest, vs the '-server' machine roles): what a box-minted guest becomes
# (issue #31). box mints the thin, creds-free seed (base image, user, rig
# preinstalled — heavy-duty/box#81); rig converges the tenant content that
# used to live in the templates' cloud-init, idempotent and effective-state
# asserted, so an EXISTING box can be re-run to a new spec instead of
# re-minted.
#
# 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
# bootstrap claude-box'), so every path here is non-interactive and nothing joins
# or admits — no tailnet, no keys, no prompts. That is also why the registry
# fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
# 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.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
# shellcheck source=SCRIPTDIR/lib/manifest.sh
. "$HERE/lib/manifest.sh" # manifest_stamp — provenance, written beside the marker
log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig bootstrap <role>-box [--user <name>]
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
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.
<role>-box an agent tenant DEFINED IN THE REGISTRY
(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
agent-context file — including the box#80 guard: never
run `box setup-host` or the drill inside a box.
staging-box the server tenant (box#69's posture), in rig's own
tree: docker + sshd hardening. The tailnet workload
join is deliberately NOT here — it holds a credential,
so it stays operator-run: `box shell` → `sudo rig
bootstrap workload-server` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the
definition's USER; staging-box defaults to `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
them at mint (`box exec … rig bootstrap claude-box`). They take none of the
machine-role traits (--hostname/--root-door/--host/--join): a tenant is a guest,
not a tailnet machine. Run as root, inside the box.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
case "$ROLE" in
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 ;;
"") usage >&2; die "tenant role required (a '-box' role from the template registry, or 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
# 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_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 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)
# The machine-role traits, refused with a story rather than "unknown
# flag": a tenant is a guest, not a tailnet machine — its shape comes
# from the box seed, and the one trait-shaped thing a staging-box guest
# eventually does (join the tailnet as a workload) is deliberately not
# here: it holds a credential, so it stays operator-run.
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging-box's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 2 ;;
--ts-tag)
[ $# -ge 2 ] && shift
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging-box's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# --- guards ------------------------------------------------------------------
# A tenant role converges a box GUEST. A box already carrying a machine-role
# marker is a tailnet machine rig built on purpose, and quietly turning it into
# a tenant (or clobbering its marker) is how a fleet box gets poisoned. Checked
# BEFORE the root check so the refusals are testable non-root, off fixture
# markers (repo precedent: the coolify marker warning). Two refusals, one
# tolerance:
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
# Names the staging PAIR out loud, because whoever lands here has the two
# halves confused: the metal is `staging-server`, the guest `staging-box`.
# - a root-door policy (agent tenants) → refuse: an agent box is never a
# tailnet machine.
# - root-door=open with host=no (staging-box only) → PROCEED, and leave the
# marker alone: that is the guest AFTER its operator-run workload join, and
# re-converging docker+hardening on it is exactly what convergence is for.
# ONLY that shape — any other door policy (say root-door=closed, via
# `custom`) is a machine rig built on purpose, and staging-box hardening it
# with open-door rules would die with root-door=open-specific messaging on a
# box that was never one.
#
# "Names a root-door policy" IS this guard's "is this a machine marker?" test —
# a tenant marker deliberately carries none — so it must be asked through
# root_door_of, which reads the pre-#77 `class=` spelling as well as the current
# `root-door=` one. Pattern-matching the marker for one spelling is what this
# guard used to do, and after the rename that is a fail-OPEN bug in the
# dangerous direction: every box bootstrapped in the OTHER vocabulary stops
# looking like a machine, the refusals below never fire, and a tenant converge
# clobbers a real fleet box's marker. The resolver is the only reader.
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
EXISTING_ROOT_DOOR="$(root_door_of "$EXISTING_MARKER")"
case "$EXISTING_MARKER" in
*host=yes*)
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;;
esac
if [ -n "$EXISTING_ROOT_DOOR" ]; then
if [ "$ROLE" != "staging-box" ]; then
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
fi
# A `conflict` marker lands here too, and refuses: a box whose two door
# claims disagree is emphatically not the one shape staging-box tolerates.
if [ "$EXISTING_ROOT_DOOR" != "open" ]; then
die "this box carries a machine role whose root door is not open (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (root-door=open host=no, or its pre-#77 spelling class=server). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run."
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"
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars.
# shellcheck source=/dev/null
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
*debian*) ;;
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
# The tenant user is the SEED's to create (box.env BOX_USER + cloud-init), not
# rig's to conjure: a missing user means the seed and the role disagree, and
# inventing an account here would paper over exactly that mismatch.
id -u "$TENANT_USER" >/dev/null 2>&1 \
|| die "user '$TENANT_USER' does not exist — the box seed creates it (BOX_USER); pass --user <name> if this box's user differs"
TENANT_HOME="$(getent passwd "$TENANT_USER" | cut -d: -f6)"
TENANT_GROUP="$(id -gn "$TENANT_USER")"
[ -d "$TENANT_HOME" ] || die "user '$TENANT_USER' has no home directory ($TENANT_HOME)"
# append_line_once <file> <line> — converge a literal rc line: present exactly
# once, appended only when missing, ownership converged to the tenant user.
append_line_once() {
local file="$1" line="$2"
if [ ! -e "$file" ] || ! grep -qxF "$line" "$file"; then
printf '%s\n' "$line" >> "$file"
log "appended to ${file}: ${line}"
fi
chown "$TENANT_USER:$TENANT_GROUP" "$file"
}
# The binary on PATH is not the effective state — an image can ship crontab
# with cron.service masked or stopped, and an unarmed timer is exactly the
# silent-inert box #162 is about. Converge best-effort, then assert what
# systemd actually reports; the assert is the authority. Enabling an
# already-enabled unit is a no-op and no path here touches any crontab.
# A function so test/cli.sh can lift it verbatim and drive it against a
# stubbed systemctl, the drop_incus precedent.
converge_cron() {
if ! systemctl is-enabled cron >/dev/null 2>&1; then
log "cron.service not enabled — converging"
systemctl unmask cron >/dev/null 2>&1 || true
systemctl enable cron >/dev/null 2>&1 || true
fi
if ! systemctl is-active cron >/dev/null 2>&1; then
log "cron.service not active — starting"
systemctl start cron >/dev/null 2>&1 || true
fi
systemctl is-enabled cron >/dev/null 2>&1 || die "cron.service is not enabled after converge — the duty engine's timer never fires without it (#162)"
systemctl is-active cron >/dev/null 2>&1 || die "cron.service is not active after converge — the duty engine's timer never fires without it (#162)"
}
# --- packages ----------------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive
log "installing base packages (tenant ${ROLE})"
apt-get update -qq
if [ "$ROLE" = "staging-box" ]; then
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
# which only exists once the package is installed — pristine container/VM
# images (and thin seeds) do not ship it.
apt-get install -y -qq curl ca-certificates tmux openssh-server
else
# The shared agent toolbelt the templates carried, plus the definition's
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
# word list, every word already vetted by the parser's package-name gate.
# cron is toolbelt, not a template flavour: every agent tenant exists to
# run the cron-driven duty engine, whose unprivileged installer can detect
# a missing cron but never apt-get it (#162).
# shellcheck disable=SC2086
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential cron $TPL_APT_EXTRAS
fi
# 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
# the operator's git credential lands.
command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it"
if [ "$ROLE" != "staging-box" ]; then
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
command -v git >/dev/null 2>&1 || die "git missing after package install"
command -v crontab >/dev/null 2>&1 || die "crontab missing after package install — the duty engine arms itself with cron (#162)"
# staging-box is exempt with the rest of this block: no agent, no engine.
converge_cron
fi
# --- docker ------------------------------------------------------------------
# Every tenant gets docker (the templates all carried it; staging-box's workloads run
# their workloads in it). Docker's own installer, convergence-guarded — its
# script is not a no-op when docker exists, so rig supplies the guard.
if ! command -v docker >/dev/null 2>&1; then
log "installing docker (get.docker.com)"
curl -fsSL https://get.docker.com | sh
else
log "docker already installed"
fi
docker --version >/dev/null 2>&1 || die "docker installed but 'docker --version' does not answer"
# The client answering is not the effective state — a dead dockerd would still
# pass it. Ask the daemon, with a bounded settle for the freshly-installed case
# (get.docker.com starts it, but not instantaneously on a slow guest).
docker_up=""
for _ in 1 2 3 4 5 6; do
if docker info >/dev/null 2>&1; then docker_up=1; break; fi
sleep 5
done
[ -n "$docker_up" ] || die "dockerd does not answer 'docker info' after 30s — the daemon is not running; check 'systemctl status docker' (or the container's init) before re-running"
log "dockerd answering"
if getent group docker >/dev/null 2>&1; then
if id -nG "$TENANT_USER" | tr ' ' '\n' | grep -qx docker; then
log "${TENANT_USER} already in the docker group"
else
usermod -aG docker "$TENANT_USER"
log "added ${TENANT_USER} to the docker group"
fi
else
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
fi
# --- node (definitions carrying NEEDS_NODE="yes") ----------------------------
# An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
# verified upstream when the template was written); claude ships node as part
# 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() {
command -v node >/dev/null 2>&1 || return 1
local major
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
[ "${major:-0}" -ge 22 ] 2>/dev/null
}
if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
if node_ok; then
log "node $(node --version) already present"
else
log "installing node 22 (nodesource)"
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq nodejs
fi
node_ok || die "node >= 22 still missing after install — check the nodesource setup"
fi
# --- the agent CLI -----------------------------------------------------------
# Per-definition install, shared discipline: install only when the CLI is
# absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
# definition names one, `command -v` when it does not (an npm global's path
# is the prefix's fact, not the data file's) — then put it on the SYSTEM
# path: 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads
# 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=""
if [ "$ROLE" != "staging-box" ]; then
CLI="$TPL_CLI_NAME"
# '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
# substitution, never by the shell (hence the literal quoted tilde, SC2088).
# shellcheck disable=SC2088
case "$TPL_CLI_SRC" in
'~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
*) CLI_SRC="$TPL_CLI_SRC" ;;
esac
installed=""
if [ -n "$CLI_SRC" ]; then
[ -e "$CLI_SRC" ] && installed=1
elif command -v "$CLI" >/dev/null 2>&1; then
installed=1
fi
if [ -z "$installed" ]; then
log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
bash "$TPL_DIR/install.sh" \
|| die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
else
log "${CLI} CLI already installed"
fi
if [ -z "$CLI_SRC" ]; then
CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
[ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?"
fi
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
# One capture serves both the assert and the log line; emptiness IS the
# failure signal (head exits 0 regardless, so a pipeline status can't be).
CLI_VER="$(runuser -l "$TENANT_USER" -c "$CLI --version" 2>/dev/null | head -n1)"
[ -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})"
# The interactive-shell PATH export the templates carried, converged as a
# literal rc line (written once, never duplicated). The definition's
# PATH_LINE is DATA, appended verbatim: it must expand in the USER's
# shell, not here.
append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
fi
# --- the agent-context file --------------------------------------------------
# The one file every agent reads before touching anything. The skeleton —
# including 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") — is MECHANISM,
# rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
# template; only the creds paragraph is the definition's (creds.md).
# 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")"
if [ ! -d "$CTX_DIR" ]; then
mkdir -p "$CTX_DIR"
log "created ${CTX_DIR}"
fi
# The dotdir is the AGENT's (it writes state next to its instructions), so
# its ownership is converged on every run, not only on creation.
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
CTX_TMP="$(mktemp)"
render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$CTX_TMP"
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
log "agent-context file written: ${CTX_PATH}"
else
log "agent-context file already current"
fi
rm -f "$CTX_TMP"
fi
# --- staging-box server posture --------------------------------------------------
# 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
# workload server in waiting, and its door must never be password-open even
# before the operator joins it. root-door=open: root SSH stays the control
# plane's future automation door.
if [ "$ROLE" = "staging-box" ]; then
harden_sshd open
fi
# --- role marker --------------------------------------------------------------
# Same ground truth the machine roles write, tenant-shaped: no root-door trait
# at all (a tenant has no root-door policy of its own — close-root fails closed
# on it), and host=no so `rig users apply` box-role gating keeps working.
# staging-box SKIPS the write when a machine marker is already present: after
# the operator-run workload join, the workload marker is the truer statement and
# rig never clobbers state a joined box earned.
#
# "Is a machine marker already here?" is the same question the guard above
# asks, and is asked the same way — through root_door_of, so both the current
# `root-door=` spelling and the pre-#77 `class=` one count (#77). Testing for
# one spelling would let this write CLOBBER a marker written in the other, which
# on a joined workload box means silently replacing its root-door policy with a
# tenant line that close-root then refuses on.
if [ -z "$EXISTING_ROOT_DOOR" ]; then
MARKER_TMP="$(mktemp)"
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
mkdir -p "$(dirname "$MARKER_PATH")"
install -m 0644 "$MARKER_TMP" "$MARKER_PATH"
log "role marker written: role=$ROLE tenant=yes host=no"
else
log "role marker already current"
fi
rm -f "$MARKER_TMP"
else
log "machine role marker present (${EXISTING_MARKER}); leaving it alone"
fi
# --- provenance manifest ------------------------------------------------------
# A tenant gets a manifest, in the SAME /etc/rig/manifest, through the same
# writer — and UNCONDITIONALLY, outside the marker gate above (#61's open
# question, answered here).
#
# The reason the marker needs that gate is that it holds TRAITS, and a guest's
# traits and the traits it earns after an operator-run `rig bootstrap workload`
# join are two different, competing statements about one box — so the marker
# has to pick, and it picks the truer one. Provenance has no such conflict.
# "Which rig converged this guest, and when" is a fact whichever bootstrap ran,
# and the two-pair shape composes across them exactly as designed: a staging
# guest later joined as a workload keeps the TENANT bootstrap as its birth —
# that genuinely is when this machine was first converged — and the machine
# bootstrap moves converged_* forward. Skipping the write on a joined guest
# would lose the birth stamp that only this run knows.
#
# One file, not a tenant-shaped second one: the manifest answers a question
# about the MACHINE, and a guest is a machine. The marker already carries
# `tenant=yes` for anyone who needs to know which kind.
if manifest_stamp "$(manifest_running_version "$HERE/..")"; then
log "provenance manifest written: $(manifest_path)"
else
log "provenance manifest already current"
fi
log "done — tenant ${ROLE}, user ${TENANT_USER}"
if [ "$ROLE" = "staging-box" ]; then
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --hostname <name> with a tagged pre-auth key"
else
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
fi