Skip to content

Commit de92e4d

Browse files
committed
fix(arc): read the App's id from an existing App Secret
The role required every org's app_id even with github_runner_arc_manage_secrets false, when it writes no App Secret and reads the id from the existing Secret wherever it needs one, so a consumer had to read that Secret itself just to pass the id back. app_id is now required only on a host that writes the App Secret; the fleet-wide expansion no longer asks for it, since only each host's own check knows whether it writes the Secret. With an existing App Secret the role now checks it holds github_app_id, github_app_installation_id and github_app_private_key, and that an app_id the org still sets matches the Secret's github_app_id, before anything changes. The Secret lookup is no_log, since its results hold the Secrets' data.
1 parent 0220686 commit de92e4d

7 files changed

Lines changed: 44 additions & 13 deletions

File tree

‎plugins/filter/arc.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -417,13 +417,14 @@ def _expand_profile(org_name: str, app_secret: str, index: int, profile: Any, er
417417
return expanded
418418

419419

420-
def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]:
420+
def arc_profiles(orgs: Sequence[Any], require_app_id: bool = True) -> dict[str, Any]:
421421
"""Expand github_runner_arc_orgs entries into one record per scale-set profile, and check them.
422422
423423
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.
424424
425425
Args:
426-
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.
426+
require_app_id: whether each org must set app_id. The role needs it only when it writes the App Secret; otherwise the App's id is in the existing App Secret, and an app_id that is set is only checked against it.
427+
orgs: github_runner_arc_orgs entries, each with name, app_id (see require_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.
427428
428429
Returns:
429430
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).
@@ -442,9 +443,10 @@ def arc_profiles(orgs: Sequence[Any]) -> dict[str, Any]:
442443
if not org_name:
443444
errors.append(f"{where} has no name")
444445
continue
445-
for key in ("app_id", "image"):
446-
if not _text(org, key):
447-
errors.append(f"{org_name}: {key} is required")
446+
if require_app_id and not _text(org, "app_id"):
447+
errors.append(f"{org_name}: app_id is required when the role writes the App Secret (github_runner_arc_manage_secrets); with an existing App Secret it is read from that Secret's github_app_id")
448+
if not _text(org, "image"):
449+
errors.append(f"{org_name}: image is required")
448450
# Presence only: reading the value would decrypt an ansible-vault string for no reason.
449451
if org.get("private_key") is not None and org.get("private_key_op_reference") is not None:
450452
errors.append(f"{org_name}: set private_key or private_key_op_reference, not both (the 1Password adapter fills private_key from the reference)")

‎roles/github_runner_arc/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Before touching the cluster the role then checks the secrets it is about to writ
1212

1313
## Variables
1414

15-
- `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:
15+
- `github_runner_arc_orgs`: the orgs this host installs. Each entry has `name`, `app_id` (needed only when the role writes the App Secret, see `github_runner_arc_manage_secrets`), `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:
1616
- `suffix`: appended to the namespace (`arc-runners-<org><suffix>`) and release (`<org>-runners<suffix>`) names. Default empty.
1717
- `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.
1818
- `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).
@@ -31,7 +31,7 @@ Before touching the cluster the role then checks the secrets it is about to writ
3131
- `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.
3232
- `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`.
3333
- `github_runner_arc_ghcr_username`, `github_runner_arc_ghcr_token`, `github_runner_arc_heartbeat_gh_token`: secrets, as plain variables. The pull credential is needed only for a static pull Secret.
34-
- `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`.
34+
- `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, and that each App Secret holds `github_app_id`, `github_app_installation_id` and `github_app_private_key`. The role then needs no `private_key` or `installation_id`, and no `app_id` either: it reads the App's id from the Secret where it needs it. An `app_id` that is set must match the Secret's `github_app_id`.
3535
- `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, kept current by something else. 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.
3636
- `github_runner_arc_image_pull_secret_source`: where a pull Secret the role writes gets its credential: `static` (the default) from `github_runner_arc_ghcr_username` and `github_runner_arc_ghcr_token`, or `app` from each org's GitHub App, renewed in the cluster (see [Image pull Secret from the GitHub App](#image-pull-secret-from-the-github-app)). `github_runner_arc_image_pull_secret_renewal_schedule` (default every 15 minutes) and `github_runner_arc_image_pull_secret_renewer_image` configure the renewal.
3737
- `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.
@@ -162,8 +162,8 @@ github_runner_arc_manage_secrets: false
162162
github_runner_arc_manage_image_pull_secret: false
163163
github_runner_arc_image_pull_secret_name: "existing-pull-secret"
164164
github_runner_arc_orgs:
165+
# No app_id: the role reads it from the existing App Secret.
165166
- name: ExampleOrg
166-
app_id: 123456
167167
app_secret_name: "existing-app-secret"
168168
image: "ghcr.io/example/github-runner:2026.01.01"
169169
scale_set_profiles:

‎roles/github_runner_arc/defaults/main.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
# ARC (Actions Runner Controller) in an existing Kubernetes cluster: the controller, each org's runner scale sets, and the fleet-health platform (heartbeat and autoscaler). Each part is gated on this host's own github_runner_arc_orgs or github_runner_arc_heartbeat_gist_id, so any host that can reach the cluster's API can be the one Ansible installs from: a k3s server host brought up by github_runner_cluster, or localhost with a kubeconfig (playbooks/arc.yml). On a host github_runner_cluster made an agent, this role installs nothing.
33

4-
# Orgs whose runner scale sets this host installs. Each entry: 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 the 1Password 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 {suffix, namespace?, release_name?, values_file?, max_runners?, node_selector?, runs_on_label?, scale_set_labels?, autoscale?, sizing?}. sizing takes uniform node figures, or measured: true to read each eligible node's free capacity from the cluster at install time. Each profile becomes one namespace (namespace, default arc-runners-<org><suffix>), two Secrets in it, and one Helm release (release_name, default <org>-runners<suffix>); setting both overrides lets the role take over scale sets an existing install created under its own names. See the README for every profile key.
4+
# Orgs whose runner scale sets this host installs. Each entry: name, app_id (needed only when the role writes the App Secret; otherwise read from the existing Secret, and checked against it when set), image, private_key (the App's PEM private key, from any source Ansible reads) or private_key_op_reference (an op:// reference the 1Password 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 {suffix, namespace?, release_name?, values_file?, max_runners?, node_selector?, runs_on_label?, scale_set_labels?, autoscale?, sizing?}. sizing takes uniform node figures, or measured: true to read each eligible node's free capacity from the cluster at install time. Each profile becomes one namespace (namespace, default arc-runners-<org><suffix>), two Secrets in it, and one Helm release (release_name, default <org>-runners<suffix>); setting both overrides lets the role take over scale sets an existing install created under its own names. See the README for every profile key.
55
github_runner_arc_orgs: []
66

77
# Directory on the control node that a relative scale_set_profiles[].values_file is read from. Values files are Jinja templates rendered on the control node, so the target host needs no checkout. A profile with no values_file uses the role's own templates/runner-scale-set-values.yaml.j2.

‎roles/github_runner_arc/meta/argument_specs.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ argument_specs:
99
elements: dict
1010
default: []
1111
description:
12-
- Orgs whose runner scale sets this host installs. Each entry has name, app_id, image, private_key or private_key_op_reference, and optionally installation_id and app_secret_name (the App Secret's name, default <org>-github-app).
12+
- Orgs whose runner scale sets this host installs. Each entry has name, app_id (needed only when the role writes the App Secret; otherwise the existing Secret supplies it), image, private_key or private_key_op_reference, and optionally installation_id and app_secret_name (the App Secret's name, default <org>-github-app).
1313
- Each entry's scale_set_profiles is a non-empty list of profiles, each with suffix and optionally namespace (default arc-runners-<org><suffix>), release_name (default <org>-runners<suffix>), values_file, max_runners, min_runners, container_mode, resources, node_selector, runs_on_label, scale_set_labels, autoscale and sizing.
1414
- max_runners, when set, is the release's maxRunners and takes precedence over its values file and its sizing.
1515
- scale_set_labels replaces runs_on_label with the whole list of scaleSetLabels; an empty list sets none, so jobs target the release name.

‎roles/github_runner_arc/tasks/check_existing_secrets.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
loop_control:
2121
label: "{{ item.0 }}/{{ item.1 }}"
2222
register: github_runner_arc_existing_secrets
23+
# The results hold the Secrets' data.
24+
no_log: true
2325

2426
- name: Fail when a referenced Secret does not exist
2527
ansible.builtin.fail:
@@ -31,3 +33,23 @@
3133
loop_control:
3234
label: "{{ item.item.0 }}/{{ item.item.1 }}"
3335
when: item.resources | length == 0
36+
37+
# The App Secrets this host does not write, with the org each belongs to. The role reads the App's id from here rather than requiring the org's app_id, so a Secret missing a key, or disagreeing with an app_id that is set, fails now rather than as a listener that cannot authenticate.
38+
- name: Check each existing App Secret holds what the controller reads
39+
ansible.builtin.fail:
40+
msg: >-
41+
App Secret {{ item.0.item.0 }}/{{ item.0.item.1 }} of {{ item.1.org_name }}
42+
{{ ('has no ' ~ (github_runner_arc_missing_keys | join(', ')) ~ '; it needs github_app_id, github_app_installation_id and github_app_private_key.')
43+
if github_runner_arc_missing_keys | length > 0 else
44+
('holds github_app_id ' ~ github_runner_arc_secret_app_id ~ ', but the org sets app_id ' ~ github_runner_arc_org_app_id ~ '. Correct or remove the org\'s app_id.') }}
45+
loop: "{{ github_runner_arc_existing_secrets.results | zip(github_runner_arc_local.profiles) | list if not (github_runner_arc_manage_secrets | bool) else [] }}"
46+
loop_control:
47+
label: "{{ item.0.item.0 }}/{{ item.0.item.1 }}"
48+
vars:
49+
github_runner_arc_secret_data: "{{ item.0.resources[0].data | default({}, true) }}"
50+
github_runner_arc_missing_keys: "{{ ['github_app_id', 'github_app_installation_id', 'github_app_private_key'] | reject('in', github_runner_arc_secret_data) | list }}"
51+
github_runner_arc_secret_app_id: "{{ github_runner_arc_secret_data.github_app_id | default('') | b64decode | trim }}"
52+
github_runner_arc_org_app_id: "{{ (github_runner_arc_orgs | selectattr('name', 'equalto', item.1.org_name) | first).app_id | default('', true) | string }}"
53+
when:
54+
- item.0.resources | length > 0
55+
- github_runner_arc_missing_keys | length > 0 or (github_runner_arc_org_app_id | length > 0 and github_runner_arc_org_app_id != github_runner_arc_secret_app_id)

‎roles/github_runner_arc/tasks/validate.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
# This host's orgs come from github_runner_arc_orgs as this play resolves it, which includes play and role variables and -e; another host's can only be read from hostvars, which holds its inventory variables and facts. Taking this host's from hostvars too would miss orgs passed as a play variable, and every check and lookup built on the fleet (the scale sets' namespaces that measured sizing leaves out, the autoscaled profile) would then not see them. An implicit localhost is not in groups['all'] at all.
55
- name: Expand every org configured anywhere in the inventory into its scale-set profiles
66
ansible.builtin.set_fact:
7+
# Whether an org needs app_id depends on whether its host writes the App Secret, which only that host's own check knows, so the fleet-wide expansion (for the cross-host checks) does not ask for it.
78
github_runner_arc_fleet: >-
89
{{
910
((groups['all'] | reject('equalto', inventory_hostname) | map('extract', hostvars) | map(attribute='github_runner_arc_orgs', default=[]) | flatten)
1011
+ github_runner_arc_orgs)
11-
| exadev.github_runner.arc_profiles
12+
| exadev.github_runner.arc_profiles(require_app_id=false)
1213
}}
13-
github_runner_arc_local: "{{ github_runner_arc_orgs | exadev.github_runner.arc_profiles }}"
14+
github_runner_arc_local: "{{ github_runner_arc_orgs | exadev.github_runner.arc_profiles(require_app_id=github_runner_arc_manage_secrets | bool) }}"
1415

1516
- name: Fail on invalid orgs or scale-set profiles
1617
ansible.builtin.fail:

‎tests/unit/test_arc_filters.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,17 @@ def test_invalid_autoscale_value_is_an_error(self) -> None:
106106

107107
def test_missing_required_org_fields_are_errors(self) -> None:
108108
result = arc.arc_profiles([{"name": "X", "scale_set_profiles": [{}]}, {"app_id": 1}, {"name": "Y", "app_id": 1, "image": "i", "scale_set_profiles": []}])
109-
self.assertIn("X: app_id is required", result["errors"])
109+
self.assertIn("X: app_id is required when the role writes the App Secret (github_runner_arc_manage_secrets); with an existing App Secret it is read from that Secret's github_app_id", result["errors"])
110110
self.assertIn("X: image is required", result["errors"])
111111
self.assertIn("github_runner_arc_orgs[1] has no name", result["errors"])
112112
self.assertIn("Y: scale_set_profiles must be a non-empty list", result["errors"])
113113

114+
def test_app_id_is_optional_when_the_role_does_not_write_the_app_secret(self) -> None:
115+
without = {"name": "Example", "image": "ghcr.io/example/runner:1", "scale_set_profiles": [{"max_runners": 1}]}
116+
self.assertEqual(arc.arc_profiles([without], require_app_id=False)["errors"], [])
117+
self.assertEqual(arc.arc_profiles([without])["errors"], ["Example: app_id is required when the role writes the App Secret (github_runner_arc_manage_secrets); with an existing App Secret it is read from that Secret's github_app_id"])
118+
self.assertIn("Example: image is required", arc.arc_profiles([{**without, "image": ""}], require_app_id=False)["errors"])
119+
114120
def test_a_private_key_and_a_1password_reference_together_are_an_error(self) -> None:
115121
both = dict(org(), private_key="pem", private_key_op_reference="op://v/i/f")
116122
self.assertIn("Example: set private_key or private_key_op_reference, not both (the 1Password adapter fills private_key from the reference)", arc.arc_profiles([both])["errors"])

0 commit comments

Comments
 (0)