Files
local-ci-cd-system/tools/gitea-runner-list.py
T
Simone 498b0e9c05 feat(runner): dedicated rust-build runner instance for LinuxRust2404
`runs-on: rust-build` needs a second act_runner instance, not another label on
the existing one: act_runner resolves a single runner.envs block per process
and never tells the workflow which label picked the job. A label can select a
template only by selecting a runner whose envs name that template. Registered
as ci-linux-rust (id 3), online, announcing rust-build.

- deploy/runner-rust-config.yaml: label rust-build:host only, envs pointing at
  LinuxRust2404 + BaseClean-LinuxRust, its own cache dir (two processes must
  not share one actcache database), capacity 1 on top of the main runner's 2.
  runner.file is absolute: act_runner resolves a relative `file:` against the
  process CWD, so registering from elsewhere drops the registration state --
  which holds the runner token -- wherever it was invoked, and the daemon then
  fails with "registration file not found".
- scripts/install-rust-runner.sh: idempotent, --dry-run, refuses to touch the
  main runner directory, requires the template to exist first. Takes
  --token-file rather than --token so the secret stays out of the process
  argument list; the dry-run echo redacts it.
- tools/gitea-runner-token.py: mints a registration token from the stored
  GiteaPAT and writes it to a 0600 file. Neither secret is ever printed. Sets
  an explicit User-Agent because Cloudflare rejects urllib's default with
  error 1010.
- tools/gitea-runner-list.py: --label NAME exits non-zero unless an ONLINE
  runner announces it. A runs-on value no runner advertises leaves jobs
  unschedulable with no error anywhere, which is exactly the failure the
  consumer repo warned about.
2026-07-22 14:09:22 +02:00

107 lines
3.6 KiB
Python

#!/opt/ci/venv/bin/python
"""List the act_runners Gitea knows about, with their status and labels.
Answers the question that matters before writing a `runs-on:` value: is that
label actually announced by an online runner? A runs-on that no runner
advertises leaves the job unschedulable with no error anywhere — the CI just
goes quiet.
Usage:
sudo -u ci-runner /opt/ci/venv/bin/python tools/gitea-runner-list.py
... --label rust-build # exit 1 unless an online runner announces it
Reads the PAT from the keyring and never prints it.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.request
DEFAULT_INSTANCE = "https://gitea.emulab.it"
CRED_TARGET = "GiteaPAT"
# Cloudflare in front of the instance rejects the default "Python-urllib/x.y"
# agent with error 1010; any explicit agent passes.
USER_AGENT = "ci-orchestrator-gitea-runner-list/1.0"
def _read_pat(target: str) -> str:
try:
import keyring
except ImportError: # pragma: no cover - environment problem, not logic
sys.exit("keyring not installed")
cred = keyring.get_credential(target, None)
if cred and cred.password:
return str(cred.password)
username = keyring.get_password(f"{target}:meta", "username")
if username:
password = keyring.get_password(target, username)
if password:
return str(password)
sys.exit(f"credential {target!r} not found in keyring")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--instance", default=DEFAULT_INSTANCE)
ap.add_argument("--target", default=CRED_TARGET)
ap.add_argument("--label", default="", help="require this label on an online runner")
args = ap.parse_args()
url = args.instance.rstrip("/") + "/api/v1/admin/actions/runners"
req = urllib.request.Request(
url,
headers={
"Authorization": f"token {_read_pat(args.target)}",
"Accept": "application/json",
"User-Agent": USER_AGENT,
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
print(f"HTTP {exc.code} from {url}", file=sys.stderr)
return 1
except urllib.error.URLError as exc:
print(f"cannot reach {url}: {exc.reason}", file=sys.stderr)
return 1
runners = payload.get("runners", payload) if isinstance(payload, dict) else payload
if not isinstance(runners, list):
print(f"unexpected response shape: {type(runners).__name__}", file=sys.stderr)
return 1
print(f"{'ID':<5} {'NAME':<20} {'STATUS':<10} LABELS")
matched = False
for r in runners:
# Gitea reports labels either as plain strings or as {name: ...} objects.
labels = [
str(x.get("name", x)) if isinstance(x, dict) else str(x)
for x in (r.get("labels") or [])
]
status = str(r.get("status", "?"))
name = str(r.get("name", "?"))
print(f"{r.get('id', '?'):<5} {name:<20} {status:<10} {','.join(labels)}")
if args.label and args.label in labels and status.lower() == "online":
matched = True
if args.label:
if matched:
print(f"\nOK — an online runner announces {args.label!r}.")
return 0
print(
f"\nFAIL — no ONLINE runner announces {args.label!r}. "
"A job with that runs-on would sit unschedulable, silently.",
file=sys.stderr,
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())