Files
grm/scripts/ci/discover_runners.py
T

181 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Discover available Gitea Actions runners for dynamic job distribution.
Queries the Gitea API for registered runners at three levels:
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
2. Organization level: GET /orgs/{org}/actions/runners
3. Instance (admin) level: GET /admin/actions/runners
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
Outputs:
- ``--count``: prints the number of available runners
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
dynamic matrix in Gitea Actions
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
Usage:
python3 scripts/ci/discover_runners.py --owner oblachno-oss --repo grm
python3 scripts/ci/discover_runners.py --indices
python3 scripts/ci/discover_runners.py --count
"""
from __future__ import annotations
import json
import os
import click
import requests
from gitea_runner_manager.config import GITEA_API_URL
DEFAULT_MAX_RUNNERS = 3
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
"""Query the Gitea API for registered runners at all levels.
Returns the total count of active runners. If the API call fails
(e.g., no admin access for instance-level runners), falls back to
what we can see.
"""
headers = {"Authorization": f"token {token}"}
total = 0
# 1. Repository-level runners
try:
r = requests.get(
f"{api_url}/repos/{owner}/{repo}/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
# 2. Organization-level runners
try:
r = requests.get(
f"{api_url}/orgs/{owner}/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
# 3. Instance-level runners (requires admin scope)
try:
r = requests.get(
f"{api_url}/admin/actions/runners",
headers=headers,
timeout=10,
)
if r.status_code == 200:
data = r.json()
total += data.get("total_count", 0)
except (requests.RequestException, ValueError):
pass
return total
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
"""Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default.
"""
# Try API query if we have a token
if token:
api_count = query_runners(api_url, token, owner, repo)
if api_count > 0:
return api_count
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
env_count = os.environ.get("MOLECULE_RUNNERS")
if env_count:
try:
count = int(env_count)
if count > 0:
return count
except ValueError:
pass
# Fall back to default
return DEFAULT_MAX_RUNNERS
def generate_indices(count: int) -> list[str]:
"""Generate a list of runner indices ["1", "2", ..., "N"].
Uses 1-based string indices because Gitea Actions renders
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
expressions, causing --runner-index to be passed without a value.
The distribute_molecule.py script converts these back to 0-based
internally.
"""
return [str(i + 1) for i in range(count)]
@click.command()
@click.option("--owner", default=None, help="Repository owner (for API query).")
@click.option("--repo", default=None, help="Repository name (for API query).")
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
)
def main(
owner: str | None,
repo: str | None,
output_count: bool,
output_indices: bool,
github_output: bool,
) -> None:
token = os.environ.get("REPO_TOKEN", "")
if owner is None:
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
if repo is None:
repo = os.environ.get("GRM_REPO_NAME", "grm")
count = get_runner_count(GITEA_API_URL, token, owner, repo)
indices = generate_indices(count)
if github_output:
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}")
click.echo(f"Runner indices: {indices}")
return
if output_count:
click.echo(str(count))
return
if output_indices:
click.echo(json.dumps(indices))
return
# Default: output both as key=value pairs for CI consumption
click.echo(f"count={count}")
click.echo(f"indices={json.dumps(indices)}")
if __name__ == "__main__": # pragma: no cover
main()