Addresses codex (1538) and kimi (1539): the sweep caught the reported
incident and not the class. Both are right, and there were four sites, not
three.
forgejo-runner-install useradd -> useradd usermod (usermod -aG docker,
reached only after the token has been spent)
users-apply useradd usermod -> + groupadd (called two lines
into convergence), and visudo when a role needs it
bootstrap-tenant NEW site (kimi) — usermod -aG docker runs AFTER
docker and node are installed, so an unguarded
PATH fails it mid-convergence on a changed machine
runner-install unchanged: useradd is the only admin binary it
calls, and declaring more would refuse boxes that
are fine
visudo is checked after the sudo-install block rather than beside the root
check, because until sudo is installed its absence has an innocent cause.
Below that block it does not: sudo is present, so a missing visudo means
/usr/sbin is off PATH. That case is the quiet one — the sudoers block reads
`command -v visudo` as "no sudo on the box means no role needed it", so
apply reported success having granted roles without the escalation those
roles exist for. The other three sites at least crash.
Measured which binaries this covers (Debian 13): useradd, usermod, groupadd,
userdel, groupdel and visudo are /usr/sbin; gpasswd is /usr/bin and so is
NOT affected and deliberately not preflighted. visudo shares the directory
but ships in `sudo`, not `passwd` — which is why it needs its own treatment.
Tests: the sbin-less fixtures could only ever prove the FIRST binary is
named, since useradd wins every race. Six new checks use partial PATHs that
resolve the earlier binaries and withhold exactly one, plus the ordering
assertions (no token prompt, no group created) and the negative case — a
users file needing no sudo must NOT be refused for a missing visudo.
Refs #139
515 lines
28 KiB
Bash
Executable file
515 lines
28 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/admin-path.sh
|
|
. "$HERE/lib/admin-path.sh" # require_admin_bins
|
|
# 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"
|
|
# Root is not enough here either (#139). This mint adds the tenant user to the
|
|
# docker group with `usermod` far below — AFTER installing docker and node,
|
|
# which is what makes it the worst-placed of the four call sites: on a
|
|
# PATH-shorn root it dies mid-convergence with a bare `usermod: command not
|
|
# found`, having already changed the machine, rather than before touching it.
|
|
#
|
|
# Unconditional because the docker block below is unconditional — "every tenant
|
|
# gets docker" is the stated rule there, so every tenant reaches the usermod.
|
|
require_admin_bins usermod
|
|
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
|