Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions plugins/filter/arc.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,43 @@ def _check_dns_label(kind: str, name: str, where: str, errors: list[str]) -> Non
errors.append(f"{where}: the derived {kind} '{name}' is not a valid Kubernetes name (lower-case letters, digits and '-', at most {_DNS_LABEL_MAX_LENGTH} characters)")


def _expand_profile(org: Mapping[str, Any], org_name: str, index: int, profile: Any, errors: list[str]) -> dict[str, Any] | None:
def _optional_name(mapping: Mapping[str, Any], key: str, where: str, errors: list[str]) -> str:
"""Return an optional name override, or empty when it is unset, recording an error when it is set but not a non-empty string."""
value = mapping.get(key)
if value is None:
return ""
if not isinstance(value, str) or not value:
errors.append(f"{where}.{key} must be a non-empty string when set")
return ""
return value


def _scale_set_labels(profile: Mapping[str, Any], default: str, where: str, errors: list[str]) -> list[str]:
"""Return the scaleSetLabels a profile's release sets: scale_set_labels when given (an empty list sets none), otherwise runs_on_label or the org's pooled default."""
if profile.get("scale_set_labels") is None:
return [_text(profile, "runs_on_label") or default]
if profile.get("runs_on_label") is not None:
errors.append(f"{where}: set runs_on_label or scale_set_labels, not both")
labels = profile["scale_set_labels"]
if not isinstance(labels, Sequence) or isinstance(labels, (str, bytes)) or not all(isinstance(label, str) and label for label in labels):
errors.append(f"{where}.scale_set_labels must be a list of non-empty strings (empty sets no scaleSetLabels)")
return []
return list(labels)


def _explicit_max_runners(profile: Mapping[str, Any], where: str, errors: list[str]) -> int | None:
"""Return the profile's own max_runners as a whole number, None when unset, recording an error when it is not a whole number of at least 0."""
value = profile.get("max_runners")
if value is None:
return None
number = _decimal(value)
if number is None or number < 0 or number != number.to_integral_value():
errors.append(f"{where}.max_runners must be a whole number of at least 0 (got {value!r})")
return None
return int(number)


def _expand_profile(org_name: str, app_secret: str, index: int, profile: Any, errors: list[str]) -> dict[str, Any] | None:
"""Describe one scale-set profile of an org, or record why it cannot be described."""
where = f"{org_name} scale_set_profiles[{index}]"
if not isinstance(profile, Mapping):
Expand All @@ -139,17 +175,26 @@ def _expand_profile(org: Mapping[str, Any], org_name: str, index: int, profile:
if profile.get("sizing") is not None:
sizing_result = arc_max_runners(profile["sizing"])
errors.extend(f"{where}: {message}" for message in sizing_result["errors"])
explicit_max_runners = _explicit_max_runners(profile, where, errors)
# The static maxRunners the role sets on the release: the profile's own max_runners wins; otherwise sizing supplies it, except on the autoscaled profile, whose sizing sets the autoscaler's ceiling rather than its floor.
if explicit_max_runners is not None:
max_runners = explicit_max_runners
elif sizing_result is not None and not sizing_result["errors"] and not autoscale:
max_runners = sizing_result["max_runners"]
else:
max_runners = None
expanded = {
"org_name": org_name,
"suffix": suffix,
"namespace": f"arc-runners-{org_lower}{suffix}",
"release": f"{org_lower}-runners{suffix}",
"app_secret": f"{org_lower}-github-app",
"runs_on_label": _text(profile, "runs_on_label") or f"{org_lower}-runners",
"namespace": _optional_name(profile, "namespace", where, errors) or f"arc-runners-{org_lower}{suffix}",
"release": _optional_name(profile, "release_name", where, errors) or f"{org_lower}-runners{suffix}",
"app_secret": app_secret,
"scale_set_labels": _scale_set_labels(profile, f"{org_lower}-runners", where, errors),
"values_file": _text(profile, "values_file"),
"node_selector": dict(node_selector),
"autoscale": autoscale,
"sizing": sizing_result,
"max_runners": max_runners,
"label": f"{org_name}{suffix}",
"settings": dict(profile),
}
Expand All @@ -167,10 +212,10 @@ def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]:
Pass every org configured anywhere in the inventory, not one host's, so that the cross-host checks (an org configured twice, two profiles sharing a namespace, more than one autoscaled profile) see the whole fleet.

Args:
orgs: github_runner_arc_orgs entries, each with name, app_id, image and a non-empty scale_set_profiles list, and at most one of private_key and private_key_op_reference.
orgs: github_runner_arc_orgs entries, each with name, app_id, image and a non-empty scale_set_profiles list, at most one of private_key and private_key_op_reference, and optionally app_secret_name. A profile may override its namespace and release_name, and set scale_set_labels in place of runs_on_label.

Returns:
A dict with ``errors`` (messages, empty when valid), ``profiles`` (one dict per profile with org_name, suffix, namespace, release, app_secret, runs_on_label, values_file, node_selector, autoscale, sizing, label and settings, the profile's own keys) and ``autoscaled`` (the one profile flagged autoscale, or None).
A dict with ``errors`` (messages, empty when valid), ``profiles`` (one dict per profile with org_name, suffix, namespace, release, app_secret, scale_set_labels, values_file, node_selector, autoscale, sizing, max_runners (the static maxRunners the role sets, or None), label and settings, the profile's own keys) and ``autoscaled`` (the one profile flagged autoscale, or None).
"""
errors: list[str] = []
profiles: list[dict[str, Any]] = []
Expand Down Expand Up @@ -200,15 +245,22 @@ def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]:
if not isinstance(org_profiles, Sequence) or isinstance(org_profiles, (str, bytes)) or not org_profiles:
errors.append(f"{org_name}: scale_set_profiles must be a non-empty list")
continue
app_secret = _optional_name(org, "app_secret_name", org_name, errors) or f"{org_name.lower()}-github-app"
for profile_index, profile in enumerate(org_profiles):
expanded = _expand_profile(org, org_name, profile_index, profile, errors)
expanded = _expand_profile(org_name, app_secret, profile_index, profile, errors)
if expanded is not None:
profiles.append(expanded)
namespaces: dict[str, str] = {}
releases: dict[tuple[str, str], str] = {}
for profile in profiles:
if profile["namespace"] in namespaces:
errors.append(f"{profile['label']} and {namespaces[profile['namespace']]} both map to namespace '{profile['namespace']}'; give one of them a different suffix")
errors.append(f"{profile['label']} and {namespaces[profile['namespace']]} both map to namespace '{profile['namespace']}'; give one of them a different suffix or namespace")
namespaces.setdefault(profile["namespace"], profile["label"])
# The release name is also the scale set's registration name on GitHub, so two of an org's releases sharing it would register a duplicate even from different namespaces.
release_key = (profile["org_name"].lower(), profile["release"])
if release_key in releases:
errors.append(f"{profile['label']} and {releases[release_key]} both use release name '{profile['release']}', which is the scale set's name on GitHub; give one of them a different suffix or release_name")
releases.setdefault(release_key, profile["label"])
autoscaled = [profile for profile in profiles if profile["autoscale"]]
if len(autoscaled) > 1:
errors.append(f"At most one scale-set profile may set autoscale: true, because the autoscaler budgets one pool's memory for one scale set; found {', '.join(profile['label'] for profile in autoscaled)}")
Expand Down
38 changes: 34 additions & 4 deletions roles/github_runner_arc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,33 @@ It runs in two ways. Inside `playbooks/site.yml`, against a k3s server host that

## Validation

`tasks/validate.yml` checks every input that needs neither secrets nor a cluster: each org's fields, profile names that would collide or make invalid Kubernetes names, the same org configured on two hosts, more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record.
`tasks/validate.yml` checks every input that needs neither secrets nor a cluster: each org's fields, profile names (derived or overridden) that would collide or make invalid Kubernetes names, two of an org's profiles sharing a release name, a `max_runners` that is not a whole number, the controller's release name and namespace, the same org configured on two hosts, more than one autoscaled profile, values files that do not exist, the autoscaler's pool settings, sizing inputs, and the controller, probe and node label settings. Both playbooks run it in a play of its own before anything changes; the role runs it itself when included some other way. It also bootstraps the heartbeat gist when `github_runner_arc_heartbeat_bootstrap_gist` is on and no host in the inventory has one: it creates the gist with the control node's `gh` session and stops, printing the id to record.

Before touching the cluster the role then checks the secrets it is about to write (each org's App key looks like a PEM key, the pull credential and heartbeat token are set) and proves the pull credential can read every image it installs from `github_runner_arc_image_pull_registry`, by getting a pull-scoped registry token and fetching each image's manifest.

## Variables

- `github_runner_arc_orgs`: the orgs this host installs. Each entry has `name`, `app_id`, `image`, `private_key` (the App's PEM private key, from any source Ansible reads) or `private_key_op_reference` (an `op://` reference that the `github_runner_secrets_onepassword` adapter resolves into `private_key`), optionally `installation_id` (resolved from the App when empty), and `scale_set_profiles`, a list of profiles:
- `github_runner_arc_orgs`: the orgs this host installs. Each entry has `name`, `app_id`, `image`, `private_key` (the App's PEM private key, from any source Ansible reads) or `private_key_op_reference` (an `op://` reference that the `github_runner_secrets_onepassword` adapter resolves into `private_key`), optionally `installation_id` (resolved from the App when empty), optionally `app_secret_name` (the App Secret's name in each of the org's namespaces, default `<org>-github-app`), and `scale_set_profiles`, a list of profiles:
- `suffix`: appended to the namespace (`arc-runners-<org><suffix>`) and release (`<org>-runners<suffix>`) names. Default empty.
- `namespace`, `release_name`: the profile's namespace and Helm release name, in place of the names derived from the suffix. The release name is also the scale set's name on GitHub, so two profiles of one org cannot share it.
- `values_file`: Helm values for the release, a Jinja template read from `github_runner_arc_values_dir` on the control node (or an absolute path). Without it the role's `templates/runner-scale-set-values.yaml.j2` is used, driven by the profile's own `max_runners`, `min_runners`, `container_mode` (`dind` for the chart's Docker-in-Docker mode) and `resources` (the runner container's requests and limits).
- `node_selector`: a nodeSelector for the runner pods, merged over the eligibility label below.
- `max_runners`: the release's `maxRunners`. It takes precedence over the values file and over `sizing`; on the autoscaled profile it is the floor the autoscaler starts from.
- `runs_on_label`: the extra `scaleSetLabels` entry the runners carry. Default `<org>-runners`, which pools every profile of the org under one `runs-on` label; set a different one to keep a profile out of that pool.
- `scale_set_labels`: the whole `scaleSetLabels` list, in place of `runs_on_label`. An empty list sets no `scaleSetLabels`, so jobs target the scale set by its release name.
- `autoscale`: `true` on at most one profile in the whole inventory makes it the scale set the autoscaler manages.
- `sizing`: derive a runner ceiling from node capacity (see Sizing). On an ordinary profile it sets `maxRunners`; on the autoscaled profile it sets the autoscaler's ceiling, leaving `maxRunners` as the floor.
- `sizing`: derive a runner ceiling from node capacity (see Sizing). On an ordinary profile without `max_runners` it sets `maxRunners`; on the autoscaled profile it sets the autoscaler's ceiling, leaving `maxRunners` as the floor.
- `github_runner_arc_values_dir`: the control-node directory relative `values_file` paths are read from.
- `github_runner_arc_kubeconfig_path`: the kubeconfig on the host the role runs against. Defaults to `github_runner_cluster`'s kubeconfig; empty means `KUBECONFIG` or `~/.kube/config`.
- `github_runner_arc_controller_chart_version`, `github_runner_arc_scaleset_chart_version`: chart pins; unset installs the latest chart.
- `github_runner_arc_controller_release_name`, `github_runner_arc_controller_namespace`: the controller's Helm release and namespace, default `arc` in `actions-runner-controller`. The stale-listener check, the heartbeat's RBAC and the heartbeat's controller check use them too.
- `github_runner_arc_controller_replicas`: above 1, the chart turns on leader election and the role adds a preferred pod anti-affinity across nodes. `github_runner_arc_controller_extra_values` is merged over the role's controller values.
- `github_runner_arc_metrics_enabled` (and the `_addr`/`_endpoint` settings): Prometheus metrics for the controller and every listener; the 0.14 chart only enables them together.
- `github_runner_arc_listener_probes_enabled`: readiness and liveness probes on each listener's metrics endpoint, so a listener that starts and then fails its GitHub authentication is not counted ready during a rollout. Needs metrics on.
- `github_runner_arc_node_label_key`, `github_runner_arc_node_label_value`: when the key is set, the controller, listeners and runner pods are restricted to nodes carrying the label. The role labels the nodes named in `github_runner_arc_labelled_nodes`.
- `github_runner_arc_ghcr_username`, `github_runner_arc_ghcr_token`, `github_runner_arc_heartbeat_gh_token`: secrets, as plain variables.
- `github_runner_arc_manage_secrets`: `false` stops the role writing the App and pull Secrets, and instead checks before any change that each namespace already holds them.
- `github_runner_arc_manage_secrets`: `false` stops the role writing the App Secrets and `heartbeat-gh-token`, and instead checks before any change that they already exist. The role then needs no `private_key` or `installation_id`.
- `github_runner_arc_manage_image_pull_secret`: `false` stops the role writing the pull Secret, so an existing one named `github_runner_arc_image_pull_secret_name` is used as it is, for example one that something else mints from a GitHub App installation token. No pull credential is needed and the pull check is skipped; the role checks the Secret exists. Follows `github_runner_arc_manage_secrets` unless set.
- `github_runner_arc_image_pull_registry`, `github_runner_arc_image_pull_secret_name`, `github_runner_arc_verify_image_pull`: the pull Secret's registry and name, and whether to prove the credential before writing it.
- `github_runner_arc_heartbeat_gist_id`: install the fleet-health platform from this host, refreshing this gist. `github_runner_arc_heartbeat_bootstrap_gist`, `github_runner_arc_heartbeat_gist_description` and `github_runner_arc_heartbeat_gist_consumer` control the bootstrap described above.
- `github_runner_arc_autoscaler_usable_budget_gi`, `github_runner_arc_autoscaler_max_ceiling`, `github_runner_arc_autoscaler_floor`: required when a profile sets `autoscale: true` and the platform is installed. The floor must equal the autoscaled profile's `maxRunners`, which every Helm upgrade reverts to. The other `github_runner_arc_autoscaler_*` and `github_runner_arc_heartbeat_*` settings have defaults; see `defaults/main.yml`.
Expand Down Expand Up @@ -84,3 +89,28 @@ github_runner_arc_orgs:
values_file: "example-docker-values.yaml"
runs_on_label: "example-docker"
```

## Managing an existing install

A cluster whose controller and scale set were installed under other names can be managed by setting those names, rather than getting a second controller and scale set beside them. The role then upgrades the existing releases in place.

```yaml
github_runner_arc_controller_release_name: "existing-controller"
github_runner_arc_controller_namespace: "existing-controller-ns"
# The App Secret and the pull Secret already exist and are kept current elsewhere.
github_runner_arc_manage_secrets: false
github_runner_arc_manage_image_pull_secret: false
github_runner_arc_image_pull_secret_name: "existing-pull-secret"
github_runner_arc_orgs:
- name: ExampleOrg
app_id: 123456
app_secret_name: "existing-app-secret"
image: "ghcr.io/example/github-runner:2026.01.01"
scale_set_profiles:
- namespace: "existing-runners-ns"
release_name: "existing-runners"
values_file: "existing-runners-values.yaml"
max_runners: 6
# Jobs target the release name, so no scaleSetLabels.
scale_set_labels: []
```
Loading
Loading