#!/usr/bin/env bash # Shared reader for the Forgejo runner's own on-disk config ($RUNNER_DIR/.runner). # Sourced by the forgejo-runner-* commands; never executed on its own. # # WHY A SECOND LIB, not an arm inside lib/runner-config.sh: the two files are # different documents making different claims, and the sibling's helpers answer # questions this one cannot ask. GitHub's .runner names a REPOSITORY # (gitHubUrl), so `runner install` converges toward --repo. Forgejo's names an # INSTANCE (address) and nothing else about scope — whether a registration is # instance-wide, org, or single-repo is a property of the TOKEN, decided in # Forgejo's UI before rig ever sees it. There is no repo here to converge # toward, and no way to read one back. Sharing a reader would mean a # gitHubUrl accessor that returns empty forever on one of the two forges. # # json_field is deliberately re-used FROM the sibling rather than copied: a # rig-bootstrapped box has no jq, both files are flat JSON, and one grep/sed # reader for both is the same trade lib/runner-config.sh already argued. HERE_FJ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=SCRIPTDIR/runner-config.sh . "$HERE_FJ/runner-config.sh" # json_field # THE CREDENTIAL FACT that shapes this whole family: Forgejo's .runner holds # the runner's own long-lived token — the secret it authenticates every poll # with — alongside address/name/labels. GitHub's holds no such thing. # # So the mode is part of the contract, not hygiene: a registration secret # readable by every account on the box is a quiet, permanent credential leak, # and it leaks silently — nothing fails, the runner keeps working. Converge is # the only moment rig can notice a mode that drifted (an operator's editor, a # restore from a tarball that lost modes, a hand-edit to add a label). FORGEJO_RUNNER_FILE_MODE=600 # forgejo_runner_instance — the Forgejo instance this box's runner # is registered to, empty when nothing is registered there. forgejo_runner_instance() { [ -e "$1/.runner" ] || return 0 json_field "$1/.runner" address } # forgejo_runner_name — the runner's name, empty when unregistered. forgejo_runner_name() { [ -e "$1/.runner" ] || return 0 json_field "$1/.runner" name } # forgejo_runner_secure — converge .runner to 0600 # owned by the runner user. Called on every install, not only at registration. # Silent on success: this is a mode that should always already be right, and a # line saying so on every converge would train the reader to skip it. forgejo_runner_secure() { local dir="$1" user="$2" group="$3" [ -e "$dir/.runner" ] || return 0 chmod "$FORGEJO_RUNNER_FILE_MODE" "$dir/.runner" chown "$user:$group" "$dir/.runner" } # runner_version_of — the bare version number ("12.13.2") the binary # reports, empty when it cannot answer. `forgejo-runner --version` prints # "forgejo-runner version v12.13.2"; the leading v is stripped so this compares # against a --version argument, which has its own v stripped at parse. # # `|| true` for json_field's reason, which bites harder here. Callers run under # `set -euo pipefail`, where a pipeline whose FIRST stage exits non-zero fails # the whole pipeline — and `PRESENT_VER="$(runner_version_of "$BIN")"` is an # assignment, so the script dies AT THAT LINE, with no message. "Empty when it # cannot answer" is only true if this says so out loud. # # That is not a hypothetical shape: the binary is `[ -x ]` but unrunnable # exactly when a ci-box's template preinstall landed a truncated or wrong-arch # download — the one path this command family exists for. Without this, install # exits 1 in silence and the refusal written for that case ("the download # landed but cannot run") is unreachable code. runner_version_of() { "$1" --version 2>/dev/null | head -n1 \ | sed -nE 's/.*[Vv]ersion[[:space:]]+v?([0-9][0-9A-Za-z.+-]*).*/\1/p' || true } # runner_download_decision # -> "install" | "skip" | "converge" # # A PURE function, and pure on purpose: this is the decision review !110 caught # being wrong, and it was wrong in a way no grep could see. Lifting it out of # the root-only install path is what makes "a pre-existing binary plus # --version" a real test rather than a string match. # # The rule, and why it is not the GitHub sibling's: # # no binary -> install. Nothing to reason about. # binary, no --version -> skip. Chasing "latest" on every converge would make # a plain re-run an unrequested upgrade, and a # convergent verb must not be a moving target. # binary, pin matches -> skip. # binary, pin differs -> CONVERGE, including downward. A pin is an # instruction, not a floor. # # runner-install.sh skips on mere presence because actions/runner SELF-UPDATES, # so its version moves regardless and freezing it would only make GitHub refuse # the runner's jobs. forgejo-runner does not self-update: nothing else ever # moves this version, and a ci-box's template preinstalls the binary at mint — # so mere-presence here would leave --version dead on the one path this whole # command exists to serve. # # An unreadable present version (empty) with a pin asked for falls to # "converge", which is the right direction: a binary that cannot say what it is # should be replaced by one that can. runner_download_decision() { local have="$1" present="$2" want="$3" [ "$have" = yes ] || { printf 'install\n'; return 0; } [ -n "$want" ] || { printf 'skip\n'; return 0; } [ "$present" = "$want" ] && { printf 'skip\n'; return 0; } printf 'converge\n' } # assert_runner_instance # # Returns 0 when the box has no runner, or has one already registered to # : re-running `install` against the instance the box is already # on is real convergence — it re-uses the binary, skips registration, exits 0. # # Returns 1, explaining itself on stderr, when the runner is registered to a # DIFFERENT instance. Skipping *that* is not convergence, it is ignoring the # argument: `install` would skip its registration step, restart the service # against the OLD instance, and report success — leaving the instance you asked # for with no runner and its jobs queued against one that will never come. # # This is assert_runner_repo's reasoning, asked about the axis Forgejo actually # has. There is deliberately no `repoint` sibling: Forgejo has no # deregistration handshake to perform against the old instance, so moving a # runner is `remove` then `install` — two acts that are already honest about # leaving a stale entry behind, rather than one verb pretending to be atomic. assert_runner_instance() { local dir="$1" wanted="$2" current [ -e "$dir/.runner" ] || return 0 current="$(forgejo_runner_instance "$dir")" if [ -z "$current" ]; then printf 'rig-forgejo-runner: ERROR: %s\n' \ "${dir}/.runner exists but names no instance — this box's registration cannot be read, so rig cannot tell whether it is already on ${wanted}. Wipe the local registration and install again: rig forgejo-runner remove" >&2 return 1 fi # Trailing slashes are a spelling difference, not a different instance: # forgejo-runner records the URL as given, so `--instance https://f.example/` # and `--instance https://f.example` would otherwise read as a move. if [ "${current%/}" = "${wanted%/}" ]; then return 0 fi printf 'rig-forgejo-runner: ERROR: %s\n' \ "this box's runner is already registered to ${current}, not ${wanted}. install will not move a runner between instances: it would leave the service running against the OLD instance and report success. To move it, take it off the old instance first: rig forgejo-runner remove then install against the new one. Forgejo has no deregistration handshake, so the old entry stays listed until you delete it in that instance's admin UI." >&2 return 1 }