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_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_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 |
|
| *(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 |
|
| *(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
|
snapshot. Logs mark the installed path as `(snapshot)` so drill evidence
|
||||||
records which source actually served the converge.
|
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
|
**The security trade — in bold, not a footnote.** **A main-tracked
|
||||||
rig-templates repo means every merged PR there executes as root inside every
|
rig-templates repo means every merged PR there executes as root inside every
|
||||||
future mint.** This is acceptable — and an improvement — only because of
|
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,
|
Convergent — repointing to the repo it is already on changes nothing, exits 0,
|
||||||
and never asks for a token.
|
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>`
|
### `rig users apply --file <path>`
|
||||||
|
|
||||||
Converges named operator accounts from a declarative users file — on **every**
|
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
|
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.
|
token for the old repo and a registration token for the new one.
|
||||||
Run as root.
|
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>
|
users apply --file <path>
|
||||||
Converge named operator accounts from a declarative users file, on
|
Converge named operator accounts from a declarative users file, on
|
||||||
every class: groups by role (admin/rig/box), passwords locked always,
|
every class: groups by role (admin/rig/box), passwords locked always,
|
||||||
|
|
@ -443,6 +460,37 @@ case "$cmd" in
|
||||||
;;
|
;;
|
||||||
esac
|
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)
|
users)
|
||||||
shift
|
shift
|
||||||
sub="${1:-}"
|
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"
|
die "a GitHub runner is installed — run 'rig runner remove' first so undo does not leave a ghost runner in the repository"
|
||||||
fi
|
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=""
|
join_by=""
|
||||||
while IFS= read -r field; do
|
while IFS= read -r field; do
|
||||||
case "$field" in
|
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
|
# <role>/creds.md the per-vendor creds-free paragraph the context
|
||||||
# renderer splices in
|
# 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:
|
# _DIR > _REF > snapshot > pin fetch:
|
||||||
# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
|
# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
|
||||||
# offline-test path, and "try a template before it
|
# offline-test path, and "try a template before it
|
||||||
|
|
@ -27,6 +27,8 @@
|
||||||
# bootstrap time (the same shape as the rig preinstall)
|
# bootstrap time (the same shape as the rig preinstall)
|
||||||
# RIG_TEMPLATES_REPO which repo that ref lives in (default
|
# RIG_TEMPLATES_REPO which repo that ref lives in (default
|
||||||
# heavy-duty/rig-templates)
|
# 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
|
# 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.
|
# 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.
|
# agent tenants ported byte-equivalent from the case arms this PR cut.
|
||||||
RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0
|
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
|
# The template.env schema. Grammar: blank lines, '#' comments, and
|
||||||
# KEY="value" — nothing else. Parsed by regex, never sourced.
|
# KEY="value" — nothing else. Parsed by regex, never sourced.
|
||||||
TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE)
|
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
|
# templates_source_desc — where the resolved registry came from, for error
|
||||||
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
|
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
|
||||||
# the unknown-role refusal rather than looking like a typo.
|
# 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() {
|
templates_source_desc() {
|
||||||
|
local host="${RIG_TEMPLATES_HOST:-$RIG_TEMPLATES_HOST_DEFAULT}"
|
||||||
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
||||||
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
|
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
|
||||||
elif [ -z "${RIG_TEMPLATES_REF:-}" ] && templates_snapshot_usable; then
|
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_REPO:-heavy-duty/rig-templates}" \
|
||||||
"$RIG_TEMPLATES_PIN"
|
"$RIG_TEMPLATES_PIN"
|
||||||
else
|
else
|
||||||
printf '%s@%s%s' \
|
printf '%s/%s@%s%s' \
|
||||||
|
"${host%/}" \
|
||||||
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
|
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
|
||||||
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
|
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
|
||||||
"$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree 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,
|
# $(…) 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
|
# 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
|
# 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
|
# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs come from
|
||||||
# precedence — a tag outranks a branch that shares its name — plus the bare
|
# templates_archive_urls, which is forge-aware — see there for why the two
|
||||||
# archive/<ref> form, which is how a commit-SHA pin (the default) downloads.
|
# forges cannot share one list.
|
||||||
|
#
|
||||||
# Failure lists every URL tried: the fetch is unauthenticated by contract
|
# Failure lists every URL tried: the fetch is unauthenticated by contract
|
||||||
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
|
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
|
||||||
# and the ref real" is the whole diagnosis.
|
# 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=""
|
TEMPLATES_TMP=""
|
||||||
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
|
||||||
REGISTRY_DIR=""
|
REGISTRY_DIR=""
|
||||||
templates_resolve() {
|
templates_resolve() {
|
||||||
local repo ref url got=""
|
local repo ref host url got=""
|
||||||
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
|
||||||
[ -d "$RIG_TEMPLATES_DIR" ] || {
|
[ -d "$RIG_TEMPLATES_DIR" ] || {
|
||||||
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
|
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
|
||||||
|
|
@ -114,29 +163,32 @@ templates_resolve() {
|
||||||
fi
|
fi
|
||||||
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
||||||
ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}"
|
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 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; }
|
command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; }
|
||||||
TEMPLATES_TMP="$(mktemp -d)"
|
TEMPLATES_TMP="$(mktemp -d)"
|
||||||
for url in \
|
while IFS= read -r url; do
|
||||||
"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
|
|
||||||
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
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
|
if [ -z "$got" ]; then
|
||||||
printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2
|
printf 'cannot fetch the template registry %s/%s@%s — tried:\n' "${host%/}" "$repo" "$ref" >&2
|
||||||
printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2
|
templates_archive_urls "$host" "$repo" "$ref" | sed 's/^/ /' >&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 '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
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
|
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
|
||||||
printf 'cannot extract the registry tarball from %s\n' "$got" >&2
|
printf 'cannot extract the registry tarball from %s\n' "$got" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
# A GitHub archive holds exactly one top-level directory (<repo>-<ref>,
|
# A source archive holds exactly one top-level directory — assert that
|
||||||
# slashes flattened) — assert that shape instead of assuming the name.
|
# 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"/*/
|
set -- "$TEMPLATES_TMP"/*/
|
||||||
{ [ $# -eq 1 ] && [ -d "$1" ]; } || {
|
{ [ $# -eq 1 ] && [ -d "$1" ]; } || {
|
||||||
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
|
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"
|
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 -----------------------------------------------------------
|
# --- prerequisites -----------------------------------------------------------
|
||||||
# curl only when something must be downloaded — a local RIG_INSTALL_SOURCE
|
# 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.
|
# 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,
|
# Failure is deliberately a warning: rig itself is still a complete install,
|
||||||
# and templates_resolve preserves the live-fetch fallback.
|
# and templates_resolve preserves the live-fetch fallback.
|
||||||
snapshot_templates() {
|
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)"
|
pin="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$tree/commands/lib/templates.sh" 2>/dev/null | head -n1 || true)"
|
||||||
if [ -z "$pin" ]; then
|
if [ -z "$pin" ]; then
|
||||||
warn "installed tree carries no RIG_TEMPLATES_PIN; template registry snapshot skipped."
|
warn "installed tree carries no RIG_TEMPLATES_PIN; template registry snapshot skipped."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
|
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"
|
snapshot="$tree/templates@$pin"
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
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)."
|
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"
|
unpack="$TMPDIR/templates-unpack"
|
||||||
rm -rf "$unpack"
|
rm -rf "$unpack"
|
||||||
mkdir -p "$unpack"
|
mkdir -p "$unpack"
|
||||||
log "downloading template registry snapshot $repo@$pin"
|
log "downloading template registry snapshot ${host%/}/$repo@$pin"
|
||||||
for url in \
|
while IFS= read -r url; do
|
||||||
"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
|
|
||||||
if curl -fsSL "$url" -o "$TMPDIR/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
|
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
|
if [ -z "$got" ]; then
|
||||||
warn "could not fetch template registry snapshot $repo@$pin; rig installed without it (converge will retry the live fetch)."
|
warn "could not fetch template registry snapshot $repo@$pin; rig installed without it (converge will retry the live fetch)."
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
199
test/cli.sh
199
test/cli.sh
|
|
@ -3094,6 +3094,205 @@ rm -f "$WBBIN" "$WBINST"
|
||||||
|
|
||||||
rm -rf "$WORK"
|
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 "---"
|
||||||
echo "$PASS passed, $FAIL failed"
|
echo "$PASS passed, $FAIL failed"
|
||||||
[ "$FAIL" -eq 0 ]
|
[ "$FAIL" -eq 0 ]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue