diff --git a/CHANGELOG.md b/CHANGELOG.md index 9710954..dc9cb27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ on the way to cutting its first release, and this file starts there. - `rig platform` prints a stable machine `ID`, derived from `/etc/machine-id`, never the raw value (#95) - Platform, drill, docs and labels changes receive dedicated scope labels (#119) - CI drills the install lifecycle against a real tree — install from the checkout, converge to an empty diff, uninstall to proven absence (#106) +- `rig bootstrap --undo` removes only a tailnet join rig can prove it made (#63) - `kimi-box` joins the box tenant roles — the Kimi CLI agent guest (#109) - The `changelog-armed` guard returns, version-keyed (#112, ceremony#13) - The `.ceremony/` doctrine mirror, verified by `docs-sync` on every PR (#112, ceremony#19) diff --git a/README.md b/README.md index 0e0f029..e358237 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,33 @@ the only shapes it manages — every other role refuses an effective `tag:server` after join, one rule instead of per-role exceptions. After the tag verification passes, bootstrap writes `/etc/rig/role` — one -line, `role=… root-door=… host=… join=…` — recording the **effective** traits, -overrides and all, so an overridden role never lies to the commands that read -the marker later (`rig users` keys root policy off `root-door=`). Written -post-join and cmp-guarded, so a marker never describes a box that failed to -become what it claims. +line, `role=… root-door=… host=… join=… join-by=…` — recording the +**effective** traits, overrides and all, plus whether this run performed the +tailnet join. `join-by=rig` means bootstrap called `tailscale up`; +`join-by=preexisting` means it found the node already joined. Old markers name +neither and are treated as unknown, never as permission to remove a join. +Written post-join and cmp-guarded, so a marker never describes a box that +failed to become what it claims. + +### `rig bootstrap --undo` + +```sh +sudo rig bootstrap --undo +``` + +Leaves the tailnet and then removes `/etc/rig/role`, but only when the marker +says `join-by=rig`. A pre-existing join, an old marker with no provenance, or +no marker at all is refused without calling `tailscale logout`; the refusal +names the manual repair. Re-running bootstrap writes the current marker shape. + +Undo also refuses while a GitHub runner is installed and points at +`rig runner remove`, because restoring the local machine while leaving an +off-box runner registration would create a ghost in the repository. If +`tailscale logout` fails, the marker stays in place so the command is retryable. + +This is intentionally not a general rollback. It does not uninstall packages, +reverse sshd hardening, remove Docker, Node, agent CLIs, or users. Those changes +are convergent rather than transactional and cannot be safely inferred away. Immediately after it, bootstrap stamps `/etc/rig/manifest` — **provenance**: which rig converged this box and when (see [`rig diff --git a/bin/rig b/bin/rig index 8367638..690432e 100755 --- a/bin/rig +++ b/bin/rig @@ -33,6 +33,10 @@ commands: tenant roles); custom and workstation take none. join=login (workstation) needs no key: interactive login, node must come up untagged. Run as root. + bootstrap --undo + Leave the tailnet only when /etc/rig/role proves rig performed the + join, then remove that marker. Refuses for pre-existing or old unknown + joins, and while a GitHub runner is installed. Run as root. bootstrap [--user ] The box TENANT roles: converge a box-minted guest. The '-box' suffix names the family (a guest, vs the '-server' machine roles above). diff --git a/commands/bootstrap-undo.sh b/commands/bootstrap-undo.sh new file mode 100755 index 0000000..71e7339 --- /dev/null +++ b/commands/bootstrap-undo.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# rig bootstrap --undo — remove only off-box state rig can prove it created. +set -euo pipefail + +log() { printf 'rig-bootstrap: %s\n' "$*"; } +die() { printf 'rig-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; } + +MARKER="${RIG_ROLE_MARKER:-/etc/rig/role}" + +[ "$(id -u)" -eq 0 ] || die "must run as root" +[ -e "$MARKER" ] || die "no /etc/rig/role marker — refusing to touch the tailnet" + +runner_installed=0 +if [ -n "${RIG_RUNNER_DIR:-}" ]; then + [ -e "$RIG_RUNNER_DIR/.runner" ] && runner_installed=1 +else + for runner_config in /home/*/actions-runner/.runner /root/actions-runner/.runner; do + [ -e "$runner_config" ] && runner_installed=1 + done + compgen -G '/etc/systemd/system/actions.runner.*.service' >/dev/null \ + && runner_installed=1 +fi +if [ "$runner_installed" -eq 1 ]; then + die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository" +fi + +join_by="" +while IFS= read -r field; do + case "$field" in + join-by=*) join_by="${field#join-by=}" ;; + esac +done < <(tr '[:space:]' '\n' < "$MARKER") + +case "$join_by" in + rig) ;; + preexisting) + die "the tailnet join predates this bootstrap run (join-by=preexisting), so rig will not remove state it did not create; run 'tailscale logout' by hand if that is intended" ;; + "") + die "the role marker predates join-by provenance, so rig cannot prove it made this tailnet join and will not remove it; re-run bootstrap to write a current marker, or run 'tailscale logout' by hand" ;; + *) + die "the role marker has unknown join-by=$join_by, so rig cannot prove it made this tailnet join and will not remove it; run 'tailscale logout' by hand if that is intended" ;; +esac + +# The same back-out/keep law as first-join verification: logout is earned only +# when the marker proves rig performed the join. Preserve the marker on failure +# so the operation remains retryable and never reports a half-undone machine. +if ! tailscale logout; then + die "tailscale logout failed; role marker kept so 'rig bootstrap --undo' can be retried" +fi + +rm -f -- "$MARKER" +log "tailnet join removed; role marker removed" diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index e310b2f..81ace2c 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -32,6 +32,9 @@ usage: rig bootstrap [--user ] (the box TENANT roles — see their own --help; they take no --users, see below) + rig bootstrap --undo + leave the tailnet only when the role marker proves rig + performed the join, then remove the role marker --users the users file this box's operators come from — REQUIRED. It is applied as bootstrap's last phase, exactly as `rig users apply @@ -114,6 +117,10 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in + --undo) + shift + [ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2 + exec "$HERE/bootstrap-undo.sh" ;; control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;; claude-box|codex-box|grok-box|kimi-box|staging-box) # The box TENANT roles (#31) are a different family — guests a box mints, @@ -534,6 +541,7 @@ if ! command -v tailscale >/dev/null 2>&1; then log "installing tailscale" curl -fsSL https://tailscale.com/install.sh | sh fi +JOIN_BY=preexisting if tailscale status >/dev/null 2>&1; then log "tailnet already joined; skipping tailscale up (no pre-auth key needed)" # ...but skipping `tailscale up` also skipped --hostname, so the TAILNET name @@ -574,6 +582,7 @@ elif [ "$JOIN" = "login" ]; then log "joining tailnet as ${TS_HOSTNAME} (interactive login; follow the URL tailscale prints)" tailscale up --hostname="$TS_HOSTNAME" verify_user_owned back-out + JOIN_BY=rig else # env override, else prompt; never touches disk. The prompt only fires on a # tty: with no terminal, a bare `read` exits non-zero and `set -e` would end @@ -594,6 +603,7 @@ else log "joining tailnet as ${TS_HOSTNAME} (tag comes from the pre-auth key)" tailscale up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" verify_effective_tag back-out + JOIN_BY=rig fi # --- role marker -------------------------------------------------------------- @@ -611,11 +621,12 @@ fi # those exist in the field by the thousand and nothing will rewrite them. MARKER=/etc/rig/role MARKER_TMP="$(mktemp)" -printf 'role=%s root-door=%s host=%s join=%s\n' "$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" > "$MARKER_TMP" +printf 'role=%s root-door=%s host=%s join=%s join-by=%s\n' \ + "$ROLE" "$ROOT_DOOR" "$HOST" "$JOIN" "$JOIN_BY" > "$MARKER_TMP" if ! cmp -s "$MARKER_TMP" "$MARKER" 2>/dev/null; then mkdir -p /etc/rig install -m 0644 "$MARKER_TMP" "$MARKER" - log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN" + log "role marker written: role=$ROLE root-door=$ROOT_DOOR host=$HOST join=$JOIN join-by=$JOIN_BY" else log "role marker already current" fi diff --git a/test/cli.sh b/test/cli.sh index f51a921..54acc30 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -143,6 +143,67 @@ check "bootstrap: login verify fails closed on a stalled backend" 0 "" \ # The marker is the traits' ground truth for rig users; assert the write exists. check "bootstrap: role marker write is present" 0 "" \ grep -q "/etc/rig/role" "$ROOT/commands/bootstrap.sh" +check "bootstrap: role marker records join provenance" 0 "join-by=%s" \ + grep -F "join-by=%s" "$ROOT/commands/bootstrap.sh" +check "bootstrap: both first-join paths record join-by=rig" 0 "2" \ + grep -c "^[[:space:]]*JOIN_BY=rig$" "$ROOT/commands/bootstrap.sh" +check "bootstrap: already-joined path defaults to join-by=preexisting" 0 "JOIN_BY=preexisting" \ + grep -F "JOIN_BY=preexisting" "$ROOT/commands/bootstrap.sh" + +# Drive the narrow inverse end to end. Every refusal also asserts the tailscale +# shim was NOT called: exit status alone would miss the destructive regression. +UNDO_FIX="$(mktemp -d)" +UNDO_BIN="$UNDO_FIX/bin" +UNDO_MARKER="$UNDO_FIX/role" +UNDO_RUNNER="$UNDO_FIX/runner" +UNDO_CALLS="$UNDO_FIX/tailscale.calls" +mkdir -p "$UNDO_BIN" "$UNDO_RUNNER" +cat > "$UNDO_BIN/tailscale" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$UNDO_CALLS" +if [ "${TAILSCALE_LOGOUT_FAIL:-0}" = 1 ]; then exit 1; fi +SH +cat > "$UNDO_BIN/id" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -u ]; then printf '0\n'; else exec /usr/bin/id "$@"; fi +SH +chmod +x "$UNDO_BIN/tailscale" "$UNDO_BIN/id" +undo() { + env PATH="$UNDO_BIN:$PATH" UNDO_CALLS="$UNDO_CALLS" \ + RIG_ROLE_MARKER="$UNDO_MARKER" RIG_RUNNER_DIR="$UNDO_RUNNER" \ + "$ROOT/bin/rig" bootstrap --undo +} +undo_untouched() { + : > "$UNDO_CALLS" + if undo >"$UNDO_FIX/undo.out" 2>&1; then return 1; fi + [ ! -s "$UNDO_CALLS" ] +} +rm -f "$UNDO_MARKER" +check "bootstrap --undo: no marker refuses without touching tailnet" 0 "" undo_untouched +printf '%s\n' 'role=workload-server root-door=open host=no join=authkey' > "$UNDO_MARKER" +check "bootstrap --undo: old marker names missing provenance" \ + 1 "marker predates join-by provenance" undo +check "bootstrap --undo: old marker leaves tailnet untouched" 0 "" undo_untouched +printf '%s\n' 'role=workload-server root-door=open host=no join=authkey join-by=preexisting' > "$UNDO_MARKER" +check "bootstrap --undo: pre-existing join refuses by name" 1 "join-by=preexisting" undo +check "bootstrap --undo: pre-existing join leaves tailnet untouched" 0 "" undo_untouched +printf '%s\n' 'role=runner-server root-door=open host=no join=authkey join-by=rig' > "$UNDO_MARKER" +printf '%s\n' '{}' > "$UNDO_RUNNER/.runner" +check "bootstrap --undo: installed runner points at its removal verb" \ + 1 "rig runner remove" undo +check "bootstrap --undo: installed runner leaves tailnet untouched" 0 "" undo_untouched +rm -f "$UNDO_RUNNER/.runner" +check "bootstrap --undo: failed logout is loud" \ + 1 "role marker kept" env TAILSCALE_LOGOUT_FAIL=1 PATH="$UNDO_BIN:$PATH" \ + UNDO_CALLS="$UNDO_CALLS" RIG_ROLE_MARKER="$UNDO_MARKER" \ + RIG_RUNNER_DIR="$UNDO_RUNNER" "$ROOT/bin/rig" bootstrap --undo +check "bootstrap --undo: failed logout preserves the marker" 0 "" test -e "$UNDO_MARKER" +: > "$UNDO_CALLS" +check "bootstrap --undo: proven rig join succeeds" 0 "tailnet join removed" undo +check "bootstrap --undo: successful logout was called" 0 "logout" cat "$UNDO_CALLS" +check "bootstrap --undo: success removes the marker" 1 "" test -e "$UNDO_MARKER" +check "bootstrap --undo: second run refuses cleanly" 1 "no /etc/rig/role marker" undo +rm -rf "$UNDO_FIX" # ...and that it is written in the CURRENT vocabulary (#77). New markers say # root-door=; the retired class= spelling is something rig READS forever and # WRITES never, so a marker line that reintroduces it must not ship green.