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.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user