fix: honour --version, scope .rig-labels, close the checksum gate
Three defects from review !110, all the same family — a stated contract the code did not keep. --version was swallowed on the path this command exists for. The download block skipped on mere presence, copying runner-install.sh's shape without its justification: actions/runner SELF-UPDATES, so freezing it would only make GitHub refuse its jobs. forgejo-runner does not self-update, so nothing else ever moves the version — and a ci-box's template preinstalls the binary at mint, which meant the documented deterministic-pin lever could never fire on a ci-box. It now converges toward the pin, downward included, because a pin is an instruction and not a floor; absent a pin an existing binary is left alone, since chasing latest would make a re-run an unrequested upgrade. The decision moved to runner_download_decision in the lib as a pure function: the first attempt at a test here grepped for a log string and survived the logic being disabled, which is exactly the weak test the review warned about. The binary is now renamed into place rather than written over — the converge path runs while the daemon is live, and in-place is ETXTBSY. .rig-labels outlived the registration it described. The write had escaped the registration branch, where runner-install.sh correctly keeps its copy, so a plain re-run stamped this invocation's labels over a registration made with different ones and status then reported confidently wrong labels while Forgejo still held the originals. Scoped again, and an EXPLICIT --labels on a re-run now warns that Forgejo owns labels from registration time rather than letting the request evaporate silently. The checksum gate failed open. A missing .sha256 warned and installed anyway, contradicting both the README and the template's own comment about unverified root downloads. The original reasoning — do not let an upstream layout change break installs — reasons about the wrong failure: a layout change breaks the BINARY url too, so "binary yes, checksum no" is the shape of an interfered fetch, which is precisely what the checksum exists to catch. Both paths refuse now, with no bypass flag; if upstream really moves its assets that is a rig PR editing the URL, not an operator improvising past a security gate. Tests: the checksum paths are now DRIVEN against a stub curl through the real template install.sh — matching, missing, mismatched and empty — instead of grepped, and all three fixes were mutation-checked by reverting each and confirming the suite goes red. 739/31/43 pass, shellcheck clean. forgejo#109 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
903d8371b3
commit
1933b07fd4
6 changed files with 374 additions and 50 deletions
22
README.md
22
README.md
|
|
@ -1094,12 +1094,28 @@ the machine. Here it is a disposable guest. Same trade, different box, opposite
|
||||||
answer — which is why these are two commands and not one with a `--forge` flag.
|
answer — which is why these are two commands and not one with a `--forge` flag.
|
||||||
|
|
||||||
- `--version <pin>` — `forgejo-runner` release (default: latest at install
|
- `--version <pin>` — `forgejo-runner` release (default: latest at install
|
||||||
time). The published `.sha256` is **verified before the binary is
|
time). **Convergent, and it replaces an existing binary** — including
|
||||||
installed**; a mismatch refuses.
|
downward, because a pin is an instruction rather than a floor. Without a
|
||||||
|
pin, a binary already on the box is left alone: chasing "latest" on every
|
||||||
|
converge would make a plain re-run an unrequested upgrade.
|
||||||
|
The published `.sha256` is **verified before the binary is installed**, and
|
||||||
|
a missing or unreadable checksum **refuses** just as a mismatch does — this
|
||||||
|
binary runs as root under a systemd unit, so the gate does not fail open.
|
||||||
|
|
||||||
|
> Unlike `rig runner install`, presence alone is not enough to skip the
|
||||||
|
> download here. That command can skip because `actions/runner`
|
||||||
|
> **self-updates**; `forgejo-runner` does not, so nothing else would ever
|
||||||
|
> move the version — and a ci-box's template preinstalls the binary at mint,
|
||||||
|
> which would leave `--version` doing nothing on the exact path this command
|
||||||
|
> is for.
|
||||||
- `--name <name>` — runner name (default: this host's hostname)
|
- `--name <name>` — runner name (default: this host's hostname)
|
||||||
- `--labels <csv>` — replaces the default map:
|
- `--labels <csv>` — replaces the default map:
|
||||||
`ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`,
|
`ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm`,
|
||||||
so `runs-on: ubuntu-latest` works in a workflow written for GitHub
|
so `runs-on: ubuntu-latest` works in a workflow written for GitHub.
|
||||||
|
**Applied at registration only.** Forgejo owns a runner's labels from the
|
||||||
|
moment it registers, so passing `--labels` to a re-run cannot change them —
|
||||||
|
rig says so rather than letting the request evaporate, and changing labels
|
||||||
|
means `remove` then `install` again
|
||||||
- `--user <name>` — service user (default: `ci` when it exists, else
|
- `--user <name>` — service user (default: `ci` when it exists, else
|
||||||
`forgejo-runner`)
|
`forgejo-runner`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,12 @@ INSTANCE=""
|
||||||
VERSION=""
|
VERSION=""
|
||||||
RUNNER_NAME="$(hostname)"
|
RUNNER_NAME="$(hostname)"
|
||||||
LABELS="$DEFAULT_LABELS"
|
LABELS="$DEFAULT_LABELS"
|
||||||
|
# Whether --labels was ASKED FOR, distinct from what it resolved to. A rerun
|
||||||
|
# cannot apply labels (Forgejo owns them from registration time), and the
|
||||||
|
# difference between "operator requested a change" and "operator passed
|
||||||
|
# nothing" is what separates a warning worth printing from noise on every
|
||||||
|
# converge.
|
||||||
|
LABELS_EXPLICIT=0
|
||||||
RUNNER_USER=""
|
RUNNER_USER=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -93,7 +99,7 @@ while [ $# -gt 0 ]; do
|
||||||
RUNNER_NAME="$2"; shift 2 ;;
|
RUNNER_NAME="$2"; shift 2 ;;
|
||||||
--labels)
|
--labels)
|
||||||
[ $# -ge 2 ] || die "--labels needs a value" 2
|
[ $# -ge 2 ] || die "--labels needs a value" 2
|
||||||
LABELS="$2"; shift 2 ;;
|
LABELS="$2"; LABELS_EXPLICIT=1; shift 2 ;;
|
||||||
--user)
|
--user)
|
||||||
[ $# -ge 2 ] || die "--user needs a value" 2
|
[ $# -ge 2 ] || die "--user needs a value" 2
|
||||||
RUNNER_USER="$2"; shift 2 ;;
|
RUNNER_USER="$2"; shift 2 ;;
|
||||||
|
|
@ -207,9 +213,52 @@ fi
|
||||||
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
|
# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each
|
||||||
# one. Taking that checksum is nearly free and makes the install auditable —
|
# one. Taking that checksum is nearly free and makes the install auditable —
|
||||||
# the same instinct as `coolify install`'s mandatory version pin.
|
# the same instinct as `coolify install`'s mandatory version pin.
|
||||||
|
#
|
||||||
|
# "Already present" is NOT enough to skip here, and this is where the GitHub
|
||||||
|
# sibling's shape must not be copied. Its skip is justified by "self-update
|
||||||
|
# owns upgrades" — actions/runner updates itself, and GitHub refuses jobs from
|
||||||
|
# stale runners, so freezing it would be pointless. **forgejo-runner does not
|
||||||
|
# self-update.** Nothing else ever moves the version, so a bare presence check
|
||||||
|
# would mean the binary a box first happened to get is the binary it keeps
|
||||||
|
# forever.
|
||||||
|
#
|
||||||
|
# That lands hardest on the path this command is FOR: a ci-box's template
|
||||||
|
# install.sh preinstalls /usr/local/bin/forgejo-runner at mint, so the
|
||||||
|
# executable always exists before an operator ever runs this — and --version,
|
||||||
|
# documented as the deterministic-pin lever, would silently do nothing on
|
||||||
|
# every ci-box in the fleet.
|
||||||
|
#
|
||||||
|
# So: converge toward --version when it is given, exactly as this command
|
||||||
|
# converges toward --instance. A pin is not a trust boundary the way an
|
||||||
|
# instance is (that one refuses), it is an instruction — including downward,
|
||||||
|
# which is what a pin is for. Absent a pin, an existing binary is left alone:
|
||||||
|
# chasing "latest" on every converge would make a re-run an unrequested
|
||||||
|
# upgrade, and convergence must not be a moving target.
|
||||||
|
|
||||||
|
# runner_version_of / runner_download_decision live in the lib, so the rule can
|
||||||
|
# be driven by test/cli.sh without root — see there for the full reasoning.
|
||||||
|
PRESENT_VER=""
|
||||||
|
HAVE_BIN=no
|
||||||
if [ -x "$BIN" ]; then
|
if [ -x "$BIN" ]; then
|
||||||
log "forgejo-runner binary already present at ${BIN}; skipping download"
|
HAVE_BIN=yes
|
||||||
else
|
PRESENT_VER="$(runner_version_of "$BIN")"
|
||||||
|
fi
|
||||||
|
case "$(runner_download_decision "$HAVE_BIN" "$PRESENT_VER" "$VERSION")" in
|
||||||
|
skip)
|
||||||
|
NEED_DOWNLOAD=0
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
log "forgejo-runner ${VERSION} already installed; skipping download"
|
||||||
|
else
|
||||||
|
log "forgejo-runner ${PRESENT_VER:-(version unreadable)} already present at ${BIN}; skipping download (pass --version <pin> to converge to a specific release)"
|
||||||
|
fi ;;
|
||||||
|
converge)
|
||||||
|
NEED_DOWNLOAD=1
|
||||||
|
log "converging ${BIN}: ${PRESENT_VER:-unreadable} -> ${VERSION} (--version)" ;;
|
||||||
|
*)
|
||||||
|
NEED_DOWNLOAD=1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ "$NEED_DOWNLOAD" -eq 1 ]; then
|
||||||
case "$(uname -m)" in
|
case "$(uname -m)" in
|
||||||
x86_64) ARCH="amd64" ;;
|
x86_64) ARCH="amd64" ;;
|
||||||
aarch64) ARCH="arm64" ;;
|
aarch64) ARCH="arm64" ;;
|
||||||
|
|
@ -237,27 +286,54 @@ else
|
||||||
log "downloading forgejo-runner ${VERSION} (${ARCH})"
|
log "downloading forgejo-runner ${VERSION} (${ARCH})"
|
||||||
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
||||||
|| die "could not download ${URL}"
|
|| die "could not download ${URL}"
|
||||||
if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then
|
# THE CHECKSUM IS A GATE, NOT A COURTESY — an unfetchable one refuses.
|
||||||
# The published .sha256 names the asset, not our temp path. Compare the
|
#
|
||||||
# digest itself rather than rewriting the file into sha256sum -c's format:
|
# This previously warned and installed anyway, reasoning that rig should not
|
||||||
# one comparison, no parsing of a file we did not write.
|
# become unable to install if upstream changed its asset layout. That reasons
|
||||||
WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)"
|
# about the wrong failure. The binary lands as root and is executed by a
|
||||||
GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')"
|
# systemd unit, and the two ways the checksum can go missing are:
|
||||||
[ -n "$WANT" ] || die "the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary"
|
#
|
||||||
[ "$WANT" = "$GOT" ] \
|
# - upstream moved the assets — in which case the BINARY url moved too, and
|
||||||
|| die "checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install"
|
# the download above would already have died. A layout change does not
|
||||||
log "checksum verified (${GOT})"
|
# present as "binary yes, checksum no".
|
||||||
else
|
# - something is interfering with the fetch — which is precisely the case
|
||||||
# A warning, not a refusal: rig should not become unable to install because
|
# the checksum exists to catch.
|
||||||
# upstream changed its asset layout. But it must be LOUD — an operator who
|
#
|
||||||
# needs a verified install has to know this one was not.
|
# So the asymmetry is itself the signal: same origin, same release tag, one
|
||||||
warn "no published .sha256 for ${ASSET} — installing WITHOUT checksum verification"
|
# answers and one does not. Failing open there hands an unverified root
|
||||||
fi
|
# install to anyone who can block a single URL. There is deliberately no
|
||||||
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
# bypass flag: if upstream really does change layout, that is a rig PR
|
||||||
|
# editing the URL above, not an operator improvising past a security gate.
|
||||||
|
curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null \
|
||||||
|
|| die "no published .sha256 for ${ASSET} at ${URL}.sha256 — refusing to install an unverified binary that runs as root. The binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch."
|
||||||
|
# The published .sha256 names the asset, not our temp path. Compare the
|
||||||
|
# digest itself rather than rewriting the file into sha256sum -c's format:
|
||||||
|
# one comparison, no parsing of a file we did not write.
|
||||||
|
WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)"
|
||||||
|
GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')"
|
||||||
|
[ -n "$WANT" ] || die "the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary"
|
||||||
|
[ "$WANT" = "$GOT" ] \
|
||||||
|
|| die "checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install"
|
||||||
|
log "checksum verified (${GOT})"
|
||||||
|
|
||||||
|
# Staged beside the target and RENAMED into place, never written over.
|
||||||
|
# Replacing a running executable in place fails with ETXTBSY, and this path
|
||||||
|
# now runs on boxes where the daemon is live (a --version converge). A
|
||||||
|
# rename is atomic and leaves the running process on the old inode until the
|
||||||
|
# restart below picks up the new one.
|
||||||
|
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN.rig-new"
|
||||||
|
mv -f "$BIN.rig-new" "$BIN"
|
||||||
log "installed ${BIN}"
|
log "installed ${BIN}"
|
||||||
fi
|
fi
|
||||||
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)"
|
INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)"
|
||||||
[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run"
|
[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run"
|
||||||
|
# The converge actually took — asserted, not assumed. A pin that silently did
|
||||||
|
# not land is exactly the failure --version exists to make impossible.
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
EFFECTIVE_VER="$(runner_version_of "$BIN")"
|
||||||
|
[ "$EFFECTIVE_VER" = "$VERSION" ] \
|
||||||
|
|| die "asked for forgejo-runner ${VERSION} but ${BIN} reports ${EFFECTIVE_VER:-nothing} after install"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- register ----------------------------------------------------------------
|
# --- register ----------------------------------------------------------------
|
||||||
# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register`
|
# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register`
|
||||||
|
|
@ -285,6 +361,17 @@ INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)"
|
||||||
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR"
|
install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR"
|
||||||
if [ -e "$RUNNER_DIR/.runner" ]; then
|
if [ -e "$RUNNER_DIR/.runner" ]; then
|
||||||
log "already registered; skipping registration"
|
log "already registered; skipping registration"
|
||||||
|
# Registration was skipped, so the labels on the instance are the ones it was
|
||||||
|
# registered with — NOT whatever this invocation was passed. Say so when the
|
||||||
|
# operator explicitly asked for different ones, rather than letting the
|
||||||
|
# request evaporate. Only when EXPLICIT: comparing the default against a
|
||||||
|
# runner registered with custom labels would warn on every plain converge.
|
||||||
|
if [ "$LABELS_EXPLICIT" -eq 1 ] && [ -r "$RUNNER_DIR/.rig-labels" ]; then
|
||||||
|
RECORDED="$(cat "$RUNNER_DIR/.rig-labels")"
|
||||||
|
if [ "$RECORDED" != "$LABELS" ]; then
|
||||||
|
warn "--labels was not applied: this runner is already registered, and Forgejo owns its labels from registration time. It still has: ${RECORDED}. Labels are what 'runs-on' matches, so changing them means re-registering: 'rig forgejo-runner remove' then install again with the labels you want."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
|
log "registering runner ${RUNNER_NAME} against ${INSTANCE}"
|
||||||
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
|
(cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \
|
||||||
|
|
@ -294,17 +381,21 @@ else
|
||||||
|| die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already"
|
|| die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already"
|
||||||
[ -e "$RUNNER_DIR/.runner" ] \
|
[ -e "$RUNNER_DIR/.runner" ] \
|
||||||
|| die "register reported success but wrote no ${RUNNER_DIR}/.runner"
|
|| die "register reported success but wrote no ${RUNNER_DIR}/.runner"
|
||||||
|
# INSIDE the registration branch, where runner-install.sh keeps its copy and
|
||||||
|
# for the same reason: this file records what rig ACTUALLY registered with,
|
||||||
|
# so `status` has something to read back. Writing it unconditionally — as an
|
||||||
|
# earlier draft did — makes a plain re-run stamp this invocation's labels
|
||||||
|
# over a registration that used different ones, and `status` then reports
|
||||||
|
# confidently wrong labels while Forgejo still holds the originals. A
|
||||||
|
# metadata file that can disagree with the thing it describes is worse than
|
||||||
|
# no metadata file.
|
||||||
|
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
|
||||||
|
chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels"
|
||||||
fi
|
fi
|
||||||
# EVERY run, registration or not: .runner holds the runner's own long-lived
|
# EVERY run, registration or not: .runner holds the runner's own long-lived
|
||||||
# token, and a mode that drifted leaks it silently. See the lib.
|
# token, and a mode that drifted leaks it silently. See the lib.
|
||||||
forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP"
|
forgejo_runner_secure "$RUNNER_DIR" "$RUNNER_USER" "$RUNNER_GROUP"
|
||||||
|
|
||||||
# rig records the labels beside the registration for `status` to read back.
|
|
||||||
# Box-local metadata, never a credential — the same note runner-install.sh
|
|
||||||
# writes, for the same reason.
|
|
||||||
printf '%s\n' "$LABELS" > "$RUNNER_DIR/.rig-labels"
|
|
||||||
chown "$RUNNER_USER:$RUNNER_GROUP" "$RUNNER_DIR/.rig-labels"
|
|
||||||
|
|
||||||
# --- service -------------------------------------------------------------
|
# --- service -------------------------------------------------------------
|
||||||
# Written by rig rather than shipped by upstream: forgejo-runner has no
|
# Written by rig rather than shipped by upstream: forgejo-runner has no
|
||||||
# svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to
|
# svc.sh, so there is no vendor unit to defer to (the GitHub sibling defers to
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,51 @@ forgejo_runner_secure() {
|
||||||
chown "$user:$group" "$dir/.runner"
|
chown "$user:$group" "$dir/.runner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# runner_version_of <bin> — 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.
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
# runner_download_decision <have-binary yes|no> <present-ver> <wanted-ver>
|
||||||
|
# -> "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 <runner_dir> <instance-url>
|
# assert_runner_instance <runner_dir> <instance-url>
|
||||||
#
|
#
|
||||||
# Returns 0 when the box has no runner, or has one already registered to
|
# Returns 0 when the box has no runner, or has one already registered to
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,37 @@ registered to re-uses the binary and skips registration; a **different**
|
||||||
instance refuses and names both. Same trust-boundary reasoning as
|
instance refuses and names both. Same trust-boundary reasoning as
|
||||||
`assert_runner_repo`, asked about the axis Forgejo actually has.
|
`assert_runner_repo`, asked about the axis Forgejo actually has.
|
||||||
|
|
||||||
|
### Three corrections from review !110
|
||||||
|
|
||||||
|
The first draft carried three defects that review caught, all of the same
|
||||||
|
family — a stated contract that the code did not actually keep:
|
||||||
|
|
||||||
|
1. **`--version` was swallowed on the primary path.** The download block
|
||||||
|
skipped on mere presence, copying `rig runner install`'s shape without its
|
||||||
|
justification: `actions/runner` self-updates, `forgejo-runner` does not. And
|
||||||
|
a ci-box's template *preinstalls* the binary at mint, so the flag documented
|
||||||
|
as the deterministic-pin lever could never fire on a ci-box. The decision is
|
||||||
|
now `runner_download_decision` in the lib — a pure function, so the rule is
|
||||||
|
driven by tests instead of asserted by grep — and the binary is renamed into
|
||||||
|
place rather than written over, since the converge path now runs while the
|
||||||
|
daemon is live (in-place would be `ETXTBSY`).
|
||||||
|
|
||||||
|
2. **`.rig-labels` outlived the registration it described.** The write had
|
||||||
|
escaped the registration branch, where `runner-install.sh` correctly keeps
|
||||||
|
its copy. A plain re-run stamped that invocation's labels over a
|
||||||
|
registration made with different ones, and `status` then reported
|
||||||
|
confidently wrong labels while Forgejo held the originals. It is scoped
|
||||||
|
again, and an *explicit* `--labels` on a re-run now warns that Forgejo owns
|
||||||
|
labels from registration time.
|
||||||
|
|
||||||
|
3. **The checksum gate failed open.** A missing `.sha256` warned and installed
|
||||||
|
anyway — contradicting both the README and this file's own comment about
|
||||||
|
unverified root downloads. The original reasoning (do not let an upstream
|
||||||
|
layout change break installs) reasons about the wrong failure: a layout
|
||||||
|
change breaks the *binary* URL too, so "binary yes, checksum no" is the
|
||||||
|
shape of an interfered fetch, which is what the checksum is for. Both paths
|
||||||
|
refuse now, with no bypass flag.
|
||||||
|
|
||||||
### `.runner` holds a credential here
|
### `.runner` holds a credential here
|
||||||
|
|
||||||
GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels`
|
GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels`
|
||||||
|
|
|
||||||
36
docs/templates/ci-box/install.sh
vendored
36
docs/templates/ci-box/install.sh
vendored
|
|
@ -58,21 +58,29 @@ curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \
|
||||||
# Forgejo publishes a .sha256 beside each binary. Verifying it costs one
|
# Forgejo publishes a .sha256 beside each binary. Verifying it costs one
|
||||||
# request and makes the install auditable; this file executes as root inside
|
# request and makes the install auditable; this file executes as root inside
|
||||||
# every future mint, so an unverified download is the last thing it should do.
|
# every future mint, so an unverified download is the last thing it should do.
|
||||||
if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then
|
# An unfetchable checksum REFUSES — it does not warn and continue. The comment
|
||||||
WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)"
|
# above says an unverified root download is the last thing this file should do,
|
||||||
GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')"
|
# and an earlier draft then did exactly that whenever the .sha256 404'd. The
|
||||||
if [ -z "$WANT" ]; then
|
# binary downloading while its checksum does not is not an upstream layout
|
||||||
echo "ci-box install: the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" >&2
|
# change (that would break both URLs); it is the shape of an interfered fetch,
|
||||||
exit 1
|
# which is the case the checksum exists to catch. Failing open here would hand
|
||||||
fi
|
# an unverified root install, inside every future mint, to anyone who can block
|
||||||
if [ "$WANT" != "$GOT" ]; then
|
# one URL.
|
||||||
echo "ci-box install: checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" >&2
|
if ! curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then
|
||||||
exit 1
|
echo "ci-box install: no published .sha256 for ${ASSET} — refusing to install an unverified binary that runs as root. The binary itself downloaded, so this is not an upstream layout change; check what is intercepting the fetch." >&2
|
||||||
fi
|
exit 1
|
||||||
echo "ci-box install: checksum verified (${GOT})"
|
|
||||||
else
|
|
||||||
echo "ci-box install: WARNING: no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" >&2
|
|
||||||
fi
|
fi
|
||||||
|
WANT="$(tr -d '\r' < "$WORKDIR/forgejo-runner.sha256" | awk '{print $1}' | head -n1)"
|
||||||
|
GOT="$(sha256sum "$WORKDIR/forgejo-runner" | awk '{print $1}')"
|
||||||
|
if [ -z "$WANT" ]; then
|
||||||
|
echo "ci-box install: the published checksum for ${ASSET} is unreadable — refusing to install an unverified binary" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$WANT" != "$GOT" ]; then
|
||||||
|
echo "ci-box install: checksum mismatch for ${ASSET}: published ${WANT}, downloaded ${GOT} — refusing to install" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ci-box install: checksum verified (${GOT})"
|
||||||
|
|
||||||
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN"
|
||||||
echo "ci-box install: installed ${BIN}"
|
echo "ci-box install: installed ${BIN}"
|
||||||
|
|
|
||||||
147
test/cli.sh
147
test/cli.sh
|
|
@ -3266,13 +3266,146 @@ check "forgejo-runner: status warns on a drifted mode" 0 "FORGEJO_RUNNER_FILE_MO
|
||||||
grep -o "FORGEJO_RUNNER_FILE_MODE" "$ROOT/commands/forgejo-runner-status.sh"
|
grep -o "FORGEJO_RUNNER_FILE_MODE" "$ROOT/commands/forgejo-runner-status.sh"
|
||||||
rm -rf "$FRW"
|
rm -rf "$FRW"
|
||||||
|
|
||||||
# The download must be verified. This binary is installed as root and executed
|
# --- the checksum gate, DRIVEN not grepped (review !110) --------------------
|
||||||
# by a systemd unit; an unverified fetch is the one step here that silently
|
# This binary is installed as root and executed by a systemd unit, so the
|
||||||
# turns a network compromise into root on the box.
|
# verification is the one step here that silently turns a network compromise
|
||||||
check "forgejo-runner: install verifies the published checksum" 0 "checksum mismatch" \
|
# into root on the box. A grep for the mismatch string proved only that a
|
||||||
grep -o "checksum mismatch" "$FR"
|
# string existed — it could not have caught the fail-open branch that used to
|
||||||
check "ci-box: its install.sh verifies the published checksum too" 0 "checksum mismatch" \
|
# sit beside it. So drive the real script against a stub curl, the
|
||||||
grep -o "checksum mismatch" "$ROOT/docs/templates/ci-box/install.sh"
|
# test/release.sh idiom, and assert on what it DOES.
|
||||||
|
CIBOX="$ROOT/docs/templates/ci-box/install.sh"
|
||||||
|
CBW="$(mktemp -d)"; CBSTUB="$CBW/stub"; mkdir -p "$CBSTUB"
|
||||||
|
cat > "$CBSTUB/curl" <<'CBCURL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Scripted curl — never the network.
|
||||||
|
# CB_REDIRECT what -w %{url_effective} answers (the latest-release probe)
|
||||||
|
# CB_PAYLOAD file copied to -o for the BINARY url
|
||||||
|
# CB_SUM text written to -o for the .sha256 url; unset => that url 404s
|
||||||
|
set -u
|
||||||
|
out="" url="" probe=0
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-o) out="$2"; shift 2 ;;
|
||||||
|
-w) probe=1; shift 2 ;;
|
||||||
|
-*) shift ;;
|
||||||
|
*) url="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ "$probe" -eq 1 ]; then printf '%s' "${CB_REDIRECT:-}"; exit 0; fi
|
||||||
|
case "$url" in
|
||||||
|
*.sha256)
|
||||||
|
[ -n "${CB_SUM:-}" ] || exit 22
|
||||||
|
# A fetch that SUCCEEDS but yields nothing usable is its own case, distinct
|
||||||
|
# from a 404 — a truncated proxy response looks exactly like this.
|
||||||
|
if [ "$CB_SUM" = EMPTY ]; then : > "${out:?}"; exit 0; fi
|
||||||
|
printf '%s asset\n' "$CB_SUM" > "${out:?}"; exit 0 ;;
|
||||||
|
*)
|
||||||
|
cp "${CB_PAYLOAD:?}" "${out:?}"; exit 0 ;;
|
||||||
|
esac
|
||||||
|
CBCURL
|
||||||
|
chmod +x "$CBSTUB/curl"
|
||||||
|
printf 'not-really-a-binary\n' > "$CBW/payload"
|
||||||
|
CB_GOOD="$(sha256sum "$CBW/payload" | awk '{print $1}')"
|
||||||
|
# install(1) must not touch the real /usr/local/bin, and the script runs as a
|
||||||
|
# non-root user here — stub it to a writable target so the test reaches the
|
||||||
|
# checksum logic rather than dying on permissions.
|
||||||
|
cat > "$CBSTUB/install" <<CBINST
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exec /usr/bin/install -m 0755 "\${@: -2:1}" "$CBW/installed"
|
||||||
|
CBINST
|
||||||
|
chmod +x "$CBSTUB/install"
|
||||||
|
|
||||||
|
cibox_run() { # cibox_run [VAR=val ...] — the REAL template install.sh, stubbed
|
||||||
|
rm -f "$CBW/installed"
|
||||||
|
env PATH="$CBSTUB:$PATH" \
|
||||||
|
CB_REDIRECT=https://code.forgejo.org/forgejo/runner/releases/tag/v9.9.9 \
|
||||||
|
CB_PAYLOAD="$CBW/payload" "$@" bash "$CIBOX"
|
||||||
|
}
|
||||||
|
cibox_installed() { [ -e "$CBW/installed" ]; }
|
||||||
|
|
||||||
|
check "ci-box: a matching checksum installs" 0 "checksum verified" \
|
||||||
|
cibox_run CB_SUM="$CB_GOOD"
|
||||||
|
check "ci-box: ...and the binary really landed" 0 "" cibox_installed
|
||||||
|
# The regression this replaces: a 404 on .sha256 used to warn and install.
|
||||||
|
check "ci-box: a MISSING checksum refuses (exit 1)" 1 "refusing to install an unverified binary" \
|
||||||
|
cibox_run
|
||||||
|
check "ci-box: ...and installed NOTHING" 1 "" cibox_installed
|
||||||
|
check "ci-box: a MISMATCHED checksum refuses" 1 "checksum mismatch" \
|
||||||
|
cibox_run CB_SUM=0000000000000000000000000000000000000000000000000000000000000000
|
||||||
|
check "ci-box: ...and installed nothing either" 1 "" cibox_installed
|
||||||
|
check "ci-box: an EMPTY checksum file refuses" 1 "unreadable" cibox_run CB_SUM=EMPTY
|
||||||
|
check "ci-box: ...and installed nothing there either" 1 "" cibox_installed
|
||||||
|
rm -rf "$CBW"
|
||||||
|
|
||||||
|
# The rig command's copy of the gate must not drift back to failing open.
|
||||||
|
# Grep-pinned only for the branch SHAPE (driving it needs root and systemd);
|
||||||
|
# the behaviour itself is proven above on the byte-equivalent template path.
|
||||||
|
check "forgejo-runner: install refuses an unfetchable checksum" 0 "refusing to install an unverified binary" \
|
||||||
|
grep -o "refusing to install an unverified binary" "$FR"
|
||||||
|
check "forgejo-runner: install has no warn-and-continue checksum branch" 1 "" \
|
||||||
|
grep -q "WITHOUT checksum verification" "$FR"
|
||||||
|
check "ci-box: no warn-and-continue checksum branch either" 1 "" \
|
||||||
|
grep -q "WITHOUT checksum verification" "$CIBOX"
|
||||||
|
|
||||||
|
# --- --version must converge, not be swallowed (review !110) ----------------
|
||||||
|
# forgejo-runner does NOT self-update, and a ci-box's template preinstalls the
|
||||||
|
# binary at mint — so a bare presence check would make --version dead on the
|
||||||
|
# exact path this command exists for.
|
||||||
|
FRLIB="$ROOT/commands/lib/forgejo-runner-config.sh"
|
||||||
|
vparse() { bash -c ". '$FRLIB'; runner_version_of '$1'"; }
|
||||||
|
VSTUB="$(mktemp -d)"
|
||||||
|
printf '#!/bin/sh\necho "forgejo-runner version v12.13.2"\n' > "$VSTUB/fr"; chmod +x "$VSTUB/fr"
|
||||||
|
printf '#!/bin/sh\necho "garbage"\n' > "$VSTUB/bad"; chmod +x "$VSTUB/bad"
|
||||||
|
check "forgejo-runner: the version reader strips the leading v" 0 "12.13.2" vparse "$VSTUB/fr"
|
||||||
|
check "forgejo-runner: an unreadable version yields empty, not garbage" 0 "" vparse "$VSTUB/bad"
|
||||||
|
rm -rf "$VSTUB"
|
||||||
|
|
||||||
|
# The decision itself, driven — this is the case review !110 caught, and a
|
||||||
|
# grep could not have caught it. Each row is a real lifecycle situation.
|
||||||
|
dec() { bash -c ". '$FRLIB'; runner_download_decision \"\$1\" \"\$2\" \"\$3\"" _ "$@"; }
|
||||||
|
check "version: nothing installed -> install" 0 "install" dec no "" ""
|
||||||
|
check "version: nothing installed, pin asked -> install" 0 "install" dec no "" 12.13.2
|
||||||
|
check "version: binary present, no pin -> skip (no surprise upgrade)" 0 "skip" dec yes 12.13.2 ""
|
||||||
|
check "version: binary present, pin MATCHES -> skip" 0 "skip" dec yes 12.13.2 12.13.2
|
||||||
|
# THE ci-box CASE: the template preinstalled a binary at mint, and the operator
|
||||||
|
# then pins. Mere presence used to swallow this entirely.
|
||||||
|
check "version: binary present, pin DIFFERS -> converge" 0 "converge" dec yes 12.13.2 12.13.0
|
||||||
|
check "version: a pin may converge DOWNWARD (a pin is not a floor)" 0 "converge" dec yes 12.13.2 9.0.0
|
||||||
|
check "version: an unreadable present version + pin -> converge" 0 "converge" dec yes "" 12.13.2
|
||||||
|
# ...and the command must actually consult it rather than re-deciding inline.
|
||||||
|
check "forgejo-runner: install routes the decision through the shared rule" 0 "runner_download_decision" \
|
||||||
|
grep -o "runner_download_decision" "$FR"
|
||||||
|
# shellcheck disable=SC2016 # the '${BIN}' is the LITERAL text being grepped for
|
||||||
|
check "forgejo-runner: the pin is asserted to have LANDED, not assumed" 0 "reports" \
|
||||||
|
grep -o 'but ${BIN} reports' "$FR"
|
||||||
|
# Replacing a live executable in place is ETXTBSY; the converge path now runs
|
||||||
|
# on boxes where the daemon is up, so it must rename into place.
|
||||||
|
# shellcheck disable=SC2016 # the '$BIN' is the LITERAL text being grepped for
|
||||||
|
check "forgejo-runner: the binary is renamed into place, never written over" 0 "mv -f" \
|
||||||
|
grep -o 'mv -f "\$BIN.rig-new" "\$BIN"' "$FR"
|
||||||
|
|
||||||
|
# --- .rig-labels must not outlive the registration it describes (review !110)
|
||||||
|
# A plain re-run used to stamp this invocation's labels over a registration
|
||||||
|
# made with different ones — status then reported confidently wrong labels
|
||||||
|
# while Forgejo still held the originals.
|
||||||
|
labels_write_is_scoped() {
|
||||||
|
# The write must sit INSIDE the else-branch that actually registers, the way
|
||||||
|
# runner-install.sh keeps its copy.
|
||||||
|
awk '/^else$/,/^fi$/' "$FR" | grep -q 'rig-labels'
|
||||||
|
}
|
||||||
|
check "forgejo-runner: .rig-labels is written only where registration happens" 0 "" \
|
||||||
|
labels_write_is_scoped
|
||||||
|
check "forgejo-runner: an explicit --labels on a rerun warns it was not applied" 0 "was not applied" \
|
||||||
|
grep -o -- "--labels was not applied" "$FR"
|
||||||
|
check "forgejo-runner: that warning is gated on --labels being EXPLICIT" 0 "LABELS_EXPLICIT" \
|
||||||
|
grep -o "LABELS_EXPLICIT" "$FR"
|
||||||
|
# The GitHub sibling is the precedent this restores — pin that it still scopes
|
||||||
|
# its own write, so the two cannot drift apart again.
|
||||||
|
gh_labels_write_is_scoped() {
|
||||||
|
awk '/^else$/,/^fi$/' "$ROOT/commands/runner-install.sh" | grep -q 'rig-labels'
|
||||||
|
}
|
||||||
|
check "rig runner: the sibling still scopes ITS .rig-labels write too" 0 "" \
|
||||||
|
gh_labels_write_is_scoped
|
||||||
|
|
||||||
# undo must refuse under a live Forgejo runner for a STRONGER reason than the
|
# undo must refuse under a live Forgejo runner for a STRONGER reason than the
|
||||||
# GitHub one: Forgejo has no deregistration endpoint, so the ghost it strands
|
# GitHub one: Forgejo has no deregistration endpoint, so the ghost it strands
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue