diff --git a/README.md b/README.md index 09e06ef..b298cf7 100644 --- a/README.md +++ b/README.md @@ -470,6 +470,7 @@ actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacen |------|---------| | `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" | | `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time | +| `RIG_TEMPLATES_HOST` | which **forge** that repo lives on (default `https://github.com`) — not a mirror knob but a URL-grammar one: GitHub serves three candidate archive paths (`refs/tags`, `refs/heads`, bare) and Forgejo serves exactly one (`/archive/.tar.gz`), so the host decides what is even worth requesting. `install.sh` reads the same variable for its snapshot, so the two cannot disagree about where the registry is | | *(neither set; matching snapshot installed)* | **the installed pin snapshot** — `install.sh` best-effort fetches `RIG_TEMPLATES_PIN` once into `templates@/` inside the versioned rig tree; default converges read it with zero registry network I/O | | *(snapshot absent, empty, or stale)* | **live fetch of the in-tree pin** — the pre-snapshot fallback: `RIG_TEMPLATES_PIN` in `commands/lib/templates.sh` is fetched at converge time. A failed snapshot download only warns during install, so rig remains usable and retries here | @@ -479,6 +480,15 @@ directory cannot answer after a pin bump, and an explicit snapshot. Logs mark the installed path as `(snapshot)` so drill evidence records which source actually served the converge. +> **A self-hosted forge must serve public repos anonymously.** The fetch is +> unauthenticated *by contract* — box auto-runs a tenant bootstrap at mint, +> holding nothing — so `RIG_TEMPLATES_HOST` can only point somewhere a +> credential-less `curl` succeeds. Forgejo with `REQUIRE_SIGNIN_VIEW=true` +> answers **404 for a repo it reports as public**, which is exactly what a +> wrong ref looks like; the refusal names this case rather than leaving you +> hunting for a typo. Set `FORGEJO__service__REQUIRE_SIGNIN_VIEW=false` on the +> instance before hosting a registry there. + **The security trade — in bold, not a footnote.** **A main-tracked rig-templates repo means every merged PR there executes as root inside every future mint.** This is acceptable — and an improvement — only because of @@ -1054,6 +1064,115 @@ prints the exact `runner install` line that finishes the job. Convergent — repointing to the repo it is already on changes nothing, exits 0, and never asks for a token. +### `rig forgejo-runner install --instance ` + +The other forge's runner, and a different shape of box. Where `rig runner` +converges a fleet **machine** (`runner-server`), this converges a **ci-box +tenant** — a box guest whose whole job is running CI: + +```sh +box mint ci-box # box auto-runs: rig bootstrap ci-box +box shell ci-box +sudo rig forgejo-runner install --instance https://forgejo.heavyduty.builders +``` + +Installs `forgejo-runner` as a systemd service under an unprivileged user +(default: the tenant user `ci`). Like its GitHub sibling the runner is an +agent, not a server — it long-polls the instance outbound and needs **zero +inbound ports**. + +**Jobs run in containers, and there is no docker-in-docker.** +`rig bootstrap ci-box` already installed Docker and put the tenant user in the +`docker` group, so the runner drives that daemon directly. The usual dind +sidecar — privileged, with a plaintext `tcp://…:2375` socket — exists to +isolate jobs from a *shared* CI server; inside a box that boundary is already +paid for. The box is network-isolated, has no inbound path, and is thrown away. + +That is also why this command allows what `rig runner install` refuses. Docker +group membership is root-equivalent, and on a fleet machine the blast radius is +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. + +- `--version ` — `forgejo-runner` release (default: latest at install + time). The published `.sha256` is **verified before the binary is + installed**; a mismatch refuses. +- `--name ` — runner name (default: this host's hostname) +- `--labels ` — replaces the default map: + `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 +- `--user ` — service user (default: `ci` when it exists, else + `forgejo-runner`) + +**There is no `--repo`, and that is the substantive difference.** A Forgejo +runner registers to an *instance*; whether it then serves that whole instance, +one organisation, or one repository is a property of the **registration +token**, which you mint in Forgejo's UI at the scope you want: + +| scope | where the token comes from | +|---|---| +| instance | Site Administration → Actions → Runners | +| organisation | Org → Settings → Actions → Runners | +| repository | Repo → Settings → Actions → Runners | + +Pass it via `FORGEJO_RUNNER_TOKEN` or the interactive prompt. It is consumed at +registration and never written to disk by rig. + +Convergent **toward `--instance`** — re-running against the instance the box is +already on re-uses the binary and skips registration. Pointed at a *different* +instance it refuses and names both, for the same reason `rig runner install` +refuses a different repo. + +> **`.runner` holds a credential here.** GitHub's names a repository; Forgejo's +> holds the runner's own long-lived token. rig installs it `0600` owned by the +> runner user and **re-asserts that mode on every converge** — a mode that +> drifted leaks the secret silently, since nothing fails and the runner keeps +> working. `status` warns when it finds one that has. + +### `rig forgejo-runner status` + +Which instance this box's runner is registered to — instance, name, labels, +directory, unit and state. Reads the box only: no token, no network call, and +it never prints the registration secret `.runner` holds. Exits 1 when no runner +is installed. + +### `rig forgejo-runner remove` + +Stops and disables the service and wipes the local registration. The binary and +the user stay, so a later `install` re-registers without downloading anything. + +**Always local-only, and there is no `--local` flag.** Forgejo has no runner +deregistration endpoint — no removal token, nothing to hand back — so the box +is cleaned and the runner stays listed as offline until you delete it under +Actions → Runners. Offering the flag would advertise a server-side alternative +that does not exist. For the same reason there is no `forgejo-runner repoint`: +a move cannot be one atomic act, so it is `remove` then `install`. + +### Enabling Actions on the Forgejo side + +Two environment variables on the Forgejo service (Coolify → Environment +Variables → redeploy). Forgejo maps `FORGEJO__
__` onto its +config, so this survives image upgrades in a way an edited `app.ini` does not: + +``` +FORGEJO__actions__ENABLED=true +FORGEJO__actions__DEFAULT_ACTIONS_URL=https://code.forgejo.org +``` + +> **A registry served from Forgejo needs one more.** `RIG_TEMPLATES_HOST` +> (below) lets the template registry live on any forge, but the mint-time fetch +> is **unauthenticated by contract** — box auto-runs `rig bootstrap -box` +> at mint, holding no credentials. A Forgejo instance with +> `REQUIRE_SIGNIN_VIEW=true` answers **404 for public repos** to anonymous +> callers, which is indistinguishable from a wrong ref. So hosting the registry +> there also needs: +> +> ``` +> FORGEJO__service__REQUIRE_SIGNIN_VIEW=false +> ``` +> +> This affects only the *registry* fetch. `rig forgejo-runner` itself +> authenticates with a token and works either way. + ### `rig users apply --file ` Converges named operator accounts from a declarative users file — on **every** diff --git a/bin/rig b/bin/rig index 4ec2cb3..1f9b7ee 100755 --- a/bin/rig +++ b/bin/rig @@ -87,6 +87,23 @@ commands: 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. + forgejo-runner install --instance [options] + Forgejo Actions runner as a systemd service under an unprivileged + user — outbound-only, jobs in containers on this box's own dockerd. + Its home is a ci-box tenant, where 'rig bootstrap ci-box' already + installed that daemon. Registers to an INSTANCE: whether the runner + serves that instance, one org or one repo is a property of the + registration token, so there is no --repo. Prompts for the token + (FORGEJO_RUNNER_TOKEN env overrides). Run as root. + forgejo-runner status [--user ] + Which instance this box's Forgejo runner is registered to: instance, + name, labels, unit. Reads the box only — no token, no network call, + and never prints the registration secret .runner holds. Run as root. + forgejo-runner remove [--user ] + Take the service down and wipe this box's registration. Always + local-only: Forgejo has no deregistration endpoint, so the runner + stays listed offline until you delete it in the instance. Needs no + token. Run as root. users apply --file Converge named operator accounts from a declarative users file, on every class: groups by role (admin/rig/box), passwords locked always, @@ -443,6 +460,37 @@ case "$cmd" in ;; esac ;; + forgejo-runner) + shift + sub="${1:-}" + case "$sub" in + install) + shift + exec "$ROOT/commands/forgejo-runner-install.sh" "$@" + ;; + status) + shift + exec "$ROOT/commands/forgejo-runner-status.sh" "$@" + ;; + remove) + shift + exec "$ROOT/commands/forgejo-runner-remove.sh" "$@" + ;; + repoint) + # The GitHub sibling HAS this verb, so an operator will try it. Say why + # it cannot exist here rather than printing usage and leaving them to + # infer it: 'repoint' is atomic because GitHub lets rig deregister from + # the old repo; Forgejo has no such endpoint, so the honest shape is + # two acts, and one of them leaves a stale entry behind. + printf 'rig: forgejo-runner has no repoint: Forgejo has no deregistration endpoint, so a move cannot be one atomic act. Use "rig forgejo-runner remove" then "rig forgejo-runner install --instance ", and delete the stale runner in the old instance under Actions > Runners.\n' >&2 + exit 2 + ;; + *) + usage >&2 + exit 2 + ;; + esac + ;; users) shift sub="${1:-}" diff --git a/changelog.d/109.md b/changelog.d/109.md new file mode 100644 index 0000000..fdfbcfc --- /dev/null +++ b/changelog.d/109.md @@ -0,0 +1,4 @@ +### Added + +- `rig forgejo-runner install|status|remove` registers a Forgejo Actions runner against an instance, jobs in containers on the box's own dockerd (forgejo#109) +- The template registry can be served from any forge — `RIG_TEMPLATES_HOST` (forgejo#109) diff --git a/commands/bootstrap-undo.sh b/commands/bootstrap-undo.sh index 71e7339..1af226d 100755 --- a/commands/bootstrap-undo.sh +++ b/commands/bootstrap-undo.sh @@ -24,6 +24,28 @@ 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 +# The same hazard, the other forge (#109): leaving the tailnet under a live +# Forgejo runner strands a registration this box can no longer serve, and +# Forgejo has no deregistration endpoint — so the ghost it leaves is one +# somebody has to delete BY HAND in the instance's admin UI. That makes the +# refusal more load-bearing here than for GitHub, not less. +# +# RIG_FORGEJO_RUNNER_DIR mirrors RIG_RUNNER_DIR above so tests can point this +# at a fixture. The glob covers the tenant default (`ci`) and the dedicated +# account alike, because both are reachable defaults of `install --user`. +forgejo_runner_installed=0 +if [ -n "${RIG_FORGEJO_RUNNER_DIR:-}" ]; then + [ -e "$RIG_FORGEJO_RUNNER_DIR/.runner" ] && forgejo_runner_installed=1 +else + for runner_config in /home/*/forgejo-runner/.runner /root/forgejo-runner/.runner; do + [ -e "$runner_config" ] && forgejo_runner_installed=1 + done + [ -e /etc/systemd/system/forgejo-runner.service ] && forgejo_runner_installed=1 +fi +if [ "$forgejo_runner_installed" -eq 1 ]; then + die "a Forgejo runner is installed — run 'rig forgejo-runner remove' first so undo does not leave a ghost runner in the instance" +fi + join_by="" while IFS= read -r field; do case "$field" in diff --git a/commands/forgejo-runner-install.sh b/commands/forgejo-runner-install.sh new file mode 100755 index 0000000..723baa9 --- /dev/null +++ b/commands/forgejo-runner-install.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +# rig forgejo-runner install — Forgejo Actions runner as a systemd service +# under an unprivileged user. Outbound-only (long-poll to the instance), no +# inbound ports. Convergent toward --instance: re-running against the instance +# the box is already on leaves it alone; a box registered to a DIFFERENT +# instance is refused, never silently restarted on the old one. +# +# The GitHub sibling (runner-install.sh) refuses Docker outright: it converges +# a fleet MACHINE, where `docker` group membership is root-equivalent and the +# blast radius is the machine. This command's home is a ci-box TENANT, where +# bootstrap-tenant.sh has already installed Docker and added the tenant user to +# the group, and where the blast radius is a disposable guest with no inbound +# path. Same trade, different machine, opposite answer — which is why this is a +# separate command and not a flag on that one. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh +. "$HERE/lib/forgejo-runner-config.sh" + +log() { printf 'rig-forgejo-runner: %s\n' "$*"; } +warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +# The default label map. `runs-on: ubuntu-latest` is what a workflow written +# for GitHub says, so it must mean something here or every workflow needs +# editing to migrate; catthehacker's image is the act/Forgejo ecosystem's +# stand-in for GitHub's runner image. `docker` is the lean second option. +# +# Both are `docker://` — jobs run in CONTAINERS on the box's own dockerd, not +# on the box itself. No docker-in-docker: the guide this came from stacks a +# privileged dind sidecar with a plaintext tcp://…:2375 daemon to isolate jobs +# from a shared CI server, and inside a box that boundary is already paid for. +DEFAULT_LABELS='ubuntu-latest:docker://ghcr.io/catthehacker/ubuntu:act-22.04,docker:docker://node:22-bookworm' + +usage() { + cat <<'EOF' +usage: rig forgejo-runner install --instance [options] + + --instance Forgejo instance the runner registers to (required), + e.g. https://forgejo.example.com + --version forgejo-runner release to install, e.g. 12.13.2 + (default: the latest release, resolved at install + time). Pin it for a deterministic, auditable install. + --name runner name (default: this host's hostname) + --labels runner labels; replaces the default. The default maps + ubuntu-latest and docker onto container images, so a + workflow written for GitHub runs unchanged. + --user unprivileged service user (default: the tenant user + `ci` when it exists, else forgejo-runner; created if + absent; never root) + +Installs forgejo-runner as a systemd service under an unprivileged user. The +runner is an agent, not a server: it long-polls the instance outbound and +receives jobs down that already-established connection, so it needs ZERO +inbound ports. + +Jobs run in Docker containers on this box's own daemon. Inside a ci-box tenant +that daemon is already there — `rig bootstrap ci-box` installs it and puts the +tenant user in the `docker` group. + +Provide the runner registration token via the FORGEJO_RUNNER_TOKEN env var or +the interactive prompt. Get one from the scope you want the runner to serve: + instance Site Administration > Actions > Runners > Create new Runner + org Org > Settings > Actions > Runners + repo Repo > Settings > Actions > Runners +The SCOPE IS THE TOKEN'S, not a flag here. It is consumed at registration and +never written to disk by rig. + +Convergent toward --instance: re-running against the instance this box is +already on re-uses the binary, skips registration, and never asks for a token. +A box registered to a DIFFERENT instance is refused — take it off the old one +with `rig forgejo-runner remove` first. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +INSTANCE="" +VERSION="" +RUNNER_NAME="$(hostname)" +LABELS="$DEFAULT_LABELS" +RUNNER_USER="" +while [ $# -gt 0 ]; do + case "$1" in + --instance) + [ $# -ge 2 ] || die "--instance needs a value" 2 + INSTANCE="$2"; shift 2 ;; + --version) + [ $# -ge 2 ] || die "--version needs a value" 2 + VERSION="$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 ;; + --repo) + # Named, not "unknown flag": everyone arrives here from `rig runner + # install --repo`, and the honest answer is that the argument does not + # exist on this forge rather than that it is misspelled. + [ $# -ge 2 ] && shift + die "--repo does not exist here: a Forgejo runner registers to an INSTANCE, and whether it serves that whole instance, one org, or one repo is a property of the registration TOKEN you mint in Forgejo's UI. Pass --instance and mint the token at the scope you want." 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +# --- validation ---------------------------------------------------------- +[ -n "$INSTANCE" ] || die "--instance is required" 2 +case "$INSTANCE" in + https://*|http://*) ;; + *) die "--instance must be a URL with a scheme, e.g. https://forgejo.example.com (got: $INSTANCE)" 2 ;; +esac +# A path component would be a repo URL — the GitHub habit, and the one mistake +# that produces a runner registered somewhere subtly wrong rather than a clean +# failure. Refuse it by name. +case "${INSTANCE#*://}" in + */*[!/]*) die "--instance takes the instance ROOT, not a repository URL: got ${INSTANCE}. Scope comes from the token, not the URL." 2 ;; +esac +VERSION="${VERSION#v}" +[ -n "$LABELS" ] || die "--labels must not be empty" 2 + +# The tenant user is the default when it is there: inside a ci-box the runner +# IS the tenant, and inventing a second service account beside it would leave +# the docker-group membership bootstrap-tenant.sh converged on the wrong user. +# Falls back to a dedicated account so this still works on a plain machine. +if [ -z "$RUNNER_USER" ]; then + if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi +fi +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" +if [ -r /etc/os-release ]; then + # Sourced in a subshell: os-release defines VERSION (e.g. "13 (trixie)"), + # which would clobber this script's $VERSION. + # 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 +command -v curl >/dev/null || die "curl is required (run rig bootstrap first)" +command -v systemctl >/dev/null || die "systemctl is required — this command installs the runner as a systemd service" + +# --- is this box already registered somewhere else? -------------------------- +# Before anything is prompted for, downloaded, or started: --instance must +# agree with what is already on the box. Everything below treats an existing +# .runner as "nothing to do" — right for the instance the box is already on, +# silently wrong for any other. See assert_runner_instance. +REG_PENDING=1 +if id -u "$RUNNER_USER" >/dev/null 2>&1; then + USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" + RUNNER_DIR="$USER_HOME/forgejo-runner" + assert_runner_instance "$RUNNER_DIR" "$INSTANCE" || exit 1 + if [ -e "$RUNNER_DIR/.runner" ]; then + REG_PENDING=0 + fi +fi + +# --- registration token — only when registration is actually pending ------- +if [ "$REG_PENDING" -eq 1 ]; then + FORGEJO_RUNNER_TOKEN="${FORGEJO_RUNNER_TOKEN:-}" + # Prompt only on a tty: headless, a bare `read` dies under set -e with no + # message at all. Refuse loudly, naming the variable. + if [ -z "$FORGEJO_RUNNER_TOKEN" ]; then + [ -t 0 ] || die "FORGEJO_RUNNER_TOKEN is unset and stdin is not a tty — set FORGEJO_RUNNER_TOKEN to run unattended" + read -rsp "forgejo runner registration token: " FORGEJO_RUNNER_TOKEN || { echo; die "no registration token read (EOF) — set FORGEJO_RUNNER_TOKEN to run unattended"; } + echo + fi + [ -n "$FORGEJO_RUNNER_TOKEN" ] || die "empty registration token" +fi + +# --- user -------------------------------------------------------------------- +if ! id -u "$RUNNER_USER" >/dev/null 2>&1; then + useradd --create-home --shell /bin/bash "$RUNNER_USER" + log "created user ${RUNNER_USER}" +else + log "user exists" +fi +USER_HOME="$(getent passwd "$RUNNER_USER" | cut -d: -f6)" +RUNNER_GROUP="$(id -gn "$RUNNER_USER")" +RUNNER_DIR="$USER_HOME/forgejo-runner" +BIN=/usr/local/bin/forgejo-runner + +# The runner talks to dockerd over its socket, so it needs the group. In a +# ci-box bootstrap-tenant.sh already did this for the tenant user; on a plain +# machine, or for a --user that is not the tenant, it has not. +if getent group docker >/dev/null 2>&1; then + if id -nG "$RUNNER_USER" | tr ' ' '\n' | grep -qx docker; then + log "${RUNNER_USER} already in the docker group" + else + usermod -aG docker "$RUNNER_USER" + log "added ${RUNNER_USER} to the docker group" + fi +else + warn "no docker group on this box — jobs using docker:// labels will fail. Inside a ci-box, 'rig bootstrap ci-box' installs docker; elsewhere install it before running jobs." +fi + +# --- download ---------------------------------------------------------------- +# Forgejo publishes BARE BINARIES (not a tarball) with a .sha256 beside each +# one. Taking that checksum is nearly free and makes the install auditable — +# the same instinct as `coolify install`'s mandatory version pin. +if [ -x "$BIN" ]; then + log "forgejo-runner binary already present at ${BIN}; skipping download" +else + case "$(uname -m)" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + *) die "unsupported arch: $(uname -m)" ;; + esac + if [ -z "$VERSION" ]; then + # No pin given: resolve the latest release by following the redirect on + # the /releases/latest page — no API call, no token, no JSON to parse on + # a dependency-free box (install.sh's resolve_latest_tag idiom). + LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + https://code.forgejo.org/forgejo/runner/releases/latest)" \ + || die "could not resolve the latest forgejo-runner release" + VERSION="${LATEST_URL##*/}" + VERSION="${VERSION#v}" + case "$VERSION" in + ""|*[!0-9.]*) die "could not parse a version from ${LATEST_URL}" ;; + esac + log "resolved latest forgejo-runner: ${VERSION}" + fi + ASSET="forgejo-runner-${VERSION}-linux-${ARCH}" + URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}" + WORKDIR="$(mktemp -d)" + cleanup() { rm -rf "$WORKDIR"; } + trap cleanup EXIT + log "downloading forgejo-runner ${VERSION} (${ARCH})" + curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \ + || die "could not download ${URL}" + if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then + # 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})" + else + # A warning, not a refusal: rig should not become unable to install because + # upstream changed its asset layout. But it must be LOUD — an operator who + # needs a verified install has to know this one was not. + warn "no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" + fi + install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN" + log "installed ${BIN}" +fi +INSTALLED_VER="$("$BIN" --version 2>/dev/null | head -n1)" +[ -n "$INSTALLED_VER" ] || die "${BIN} does not answer --version — the download landed but cannot run" + +# --- register ---------------------------------------------------------------- +# UPSTREAM MARKS `register` DEPRECATED (measured on v12.13.2: both `register` +# and `create-runner-file` carry "(deprecated)" in their help). It is chosen +# here anyway, deliberately, and this is the reasoning to revisit when it +# finally goes: +# +# - It still works. `daemon` reads the `.runner` this writes, resolves the +# instance from it, and connects — verified against a live instance, where +# a planted `.runner` got as far as "Unauthenticated: unregistered runner". +# The mechanism is intact; only the credential was fake. +# - The successor needs MORE than rig can honestly ask for at this layer: +# `daemon --url --uuid --token-url` requires the runner to already exist on +# the instance, so the operator would have to create it via API/UI and +# carry back a UUID. That is a second, differently-shaped credential dance +# for no gain today. +# - `register` writes a file `status` can read back. The successor's config +# lives in flags on a unit line, where "what is this box registered to" has +# no on-disk answer that is not just rig's own copy of what it was told. +# +# When upstream removes it: the shape becomes `daemon --url/--uuid`, the unit +# gains those flags, and forgejo-runner-config.sh's readers move to whatever +# holds the UUID. assert_runner_instance's contract survives either way — it +# asks about the instance, which both spellings record. +install -d -m 0755 -o "$RUNNER_USER" -g "$RUNNER_GROUP" "$RUNNER_DIR" +if [ -e "$RUNNER_DIR/.runner" ]; then + log "already registered; skipping registration" +else + log "registering runner ${RUNNER_NAME} against ${INSTANCE}" + (cd "$RUNNER_DIR" && runuser -u "$RUNNER_USER" -- env HOME="$USER_HOME" \ + "$BIN" register --no-interactive \ + --instance "$INSTANCE" --token "$FORGEJO_RUNNER_TOKEN" \ + --name "$RUNNER_NAME" --labels "$LABELS") \ + || die "registration failed — check the token is a RUNNER registration token from ${INSTANCE} and has not been used already" + [ -e "$RUNNER_DIR/.runner" ] \ + || die "register reported success but wrote no ${RUNNER_DIR}/.runner" +fi +# 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. +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 ------------------------------------------------------------- +# 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 +# actions/runner's). Converged like every file rig writes — cmp-guarded, so a +# re-run that changes nothing reloads nothing. +UNIT=/etc/systemd/system/forgejo-runner.service +UNIT_TMP="$(mktemp)" +cat > "$UNIT_TMP" </dev/null; then + install -m 0644 "$UNIT_TMP" "$UNIT" + systemctl daemon-reload + log "systemd unit written: ${UNIT}" +else + log "systemd unit already current" +fi +rm -f "$UNIT_TMP" + +systemctl enable forgejo-runner >/dev/null 2>&1 || die "could not enable forgejo-runner.service" +systemctl restart forgejo-runner || die "could not start forgejo-runner.service — see 'journalctl -u forgejo-runner'" + +# Assert the EFFECTIVE state, not systemctl's exit code: a unit that starts and +# immediately dies (bad token, unreachable instance) leaves restart succeeding +# and the runner absent. Settle briefly, then ask. +active="" +for _ in 1 2 3 4 5 6; do + if systemctl is-active forgejo-runner >/dev/null 2>&1; then active=1; break; fi + sleep 2 +done +[ -n "$active" ] || die "forgejo-runner.service is not active after 12s — see 'journalctl -u forgejo-runner' (a bad token or an unreachable instance both land here)" + +log "runner ${RUNNER_NAME} (${INSTALLED_VER}) installed and running" +log "labels: ${LABELS}" +log "verify it shows Idle under ${INSTANCE} > Site Administration > Actions > Runners" +log "this box needs no inbound ports — the runner polls the instance outbound" diff --git a/commands/forgejo-runner-remove.sh b/commands/forgejo-runner-remove.sh new file mode 100755 index 0000000..289f9ba --- /dev/null +++ b/commands/forgejo-runner-remove.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# rig forgejo-runner remove — take the service down and wipe this box's +# registration. Convergent: a box with nothing installed exits 0. +# +# There is no --local flag here, and its absence is the design. The GitHub +# sibling offers --local as an ESCAPE HATCH from a real deregistration +# handshake (config.sh remove --token, against an endpoint that mints removal +# tokens). Forgejo has no such handshake and no such endpoint: local is the +# only thing removal can ever be. Shipping the flag would advertise a +# server-side alternative that does not exist, and an operator would spend the +# afternoon hunting for the token that turns it off. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh +. "$HERE/lib/forgejo-runner-config.sh" + +log() { printf 'rig-forgejo-runner: %s\n' "$*"; } +warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig forgejo-runner remove [--user ] + + --user unprivileged service user (default: the tenant user `ci` + when it exists, else forgejo-runner) + +Stops and disables the systemd service, then wipes this box's registration. +The binary and the user stay put, so a later `rig forgejo-runner install` +re-registers without downloading anything. + +Forgejo has no runner deregistration endpoint, so this is always local-only: +the box is cleaned, and the runner stays listed as offline in the instance +until you delete it under Actions > Runners. No token is needed or asked for. + +Convergent: safe to re-run; a box with no runner installed exits 0. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +RUNNER_USER="" +while [ $# -gt 0 ]; do + case "$1" in + --user) + [ $# -ge 2 ] || die "--user needs a value" 2 + RUNNER_USER="$2"; shift 2 ;; + --local) + # Named rather than "unknown flag": it is the GitHub sibling's spelling, + # and the answer is that removal here is ALWAYS what --local means. + die "--local is not a flag here: Forgejo has no deregistration endpoint, so 'rig forgejo-runner remove' is always local-only. Re-run it without the flag, then delete the offline runner in the instance's Actions > Runners." 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown flag: $1" 2 ;; + esac +done + +if [ -z "$RUNNER_USER" ]; then + if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi +fi + +# --- validation ------------------------------------------------------------ +[ "$RUNNER_USER" != "root" ] || die "runner user must not be root" 2 + +# --- guards ---------------------------------------------------------------- +[ "$(id -u)" -eq 0 ] || die "must run as root" + +UNIT=/etc/systemd/system/forgejo-runner.service + +# --- 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/forgejo-runner" +if [ ! -e "$RUNNER_DIR/.runner" ] && [ ! -e "$UNIT" ]; then + log "no runner registered in ${RUNNER_DIR}; nothing to remove" + exit 0 +fi + +INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")" +RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")" + +# --- service --------------------------------------------------------------- +# First, in both paths: stopping after the registration is wiped would strand a +# running daemon polling with credentials that no longer exist on disk. +if [ -e "$UNIT" ]; then + log "stopping and disabling forgejo-runner.service" + systemctl stop forgejo-runner >/dev/null 2>&1 || true + systemctl disable forgejo-runner >/dev/null 2>&1 || true + rm -f "$UNIT" + systemctl daemon-reload +else + log "no service installed; skipping" +fi + +# --- registration ----------------------------------------------------------- +if [ -e "$RUNNER_DIR/.runner" ]; then + rm -f "$RUNNER_DIR/.runner" + log "wiped the local registration" +fi +rm -f "$RUNNER_DIR/.rig-labels" + +# END WITH THE ABSENCE ASSERT: "removed" is a claim, and claims get verified +# (the `rig uninstall` precedent). +leftover="" +[ -e "$RUNNER_DIR/.runner" ] && leftover="$leftover $RUNNER_DIR/.runner" +[ -e "$UNIT" ] && leftover="$leftover $UNIT" +if [ -n "$leftover" ]; then + printf 'rig-forgejo-runner: remove INCOMPLETE — still present:%s\n' "$leftover" >&2 + exit 1 +fi + +log "runner removed; the binary stays for a future rig forgejo-runner install" +warn "the runner${RUNNER_NAME:+ ${RUNNER_NAME}} is still listed as offline in ${INSTANCE:-the instance} — delete it under Actions > Runners. Forgejo has no deregistration endpoint, so rig cannot do this for you." diff --git a/commands/forgejo-runner-status.sh b/commands/forgejo-runner-status.sh new file mode 100755 index 0000000..f201e9f --- /dev/null +++ b/commands/forgejo-runner-status.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# rig forgejo-runner status — what is this box's Forgejo runner registered to? +# Read-only: reports what is already on the box. No credential, no network call. +set -euo pipefail + +HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" +# shellcheck source=SCRIPTDIR/lib/forgejo-runner-config.sh +. "$HERE/lib/forgejo-runner-config.sh" + +log() { printf 'rig-forgejo-runner: %s\n' "$*"; } +warn() { printf 'rig-forgejo-runner: WARNING: %s\n' "$*" >&2; } +die() { printf 'rig-forgejo-runner: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } + +usage() { + cat <<'EOF' +usage: rig forgejo-runner status [--user ] + + --user unprivileged service user (default: the tenant user `ci` + when it exists, else forgejo-runner) + +Prints the Forgejo instance 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 token, no network call. The +registration secret that config holds is never printed. Exits 1 when no runner +is installed. +EOF +} + +# --- args (validated before the root check, so errors are testable) --------- +RUNNER_USER="" +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 + +if [ -z "$RUNNER_USER" ]; then + if id -u ci >/dev/null 2>&1; then RUNNER_USER="ci"; else RUNNER_USER="forgejo-runner"; fi +fi + +# --- 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/forgejo-runner" +[ -e "$RUNNER_DIR/.runner" ] \ + || die "no runner registered in ${RUNNER_DIR}" + +# --- read the runner's own config ------------------------------------------- +INSTANCE="$(forgejo_runner_instance "$RUNNER_DIR")" +RUNNER_NAME="$(forgejo_runner_name "$RUNNER_DIR")" + +if [ -r "$RUNNER_DIR/.rig-labels" ]; then + LABELS="$(cat "$RUNNER_DIR/.rig-labels")" +else + LABELS="(not recorded on this box — see the instance's Actions > Runners)" +fi + +UNIT=/etc/systemd/system/forgejo-runner.service +if [ -e "$UNIT" ]; then + STATE="$(systemctl is-active forgejo-runner 2>/dev/null || true)" + SERVICE="forgejo-runner.service (${STATE:-unknown})" +else + SERVICE="(not installed as a service)" +fi + +log "instance: ${INSTANCE:-unknown}" +log "name: ${RUNNER_NAME:-unknown}" +log "labels: ${LABELS}" +log "dir: ${RUNNER_DIR}" +log "service: ${SERVICE}" + +# status is the only command an operator runs when nothing is obviously wrong, +# which makes it the right place to notice a mode that drifted. It reports and +# does not fix: converging state is `install`'s job, and a read-only verb that +# quietly writes is a worse surprise than a loud warning. +MODE="$(stat -c '%a' "$RUNNER_DIR/.runner" 2>/dev/null || true)" +if [ -n "$MODE" ] && [ "$MODE" != "$FORGEJO_RUNNER_FILE_MODE" ]; then + warn ".runner is mode ${MODE}, not ${FORGEJO_RUNNER_FILE_MODE} — it holds this runner's registration secret, and every account on this box can read it. Re-run 'rig forgejo-runner install --instance ${INSTANCE:-}' to converge the mode." +fi diff --git a/commands/lib/forgejo-runner-config.sh b/commands/lib/forgejo-runner-config.sh new file mode 100644 index 0000000..6d8b95f --- /dev/null +++ b/commands/lib/forgejo-runner-config.sh @@ -0,0 +1,106 @@ +#!/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" +} + +# 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 +} diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh index 15c26f9..5185aaf 100644 --- a/commands/lib/templates.sh +++ b/commands/lib/templates.sh @@ -18,7 +18,7 @@ # /creds.md the per-vendor creds-free paragraph the context # renderer splices in # -# THE SOURCE IS THREE KNOBS plus the installed pin snapshot, precedence +# THE SOURCE IS FOUR KNOBS plus the installed pin snapshot, precedence # _DIR > _REF > snapshot > pin fetch: # RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the # offline-test path, and "try a template before it @@ -27,6 +27,8 @@ # bootstrap time (the same shape as the rig preinstall) # RIG_TEMPLATES_REPO which repo that ref lives in (default # heavy-duty/rig-templates) +# RIG_TEMPLATES_HOST which FORGE that repo lives on (default +# https://github.com) — see templates_archive_urls # and, absent both overrides, the snapshot installed beside this file when it # matches the PIN below, then a live fetch of that pin as the fallback. @@ -41,6 +43,40 @@ # agent tenants ported byte-equivalent from the case arms this PR cut. RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0 +# The forge the registry lives on. GitHub by default, so every existing caller +# is byte-unchanged; overridable because a self-hosted Forgejo is a different +# origin AND a different URL grammar (#109). +RIG_TEMPLATES_HOST_DEFAULT="https://github.com" + +# templates_archive_urls — the source-tarball candidates +# for , in order, one per line. A PURE function (no network, no globals): +# test/cli.sh lifts it and drives it against both forges, the resolve_latest_tag +# precedent in install.sh. +# +# The two forges are not URL-compatible, and the difference is not cosmetic: +# +# GitHub three forms, refs/tags FIRST so a tag always outranks a branch +# sharing its name (a pin must win), refs/heads as the fallback +# that keeps a branch ref working, then the bare form a commit SHA +# downloads through. +# Forgejo ONE form. /archive/.tar.gz resolves tags, branches and SHAs +# alike, and the refs/{tags,heads}/ paths are not served at all — +# emitting them would mean two guaranteed 404s ahead of every fetch +# and a failure message listing URLs that never could have worked. +# +# Measured against forgejo.heavyduty.builders, not inferred from the docs. +templates_archive_urls() { + local host="${1%/}" repo="$2" ref="$3" + case "$host" in + https://github.com|http://github.com|*//github.com) + printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + *) + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + esac +} + # The template.env schema. Grammar: blank lines, '#' comments, and # KEY="value" — nothing else. Parsed by regex, never sourced. TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE) @@ -50,7 +86,11 @@ MACHINE_KEYS_REQUIRED=(ROOT_DOOR HOST JOIN) # templates_source_desc — where the resolved registry came from, for error # messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in # the unknown-role refusal rather than looking like a typo. +# The host rides every non-snapshot description: a registry served from the +# wrong FORGE fails exactly like a misspelled repo, and naming only the repo +# would send the reader hunting for a typo that is not there (#109). templates_source_desc() { + local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}" if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR" elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then @@ -58,7 +98,8 @@ templates_source_desc() { "${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \ "$RIG_TEMPLATES_PIN" else - printf '%s@%s%s' \ + printf '%s/%s@%s%s' \ + "${host%/}" \ "${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \ "${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \ "$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')" @@ -89,17 +130,25 @@ templates_snapshot_usable() { # $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP, # the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is # used as-is; otherwise the repo@ref tarball is fetched and extracted under -# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's -# precedence — a tag outranks a branch that shares its name — plus the bare -# archive/ form, which is how a commit-SHA pin (the default) downloads. +# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs come from +# templates_archive_urls, which is forge-aware — see there for why the two +# forges cannot share one list. +# # Failure lists every URL tried: the fetch is unauthenticated by contract # (box auto-runs bootstrap at mint, holding nothing), so "is the repo public # and the ref real" is the whole diagnosis. +# +# On a SELF-HOSTED forge there is a third way to fail that reads exactly like +# the other two, so the refusal names it: an instance with +# REQUIRE_SIGNIN_VIEW=true serves 404 for public repos to anonymous callers — +# the same status a wrong ref gets. A mint holds no credentials and never +# will, so such an instance cannot host a registry until it serves public +# repos anonymously (#109). TEMPLATES_TMP="" # shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script REGISTRY_DIR="" templates_resolve() { - local repo ref url got="" + local repo ref host url got="" if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then [ -d "$RIG_TEMPLATES_DIR" ] || { printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2 @@ -114,29 +163,32 @@ templates_resolve() { fi repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" + host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}" command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; } command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; } TEMPLATES_TMP="$(mktemp -d)" - for url in \ - "https://github.com/$repo/archive/refs/tags/$ref.tar.gz" \ - "https://github.com/$repo/archive/refs/heads/$ref.tar.gz" \ - "https://github.com/$repo/archive/$ref.tar.gz"; do + while IFS= read -r url; do if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi - done + done < <(templates_archive_urls "$host" "$repo" "$ref") if [ -z "$got" ]; then - printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2 - printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2 - printf ' https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$repo" "$ref" >&2 - printf ' https://github.com/%s/archive/%s.tar.gz\n' "$repo" "$ref" >&2 + printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2 + templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&2 printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR= bypasses the fetch.\n' >&2 + case "${host%/}" in + https://github.com|http://github.com) ;; + *) printf 'on a self-hosted forge, check the instance serves PUBLIC repos to anonymous callers too: Forgejo with REQUIRE_SIGNIN_VIEW=true answers 404 for a public repo, which is indistinguishable from a wrong ref above (set FORGEJO__service__REQUIRE_SIGNIN_VIEW=false).\n' >&2 ;; + esac return 1 fi tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || { printf 'cannot extract the registry tarball from %s\n' "$got" >&2 return 1 } - # A GitHub archive holds exactly one top-level directory (-, - # slashes flattened) — assert that shape instead of assuming the name. + # A source archive holds exactly one top-level directory — assert that + # SHAPE, never the name, because the name is the forge's choice and the two + # disagree: GitHub writes - (slashes flattened), Forgejo writes + # bare . Globbing for the shape is what makes this line survive a + # forge swap untouched. set -- "$TEMPLATES_TMP"/*/ { [ $# -eq 1 ] && [ -d "$1" ]; } || { printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2 diff --git a/docs/plans/2026-07-27-forgejo-ci-box.md b/docs/plans/2026-07-27-forgejo-ci-box.md new file mode 100644 index 0000000..ba13092 --- /dev/null +++ b/docs/plans/2026-07-27-forgejo-ci-box.md @@ -0,0 +1,274 @@ +# Forgejo-native CI: a `ci-box` tenant and a `forgejo-runner` command family + +Status: proposed (#109) +Date: 2026-07-27 + +## The shape of the thing + +A fleet machine hosts boxes. One of those boxes is the CI box. The Forgejo +runner lives inside it, and CI jobs run in containers on that box's own +dockerd. + +``` ++-----------------------------+ outbound HTTPS (long-poll) +| Forgejo (Coolify) | <---------------------------------+ +| forgejo.heavyduty.builders | | ++-----------------------------+ | + +------------------------------+---+ + No inbound path to the box. | staging-server (host=yes) | + The runner polls out. | +---------------------------+ | + | | ci-box (tenant) | | + | | forgejo-runner (systemd)| | + | | dockerd -> job containers| | + | +---------------------------+ | + +----------------------------------+ +``` + +Three pieces, in two repos. + +## 1. A forge-agnostic registry fetch (`commands/lib/templates.sh`) + +`templates_resolve` builds three candidate URLs, all on `github.com`. A +registry hosted anywhere else cannot be fetched. + +The fix is a fourth knob beside `_DIR` / `_REF` / `_REPO`: + +```sh +RIG_TEMPLATES_HOST the forge origin (default https://github.com) +``` + +The candidate list becomes the host's fact rather than a constant, because the +two forges genuinely differ: + +| | GitHub | Forgejo | +|---|---|---| +| Candidates | `archive/refs/tags/.tar.gz`, `archive/refs/heads/.tar.gz`, `archive/.tar.gz` | `archive/.tar.gz` — one form, which resolves tags, branches and SHAs alike | +| Archive top-level dir | `-` | `` | + +Both were measured against `forgejo.heavyduty.builders`, not assumed. + +The existing "exactly one top-level directory" assert survives untouched — it +globs `*/` rather than reconstructing the name, so the differing directory name +costs nothing. Its **comment** was wrong for Forgejo and is corrected. + +`templates_source_desc` grows the host, so a misconfigured origin shows up in +the unknown-role refusal instead of reading like a typo. The GitHub default +means every existing caller behaves exactly as before. + +`install.sh`'s `snapshot_templates` duplicates the same candidate list for its +install-time cache. It gets the same knob, from the same environment variable, +so the snapshot and the live fetch cannot disagree about where the registry is. + +### The blocker this exposes + +`templates_resolve` documents a hard contract: + +> the fetch is unauthenticated by contract (box auto-runs bootstrap at mint, +> holding nothing) + +Measured: on `forgejo.heavyduty.builders`, anonymous requests for +`heavy-duty/rig` — reported by the API as `private: false` — return **404** for +the API, the web page, the git remote and the archive endpoint. Only an +authenticated request succeeds. The instance requires sign-in to view. + +A mint holds no credentials, so **a Forgejo-hosted registry is unreachable at +mint time** until the instance serves public repos anonymously: + +``` +FORGEJO__service__REQUIRE_SIGNIN_VIEW=false +``` + +This is a Coolify env-var change on the Forgejo service, next to the +`FORGEJO__actions__ENABLED=true` that Actions already needs. It is recorded +here and in the README as a prerequisite. Nothing in this change silently +assumes it: with the gate up, the fetch fails the way any unreachable ref +fails, listing every URL tried. + +## 2. The `ci-box` tenant definition + +Data, not mechanism — it belongs in the registry repo. It is staged in this PR +under `docs/templates/ci-box/` so it can be reviewed and linted here, and moves +to the registry verbatim once that repo exists on Forgejo. + +``` +USER="ci" +CONTEXT_PATH=".ci/CONTEXT.md" +CLI_NAME="forgejo-runner" +CLI_SRC="/usr/local/bin/forgejo-runner" +PATH_LINE="export PATH="$HOME/.local/bin:$PATH"" +NEEDS_NODE="no" +``` + +`CLI_SRC` is absolute rather than `~/`-relative, which is the one place this +definition departs from the agent tenants. Their CLI lives in the tenant's +home; this binary is executed by a systemd unit, and a tenant-writable binary +that a root-installed unit runs is a trivial path to root inside the box. So it +lands root-owned under `/usr/local/bin`. `test/cli.sh` pins `CLI_SRC` against +the path `install.sh` actually writes — a drifted pair converges to a CLI that +exists and cannot run, which is the scar `grok-box` left. + +`NEEDS_NODE="no"` because the runner is a static Go binary. Jobs get their Node +from the container image, which is the whole point of the label mapping below — +installing a second Node on the host would be a toolchain nobody reads. + +`install.sh` fetches the release binary for the architecture and **verifies the +published `.sha256` before installing it**. Forgejo ships bare binaries with +`.sha256` and `.asc` beside them rather than a tarball, so a checksum is +available for free; taking it makes the install auditable in the way +`coolify install`'s version pin is. + +This satisfies the tenant schema honestly rather than by paperwork: +`bootstrap-tenant.sh` asserts ` --version` answers **as the tenant user**, +and `forgejo-runner --version` does. + +`creds.md` states the box holds no Forgejo credential and that registration is +a separate, operator-run act — which is true, and is what the rendered context +file needs to say. + +### Why the box replaces docker-in-docker + +The setup guide this design came from builds a `docker:dind` sidecar with +`privileged: true` and a plaintext `tcp://…:2375` daemon socket. Inside a +tenant that is redundant: `bootstrap-tenant.sh` already installs Docker on +every tenant and adds the tenant user to the `docker` group. The runner talks +to that daemon over its own socket. + +The isolation argument that justifies dind on a shared CI server is already +paid for here by the box: it is network-isolated, disposable, and has no +inbound path. Stacking dind inside it would add a privileged container to buy +a boundary that already exists. + +Note the trade this makes explicit: `docker` group membership is +root-equivalent *within the box*. `rig runner install` refuses Docker for +exactly that reason — but it converges a fleet **machine**, where the blast +radius is the machine. Here the blast radius is a guest that is thrown away. +That is the difference that makes the same trade correct in one place and wrong +in the other. + +## 3. `rig forgejo-runner install|status|remove` + +A new family beside `rig runner`, which is left untouched. + +```sh +box shell ci-box +sudo rig forgejo-runner install \ + --instance https://forgejo.heavyduty.builders \ + --name ci-runner-1 +``` + +Default labels: + +``` +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. + +### Why not `rig runner --forge forgejo` + +GitHub's runner registers against a repository URL, and +`assert_runner_repo` converges toward `--repo`: re-running against the same +repo is a no-op, a different repo is refused because silently restarting on the +old one would leave the requested repo with jobs queued forever. + +Forgejo has no such argument. The runner registers against an **instance**, and +whether the 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 +to converge toward and nothing to compare. + +Folding that into one command would make every guard bimodal to share a flag +name, while the contract underneath differs. Two families is the smaller lie. + +### `register` is deprecated upstream, and is still the right call + +Measured on v12.13.2: both `forgejo-runner register` and `create-runner-file` +carry `(deprecated)` in their help. The successor is +`daemon --url --uuid --token-url `, which requires the +runner to **already exist on the instance** — the operator creates it via the +API or UI and carries a UUID back. + +`register` is chosen anyway: + +- It still works, and this was verified rather than assumed. A planted + `.runner` made `daemon` resolve the instance from the file, connect to a live + Forgejo, and fail with `Unauthenticated: unregistered runner` — the transport + and the file format are intact; only the credential was fake. +- The successor asks the operator for a second, differently-shaped credential + dance for no gain today. +- `register` writes a file `status` can read back. Under the successor, "what + is this box registered to" has no on-disk answer that is not merely rig's own + copy of what it was told. + +When upstream removes it: the unit line gains `--url`/`--uuid`, and the readers +in `forgejo-runner-config.sh` move to whatever holds the UUID. +`assert_runner_instance`'s contract survives either spelling, because it asks +about the instance — which both record. + +Not verified here, and left to the drill: that `register` writes `.runner` on a +live instance. That needs a real registration token, which this design cannot +mint. + +### The convergence guard that does apply + +`assert_runner_instance`: re-running against the instance this box is already +registered to re-uses the binary and skips registration; a **different** +instance refuses and names both. Same trust-boundary reasoning as +`assert_runner_repo`, asked about the axis Forgejo actually has. + +### `.runner` holds a credential here + +GitHub's `.runner` names a repo. Forgejo's holds `address`, `name`, `labels` +**and the runner's own long-lived token** — the credential it authenticates +with on every poll. + +So this family does something its GitHub sibling never had to: it installs +`.runner` as `0600` owned by the runner user, and **re-asserts that mode on +every converge**. A registration secret readable by every account on the box +would be a quiet, permanent credential leak, and convergence is the only moment +rig can notice a mode that drifted. + +`status` therefore reads `address`, `name` and `labels` and never prints the +token. + +### Removal + +Forgejo's runner has no deregistration handshake — no removal-token endpoint, +no `config.sh remove`. `remove` stops and disables the unit and wipes the local +registration, then says plainly that the entry must be deleted in Forgejo's +admin UI. `rig runner remove`'s `--local` escape hatch is the *only* mode here, +so it is not offered as a flag that suggests a server-side alternative exists. + +## Guard: `bootstrap --undo` + +`bootstrap-undo.sh` refuses to leave the tailnet while a GitHub runner is +installed, so undo cannot strand a ghost runner in a repository. The same +hazard exists for a Forgejo runner on a machine, so the guard learns the +`forgejo-runner.service` unit and the `.runner` under the runner user's home. + +## Testing + +`test/cli.sh` is dependency-free, non-root, offline. What it can prove: + +- Every new command's arg validation, `--help`, and unknown-flag refusals. +- `bin/rig` dispatch, including bare `rig forgejo-runner` showing usage. +- The forge-aware URL builder as a **pure function**, lifted and driven + directly: GitHub host yields the three-candidate list in tag-first order, + a Forgejo host yields the single `/archive/.tar.gz`. +- `templates_source_desc` names a non-default host. +- The staged `ci-box` definition passes `rig template-lint` — the same parser + a mint runs, so the definition cannot ship malformed. +- Grep-pins that the `.runner` 0600 assert and the `bootstrap --undo` + forgejo-runner guard are present, so a deleted guard cannot ship green + (the repo's existing precedent for guards that need a real machine). + +What it cannot prove, and is left to the drill: a real registration against a +live Forgejo, and a job actually executing in a container. + +## Out of scope + +`install.sh` hardcodes `github.com` in `resolve_latest_tag` and +`ref_candidate_urls` (rig's own source), and `commands/bootstrap.sh` fetches +box from `raw.githubusercontent.com`. Hosting rig itself on Forgejo needs those +too. They are follow-ups, not this change — `snapshot_templates` is included +here only because it fetches *the registry*, and leaving it behind would let +the snapshot and the live fetch disagree about where the registry lives. diff --git a/docs/templates/README.md b/docs/templates/README.md new file mode 100644 index 0000000..2c3dece --- /dev/null +++ b/docs/templates/README.md @@ -0,0 +1,25 @@ +# Staged template definitions + +Definitions here are **not** the registry. They are role definitions destined +for `heavy-duty/rig-templates`, staged in rig's tree only while the registry +repo does not yet exist on the forge that will serve them (#109). + +This is deliberately a waiting room, not a second registry: + +- `bootstrap-tenant.sh` does not look here. Nothing in this directory is + reachable by a mint, and adding a lookup would recreate exactly the coupling + the registry split was written to remove — where adding a tenant meant + editing rig. +- `test/cli.sh` lints each one with `rig template-lint`, the same parser a mint + runs. A definition that cannot pass the schema never reaches the registry. +- When the registry repo exists, a definition moves there **verbatim** and is + deleted from here in the same PR. + +To try one before it is anywhere, point a mint at it directly: + +```sh +RIG_TEMPLATES_DIR=docs/templates rig bootstrap ci-box +``` + +That is `RIG_TEMPLATES_DIR`'s stated purpose — "try a template before it exists +anywhere" — and it is the supported path, not a workaround. diff --git a/docs/templates/ci-box/creds.md b/docs/templates/ci-box/creds.md new file mode 100644 index 0000000..0b93495 --- /dev/null +++ b/docs/templates/ci-box/creds.md @@ -0,0 +1,9 @@ +- **Creds-free by default.** The box starts with no Forgejo credentials and no + git credentials. The runner binary is installed but **not registered**: + registration needs a short-lived token the operator mints in Forgejo + (Site Administration, org, or repo → Actions → Runners) and hands to + `sudo rig forgejo-runner install --instance `. The token is consumed at + registration and never written to disk by rig. After that, the runner's own + credential lives in `~/forgejo-runner/.runner`, mode 0600 — never copy it, + print it, or commit it. Secrets that CI jobs need belong in Forgejo's repo or + org secrets, injected per job, not on this box. diff --git a/docs/templates/ci-box/install.sh b/docs/templates/ci-box/install.sh new file mode 100755 index 0000000..d6351a7 --- /dev/null +++ b/docs/templates/ci-box/install.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# ci-box — the forgejo-runner binary. Run BY THE MECHANISM as root, with +# TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE exported. +# +# This lands the BINARY ONLY. Registration is deliberately not here: it needs a +# short-lived token from the Forgejo instance, and a tenant install is +# creds-free by contract — box auto-runs it at mint, holding nothing. The +# operator registers afterwards, out loud: +# +# box shell ci-box +# sudo rig forgejo-runner install --instance https://forgejo.example.com +# +# Same split as staging-box's tailnet join, for the same reason. +# +# Root-owned under /usr/local/bin rather than the tenant's home: unlike an +# agent CLI, this binary is run by a systemd unit as the tenant user, and a +# tenant-writable binary that root's unit executes is a trivial path to root +# inside the box. +set -euo pipefail + +BIN=/usr/local/bin/forgejo-runner + +if [ -x "$BIN" ]; then + exit 0 +fi + +case "$(uname -m)" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + *) echo "ci-box install: unsupported arch: $(uname -m)" >&2; exit 1 ;; +esac + +# The latest release, resolved by following the releases/latest redirect — no +# API call, no token, no JSON to parse on a dependency-free guest. A pinned +# version belongs to `rig forgejo-runner install --version`, which is where an +# operator who needs a deterministic install already is; a pin baked into the +# registry would go stale in a repo nobody watches. +LATEST_URL="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \ + https://code.forgejo.org/forgejo/runner/releases/latest)" \ + || { echo "ci-box install: could not resolve the latest forgejo-runner release" >&2; exit 1; } +VERSION="${LATEST_URL##*/}" +VERSION="${VERSION#v}" +case "$VERSION" in + ""|*[!0-9.]*) echo "ci-box install: could not parse a version from ${LATEST_URL}" >&2; exit 1 ;; +esac + +ASSET="forgejo-runner-${VERSION}-linux-${ARCH}" +URL="https://code.forgejo.org/forgejo/runner/releases/download/v${VERSION}/${ASSET}" + +WORKDIR="$(mktemp -d)" +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +echo "ci-box install: downloading forgejo-runner ${VERSION} (${ARCH})" +curl -fsSL "$URL" -o "$WORKDIR/forgejo-runner" \ + || { echo "ci-box install: could not download ${URL}" >&2; exit 1; } + +# Forgejo publishes a .sha256 beside each binary. Verifying it costs one +# 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. +if curl -fsSL "${URL}.sha256" -o "$WORKDIR/forgejo-runner.sha256" 2>/dev/null; then + 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})" +else + echo "ci-box install: WARNING: no published .sha256 for ${ASSET} — installing WITHOUT checksum verification" >&2 +fi + +install -m 0755 -o root -g root "$WORKDIR/forgejo-runner" "$BIN" +echo "ci-box install: installed ${BIN}" diff --git a/docs/templates/ci-box/template.env b/docs/templates/ci-box/template.env new file mode 100644 index 0000000..e39bedd --- /dev/null +++ b/docs/templates/ci-box/template.env @@ -0,0 +1,16 @@ +# ci-box — the Forgejo CI tenant (#109). The box guest a fleet machine mints to +# run CI jobs: forgejo-runner polls the instance outbound, jobs run in +# containers on the dockerd bootstrap-tenant.sh already installed. +# +# The schema is rig's commands/lib/templates.sh; this is data, parsed and never +# sourced. +USER="ci" +CONTEXT_PATH=".ci/CONTEXT.md" +CLI_NAME="forgejo-runner" +CLI_SRC="/usr/local/bin/forgejo-runner" +PATH_LINE="export PATH="$HOME/.local/bin:$PATH"" +# NEEDS_NODE is a question about the CLI, not about the jobs. forgejo-runner is +# a static Go binary, and every job gets its own toolchain from its container +# image — a Node on the host would be a second, invisible toolchain that no +# workflow reads. +NEEDS_NODE="no" diff --git a/install.sh b/install.sh index 1423211..f3c901e 100644 --- a/install.sh +++ b/install.sh @@ -121,6 +121,29 @@ ref_candidate_urls() { printf 'https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$1" "$2" } +# The registry's candidate URLs, forge-aware — a byte-identical copy of +# commands/lib/templates.sh's, diffed by test/cli.sh so the two cannot drift +# (#109; the valid_version / warn_bootstrapped precedent). One decision about +# where a registry lives, made in one grammar: a snapshot fetched from a forge +# converge would never fetch from is worse than no snapshot at all. +# +# Copied rather than sourced on purpose. snapshot_templates runs against an +# extracted tree, so sourcing WOULD work here — but it would make the +# installer's behaviour depend on executing code from the tarball it just +# downloaded, ahead of any of it being installed. The installer reads that +# tree (sed for the pin); it does not run it. +templates_archive_urls() { + local host="${1%/}" repo="$2" ref="$3" + case "$host" in + https://github.com|http://github.com|*//github.com) + printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/refs/heads/%s.tar.gz\n' "$host" "$repo" "$ref" + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + *) + printf '%s/%s/archive/%s.tar.gz\n' "$host" "$repo" "$ref" ;; + esac +} + # --- prerequisites ----------------------------------------------------------- # curl only when something must be downloaded — a local RIG_INSTALL_SOURCE # needs none, which is what lets test/cli.sh drive REAL installs offline. @@ -255,13 +278,19 @@ set_exec() { # $1 = a rig tree: the executable bits install.sh owns # Failure is deliberately a warning: rig itself is still a complete install, # and templates_resolve preserves the live-fetch fallback. snapshot_templates() { - local tree="$1" pin repo url got="" unpack top snapshot + local tree="$1" pin repo host url got="" unpack top snapshot pin="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$tree/commands/lib/templates.sh" 2>/dev/null | head -n1 || true)" if [ -z "$pin" ]; then warn "installed tree carries no RIG_TEMPLATES_PIN; template registry snapshot skipped." return 0 fi repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" + # The same forge knob templates_resolve reads, from the same variable (#109). + # Snapshot and live fetch MUST agree about where the registry lives: an + # install that cached from GitHub while converge fetches from Forgejo would + # serve a snapshot the pin never named, and the pin-in-the-directory-name + # staleness guard cannot catch a WRONG-ORIGIN snapshot, only an old one. + host="${RIG_TEMPLATES_HOST:-https://github.com}" snapshot="$tree/templates@$pin" if ! command -v curl >/dev/null 2>&1; then warn "curl is unavailable; template registry snapshot $repo@$pin was not installed (converge will retry the live fetch)." @@ -270,13 +299,10 @@ snapshot_templates() { unpack="$TMPDIR/templates-unpack" rm -rf "$unpack" mkdir -p "$unpack" - log "downloading template registry snapshot $repo@$pin" - for url in \ - "https://github.com/$repo/archive/refs/tags/$pin.tar.gz" \ - "https://github.com/$repo/archive/refs/heads/$pin.tar.gz" \ - "https://github.com/$repo/archive/$pin.tar.gz"; do + log "downloading template registry snapshot ${host%/}/$repo@$pin" + while IFS= read -r url; do if curl -fsSL "$url" -o "$TMPDIR/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi - done + done < <(templates_archive_urls "$host" "$repo" "$pin") if [ -z "$got" ]; then warn "could not fetch template registry snapshot $repo@$pin; rig installed without it (converge will retry the live fetch)." return 0 diff --git a/test/cli.sh b/test/cli.sh index d5b59ab..1072647 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -3094,6 +3094,205 @@ rm -f "$WBBIN" "$WBINST" rm -rf "$WORK" +# --- the forge knob: where a template registry lives (#109) ----------------- +# templates_archive_urls is a PURE function, so the fetch's URL grammar is +# testable with no network and no root — which matters because the two forges +# disagree in a way that is invisible until a fetch 404s. +# tau — the builder under test, sourced fresh each call so +# no test can leave state behind for the next one. +tau() { bash -c ". '$ROOT/commands/lib/templates.sh'; templates_archive_urls \"\$1\" \"\$2\" \"\$3\"" _ "$@"; } +taun() { tau "$@" | wc -l | tr -d ' '; } + +# GitHub keeps all three candidates, tag-first: a pin must outrank a branch +# that happens to share its name. This is the pre-#109 behaviour, pinned so the +# forge split cannot quietly change the default forge's precedence. +check "templates: github yields three candidates" 0 "3" \ + taun https://github.com o/r ref +check "templates: github puts refs/tags first (a pin outranks a branch)" 0 "refs/tags/ref.tar.gz" \ + bash -c ". '$ROOT/commands/lib/templates.sh'; templates_archive_urls https://github.com o/r ref | head -n1" +check "templates: github keeps the bare form for a SHA pin" 0 "https://github.com/o/r/archive/ref.tar.gz" \ + bash -c ". '$ROOT/commands/lib/templates.sh'; templates_archive_urls https://github.com o/r ref | tail -n1" + +# Forgejo serves ONE form. Emitting the refs/{tags,heads}/ paths there would be +# two guaranteed 404s per fetch and a failure message listing URLs that never +# could have worked — so the count is the assertion, not just the content. +check "templates: a forgejo host yields exactly one candidate" 0 "1" \ + taun https://forgejo.example.com o/r ref +check "templates: the forgejo candidate is /archive/.tar.gz" 0 "https://forgejo.example.com/o/r/archive/ref.tar.gz" \ + tau https://forgejo.example.com o/r ref +tau_greps() { tau "$2" o/r ref | grep -q "$1"; } +check "templates: a forgejo host emits no refs/ paths at all" 1 "" \ + tau_greps 'refs/' https://forgejo.example.com +# A trailing slash is a spelling of the same host, not a different one — it +# would otherwise produce a '//'-doubled URL that some proxies 404. +check "templates: a trailing slash on the host does not double the separator" 1 "" \ + tau_greps 'com//' https://forgejo.example.com/ + +# The host must ride the source description: a registry served from the wrong +# FORGE fails exactly like a misspelled repo, and naming only the repo sends +# the reader hunting for a typo that is not there. +tsd() { bash -c "RIG_TEMPLATES_HOST='$1'; . '$ROOT/commands/lib/templates.sh'; templates_source_desc"; } +check "templates: source_desc names the default forge" 0 "https://github.com/heavy-duty/rig-templates@" \ + tsd "" +check "templates: source_desc names a non-default forge" 0 "https://forgejo.example.com/heavy-duty/rig-templates@" \ + tsd "https://forgejo.example.com" + +# One decision about where a registry lives, two copies of the grammar: a +# snapshot fetched from a forge converge would never fetch from is worse than +# no snapshot at all, and the pin-in-the-name staleness guard cannot catch a +# WRONG-ORIGIN snapshot — only an old one. (valid_version's precedent.) +TAULIB="$(mktemp)"; TAUINST="$(mktemp)" +awk '/^templates_archive_urls\(\) \{/,/^\}/' "$ROOT/commands/lib/templates.sh" > "$TAULIB" +awk '/^templates_archive_urls\(\) \{/,/^\}/' "$ROOT/install.sh" > "$TAUINST" +check "templates_archive_urls: extracted from the lib (guards the awk)" 0 "archive" cat "$TAULIB" +check "templates_archive_urls: lib and install.sh copies are byte-identical" 0 "" diff "$TAULIB" "$TAUINST" +rm -f "$TAULIB" "$TAUINST" + +# --- the ci-box definition (#109) ------------------------------------------- +# Staged in rig's tree only until the registry exists on the forge that will +# serve it. Lint it with the SAME parser a mint runs, so a definition that +# cannot converge never reaches the registry. +check "ci-box: the staged definition passes template-lint" 0 "OK" \ + "$ROOT/bin/rig" template-lint "$ROOT/docs/templates/ci-box" +tfam() { bash -c ". '$ROOT/commands/lib/templates.sh'; template_family '$1'"; } +check "ci-box: it is a TENANT by the family rule" 0 "tenant" tfam ci-box + +# The mechanism asserts ' --version' answers as the tenant user, so +# CLI_SRC must be the absolute path install.sh actually writes. A drifted pair +# converges to a CLI that exists and cannot run — the grok-box scar. +cibox_src_matches_install() { + local dir="$ROOT/docs/templates/ci-box" + # shellcheck source=/dev/null + . "$ROOT/commands/lib/templates.sh" + template_parse_env "$dir/template.env" >/dev/null || return 2 + grep -qF "BIN=$TPL_CLI_SRC" "$dir/install.sh" +} +check "ci-box: CLI_SRC is the path its install.sh installs" 0 "" cibox_src_matches_install + +# Registration holds a credential, so it must NOT be in the definition: a +# tenant install is creds-free by contract — box auto-runs it at mint, holding +# nothing. Registration is the operator's separate, out-loud act. +check "ci-box: its install.sh takes no token" 1 "" \ + grep -q -- '--token' "$ROOT/docs/templates/ci-box/install.sh" +check "ci-box: its install.sh does not register" 1 "" \ + grep -qE '^[^#]*forgejo-runner +register' "$ROOT/docs/templates/ci-box/install.sh" +# The staging area must stay a waiting room: a lookup from bootstrap-tenant +# would recreate the coupling the registry split removed. +check "ci-box: bootstrap-tenant does not read the staging dir" 1 "" \ + grep -q 'docs/templates' "$ROOT/commands/bootstrap-tenant.sh" + +# --- rig forgejo-runner (#109) ---------------------------------------------- +FR="$ROOT/commands/forgejo-runner-install.sh" +check "forgejo-runner: bare subcommand shows usage, exit 2" 2 "usage:" "$ROOT/bin/rig" forgejo-runner +check "forgejo-runner: install --help exits 0" 0 "usage:" "$FR" --help +check "forgejo-runner: --instance is required" 2 "--instance is required" "$FR" +check "forgejo-runner: --instance needs a value" 2 "needs a value" "$FR" --instance +check "forgejo-runner: unknown flag exits 2" 2 "unknown flag" "$FR" --instance https://f.example.com --nope +check "forgejo-runner: empty --labels refused" 2 "must not be empty" "$FR" --instance https://f.example.com --labels '' +check "forgejo-runner: the runner user is never root" 2 "must not be root" "$FR" --instance https://f.example.com --user root +# A schemeless host and a repo URL are the two ways an operator mis-states the +# instance, and only one of them would fail loudly on its own — a repo URL +# registers somewhere subtly wrong instead. Both refuse by name. +check "forgejo-runner: a schemeless instance refuses" 2 "must be a URL with a scheme" \ + "$FR" --instance forgejo.example.com +check "forgejo-runner: a repository URL is not an instance" 2 "not a repository URL" \ + "$FR" --instance https://f.example.com/acme/widgets +# --repo is the GitHub habit. It must explain that scope lives in the TOKEN, +# not read as a typo — that is the single biggest conceptual difference +# between the two forges' runners. +check "forgejo-runner: --repo explains itself rather than 'unknown flag'" 2 "property of the registration TOKEN" \ + "$FR" --instance https://f.example.com --repo acme/widgets +# Likewise the two verbs the GitHub sibling has and this one cannot. +check "forgejo-runner: remove --local explains why it is not a flag" 2 "always local-only" \ + "$ROOT/commands/forgejo-runner-remove.sh" --local +check "forgejo-runner: repoint explains why it cannot exist" 2 "no deregistration endpoint" \ + "$ROOT/bin/rig" forgejo-runner repoint --instance https://f.example.com +check "forgejo-runner: status --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-status.sh" --help +check "forgejo-runner: remove --help exits 0" 0 "usage:" "$ROOT/commands/forgejo-runner-remove.sh" --help + +# assert_runner_instance, against fixtures — the convergence contract, which +# needs no root and no network to prove. +FRW="$(mktemp -d)" +frmk() { mkdir -p "$FRW/$1"; printf '%s\n' "$2" > "$FRW/$1/.runner"; } +frmk same '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET","address":"https://f.example.com"}' +frmk slash '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET","address":"https://f.example.com/"}' +frmk other '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET","address":"https://other.example.com"}' +frmk noaddr '{"id":3,"uuid":"u","name":"ci-1","token":"SECRET"}' +mkdir -p "$FRW/none" +fri() { bash -c ". '$ROOT/commands/lib/forgejo-runner-config.sh'; assert_runner_instance \"\$1\" \"\$2\"" _ "$@"; } +check "forgejo-runner: no registration is nothing to compare (exit 0)" 0 "" \ + fri "$FRW/none" https://f.example.com +check "forgejo-runner: the same instance converges (exit 0)" 0 "" \ + fri "$FRW/same" https://f.example.com +check "forgejo-runner: a trailing slash is the same instance" 0 "" \ + fri "$FRW/slash" https://f.example.com +check "forgejo-runner: a DIFFERENT instance refuses, naming both" 1 "https://other.example.com" \ + fri "$FRW/other" https://f.example.com +check "forgejo-runner: an unreadable address refuses rather than guessing" 1 "names no instance" \ + fri "$FRW/noaddr" https://f.example.com +# The readers must return the fields status prints — and NOT the token beside +# them. A status that leaked the registration secret into a terminal, a log or +# a screenshot would be the worst bug this family could have. +frread() { bash -c ". '$ROOT/commands/lib/forgejo-runner-config.sh'; $1 '$2'"; } +check "forgejo-runner: the instance reader reads address" 0 "https://f.example.com" \ + frread forgejo_runner_instance "$FRW/same" +check "forgejo-runner: the name reader reads name" 0 "ci-1" \ + frread forgejo_runner_name "$FRW/same" +# A status that leaked the registration secret into a terminal, a log, or a +# screenshot would be the worst bug this family could have — so prove the +# readers cannot carry it, rather than trusting that nobody prints it. +fr_leaks() { + . "$ROOT/commands/lib/forgejo-runner-config.sh" + { forgejo_runner_instance "$FRW/same"; forgejo_runner_name "$FRW/same"; } | grep -q SECRET +} +check "forgejo-runner: no reader ever returns the token" 1 "" fr_leaks +check "forgejo-runner: status reads no token field at all" 1 "" \ + grep -q 'json_field .* token' "$ROOT/commands/forgejo-runner-status.sh" + +# The mode is the contract, not hygiene: .runner holds a long-lived credential +# here (GitHub's does not), and a drifted mode leaks it SILENTLY — nothing +# fails, the runner keeps working. Converge must fix it; status must notice it. +fr_secure_mode() { + chmod 644 "$FRW/same/.runner" + . "$ROOT/commands/lib/forgejo-runner-config.sh" + forgejo_runner_secure "$FRW/same" "$(id -un)" "$(id -gn)" + stat -c%a "$FRW/same/.runner" +} +check "forgejo-runner: secure converges a world-readable .runner to 0600" 0 "600" fr_secure_mode +fr_secure_every_run() { awk '/^# EVERY run/,/^forgejo_runner_secure/' "$FR" | grep -q forgejo_runner_secure; } +check "forgejo-runner: install converges the mode on EVERY run, not only at registration" 0 "" \ + fr_secure_every_run +check "forgejo-runner: status warns on a drifted mode" 0 "FORGEJO_RUNNER_FILE_MODE" \ + grep -o "FORGEJO_RUNNER_FILE_MODE" "$ROOT/commands/forgejo-runner-status.sh" +rm -rf "$FRW" + +# The download must be verified. This binary is installed as root and executed +# by a systemd unit; an unverified fetch is the one step here that silently +# turns a network compromise into root on the box. +check "forgejo-runner: install verifies the published checksum" 0 "checksum mismatch" \ + grep -o "checksum mismatch" "$FR" +check "ci-box: its install.sh verifies the published checksum too" 0 "checksum mismatch" \ + grep -o "checksum mismatch" "$ROOT/docs/templates/ci-box/install.sh" + +# 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 +# has to be deleted by hand in the instance. Grep-pinned — proving it needs a +# tailnet, so a deleted guard would otherwise ship green (the repo's precedent +# for machine-only guards). +check "undo: refuses while a Forgejo runner is installed" 0 "rig forgejo-runner remove" \ + grep -o "rig forgejo-runner remove" "$ROOT/commands/bootstrap-undo.sh" +check "undo: the forgejo guard has a test hook like RIG_RUNNER_DIR's" 0 "RIG_FORGEJO_RUNNER_DIR" \ + grep -o "RIG_FORGEJO_RUNNER_DIR" "$ROOT/commands/bootstrap-undo.sh" +check "undo: the forgejo guard watches the unit as well as the file" 0 "forgejo-runner.service" \ + grep -o "forgejo-runner.service" "$ROOT/commands/bootstrap-undo.sh" + +# rig runner (GitHub) must be untouched by all of this — the whole reason this +# is a second family rather than a --forge flag on the first. +check "rig runner: still requires --repo, unchanged" 2 "--repo is required" \ + "$ROOT/commands/runner-install.sh" +check "rig runner: still speaks github.com" 0 "https://github.com/" \ + grep -o "https://github.com/\${repo}" "$ROOT/commands/lib/runner-config.sh" + echo "---" echo "$PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]