From fcee1101832e503426272fa6d77655306c7c1c1e Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Mon, 13 Jul 2026 13:25:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(runner):=20status,=20remove,=20and=20repoi?= =?UTF-8?q?nt=20=E2=80=94=20the=20runner=20lifecycle=20verbs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runner install is convergent by skipping: it sees a registered runner and leaves it alone. So rig could create a runner and never move or destroy one, and re-pointing a box at a different repo meant hand-rolled config.sh/svc.sh incantations against an install layout only rig knew about. - status: repo, name, labels, dir, unit — read-only, no token, no network. - remove: service down, then deregister. --local wipes the box without contacting GitHub, leaving a stale entry to delete by hand. - repoint: remove + re-register in one act, keeping the runner's name and reusing the binary already on the box. The service always comes down before deregistration in both paths: GitHub's removal throws "Uninstall service first" while the service is configured, and --local bypasses that check entirely, which would strand a running service pointed at deleted config. repoint collects both tokens up front — a token you turn out not to have must fail while the runner is still registered, not halfway through the move. Labels are the sharp edge: GitHub holds them, the runner does not persist them, and they are what runs-on matches. install now records what it registered with so repoint and status can read it back; a runner installed before that has nothing to read, so repoint falls back to the ci-runner default and warns before it touches anything. --- bin/rig | 42 ++++++++-- commands/runner-install.sh | 5 ++ commands/runner-remove.sh | 108 +++++++++++++++++++++++++ commands/runner-repoint.sh | 162 +++++++++++++++++++++++++++++++++++++ commands/runner-status.sh | 81 +++++++++++++++++++ test/cli.sh | 32 ++++++++ 6 files changed, 423 insertions(+), 7 deletions(-) create mode 100755 commands/runner-remove.sh create mode 100755 commands/runner-repoint.sh create mode 100755 commands/runner-status.sh diff --git a/bin/rig b/bin/rig index 0cebc78..8301af6 100755 --- a/bin/rig +++ b/bin/rig @@ -20,10 +20,22 @@ commands: systemd timer. rig installs the machinery and templates an empty 0600 bindings file; you fill in the age recipient and S3 details. Control-plane box only. Run as root. - runner install --repo --version [options] + runner install --repo [options] GitHub Actions runner as a systemd service under an unprivileged user — outbound-only, no Docker. Prompts for the short-lived registration token (RUNNER_TOKEN env overrides). Run as root. + runner status [--user ] + What this box's runner is registered to: repo, name, labels, unit. + Reads the box only — no token, no network call. Run as root. + runner remove [--local] [--user ] + Take the service down and deregister the runner. Prompts for the + short-lived removal token (RUNNER_REMOVE_TOKEN env overrides). + Run as root. + runner repoint --repo [options] + Move an installed runner to another repository — deregister, then + re-register, reusing the binary already on the box. Needs a removal + token for the old repo and a registration token for the new one. + Run as root. install/upgrade: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash @@ -62,12 +74,28 @@ case "$cmd" in runner) shift sub="${1:-}" - if [ "$sub" != "install" ]; then - usage >&2 - exit 2 - fi - shift - exec "$ROOT/commands/runner-install.sh" "$@" + case "$sub" in + install) + shift + exec "$ROOT/commands/runner-install.sh" "$@" + ;; + status) + shift + exec "$ROOT/commands/runner-status.sh" "$@" + ;; + remove) + shift + exec "$ROOT/commands/runner-remove.sh" "$@" + ;; + repoint) + shift + exec "$ROOT/commands/runner-repoint.sh" "$@" + ;; + *) + usage >&2 + exit 2 + ;; + esac ;; -h|--help|help) usage diff --git a/commands/runner-install.sh b/commands/runner-install.sh index a2bf3d0..fac5b61 100755 --- a/commands/runner-install.sh +++ b/commands/runner-install.sh @@ -162,6 +162,11 @@ else (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ ./config.sh --url "https://github.com/${REPO}" --token "$RUNNER_TOKEN" \ --name "$RUNNER_NAME" --labels "$LABELS" --unattended --replace) + # GitHub owns the labels and the runner does not persist them locally, so + # `runner status` and `runner repoint` would have nothing to read. Record + # what we registered with — box-local metadata, never a credential. + printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels" + chown "$RUNNER_USER:$RUNNER_USER" "$RUNNER_DIR/.rig-labels" fi # --- service ------------------------------------------------------------- diff --git a/commands/runner-remove.sh b/commands/runner-remove.sh new file mode 100755 index 0000000..87348b9 --- /dev/null +++ b/commands/runner-remove.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# rig runner remove — take the service down and deregister the runner. +# Convergent: a box with nothing installed exits 0. +set -euo pipefail + +log() { printf 'rig-runner: %s\n' "$*"; } +warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig runner remove [options] + + --local wipe this box's registration without contacting GitHub + (no token needed) + --user unprivileged service user (default: github-runner) + +Stops and uninstalls the systemd service, then deregisters the runner from +GitHub. The runner binary and its user stay on the box, so a later +`rig runner install` re-registers without downloading anything. + +Provide the short-lived REMOVAL token — not a registration token, they are +different endpoints — via the RUNNER_REMOVE_TOKEN env var or the interactive +prompt: + gh api -X POST repos//actions/runners/remove-token +It is consumed at deregistration and never written to disk by rig. + +--local is the escape hatch for when the registration is already gone +server-side, or you cannot mint a token: the box is cleaned, but a stale +offline runner is left listed in the repo — delete it by hand from +Settings > Actions > Runners. + +Convergent: safe to re-run; a box with no runner installed exits 0. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +LOCAL=0 +RUNNER_USER="github-runner" +while [ $# -gt 0 ]; do + case "$1" in + --local) LOCAL=1; shift ;; + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + RUNNER_USER="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +# --- validation ------------------------------------------------------------ +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" + +# --- nothing to remove? ----------------------------------------------------- +if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then + log "no ${RUNNER_USER} user on this box; nothing to remove" + exit 0 +fi +USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" +RUNNER_DIR="$USER_HOME/actions-runner" +if [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$RUNNER_DIR/.service" ]; then + log "no runner registered in ${RUNNER_DIR}; nothing to remove" + exit 0 +fi + +# --- removal token — only when a server-side deregistration is pending ------- +REMOVE_TOKEN="" +if [ -e "$RUNNER_DIR/.runner" ] && [ "$LOCAL" -eq 0 ]; then + REMOVE_TOKEN="${RUNNER_REMOVE_TOKEN:-}" + if [ -z "$REMOVE_TOKEN" ]; then + read -rsp "runner removal token (short-lived): " REMOVE_TOKEN + echo + fi + [ -n "$REMOVE_TOKEN" ] || die "empty removal token" +fi + +# --- service --------------------------------------------------------------- +# This must come first in BOTH paths. config.sh's removal throws "Uninstall +# service first" while the service is configured; and `remove --local` skips +# that check entirely, which would otherwise strand a running service pointed +# at config that no longer exists. +if [ -e "$RUNNER_DIR/.service" ]; then + log "stopping and uninstalling the service" + (cd "$RUNNER_DIR" && ./svc.sh stop) + (cd "$RUNNER_DIR" && ./svc.sh uninstall) +else + log "no service installed; skipping" +fi + +# --- deregister ------------------------------------------------------------- +if [ -e "$RUNNER_DIR/.runner" ]; then + if [ "$LOCAL" -eq 1 ]; then + log "wiping the local registration only (--local)" + (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ + ./config.sh remove --local) + warn "a stale offline runner is still listed in the repo — delete it from Settings > Actions > Runners" + else + log "deregistering from GitHub" + (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ + ./config.sh remove --token "$REMOVE_TOKEN") + fi + rm -f "$RUNNER_DIR/.rig-labels" +fi + +log "runner removed; the binary stays at ${RUNNER_DIR} for a future rig runner install" diff --git a/commands/runner-repoint.sh b/commands/runner-repoint.sh new file mode 100755 index 0000000..8305cca --- /dev/null +++ b/commands/runner-repoint.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# rig runner repoint — move an installed runner from one repository to another. +# Deregister, then re-register against the new repo, reusing the binary that is +# already on the box. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" + +log() { printf 'rig-runner: %s\n' "$*"; } +warn() { printf 'rig-runner: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig runner repoint --repo [options] + + --repo the repository to move the runner TO (required) + --name runner name (default: keep the name it has now) + --labels runner labels (default: the labels rig recorded at + install; else ci-runner) + --user unprivileged service user (default: github-runner) + --local skip the server-side deregistration of the OLD repo + (no removal token needed) — leaves a stale offline + runner listed there, to delete by hand + +Deregisters the runner from the repository it is on now and registers it +against --repo. The runner binary, its user, and its systemd service are +reused, so nothing is downloaded. + +Two short-lived tokens, each minted from its OWN repository: + + RUNNER_REMOVE_TOKEN removal token, from the CURRENT repo (not needed + with --local) + gh api -X POST repos//actions/runners/remove-token + RUNNER_TOKEN registration token, from the repo in --repo + gh api -X POST repos//actions/runners/registration-token + +Either may be typed at the prompt instead. Both are collected BEFORE the +runner is touched — a token you turn out not to have should fail while the +runner is still registered, not halfway through the move. Neither is written +to disk by rig. + +LABELS ARE NOT RECOVERABLE FROM THE BOX: GitHub holds them and the runner +does not persist them. rig records what it registered with, but a runner +installed before rig did that has nothing to read — repoint then falls back +to the ci-runner default and says so. Check your workflows' runs-on. + +Convergent: repointing to the repo it is already on changes nothing, exits 0, +and never asks for a token. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +REPO="" +RUNNER_NAME="" +LABELS="" +RUNNER_USER="github-runner" +LOCAL=0 +while [ $# -gt 0 ]; do + case "$1" in + --repo) + [ $# -ge 2 ] || die "--repo needs a value" 2 + REPO="$2"; shift 2 ;; + --name) + [ $# -ge 2 ] || die "--name needs a value" 2 + RUNNER_NAME="$2"; shift 2 ;; + --labels) + [ $# -ge 2 ] || die "--labels needs a value" 2 + LABELS="$2"; shift 2 ;; + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + RUNNER_USER="$2"; shift 2 ;; + --local) LOCAL=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +# --- validation ------------------------------------------------------------ +[ -n "$REPO" ] || die "--repo is required" 2 +if ! printf '%s' "$REPO" | grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then + die "--repo must be owner/repo" 2 +fi +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" + +id -u "$RUNNER_USER" >/dev/null 2>&1 \ + || die "no runner installed (no ${RUNNER_USER} user) — use: rig runner install" +USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" +RUNNER_DIR="$USER_HOME/actions-runner" +[ -e "$RUNNER_DIR/.runner" ] \ + || die "no runner registered in ${RUNNER_DIR} — use: rig runner install" + +# --- what is it registered to now? ------------------------------------------ +json_field() { + grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$1" \ + | head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' +} +CURRENT_URL="$(json_field "$RUNNER_DIR/.runner" gitHubUrl)" +TARGET_URL="https://github.com/${REPO}" + +if [ "$CURRENT_URL" = "$TARGET_URL" ]; then + log "already registered to ${REPO}; nothing to do" + exit 0 +fi + +# Keep the runner's identity across the move unless told otherwise. +if [ -z "$RUNNER_NAME" ]; then + RUNNER_NAME="$(json_field "$RUNNER_DIR/.runner" agentName)" + [ -n "$RUNNER_NAME" ] || die "could not read the current runner name from ${RUNNER_DIR}/.runner" +fi +if [ -z "$LABELS" ]; then + if [ -r "$RUNNER_DIR/.rig-labels" ]; then + LABELS="$(cat "$RUNNER_DIR/.rig-labels")" + else + LABELS="ci-runner" + warn "this box has no rig label record (installed before rig kept one)" + warn "re-registering with the default labels: ${LABELS}" + warn "if your workflows' runs-on expects anything else, ctrl-c and pass --labels" + fi +fi + +log "moving runner ${RUNNER_NAME} from ${CURRENT_URL} to ${TARGET_URL}" +log "labels: ${LABELS}" + +# --- tokens, both up front -------------------------------------------------- +# Collected before anything is torn down: a missing or expired token must fail +# while the runner is still registered and working. +if [ "$LOCAL" -eq 0 ]; then + RUNNER_REMOVE_TOKEN="${RUNNER_REMOVE_TOKEN:-}" + if [ -z "$RUNNER_REMOVE_TOKEN" ]; then + read -rsp "removal token for ${CURRENT_URL} (short-lived): " RUNNER_REMOVE_TOKEN + echo + fi + [ -n "$RUNNER_REMOVE_TOKEN" ] || die "empty removal token" + export RUNNER_REMOVE_TOKEN +fi + +RUNNER_TOKEN="${RUNNER_TOKEN:-}" +if [ -z "$RUNNER_TOKEN" ]; then + read -rsp "registration token for ${TARGET_URL} (short-lived): " RUNNER_TOKEN + echo +fi +[ -n "$RUNNER_TOKEN" ] || die "empty registration token" +export RUNNER_TOKEN + +# --- move ------------------------------------------------------------------- +REMOVE_ARGS=(--user "$RUNNER_USER") +[ "$LOCAL" -eq 1 ] && REMOVE_ARGS+=(--local) +"$HERE/runner-remove.sh" "${REMOVE_ARGS[@]}" + +if ! "$HERE/runner-install.sh" \ + --repo "$REPO" --name "$RUNNER_NAME" --labels "$LABELS" --user "$RUNNER_USER" +then + warn "the runner is now deregistered from ${CURRENT_URL} and NOT registered anywhere" + die "re-registration failed — fix the cause, then run: rig runner install --repo ${REPO} --name ${RUNNER_NAME} --labels ${LABELS} --user ${RUNNER_USER}" +fi + +log "repointed to ${TARGET_URL}" +log "verify it shows Idle under that repo's Settings > Actions > Runners, and gone from the old one" diff --git a/commands/runner-status.sh b/commands/runner-status.sh new file mode 100755 index 0000000..c3a1ffc --- /dev/null +++ b/commands/runner-status.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# rig runner status — what is this box's runner registered to? +# Read-only: reports what is already on the box. No credential, no network call. +set -euo pipefail + +log() { printf 'rig-runner: %s\n' "$*"; } +die() { printf 'rig-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig runner status [--user ] + + --user unprivileged service user (default: github-runner) + +Prints the repository this box's runner is registered to, its runner name, +the labels rig recorded when it registered, the install directory, and the +systemd unit and its state. + +Reads only the runner's own on-disk config — no GitHub token, no network +call. Exits 1 when no runner is installed. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +RUNNER_USER="github-runner" +while [ $# -gt 0 ]; do + case "$1" in + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + RUNNER_USER="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +# --- validation ------------------------------------------------------------ +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" + +id -u "$RUNNER_USER" >/dev/null 2>&1 \ + || die "no runner installed (no ${RUNNER_USER} user on this box)" +USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" +RUNNER_DIR="$USER_HOME/actions-runner" +[ -e "$RUNNER_DIR/.runner" ] \ + || die "no runner registered in ${RUNNER_DIR}" + +# --- read the runner's own config ------------------------------------------- +# .runner is JSON. Kept dependency-free on purpose: a rig-bootstrapped box has +# no jq, and installing one to read five fields would be a poor trade. +json_field() { + grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$1" \ + | head -n1 | sed 's/.*:[[:space:]]*"//; s/"$//' +} + +REPO_URL="$(json_field "$RUNNER_DIR/.runner" gitHubUrl)" +RUNNER_NAME="$(json_field "$RUNNER_DIR/.runner" agentName)" + +# GitHub owns the labels; the runner does not persist them locally. rig records +# what it registered with, so a box installed before this existed reports the +# honest answer rather than a guess. +if [ -r "$RUNNER_DIR/.rig-labels" ]; then + LABELS="$(cat "$RUNNER_DIR/.rig-labels")" +else + LABELS="(not recorded on this box — GitHub holds them; see the repo's Settings > Actions > Runners)" +fi + +if [ -r "$RUNNER_DIR/.service" ]; then + UNIT="$(cat "$RUNNER_DIR/.service")" + STATE="$(systemctl is-active "$UNIT" 2>/dev/null || true)" + SERVICE="${UNIT} (${STATE:-unknown})" +else + SERVICE="(not installed as a service)" +fi + +log "repo: ${REPO_URL:-unknown}" +log "name: ${RUNNER_NAME:-unknown}" +log "labels: ${LABELS}" +log "dir: ${RUNNER_DIR}" +log "service: ${SERVICE}" diff --git a/test/cli.sh b/test/cli.sh index 6261322..c9ba794 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -80,6 +80,38 @@ else echo "skip: runner non-root refusal (running as root)" fi +check "runner: bad subcommand exits 2" 2 "usage:" "$ROOT/bin/rig" runner frobnicate + +check "runner status: --help exits 0" 0 "usage:" "$ROOT/commands/runner-status.sh" --help +check "runner status: user needs value" 2 "needs a value" "$ROOT/commands/runner-status.sh" --user +check "runner status: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-status.sh" --user root +check "runner status: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-status.sh" --nope + +check "runner remove: --help exits 0" 0 "usage:" "$ROOT/commands/runner-remove.sh" --help +check "runner remove: user needs value" 2 "needs a value" "$ROOT/commands/runner-remove.sh" --user +check "runner remove: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-remove.sh" --user root +check "runner remove: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-remove.sh" --nope + +check "runner repoint: --help exits 0" 0 "usage:" "$ROOT/commands/runner-repoint.sh" --help +check "runner repoint: repo required" 2 "--repo" "$ROOT/commands/runner-repoint.sh" +check "runner repoint: repo needs value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo +check "runner repoint: rejects bad slug" 2 "owner/repo" "$ROOT/commands/runner-repoint.sh" --repo not-a-slug +check "runner repoint: labels need value" 2 "needs a value" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --labels +check "runner repoint: refuses --user root" 2 "must not be root" "$ROOT/commands/runner-repoint.sh" --repo acme/widgets --user root +check "runner repoint: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/runner-repoint.sh" --nope +if [ "$(id -u)" -ne 0 ]; then + check "runner status: refuses non-root" 1 "must run as root" "$ROOT/commands/runner-status.sh" + check "runner remove: refuses non-root" 1 "must run as root" \ + env RUNNER_REMOVE_TOKEN=x "$ROOT/commands/runner-remove.sh" + # --local too: the token-free path must still not be runnable by the runner user. + check "runner remove: --local refuses non-root" 1 "must run as root" \ + "$ROOT/commands/runner-remove.sh" --local + check "runner repoint: refuses non-root" 1 "must run as root" \ + env RUNNER_REMOVE_TOKEN=x RUNNER_TOKEN=y "$ROOT/commands/runner-repoint.sh" --repo acme/widgets +else + echo "skip: runner status/remove/repoint non-root refusals (running as root)" +fi + # The dump script ships to control-plane boxes as an embedded heredoc. A syntax # error in it would be invisible here and would first surface at 04:00 on a live # control plane. Extract it and syntax-check what actually gets written.