feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner command family #110
16 changed files with 1573 additions and 24 deletions
119
README.md
119
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/<ref>.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@<pin-sha>/` 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 <url>`
|
||||
|
||||
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 <pin>` — `forgejo-runner` release (default: latest at install
|
||||
time). The published `.sha256` is **verified before the binary is
|
||||
installed**; a mismatch refuses.
|
||||
- `--name <name>` — runner name (default: this host's hostname)
|
||||
- `--labels <csv>` — 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 <name>` — 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__<section>__<KEY>` 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 <role>-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 <path>`
|
||||
|
||||
Converges named operator accounts from a declarative users file — on **every**
|
||||
|
|
|
|||
48
bin/rig
48
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 <url> [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 <name>]
|
||||
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 <name>]
|
||||
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 <path>
|
||||
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 <url>", 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:-}"
|
||||
|
|
|
|||
4
changelog.d/109.md
Normal file
4
changelog.d/109.md
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
365
commands/forgejo-runner-install.sh
Executable file
365
commands/forgejo-runner-install.sh
Executable file
|
|
@ -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 <url> [options]
|
||||
|
||||
--instance <url> Forgejo instance the runner registers to (required),
|
||||
e.g. https://forgejo.example.com
|
||||
--version <pin> 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 <name> runner name (default: this host's hostname)
|
||||
--labels <csv> runner labels; replaces the default. The default maps
|
||||
ubuntu-latest and docker onto container images, so a
|
||||
workflow written for GitHub runs unchanged.
|
||||
--user <name> 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 <url> 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 <url> 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" <<EOF
|
||||
[Unit]
|
||||
Description=Forgejo Actions runner
|
||||
Documentation=https://forgejo.org/docs/latest/admin/actions/
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${RUNNER_USER}
|
||||
WorkingDirectory=${RUNNER_DIR}
|
||||
ExecStart=${BIN} daemon
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
# The runner supervises job containers on this box's docker socket; it is not
|
||||
# a sandbox for them. These keep the DAEMON from being a soft target.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=${RUNNER_DIR}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
if ! cmp -s "$UNIT_TMP" "$UNIT" 2>/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"
|
||||
115
commands/forgejo-runner-remove.sh
Executable file
115
commands/forgejo-runner-remove.sh
Executable file
|
|
@ -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 <name>]
|
||||
|
||||
--user <name> 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."
|
||||
91
commands/forgejo-runner-status.sh
Executable file
91
commands/forgejo-runner-status.sh
Executable file
|
|
@ -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 <name>]
|
||||
|
||||
--user <name> 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:-<url>}' to converge the mode."
|
||||
fi
|
||||
106
commands/lib/forgejo-runner-config.sh
Normal file
106
commands/lib/forgejo-runner-config.sh
Normal file
|
|
@ -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 <runner_dir> — 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 <runner_dir> — the runner's name, empty when unregistered.
|
||||
forgejo_runner_name() {
|
||||
[ -e "$1/.runner" ] || return 0
|
||||
json_field "$1/.runner" name
|
||||
}
|
||||
|
||||
# forgejo_runner_secure <runner_dir> <user> <group> — 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 <runner_dir> <instance-url>
|
||||
#
|
||||
# Returns 0 when the box has no runner, or has one already registered to
|
||||
# <instance-url>: 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
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
# <role>/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 <host> <repo> <ref> — the source-tarball candidates
|
||||
# for <ref>, 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/<ref>.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/<ref> 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=<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 (<repo>-<ref>,
|
||||
# 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 <repo>-<ref> (slashes flattened), Forgejo writes
|
||||
# bare <repo>. 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
|
||||
|
|
|
|||
274
docs/plans/2026-07-27-forgejo-ci-box.md
Normal file
274
docs/plans/2026-07-27-forgejo-ci-box.md
Normal file
|
|
@ -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/<ref>.tar.gz`, `archive/refs/heads/<ref>.tar.gz`, `archive/<ref>.tar.gz` | `archive/<ref>.tar.gz` — one form, which resolves tags, branches and SHAs alike |
|
||||
| Archive top-level dir | `<repo>-<ref>` | `<repo>` |
|
||||
|
||||
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 `<CLI> --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 <instance> --uuid <uuid> --token-url <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/<ref>.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.
|
||||
25
docs/templates/README.md
vendored
Normal file
25
docs/templates/README.md
vendored
Normal file
|
|
@ -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.
|
||||
9
docs/templates/ci-box/creds.md
vendored
Normal file
9
docs/templates/ci-box/creds.md
vendored
Normal file
|
|
@ -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 <url>`. 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.
|
||||
78
docs/templates/ci-box/install.sh
vendored
Executable file
78
docs/templates/ci-box/install.sh
vendored
Executable file
|
|
@ -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}"
|
||||
16
docs/templates/ci-box/template.env
vendored
Normal file
16
docs/templates/ci-box/template.env
vendored
Normal file
|
|
@ -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"
|
||||
40
install.sh
40
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
|
||||
|
|
|
|||
199
test/cli.sh
199
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 <host> <repo> <ref> — 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/<ref>.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 '<CLI> --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 <url> 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 <owner/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 ]
|
||||
|
|
|
|||
Loading…
Reference in a new issue