`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.
107 lines
3.6 KiB
Python
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())
|