#!/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())