`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.
123 lines
4.4 KiB
Python
123 lines
4.4 KiB
Python
#!/opt/ci/venv/bin/python
|
|
"""Mint a Gitea act_runner registration token using the stored GiteaPAT.
|
|
|
|
The PAT never leaves this process and the registration token is written to a
|
|
0600 file instead of stdout, so neither secret lands in a shell history, a
|
|
process argument list, or a terminal log.
|
|
|
|
Usage:
|
|
sudo -u ci-runner /opt/ci/venv/bin/python tools/gitea-runner-token.py \
|
|
--out /var/lib/ci/.rust-runner-regtoken
|
|
|
|
# non-instance scopes
|
|
... --scope user
|
|
... --scope org --org myorg
|
|
... --scope repo --repo Simone/local-ci-cd-system
|
|
|
|
Instance scope (the default) requires the PAT to belong to an admin user and
|
|
produces a runner visible to every repository — which is what a shared build
|
|
host wants. Registration tokens are short-lived and single-use.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
DEFAULT_INSTANCE = "https://gitea.emulab.it"
|
|
CRED_TARGET = "GiteaPAT"
|
|
USER_AGENT = "ci-orchestrator-gitea-runner-token/1.0"
|
|
|
|
|
|
def _read_pat(target: str) -> str:
|
|
"""Read the PAT via the same two-tier scheme as KeyringCredentialStore."""
|
|
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 _endpoint(scope: str, org: str, repo: str) -> str:
|
|
if scope == "admin":
|
|
return "/api/v1/admin/actions/runners/registration-token"
|
|
if scope == "user":
|
|
return "/api/v1/user/actions/runners/registration-token"
|
|
if scope == "org":
|
|
if not org:
|
|
sys.exit("--scope org requires --org")
|
|
return f"/api/v1/orgs/{org}/actions/runners/registration-token"
|
|
if not repo or "/" not in repo:
|
|
sys.exit("--scope repo requires --repo owner/name")
|
|
owner, name = repo.split("/", 1)
|
|
return f"/api/v1/repos/{owner}/{name}/actions/runners/registration-token"
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--instance", default=DEFAULT_INSTANCE)
|
|
ap.add_argument("--scope", default="admin", choices=("admin", "user", "org", "repo"))
|
|
ap.add_argument("--org", default="")
|
|
ap.add_argument("--repo", default="")
|
|
ap.add_argument("--target", default=CRED_TARGET, help="keyring target holding the PAT")
|
|
ap.add_argument("--out", required=True, help="file to write the token to (mode 0600)")
|
|
args = ap.parse_args()
|
|
|
|
pat = _read_pat(args.target)
|
|
url = args.instance.rstrip("/") + _endpoint(args.scope, args.org, args.repo)
|
|
|
|
req = urllib.request.Request(
|
|
url,
|
|
method="POST",
|
|
data=b"",
|
|
headers={
|
|
"Authorization": f"token {pat}",
|
|
"Accept": "application/json",
|
|
"Content-Length": "0",
|
|
# Cloudflare in front of the instance rejects the default
|
|
# "Python-urllib/x.y" agent with error 1010; any explicit agent
|
|
# passes. Identify the caller rather than impersonate a browser.
|
|
"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:
|
|
body = exc.read().decode("utf-8", "replace")[:300]
|
|
# 403 on the admin scope usually means the PAT owner is not an admin.
|
|
print(f"HTTP {exc.code} from {url}\n{body}", file=sys.stderr)
|
|
return 1
|
|
except urllib.error.URLError as exc:
|
|
print(f"cannot reach {url}: {exc.reason}", file=sys.stderr)
|
|
return 1
|
|
|
|
token = payload.get("token") or payload.get("data", {}).get("token")
|
|
if not token:
|
|
print(f"no token in response: {sorted(payload)}", file=sys.stderr)
|
|
return 1
|
|
|
|
fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, "w", encoding="ascii") as fh:
|
|
fh.write(token)
|
|
print(f"OK — registration token ({len(token)} chars) written to {args.out}")
|
|
print("Single-use and short-lived: delete the file once the runner is registered.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|